Phase 3.5: Automation & Agents Plugin
Neues automation Plugin (app/plugins/builtins/automation/): - 7 DB-Modelle: AgentDefinition, AgentVersion, AutomationDefinition, AutomationVersion, AutomationCronJob, AgentRun, AutomationRun - Migration 0001_initial.sql mit allen Tabellen + RLS - PluginManifest erweitert: agent_definitions, automation_templates, cron_jobs, heartbeat_configs, miniapps Contribution-Felder - 21 API-Endpoints: /api/v1/automation (CRUD, execute, dry-run, runs, versions, restore, settings, miniapps) + /api/v1/agents (CRUD, execute, test-run, runs, versions, restore, tools, send-message) Backend Features: - Cron-Scheduler (scheduler.py): ARQ-basiert, liest CronJob-Tabelle, enqueued run_agent/run_automation, croniter fuer next_run_at - Workflow-Timeout-Worker (workflow_timeout.py): prueft abgelaufene WorkflowInstances, setzt cancelled, sendet Notification - Agent Runner (agent_runner.py): LiteLLM + ToolRegistry, proactive/ reactive mode, Rate-Limiting, Budget-Limit, Infinite-Loop-Detection - Automation Execution Engine (execution_engine.py): Condition evaluation (eq/ne/gt/lt/contains/exists), Actions (api_call/ notification/workflow_start), Dry-Run mode - Agent-to-Agent Communication (agent_comm.py): send_agent_message tool, kommunikation plugin integration - Plugin-Beitraege: register/unregister on activate/deactivate, Konfliktloesung mit Plugin-Name als Prefix - Heartbeat-Migration: ai_proactive heartbeat als Cron-Job - Versionshistorie: Auto-Versioning bei Updates, Restore-Endpoint - Settings: GET/PATCH /api/v1/automation/settings - ARQ Worker: 11 functions, 2 cron_jobs (scheduler_tick 30s, check_workflow_timeouts 5min) Frontend: - AutomationDashboard.tsx: Automation Builder UI mit Trigger, Conditions, Actions, Execute, Dry-Run, Run-History, Versions - AgentDashboard.tsx: Agent Builder UI mit Model, Tools, Prompt, Heartbeat, Rate-Limits, Execute, Test-Run, Agent-Chat - AutomationSettings.tsx: Settings + MiniApp-Builder - automation.ts: 24 React Query Hooks - automation.ts types: TypeScript Interfaces - routes/index.tsx: /automation, /agents, /settings/automation Tests: - 22 Frontend-Tests (AutomationDashboard, AgentDashboard, API) — alle bestanden - Backend-Tests: test_automation.py (CRUD, versions, conditions, dry-run, rate-limiting) - TSC: keine neuen Errors (nur pre-existing Dms.tsx) - croniter dependency installiert
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the API client
|
||||
const mockApiGet = vi.fn();
|
||||
const mockApiPost = vi.fn();
|
||||
const mockApiPut = vi.fn();
|
||||
const mockApiDelete = vi.fn();
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiGet: (...args: any[]) => mockApiGet(...args),
|
||||
apiPost: (...args: any[]) => mockApiPost(...args),
|
||||
apiPut: (...args: any[]) => mockApiPut(...args),
|
||||
apiDelete: (...args: any[]) => mockApiDelete(...args),
|
||||
}));
|
||||
|
||||
// Mock react-query
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}),
|
||||
useQuery: vi.fn(),
|
||||
useMutation: vi.fn(({ mutationFn, onSuccess }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const result = await mutationFn(args);
|
||||
if (onSuccess) onSuccess();
|
||||
return result;
|
||||
},
|
||||
isPending: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Automation API Hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useAutomations', () => {
|
||||
it('calls apiGet with correct endpoint', async () => {
|
||||
const { useAutomations } = await import('../automation');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => {
|
||||
expect(queryKey).toEqual(['automations']);
|
||||
queryFn();
|
||||
return { data: [], isLoading: false };
|
||||
});
|
||||
|
||||
useAutomations();
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/automation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgents', () => {
|
||||
it('calls apiGet with correct endpoint', async () => {
|
||||
const { useAgents } = await import('../automation');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => {
|
||||
expect(queryKey).toEqual(['agents']);
|
||||
queryFn();
|
||||
return { data: [], isLoading: false };
|
||||
});
|
||||
|
||||
useAgents();
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/agents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateAutomation', () => {
|
||||
it('calls apiPost with correct endpoint', async () => {
|
||||
const { useCreateAutomation } = await import('../automation');
|
||||
const hook = useCreateAutomation();
|
||||
|
||||
const data = { name: 'Test', trigger_type: 'manual' as const };
|
||||
await hook.mutateAsync(data);
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith('/automation', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateAgent', () => {
|
||||
it('calls apiPost with correct endpoint', async () => {
|
||||
const { useCreateAgent } = await import('../automation');
|
||||
const hook = useCreateAgent();
|
||||
|
||||
const data = { name: 'Test Agent', llm_model: 'gpt-4' };
|
||||
await hook.mutateAsync(data);
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith('/agents', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExecuteAutomation', () => {
|
||||
it('calls apiPost with correct endpoint', async () => {
|
||||
const { useExecuteAutomation } = await import('../automation');
|
||||
const hook = useExecuteAutomation();
|
||||
|
||||
await hook.mutateAsync('123');
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith('/automation/123/execute');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExecuteAgent', () => {
|
||||
it('calls apiPost with correct endpoint', async () => {
|
||||
const { useExecuteAgent } = await import('../automation');
|
||||
const hook = useExecuteAgent();
|
||||
|
||||
await hook.mutateAsync('456');
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith('/agents/456/execute');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteAutomation', () => {
|
||||
it('calls apiDelete with correct endpoint', async () => {
|
||||
const { useDeleteAutomation } = await import('../automation');
|
||||
const hook = useDeleteAutomation();
|
||||
|
||||
await hook.mutateAsync('789');
|
||||
|
||||
expect(mockApiDelete).toHaveBeenCalledWith('/automation/789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteAgent', () => {
|
||||
it('calls apiDelete with correct endpoint', async () => {
|
||||
const { useDeleteAgent } = await import('../automation');
|
||||
const hook = useDeleteAgent();
|
||||
|
||||
await hook.mutateAsync('012');
|
||||
|
||||
expect(mockApiDelete).toHaveBeenCalledWith('/agents/012');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSendAgentMessage', () => {
|
||||
it('calls apiPost with correct endpoint and body', async () => {
|
||||
const { useSendAgentMessage } = await import('../automation');
|
||||
const hook = useSendAgentMessage();
|
||||
|
||||
await hook.mutateAsync({
|
||||
id: 'agent-1',
|
||||
toAgentName: 'target-agent',
|
||||
message: 'Hello there',
|
||||
});
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith('/agents/agent-1/send-message', {
|
||||
to_agent_name: 'target-agent',
|
||||
message: 'Hello there',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMiniApps', () => {
|
||||
it('calls apiGet with correct endpoint', async () => {
|
||||
const { useMiniApps } = await import('../automation');
|
||||
const { useQuery } = await import('@tanstack/react-query');
|
||||
|
||||
(useQuery as any).mockImplementation(({ queryKey, queryFn }: any) => {
|
||||
expect(queryKey).toEqual(['miniapps']);
|
||||
queryFn();
|
||||
return { data: [], isLoading: false };
|
||||
});
|
||||
|
||||
useMiniApps();
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/automation/miniapps');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateMiniApp', () => {
|
||||
it('calls apiPost with correct endpoint', async () => {
|
||||
const { useCreateMiniApp } = await import('../automation');
|
||||
const hook = useCreateMiniApp();
|
||||
|
||||
const data = { app_id: 'my-app', name: 'My App' };
|
||||
await hook.mutateAsync(data);
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith('/automation/miniapps', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteMiniApp', () => {
|
||||
it('calls apiDelete with correct endpoint', async () => {
|
||||
const { useDeleteMiniApp } = await import('../automation');
|
||||
const hook = useDeleteMiniApp();
|
||||
|
||||
await hook.mutateAsync('my-app');
|
||||
|
||||
expect(mockApiDelete).toHaveBeenCalledWith('/automation/miniapps/my-app');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* React Query hooks for Automation & Agents API.
|
||||
* Follows the pattern from plugins.ts.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
AutomationRun,
|
||||
AutomationVersion,
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
AgentVersion,
|
||||
AgentTool,
|
||||
AutomationSettings,
|
||||
MiniAppDef,
|
||||
} from '@/types/automation';
|
||||
|
||||
// ── Automation Hooks ──
|
||||
|
||||
export function useAutomations() {
|
||||
return useQuery({
|
||||
queryKey: ['automations'],
|
||||
queryFn: () => apiGet<AutomationDefinition[]>('/automation'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutomation(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['automation', id],
|
||||
queryFn: () => apiGet<AutomationDefinition>(`/automation/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAutomation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<AutomationDefinition>) => apiPost<AutomationDefinition>('/automation', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAutomation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<AutomationDefinition> }) =>
|
||||
apiPatch<AutomationDefinition>(`/automation/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAutomation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/automation/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExecuteAutomation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiPost(`/automation/${id}/execute`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['automationRuns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDryRunAutomation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiPost(`/automation/${id}/dry-run`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['automationRuns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutomationRuns(automationId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['automationRuns', automationId],
|
||||
queryFn: () => apiGet<AutomationRun[]>(`/automation/${automationId}/runs`),
|
||||
enabled: !!automationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutomationVersions(automationId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['automationVersions', automationId],
|
||||
queryFn: () => apiGet<AutomationVersion[]>(`/automation/${automationId}/versions`),
|
||||
enabled: !!automationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreAutomationVersion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, versionId }: { id: string; versionId: string }) =>
|
||||
apiPost(`/automation/${id}/versions/${versionId}/restore`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['automations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['automationVersions'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutomationSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['automationSettings'],
|
||||
queryFn: () => apiGet<AutomationSettings>('/automation/settings'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAutomationSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<AutomationSettings>) =>
|
||||
apiPatch<AutomationSettings>('/automation/settings', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['automationSettings'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent Hooks ──
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiGet<AgentDefinition[]>('/agents'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgent(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['agent', id],
|
||||
queryFn: () => apiGet<AgentDefinition>(`/agents/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<AgentDefinition>) => apiPost<AgentDefinition>('/agents', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<AgentDefinition> }) =>
|
||||
apiPatch<AgentDefinition>(`/agents/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/agents/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExecuteAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiPost(`/agents/${id}/execute`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agentRuns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestRunAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiPost(`/agents/${id}/test-run`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agentRuns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentRuns(agentId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['agentRuns', agentId],
|
||||
queryFn: () => apiGet<AgentRun[]>(`/agents/${agentId}/runs`),
|
||||
enabled: !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentVersions(agentId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['agentVersions', agentId],
|
||||
queryFn: () => apiGet<AgentVersion[]>(`/agents/${agentId}/versions`),
|
||||
enabled: !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreAgentVersion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, versionId }: { id: string; versionId: string }) =>
|
||||
apiPost(`/agents/${id}/versions/${versionId}/restore`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agentVersions'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentTools() {
|
||||
return useQuery({
|
||||
queryKey: ['agentTools'],
|
||||
queryFn: () => apiGet<AgentTool[]>('/agents/tools'),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent Communication Hooks ──
|
||||
|
||||
export function useSendAgentMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, toAgentName, message }: { id: string; toAgentName: string; message: string }) =>
|
||||
apiPost(`/agents/${id}/send-message`, { to_agent_name: toAgentName, message }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agentRuns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── MiniApp Hooks ──
|
||||
|
||||
export function useMiniApps() {
|
||||
return useQuery({
|
||||
queryKey: ['miniapps'],
|
||||
queryFn: () => apiGet<MiniAppDef[]>('/automation/miniapps'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMiniApp() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<MiniAppDef>) => apiPost<MiniAppDef>('/automation/miniapps', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['miniapps'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMiniApp() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (appId: string) => apiDelete(`/automation/miniapps/${appId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['miniapps'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export * from './plugins';
|
||||
export * from './settings';
|
||||
export * from './attachments';
|
||||
export * from './unifiedContacts';
|
||||
export * from './automation';
|
||||
|
||||
// ── Search hook (uses dynamic import, kept inline) ──
|
||||
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useAgents,
|
||||
useCreateAgent,
|
||||
useUpdateAgent,
|
||||
useDeleteAgent,
|
||||
useExecuteAgent,
|
||||
useTestRunAgent,
|
||||
useAgentRuns,
|
||||
useAgentVersions,
|
||||
useRestoreAgentVersion,
|
||||
useAgentTools,
|
||||
useSendAgentMessage,
|
||||
} from '@/api/automation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import type { AgentDefinition } from '@/types/automation';
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
TestTube,
|
||||
History,
|
||||
GitBranch,
|
||||
Trash2,
|
||||
Settings2,
|
||||
Bot,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Brain,
|
||||
Activity,
|
||||
Clock,
|
||||
DollarSign,
|
||||
MessageCircle,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
|
||||
const modeOptions = [
|
||||
{ value: 'reactive', label: 'Reactive' },
|
||||
{ value: 'proactive', label: 'Proactive' },
|
||||
];
|
||||
|
||||
const commonModels = [
|
||||
'gpt-4',
|
||||
'gpt-4-turbo',
|
||||
'gpt-3.5-turbo',
|
||||
'claude-3-opus',
|
||||
'claude-3-sonnet',
|
||||
'claude-3-haiku',
|
||||
'llama-3-70b',
|
||||
'llama-3-8b',
|
||||
'mistral-large',
|
||||
'mixtral-8x7b',
|
||||
];
|
||||
|
||||
function statusBadgeVariant(status: string): 'success' | 'warning' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'running': return 'info';
|
||||
case 'failed': return 'danger';
|
||||
case 'cancelled': return 'warning';
|
||||
default: return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
interface AgentFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
model: string;
|
||||
system_prompt: string;
|
||||
tools: string[];
|
||||
heartbeat_interval: number;
|
||||
mode: 'proactive' | 'reactive';
|
||||
max_executions_per_hour: number;
|
||||
max_duration: number;
|
||||
budget_limit: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: AgentFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
model: 'gpt-4',
|
||||
system_prompt: '',
|
||||
tools: [],
|
||||
heartbeat_interval: 0,
|
||||
mode: 'reactive',
|
||||
max_executions_per_hour: 100,
|
||||
max_duration: 300,
|
||||
budget_limit: 0,
|
||||
active: true,
|
||||
};
|
||||
|
||||
function AgentForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving,
|
||||
}: {
|
||||
initial?: AgentDefinition;
|
||||
onSave: (data: Partial<AgentDefinition>) => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: availableTools } = useAgentTools();
|
||||
const [form, setForm] = useState<AgentFormData>(() => {
|
||||
if (initial) {
|
||||
return {
|
||||
name: initial.name,
|
||||
description: initial.description || '',
|
||||
model: initial.model,
|
||||
system_prompt: initial.system_prompt || '',
|
||||
tools: initial.tools || [],
|
||||
heartbeat_interval: initial.heartbeat_interval,
|
||||
mode: initial.mode,
|
||||
max_executions_per_hour: initial.max_executions_per_hour,
|
||||
max_duration: initial.max_duration,
|
||||
budget_limit: initial.budget_limit,
|
||||
active: initial.active,
|
||||
};
|
||||
}
|
||||
return { ...emptyForm };
|
||||
});
|
||||
|
||||
const updateField = <K extends keyof AgentFormData>(key: K, value: AgentFormData[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleTool = (toolName: string) => {
|
||||
setForm((prev) => {
|
||||
const tools = prev.tools.includes(toolName)
|
||||
? prev.tools.filter((t) => t !== toolName)
|
||||
: [...prev.tools, toolName];
|
||||
return { ...prev, tools };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
model: form.model,
|
||||
system_prompt: form.system_prompt || undefined,
|
||||
tools: form.tools,
|
||||
heartbeat_interval: form.heartbeat_interval,
|
||||
mode: form.mode,
|
||||
max_executions_per_hour: form.max_executions_per_hour,
|
||||
max_duration: form.max_duration,
|
||||
budget_limit: form.budget_limit,
|
||||
active: form.active,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name & Description */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="My Agent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.model')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.model}
|
||||
onChange={(e) => updateField('model', e.target.value)}
|
||||
list="model-suggestions"
|
||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="gpt-4"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{commonModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.systemPrompt')}
|
||||
</label>
|
||||
<textarea
|
||||
value={form.system_prompt}
|
||||
onChange={(e) => updateField('system_prompt', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="You are a helpful assistant..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.mode')}
|
||||
</label>
|
||||
<Select
|
||||
options={modeOptions}
|
||||
value={form.mode}
|
||||
onChange={(e) => updateField('mode', e.target.value as 'proactive' | 'reactive')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
{t('agent.tools')}
|
||||
</label>
|
||||
{!availableTools || availableTools.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400 italic">{t('agent.noToolsAvailable')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto">
|
||||
{availableTools.map((tool) => (
|
||||
<label
|
||||
key={tool.name}
|
||||
className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.tools.includes(tool.name)}
|
||||
onChange={() => toggleTool(tool.name)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-secondary-700">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-secondary-400">{tool.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Heartbeat */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.heartbeatInterval')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.heartbeat_interval}
|
||||
onChange={(e) => updateField('heartbeat_interval', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-1">{t('agent.heartbeatHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Rate Limits */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.maxExecutions')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.max_executions_per_hour}
|
||||
onChange={(e) => updateField('max_executions_per_hour', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.maxDuration')} (s)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.max_duration}
|
||||
onChange={(e) => updateField('max_duration', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.budgetLimit')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.budget_limit}
|
||||
onChange={(e) => updateField('budget_limit', parseFloat(e.target.value) || 0)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Toggle */}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => updateField('active', e.target.checked)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{t('agent.active')}
|
||||
</label>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||
<Button variant="secondary" onClick={onCancel} type="button">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSaving}>
|
||||
{initial ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RunHistoryModal({
|
||||
agentId,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
agentId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: runs, isLoading } = useAgentRuns(agentId);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t('agent.runHistory')} size="lg">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !runs || runs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('agent.noRuns')}
|
||||
description={t('agent.noRunsDesc')}
|
||||
icon={<History className="h-8 w-8" />}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={runStatusBadgeVariant(run.status)}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
<span className="text-sm text-secondary-600">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{run.error && (
|
||||
<span className="text-xs text-danger-600 max-w-xs truncate" title={run.error}>
|
||||
{run.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionHistoryModal({
|
||||
agentId,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
agentId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: versions, isLoading } = useAgentVersions(agentId);
|
||||
const restoreMutation = useRestoreAgentVersion();
|
||||
|
||||
const handleRestore = async (versionId: string) => {
|
||||
try {
|
||||
await restoreMutation.mutateAsync({ id: agentId, versionId });
|
||||
toast.success(t('agent.versionRestored'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t('agent.versionHistory')} size="lg">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !versions || versions.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('agent.noVersions')}
|
||||
icon={<GitBranch className="h-8 w-8" />}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{versions.map((ver) => (
|
||||
<div
|
||||
key={ver.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">v{ver.version}</Badge>
|
||||
<span className="text-sm text-secondary-600">
|
||||
{new Date(ver.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRestore(ver.id)}
|
||||
isLoading={restoreMutation.isPending}
|
||||
>
|
||||
{t('common.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentDashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: agents, isLoading, isError, refetch } = useAgents();
|
||||
const createMutation = useCreateAgent();
|
||||
const updateMutation = useUpdateAgent();
|
||||
const deleteMutation = useDeleteAgent();
|
||||
const executeMutation = useExecuteAgent();
|
||||
const testRunMutation = useTestRunAgent();
|
||||
const sendMessageMutation = useSendAgentMessage();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<AgentDefinition | undefined>(undefined);
|
||||
const [confirmDelete, setConfirmDelete] = useState<AgentDefinition | null>(null);
|
||||
const [runHistoryId, setRunHistoryId] = useState<string | null>(null);
|
||||
const [versionHistoryId, setVersionHistoryId] = useState<string | null>(null);
|
||||
const [showAgentChat, setShowAgentChat] = useState(false);
|
||||
const [chatTargetAgent, setChatTargetAgent] = useState('');
|
||||
const [chatMessage, setChatMessage] = useState('');
|
||||
|
||||
const handleSave = async (data: Partial<AgentDefinition>) => {
|
||||
try {
|
||||
if (editingAgent) {
|
||||
await updateMutation.mutateAsync({ id: editingAgent.id, data });
|
||||
toast.success(t('agent.updated'));
|
||||
} else {
|
||||
await createMutation.mutateAsync(data);
|
||||
toast.success(t('agent.created'));
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingAgent(undefined);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||
toast.success(t('agent.deleted'));
|
||||
setConfirmDelete(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (id: string) => {
|
||||
try {
|
||||
await executeMutation.mutateAsync(id);
|
||||
toast.success(t('agent.executed'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestRun = async (id: string) => {
|
||||
try {
|
||||
await testRunMutation.mutateAsync(id);
|
||||
toast.success(t('agent.testRunSuccess'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!chatTargetAgent || !chatMessage.trim()) return;
|
||||
try {
|
||||
await sendMessageMutation.mutateAsync({
|
||||
id: chatTargetAgent,
|
||||
toAgentName: chatTargetAgent,
|
||||
message: chatMessage,
|
||||
});
|
||||
toast.success(t('agent.messageSent'));
|
||||
setChatMessage('');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (agent: AgentDefinition) => {
|
||||
setEditingAgent(agent);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingAgent(undefined);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6" data-testid="agent-dashboard">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('agent.title')}</h1>
|
||||
<p className="text-sm text-secondary-500 mt-1">{t('agent.subtitle')}</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||
{t('agent.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 text-danger-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>{t('common.errorLoading')}</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && !isError && (!agents || agents.length === 0) && (
|
||||
<EmptyState
|
||||
title={t('agent.noAgents')}
|
||||
description={t('agent.noAgentsDesc')}
|
||||
icon={<Bot className="h-8 w-8" />}
|
||||
action={
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||
{t('agent.createFirst')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Agent List */}
|
||||
{!isLoading && !isError && agents && agents.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{agents.map((agent) => (
|
||||
<Card key={agent.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">{agent.name}</h3>
|
||||
<Badge variant={statusBadgeVariant(agent.active ? 'active' : 'inactive')}>
|
||||
{agent.active ? t('agent.active') : t('agent.inactive')}
|
||||
</Badge>
|
||||
<Badge variant={agent.mode === 'proactive' ? 'info' : 'secondary'}>
|
||||
{agent.mode}
|
||||
</Badge>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="text-sm text-secondary-500 mb-2">{agent.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-secondary-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Brain className="h-3 w-3" />
|
||||
{agent.model}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
{agent.tools?.length || 0} tools
|
||||
</span>
|
||||
{agent.heartbeat_interval > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{agent.heartbeat_interval}s
|
||||
</span>
|
||||
)}
|
||||
{agent.budget_limit > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{agent.budget_limit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleExecute(agent.id)}
|
||||
isLoading={executeMutation.isPending}
|
||||
title={t('agent.execute')}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleTestRun(agent.id)}
|
||||
isLoading={testRunMutation.isPending}
|
||||
title={t('agent.testRun')}
|
||||
>
|
||||
<TestTube className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setRunHistoryId(agent.id)}
|
||||
title={t('agent.runHistory')}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setVersionHistoryId(agent.id)}
|
||||
title={t('agent.versionHistory')}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEdit(agent)}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDelete(agent)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
open={showForm}
|
||||
onClose={() => { setShowForm(false); setEditingAgent(undefined); }}
|
||||
title={editingAgent ? t('agent.edit') : t('agent.create')}
|
||||
size="xl"
|
||||
>
|
||||
<AgentForm
|
||||
initial={editingAgent}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditingAgent(undefined); }}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Run History Modal */}
|
||||
{runHistoryId && (
|
||||
<RunHistoryModal
|
||||
agentId={runHistoryId}
|
||||
open={!!runHistoryId}
|
||||
onClose={() => setRunHistoryId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Version History Modal */}
|
||||
{versionHistoryId && (
|
||||
<VersionHistoryModal
|
||||
agentId={versionHistoryId}
|
||||
open={!!versionHistoryId}
|
||||
onClose={() => setVersionHistoryId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title={t('agent.deleteConfirmTitle')}
|
||||
message={t('agent.deleteConfirmMessage', { name: confirmDelete?.name })}
|
||||
confirmLabel={t('common.delete')}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* Agent Chat Section */}
|
||||
<div className="mt-8 border-t border-secondary-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-secondary-900">{t('agent.agentChat')}</h2>
|
||||
<p className="text-sm text-secondary-500 mt-1">{t('agent.agentChatSubtitle')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={showAgentChat ? 'secondary' : 'primary'}
|
||||
onClick={() => setShowAgentChat(!showAgentChat)}
|
||||
icon={<MessageCircle className="h-4 w-4" />}
|
||||
>
|
||||
{showAgentChat ? t('common.hide') : t('agent.openChat')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAgentChat && (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.targetAgent')}
|
||||
</label>
|
||||
<select
|
||||
value={chatTargetAgent}
|
||||
onChange={(e) => setChatTargetAgent(e.target.value)}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">{t('agent.selectTargetAgent')}</option>
|
||||
{agents?.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('agent.message')}
|
||||
</label>
|
||||
<textarea
|
||||
value={chatMessage}
|
||||
onChange={(e) => setChatMessage(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder={t('agent.messagePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
isLoading={sendMessageMutation.isPending}
|
||||
disabled={!chatTargetAgent || !chatMessage.trim()}
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
>
|
||||
{t('agent.sendMessage')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useAutomations,
|
||||
useCreateAutomation,
|
||||
useUpdateAutomation,
|
||||
useDeleteAutomation,
|
||||
useExecuteAutomation,
|
||||
useDryRunAutomation,
|
||||
useAutomationRuns,
|
||||
useAutomationVersions,
|
||||
useRestoreAutomationVersion,
|
||||
} from '@/api/automation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import type { AutomationDefinition, AutomationCondition, AutomationAction } from '@/types/automation';
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
History,
|
||||
GitBranch,
|
||||
Trash2,
|
||||
Settings2,
|
||||
Workflow,
|
||||
Clock,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
const triggerTypeOptions = [
|
||||
{ value: 'event', label: 'Event' },
|
||||
{ value: 'schedule', label: 'Schedule' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
];
|
||||
|
||||
const actionTypeOptions = [
|
||||
{ value: 'api_call', label: 'API Call' },
|
||||
{ value: 'notification', label: 'Notification' },
|
||||
{ value: 'workflow_start', label: 'Workflow Start' },
|
||||
];
|
||||
|
||||
const conditionOperatorOptions = [
|
||||
{ value: 'equals', label: 'Equals' },
|
||||
{ value: 'not_equals', label: 'Not Equals' },
|
||||
{ value: 'contains', label: 'Contains' },
|
||||
{ value: 'greater_than', label: 'Greater Than' },
|
||||
{ value: 'less_than', label: 'Less Than' },
|
||||
];
|
||||
|
||||
function statusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'inactive': return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'running': return 'info';
|
||||
case 'failed': return 'danger';
|
||||
case 'cancelled': return 'warning';
|
||||
default: return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
function triggerIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'event': return <Zap className="h-4 w-4" />;
|
||||
case 'schedule': return <Clock className="h-4 w-4" />;
|
||||
default: return <Play className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
interface AutomationFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger_type: 'event' | 'schedule' | 'manual';
|
||||
trigger_config: Record<string, string>;
|
||||
conditions: AutomationCondition[];
|
||||
actions: AutomationAction[];
|
||||
active: boolean;
|
||||
dry_run: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: AutomationFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_type: 'manual',
|
||||
trigger_config: {},
|
||||
conditions: [],
|
||||
actions: [],
|
||||
active: true,
|
||||
dry_run: false,
|
||||
};
|
||||
|
||||
function AutomationForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving,
|
||||
}: {
|
||||
initial?: AutomationDefinition;
|
||||
onSave: (data: Partial<AutomationDefinition>) => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<AutomationFormData>(() => {
|
||||
if (initial) {
|
||||
return {
|
||||
name: initial.name,
|
||||
description: initial.description || '',
|
||||
trigger_type: initial.trigger_type,
|
||||
trigger_config: (initial.trigger_config as Record<string, string>) || {},
|
||||
conditions: initial.conditions || [],
|
||||
actions: initial.actions || [],
|
||||
active: initial.active,
|
||||
dry_run: initial.dry_run,
|
||||
};
|
||||
}
|
||||
return { ...emptyForm };
|
||||
});
|
||||
|
||||
const updateField = <K extends keyof AutomationFormData>(key: K, value: AutomationFormData[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
conditions: [...prev.conditions, { field: '', operator: 'equals', value: '' }],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCondition = (index: number, field: keyof AutomationCondition, value: string) => {
|
||||
setForm((prev) => {
|
||||
const conditions = [...prev.conditions];
|
||||
conditions[index] = { ...conditions[index], [field]: value };
|
||||
return { ...prev, conditions };
|
||||
});
|
||||
};
|
||||
|
||||
const removeCondition = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
conditions: prev.conditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const addAction = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
actions: [...prev.actions, { type: 'api_call', config: {} }],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateAction = (index: number, field: 'type' | 'config', value: string | Record<string, unknown>) => {
|
||||
setForm((prev) => {
|
||||
const actions = [...prev.actions];
|
||||
if (field === 'type') {
|
||||
actions[index] = { ...actions[index], type: value as 'api_call' | 'notification' | 'workflow_start' };
|
||||
} else {
|
||||
actions[index] = { ...actions[index], config: value as Record<string, unknown> };
|
||||
}
|
||||
return { ...prev, actions };
|
||||
});
|
||||
};
|
||||
|
||||
const removeAction = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
actions: prev.actions.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
trigger_type: form.trigger_type,
|
||||
trigger_config: form.trigger_config,
|
||||
conditions: form.conditions,
|
||||
actions: form.actions,
|
||||
active: form.active,
|
||||
dry_run: form.dry_run,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name & Description */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="My Automation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trigger Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.triggerType')}
|
||||
</label>
|
||||
<Select
|
||||
options={triggerTypeOptions}
|
||||
value={form.trigger_type}
|
||||
onChange={(e) => updateField('trigger_type', e.target.value as 'event' | 'schedule' | 'manual')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trigger Config */}
|
||||
{form.trigger_type === 'event' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.eventName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.trigger_config.event_name || ''}
|
||||
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, event_name: e.target.value })}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="contact.created"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{form.trigger_type === 'schedule' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.cronExpression')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.trigger_config.cron || ''}
|
||||
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, cron: e.target.value })}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="0 9 * * *"
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-1">Cron expression (e.g. 0 9 * * * for daily at 9am)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-secondary-700">
|
||||
{t('automation.conditions')}
|
||||
</label>
|
||||
<Button size="sm" variant="ghost" onClick={addCondition} type="button">
|
||||
+ {t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{form.conditions.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 italic">{t('automation.noConditions')}</p>
|
||||
)}
|
||||
{form.conditions.map((cond, i) => (
|
||||
<div key={i} className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={cond.field}
|
||||
onChange={(e) => updateCondition(i, 'field', e.target.value)}
|
||||
placeholder="Field"
|
||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<Select
|
||||
options={conditionOperatorOptions}
|
||||
value={cond.operator}
|
||||
onChange={(e) => updateCondition(i, 'operator', e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(i, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCondition(i)}
|
||||
className="text-danger-500 hover:text-danger-700 p-1"
|
||||
aria-label="Remove condition"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-secondary-700">
|
||||
{t('automation.actions')}
|
||||
</label>
|
||||
<Button size="sm" variant="ghost" onClick={addAction} type="button">
|
||||
+ {t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{form.actions.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 italic">{t('automation.noActions')}</p>
|
||||
)}
|
||||
{form.actions.map((action, i) => (
|
||||
<div key={i} className="flex items-center gap-2 mb-2">
|
||||
<Select
|
||||
options={actionTypeOptions}
|
||||
value={action.type}
|
||||
onChange={(e) => updateAction(i, 'type', e.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={JSON.stringify(action.config)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
updateAction(i, 'config', JSON.parse(e.target.value));
|
||||
} catch {
|
||||
// ignore invalid JSON while typing
|
||||
}
|
||||
}}
|
||||
placeholder='{"url": "..."}'
|
||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAction(i)}
|
||||
className="text-danger-500 hover:text-danger-700 p-1"
|
||||
aria-label="Remove action"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => updateField('active', e.target.checked)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{t('automation.active')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.dry_run}
|
||||
onChange={(e) => updateField('dry_run', e.target.checked)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
{t('automation.dryRun')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||
<Button variant="secondary" onClick={onCancel} type="button">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSaving}>
|
||||
{initial ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RunHistoryModal({
|
||||
automationId,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
automationId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: runs, isLoading } = useAutomationRuns(automationId);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t('automation.runHistory')} size="lg">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !runs || runs.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('automation.noRuns')}
|
||||
description={t('automation.noRunsDesc')}
|
||||
icon={<History className="h-8 w-8" />}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={runStatusBadgeVariant(run.status)}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
<span className="text-sm text-secondary-600">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{run.error && (
|
||||
<span className="text-xs text-danger-600 max-w-xs truncate" title={run.error}>
|
||||
{run.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionHistoryModal({
|
||||
automationId,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
automationId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: versions, isLoading } = useAutomationVersions(automationId);
|
||||
const restoreMutation = useRestoreAutomationVersion();
|
||||
|
||||
const handleRestore = async (versionId: string) => {
|
||||
try {
|
||||
await restoreMutation.mutateAsync({ id: automationId, versionId });
|
||||
toast.success(t('automation.versionRestored'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t('automation.versionHistory')} size="lg">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !versions || versions.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('automation.noVersions')}
|
||||
icon={<GitBranch className="h-8 w-8" />}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{versions.map((ver) => (
|
||||
<div
|
||||
key={ver.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">v{ver.version}</Badge>
|
||||
<span className="text-sm text-secondary-600">
|
||||
{new Date(ver.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRestore(ver.id)}
|
||||
isLoading={restoreMutation.isPending}
|
||||
>
|
||||
{t('common.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutomationDashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: automations, isLoading, isError, refetch } = useAutomations();
|
||||
const createMutation = useCreateAutomation();
|
||||
const updateMutation = useUpdateAutomation();
|
||||
const deleteMutation = useDeleteAutomation();
|
||||
const executeMutation = useExecuteAutomation();
|
||||
const dryRunMutation = useDryRunAutomation();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingAutomation, setEditingAutomation] = useState<AutomationDefinition | undefined>(undefined);
|
||||
const [confirmDelete, setConfirmDelete] = useState<AutomationDefinition | null>(null);
|
||||
const [runHistoryId, setRunHistoryId] = useState<string | null>(null);
|
||||
const [versionHistoryId, setVersionHistoryId] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async (data: Partial<AutomationDefinition>) => {
|
||||
try {
|
||||
if (editingAutomation) {
|
||||
await updateMutation.mutateAsync({ id: editingAutomation.id, data });
|
||||
toast.success(t('automation.updated'));
|
||||
} else {
|
||||
await createMutation.mutateAsync(data);
|
||||
toast.success(t('automation.created'));
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingAutomation(undefined);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||
toast.success(t('automation.deleted'));
|
||||
setConfirmDelete(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (id: string) => {
|
||||
try {
|
||||
await executeMutation.mutateAsync(id);
|
||||
toast.success(t('automation.executed'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDryRun = async (id: string) => {
|
||||
try {
|
||||
await dryRunMutation.mutateAsync(id);
|
||||
toast.success(t('automation.dryRunSuccess'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (automation: AutomationDefinition) => {
|
||||
setEditingAutomation(automation);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingAutomation(undefined);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6" data-testid="automation-dashboard">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('automation.title')}</h1>
|
||||
<p className="text-sm text-secondary-500 mt-1">{t('automation.subtitle')}</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||
{t('automation.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 text-danger-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>{t('common.errorLoading')}</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && !isError && (!automations || automations.length === 0) && (
|
||||
<EmptyState
|
||||
title={t('automation.noAutomations')}
|
||||
description={t('automation.noAutomationsDesc')}
|
||||
icon={<Workflow className="h-8 w-8" />}
|
||||
action={
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||
{t('automation.createFirst')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Automation List */}
|
||||
{!isLoading && !isError && automations && automations.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{automations.map((automation) => (
|
||||
<Card key={automation.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">{automation.name}</h3>
|
||||
<Badge variant={statusBadgeVariant(automation.active ? 'active' : 'inactive')}>
|
||||
{automation.active ? t('automation.active') : t('automation.inactive')}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-xs text-secondary-400">
|
||||
{triggerIcon(automation.trigger_type)}
|
||||
<span className="capitalize">{automation.trigger_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
{automation.description && (
|
||||
<p className="text-sm text-secondary-500 mb-2">{automation.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-secondary-400">
|
||||
<span>{automation.conditions?.length || 0} conditions</span>
|
||||
<span>{automation.actions?.length || 0} actions</span>
|
||||
{automation.dry_run && (
|
||||
<Badge variant="warning" dot>{t('automation.dryRun')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleExecute(automation.id)}
|
||||
isLoading={executeMutation.isPending}
|
||||
title={t('automation.execute')}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDryRun(automation.id)}
|
||||
isLoading={dryRunMutation.isPending}
|
||||
title={t('automation.dryRun')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setRunHistoryId(automation.id)}
|
||||
title={t('automation.runHistory')}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setVersionHistoryId(automation.id)}
|
||||
title={t('automation.versionHistory')}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEdit(automation)}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDelete(automation)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
open={showForm}
|
||||
onClose={() => { setShowForm(false); setEditingAutomation(undefined); }}
|
||||
title={editingAutomation ? t('automation.edit') : t('automation.create')}
|
||||
size="lg"
|
||||
>
|
||||
<AutomationForm
|
||||
initial={editingAutomation}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditingAutomation(undefined); }}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Run History Modal */}
|
||||
{runHistoryId && (
|
||||
<RunHistoryModal
|
||||
automationId={runHistoryId}
|
||||
open={!!runHistoryId}
|
||||
onClose={() => setRunHistoryId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Version History Modal */}
|
||||
{versionHistoryId && (
|
||||
<VersionHistoryModal
|
||||
automationId={versionHistoryId}
|
||||
open={!!versionHistoryId}
|
||||
onClose={() => setVersionHistoryId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title={t('automation.deleteConfirmTitle')}
|
||||
message={t('automation.deleteConfirmMessage', { name: confirmDelete?.name })}
|
||||
confirmLabel={t('common.delete')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useAutomationSettings,
|
||||
useUpdateAutomationSettings,
|
||||
useMiniApps,
|
||||
useCreateMiniApp,
|
||||
useDeleteMiniApp,
|
||||
} from '@/api/automation';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import type { AutomationSettings as AutomationSettingsType, MiniAppDef } from '@/types/automation';
|
||||
import { Save, AlertCircle, Plus, Trash2, AppWindow } from 'lucide-react';
|
||||
|
||||
const logLevelOptions = [
|
||||
{ value: 'debug', label: 'Debug' },
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'warning', label: 'Warning' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
];
|
||||
|
||||
export function AutomationSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data: settings, isLoading, isError, refetch } = useAutomationSettings();
|
||||
const updateMutation = useUpdateAutomationSettings();
|
||||
|
||||
const [form, setForm] = useState<AutomationSettingsType>({
|
||||
default_llm_model: '',
|
||||
heartbeat_default_interval: 0,
|
||||
max_concurrent_agents: 0,
|
||||
log_level: 'info',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setForm({
|
||||
default_llm_model: settings.default_llm_model || '',
|
||||
heartbeat_default_interval: settings.heartbeat_default_interval || 0,
|
||||
max_concurrent_agents: settings.max_concurrent_agents || 0,
|
||||
log_level: settings.log_level || 'info',
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateField = <K extends keyof AutomationSettingsType>(key: K, value: AutomationSettingsType[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateMutation.mutateAsync(form);
|
||||
toast.success(t('automation.settingsSaved'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const { data: miniapps, isLoading: miniappsLoading, refetch: refetchMiniapps } = useMiniApps();
|
||||
const createMiniAppMutation = useCreateMiniApp();
|
||||
const deleteMiniAppMutation = useDeleteMiniApp();
|
||||
const [showMiniAppForm, setShowMiniAppForm] = useState(false);
|
||||
const [miniAppForm, setMiniAppForm] = useState({
|
||||
app_id: '',
|
||||
name: '',
|
||||
icon: 'AppWindow',
|
||||
description: '',
|
||||
render_schema: '{}',
|
||||
});
|
||||
|
||||
const handleCreateMiniApp = async () => {
|
||||
try {
|
||||
let render_schema = {};
|
||||
try {
|
||||
render_schema = JSON.parse(miniAppForm.render_schema);
|
||||
} catch {
|
||||
toast.error(t('common.invalidJson'));
|
||||
return;
|
||||
}
|
||||
await createMiniAppMutation.mutateAsync({
|
||||
app_id: miniAppForm.app_id,
|
||||
name: miniAppForm.name,
|
||||
icon: miniAppForm.icon,
|
||||
description: miniAppForm.description,
|
||||
render_schema,
|
||||
});
|
||||
toast.success(t('automation.miniAppCreated'));
|
||||
setShowMiniAppForm(false);
|
||||
setMiniAppForm({ app_id: '', name: '', icon: 'AppWindow', description: '', render_schema: '{}' });
|
||||
refetchMiniapps();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMiniApp = async (appId: string) => {
|
||||
try {
|
||||
await deleteMiniAppMutation.mutateAsync(appId);
|
||||
toast.success(t('automation.miniAppDeleted'));
|
||||
refetchMiniapps();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto" data-testid="automation-settings">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('automation.settingsTitle')}</h1>
|
||||
<p className="text-sm text-secondary-500 mt-1">{t('automation.settingsSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 text-danger-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>{t('common.errorLoading')}</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Default LLM Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.defaultLlmModel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.default_llm_model}
|
||||
onChange={(e) => updateField('default_llm_model', e.target.value)}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="gpt-4"
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Default Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.heartbeatDefaultInterval')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.heartbeat_default_interval}
|
||||
onChange={(e) => updateField('heartbeat_default_interval', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-1">{t('automation.heartbeatDefaultIntervalHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Max Concurrent Agents */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.maxConcurrentAgents')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.max_concurrent_agents}
|
||||
onChange={(e) => updateField('max_concurrent_agents', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-secondary-400 mt-1">{t('automation.maxConcurrentAgentsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Log Level */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('automation.logLevel')}
|
||||
</label>
|
||||
<Select
|
||||
options={logLevelOptions}
|
||||
value={form.log_level}
|
||||
onChange={(e) => updateField('log_level', e.target.value as 'debug' | 'info' | 'warning' | 'error')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="pt-4 border-t border-secondary-200">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
isLoading={updateMutation.isPending}
|
||||
icon={<Save className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* MiniApp Builder Section */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-secondary-900">{t('automation.miniAppsTitle')}</h2>
|
||||
<p className="text-sm text-secondary-500 mt-1">{t('automation.miniAppsSubtitle')}</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowMiniAppForm(true)} icon={<Plus className="h-4 w-4" />}>
|
||||
{t('automation.createMiniApp')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{miniappsLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !miniapps || miniapps.length === 0 ? (
|
||||
<Card className="p-6 text-center">
|
||||
<AppWindow className="h-8 w-8 mx-auto text-secondary-400 mb-2" />
|
||||
<p className="text-sm text-secondary-500">{t('automation.noMiniApps')}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{miniapps.map((app: MiniAppDef) => (
|
||||
<Card key={app.app_id} className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppWindow className="h-5 w-5 text-primary-500" />
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">{app.name}</p>
|
||||
<p className="text-xs text-secondary-400">{app.app_id} — {app.description || t('common.noDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteMiniApp(app.app_id)}
|
||||
isLoading={deleteMiniAppMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create MiniApp Modal */}
|
||||
<Modal
|
||||
open={showMiniAppForm}
|
||||
onClose={() => setShowMiniAppForm(false)}
|
||||
title={t('automation.createMiniApp')}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppId')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={miniAppForm.app_id}
|
||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||
placeholder="my_custom_app"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={miniAppForm.name}
|
||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||
placeholder="My Custom App"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppIcon')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={miniAppForm.icon}
|
||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||
placeholder="AppWindow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppDescription')}</label>
|
||||
<textarea
|
||||
value={miniAppForm.description}
|
||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppSchema')}</label>
|
||||
<textarea
|
||||
value={miniAppForm.render_schema}
|
||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono"
|
||||
placeholder='{"type": "form", "fields": []}'
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||
<Button variant="secondary" onClick={() => setShowMiniAppForm(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleCreateMiniApp} isLoading={createMiniAppMutation.isPending}>{t('common.create')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AgentDashboardPage } from '../AgentDashboard';
|
||||
|
||||
// Helper to create a mock mutation result
|
||||
const mockMutation = (overrides = {}) => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
data: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Mock the API hooks
|
||||
vi.mock('@/api/automation', () => ({
|
||||
useAgents: vi.fn(),
|
||||
useCreateAgent: vi.fn(() => mockMutation()),
|
||||
useUpdateAgent: vi.fn(() => mockMutation()),
|
||||
useDeleteAgent: vi.fn(() => mockMutation()),
|
||||
useExecuteAgent: vi.fn(() => mockMutation()),
|
||||
useTestRunAgent: vi.fn(() => mockMutation()),
|
||||
useAgentRuns: vi.fn(),
|
||||
useAgentVersions: vi.fn(),
|
||||
useRestoreAgentVersion: vi.fn(() => mockMutation()),
|
||||
useAgentTools: vi.fn(() => ({ data: [] })),
|
||||
useSendAgentMessage: vi.fn(() => mockMutation()),
|
||||
}));
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Activity: () => <div data-testid="icon-activity" />,
|
||||
AlertCircle: () => <div data-testid="icon-alert" />,
|
||||
Bot: () => <div data-testid="icon-bot" />,
|
||||
Brain: () => <div data-testid="icon-brain" />,
|
||||
Clock: () => <div data-testid="icon-clock" />,
|
||||
DollarSign: () => <div data-testid="icon-dollar" />,
|
||||
GitBranch: () => <div data-testid="icon-git-branch" />,
|
||||
History: () => <div data-testid="icon-history" />,
|
||||
Loader2: () => <div data-testid="icon-loader" />,
|
||||
MessageCircle: () => <div data-testid="icon-message-circle" />,
|
||||
Play: () => <div data-testid="icon-play" />,
|
||||
Plus: () => <div data-testid="icon-plus" />,
|
||||
RefreshCw: () => <div data-testid="icon-refresh" />,
|
||||
Send: () => <div data-testid="icon-send" />,
|
||||
Settings2: () => <div data-testid="icon-settings2" />,
|
||||
TestTube: () => <div data-testid="icon-test-tube" />,
|
||||
Trash2: () => <div data-testid="icon-trash" />,
|
||||
Zap: () => <div data-testid="icon-zap" />,
|
||||
}));
|
||||
|
||||
// Mock Modal component
|
||||
vi.mock('@/components/ui/Modal', () => ({
|
||||
Modal: ({ open, title, children }: any) => open ? <div data-testid="modal"><h2>{title}</h2>{children}</div> : null,
|
||||
}));
|
||||
|
||||
// Mock AgentForm
|
||||
vi.mock('@/components/automation/AgentForm', () => ({
|
||||
AgentForm: () => <div data-testid="agent-form" />,
|
||||
}));
|
||||
|
||||
// Mock Skeleton
|
||||
vi.mock('@/components/ui/Skeleton', () => ({
|
||||
Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
|
||||
}));
|
||||
|
||||
// Mock Card
|
||||
vi.mock('@/components/ui/Card', () => ({
|
||||
Card: ({ children, className }: any) => <div className={className}>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock EmptyState
|
||||
vi.mock('@/components/ui/EmptyState', () => ({
|
||||
EmptyState: ({ title, description, icon, action }: any) => (
|
||||
<div data-testid="empty-state">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
{icon}
|
||||
{action}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Badge
|
||||
vi.mock('@/components/ui/Badge', () => ({
|
||||
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
|
||||
}));
|
||||
|
||||
const mockAgents = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Agent',
|
||||
description: 'A test agent',
|
||||
llm_model: 'gpt-4',
|
||||
mode: 'reactive',
|
||||
is_active: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Inactive Agent',
|
||||
description: 'An inactive agent',
|
||||
llm_model: 'gpt-3.5',
|
||||
mode: 'scheduled',
|
||||
is_active: false,
|
||||
created_at: '2024-01-02T00:00:00Z',
|
||||
updated_at: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{ui}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the agent list', async () => {
|
||||
const { useAgents } = await import('@/api/automation');
|
||||
(useAgents as any).mockReturnValue({
|
||||
data: mockAgents,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AgentDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Agent')).toBeDefined();
|
||||
expect(screen.getByText('Inactive Agent')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state', async () => {
|
||||
const { useAgents } = await import('@/api/automation');
|
||||
(useAgents as any).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AgentDashboardPage />);
|
||||
|
||||
expect(screen.getAllByTestId('skeleton').length).toBe(3);
|
||||
});
|
||||
|
||||
it('shows empty state when no agents', async () => {
|
||||
const { useAgents } = await import('@/api/automation');
|
||||
(useAgents as any).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AgentDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('agent.noAgents')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens create form when create button is clicked', async () => {
|
||||
const { useAgents } = await import('@/api/automation');
|
||||
(useAgents as any).mockReturnValue({
|
||||
data: mockAgents,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AgentDashboardPage />);
|
||||
|
||||
const createButton = screen.getByText('agent.create');
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls execute API when execute button is clicked', async () => {
|
||||
const { useAgents, useExecuteAgent } = await import('@/api/automation');
|
||||
const mockExecute = vi.fn().mockResolvedValue({});
|
||||
(useAgents as any).mockReturnValue({
|
||||
data: mockAgents,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
(useExecuteAgent as any).mockReturnValue({
|
||||
mutateAsync: mockExecute,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
renderWithProviders(<AgentDashboardPage />);
|
||||
|
||||
// Execute buttons have title="agent.execute" - find by title attribute
|
||||
const executeButtons = screen.getAllByTitle('agent.execute');
|
||||
expect(executeButtons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(executeButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecute).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AutomationDashboardPage } from '../AutomationDashboard';
|
||||
|
||||
// Helper to create a mock mutation result
|
||||
const mockMutation = (overrides = {}) => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
data: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Mock the API hooks
|
||||
vi.mock('@/api/automation', () => ({
|
||||
useAutomations: vi.fn(),
|
||||
useCreateAutomation: vi.fn(() => mockMutation()),
|
||||
useUpdateAutomation: vi.fn(() => mockMutation()),
|
||||
useDeleteAutomation: vi.fn(() => mockMutation()),
|
||||
useExecuteAutomation: vi.fn(() => mockMutation()),
|
||||
useDryRunAutomation: vi.fn(() => mockMutation()),
|
||||
useAutomationRuns: vi.fn(),
|
||||
useAutomationVersions: vi.fn(),
|
||||
useRestoreAutomationVersion: vi.fn(() => mockMutation()),
|
||||
}));
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
AlertCircle: () => <div data-testid="icon-alert" />,
|
||||
CheckCircle2: () => <div data-testid="icon-check-circle" />,
|
||||
Clock: () => <div data-testid="icon-clock" />,
|
||||
GitBranch: () => <div data-testid="icon-git-branch" />,
|
||||
History: () => <div data-testid="icon-history" />,
|
||||
Loader2: () => <div data-testid="icon-loader" />,
|
||||
Play: () => <div data-testid="icon-play" />,
|
||||
Plus: () => <div data-testid="icon-plus" />,
|
||||
RotateCcw: () => <div data-testid="icon-rotate-ccw" />,
|
||||
Settings2: () => <div data-testid="icon-settings2" />,
|
||||
Trash2: () => <div data-testid="icon-trash" />,
|
||||
Workflow: () => <div data-testid="icon-workflow" />,
|
||||
XCircle: () => <div data-testid="icon-x-circle" />,
|
||||
Zap: () => <div data-testid="icon-zap" />,
|
||||
}));
|
||||
|
||||
// Mock Modal component
|
||||
vi.mock('@/components/ui/Modal', () => ({
|
||||
Modal: ({ open, title, children }: any) => open ? <div data-testid="modal"><h2>{title}</h2>{children}</div> : null,
|
||||
}));
|
||||
|
||||
// Mock AutomationForm
|
||||
vi.mock('@/components/automation/AutomationForm', () => ({
|
||||
AutomationForm: () => <div data-testid="automation-form" />,
|
||||
}));
|
||||
|
||||
// Mock Skeleton
|
||||
vi.mock('@/components/ui/Skeleton', () => ({
|
||||
Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
|
||||
}));
|
||||
|
||||
// Mock Card
|
||||
vi.mock('@/components/ui/Card', () => ({
|
||||
Card: ({ children, className }: any) => <div className={className}>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock EmptyState
|
||||
vi.mock('@/components/ui/EmptyState', () => ({
|
||||
EmptyState: ({ title, description, icon, action }: any) => (
|
||||
<div data-testid="empty-state">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
{icon}
|
||||
{action}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Badge
|
||||
vi.mock('@/components/ui/Badge', () => ({
|
||||
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
|
||||
}));
|
||||
|
||||
const mockAutomations = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Automation',
|
||||
description: 'A test automation',
|
||||
trigger_type: 'manual',
|
||||
is_active: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Inactive Automation',
|
||||
description: 'An inactive automation',
|
||||
trigger_type: 'event',
|
||||
is_active: false,
|
||||
created_at: '2024-01-02T00:00:00Z',
|
||||
updated_at: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{ui}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AutomationDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the automation list', async () => {
|
||||
const { useAutomations } = await import('@/api/automation');
|
||||
(useAutomations as any).mockReturnValue({
|
||||
data: mockAutomations,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AutomationDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Automation')).toBeDefined();
|
||||
expect(screen.getByText('Inactive Automation')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state', async () => {
|
||||
const { useAutomations } = await import('@/api/automation');
|
||||
(useAutomations as any).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AutomationDashboardPage />);
|
||||
|
||||
expect(screen.getAllByTestId('skeleton').length).toBe(3);
|
||||
});
|
||||
|
||||
it('shows empty state when no automations', async () => {
|
||||
const { useAutomations } = await import('@/api/automation');
|
||||
(useAutomations as any).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AutomationDashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('automation.noAutomations')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens create form when create button is clicked', async () => {
|
||||
const { useAutomations } = await import('@/api/automation');
|
||||
(useAutomations as any).mockReturnValue({
|
||||
data: mockAutomations,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<AutomationDashboardPage />);
|
||||
|
||||
const createButton = screen.getByText('automation.create');
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls execute API when execute button is clicked', async () => {
|
||||
const { useAutomations, useExecuteAutomation } = await import('@/api/automation');
|
||||
const mockExecute = vi.fn().mockResolvedValue({});
|
||||
(useAutomations as any).mockReturnValue({
|
||||
data: mockAutomations,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
(useExecuteAutomation as any).mockReturnValue({
|
||||
mutateAsync: mockExecute,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
renderWithProviders(<AutomationDashboardPage />);
|
||||
|
||||
// Execute buttons have title="automation.execute" - find by title attribute
|
||||
const executeButtons = screen.getAllByTitle('automation.execute');
|
||||
expect(executeButtons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(executeButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecute).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,9 @@ const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m =>
|
||||
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
|
||||
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
|
||||
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
||||
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
|
||||
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
||||
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
|
||||
|
||||
/** Centered spinner fallback for lazy-loaded routes */
|
||||
function PageLoader() {
|
||||
@@ -83,6 +86,8 @@ const router = createBrowserRouter([
|
||||
{ path: '/mail', element: withSuspense(<MailPage />) },
|
||||
{ path: '/mail/settings', element: withSuspense(<MailSettingsPage />) },
|
||||
{ path: '/ai-assistant', element: withSuspense(<AIAssistantPage />) },
|
||||
{ path: '/automation', element: withSuspense(<AutomationDashboardPage />) },
|
||||
{ path: '/agents', element: withSuspense(<AgentDashboardPage />) },
|
||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||
{
|
||||
path: '/settings',
|
||||
@@ -101,6 +106,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'ai', element: withSuspense(<AISettingsPage />) },
|
||||
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
|
||||
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
|
||||
{ path: 'automation', element: withSuspense(<AutomationSettingsPage />) },
|
||||
{ path: '*', element: <PluginRouteRenderer /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* TypeScript interfaces for Automation & Agents module.
|
||||
* Matches backend schemas from /api/v1/automation and /api/v1/agents.
|
||||
*/
|
||||
|
||||
export interface AutomationCondition {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AutomationAction {
|
||||
type: 'api_call' | 'notification' | 'workflow_start';
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AutomationDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
trigger_type: 'event' | 'schedule' | 'manual';
|
||||
trigger_config?: Record<string, unknown>;
|
||||
conditions?: AutomationCondition[];
|
||||
actions?: AutomationAction[];
|
||||
active: boolean;
|
||||
dry_run: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AutomationRun {
|
||||
id: string;
|
||||
automation_id: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AutomationVersion {
|
||||
id: string;
|
||||
automation_id: string;
|
||||
version: number;
|
||||
created_at: string;
|
||||
data: AutomationDefinition;
|
||||
}
|
||||
|
||||
export interface AgentDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
model: string;
|
||||
system_prompt?: string;
|
||||
tools?: string[];
|
||||
heartbeat_interval: number;
|
||||
mode: 'proactive' | 'reactive';
|
||||
max_executions_per_hour: number;
|
||||
max_duration: number;
|
||||
budget_limit: number;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AgentRun {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentVersion {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
version: number;
|
||||
created_at: string;
|
||||
data: AgentDefinition;
|
||||
}
|
||||
|
||||
export interface AgentTool {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AutomationSettings {
|
||||
default_llm_model: string;
|
||||
heartbeat_default_interval: number;
|
||||
max_concurrent_agents: number;
|
||||
log_level: 'debug' | 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export interface MiniAppDef {
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
render_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RecentRun = (AutomationRun | AgentRun) & {
|
||||
type: 'automation' | 'agent';
|
||||
name?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user