From c606ffa64a2d371a3de427f7c371f99c1ab467dc Mon Sep 17 00:00:00 2001 From: chiguyong Date: Wed, 10 Jun 2026 01:29:01 +0800 Subject: [PATCH] feat(phase5): implement management pages, evolution dashboard, and workflow editor (U13b/U13c/U14) --- src/agentkit/orchestrator/workflow_schema.py | 80 ++ src/agentkit/server/app.py | 6 +- src/agentkit/server/frontend/package.json | 5 +- .../server/frontend/src/api/evolution.ts | 142 ++++ src/agentkit/server/frontend/src/api/kb.ts | 114 +++ .../server/frontend/src/api/skills.ts | 97 +++ .../server/frontend/src/api/terminal.ts | 81 ++ .../server/frontend/src/api/workflow.ts | 145 ++++ .../evolution/ExperienceTimeline.vue | 239 ++++++ .../src/components/evolution/MetricsChart.vue | 302 +++++++ .../evolution/PathOptimizerPanel.vue | 203 +++++ .../src/components/evolution/PitfallPanel.vue | 168 ++++ .../src/components/kb/DocumentUpload.vue | 111 +++ .../frontend/src/components/kb/SearchTest.vue | 109 +++ .../src/components/kb/SourceConfig.vue | 200 +++++ .../src/components/skills/SkillCard.vue | 111 +++ .../src/components/skills/SkillDetail.vue | 128 +++ .../components/terminal/CommandHistory.vue | 141 ++++ .../components/terminal/TerminalEmulator.vue | 151 ++++ .../src/components/workflow/ApprovalNode.vue | 114 +++ .../src/components/workflow/ConditionNode.vue | 118 +++ .../src/components/workflow/FlowCanvas.vue | 183 +++++ .../src/components/workflow/NodePalette.vue | 137 ++++ .../src/components/workflow/PropertyPanel.vue | 205 +++++ .../src/components/workflow/SkillNode.vue | 91 +++ .../server/frontend/src/stores/evolution.ts | 110 +++ .../server/frontend/src/stores/knowledge.ts | 129 +++ .../server/frontend/src/stores/settings.ts | 128 +++ .../server/frontend/src/stores/skills.ts | 118 +++ .../server/frontend/src/stores/terminal.ts | 179 +++++ .../server/frontend/src/stores/workflow.ts | 289 +++++++ .../frontend/src/utils/workflowSerializer.ts | 244 ++++++ .../frontend/src/views/EvolutionView.vue | 113 ++- .../frontend/src/views/KnowledgeBaseView.vue | 45 +- .../frontend/src/views/SettingsView.vue | 178 ++++- .../server/frontend/src/views/SkillsView.vue | 108 ++- .../frontend/src/views/TerminalView.vue | 51 +- .../frontend/src/views/WorkflowView.vue | 326 +++++++- src/agentkit/server/routes/__init__.py | 4 +- .../server/routes/evolution_dashboard.py | 506 ++++++++++++ src/agentkit/server/routes/kb_management.py | 272 +++++++ .../server/routes/skill_management.py | 229 ++++++ src/agentkit/server/routes/workflows.py | 576 +++++++++++++ tests/unit/server/test_evolution_dashboard.py | 518 ++++++++++++ tests/unit/server/test_kb_management.py | 326 ++++++++ tests/unit/server/test_skill_management.py | 243 ++++++ tests/unit/server/test_workflow_routes.py | 754 ++++++++++++++++++ 47 files changed, 8727 insertions(+), 100 deletions(-) create mode 100644 src/agentkit/orchestrator/workflow_schema.py create mode 100644 src/agentkit/server/frontend/src/api/evolution.ts create mode 100644 src/agentkit/server/frontend/src/api/kb.ts create mode 100644 src/agentkit/server/frontend/src/api/skills.ts create mode 100644 src/agentkit/server/frontend/src/api/terminal.ts create mode 100644 src/agentkit/server/frontend/src/api/workflow.ts create mode 100644 src/agentkit/server/frontend/src/components/evolution/ExperienceTimeline.vue create mode 100644 src/agentkit/server/frontend/src/components/evolution/MetricsChart.vue create mode 100644 src/agentkit/server/frontend/src/components/evolution/PathOptimizerPanel.vue create mode 100644 src/agentkit/server/frontend/src/components/evolution/PitfallPanel.vue create mode 100644 src/agentkit/server/frontend/src/components/kb/DocumentUpload.vue create mode 100644 src/agentkit/server/frontend/src/components/kb/SearchTest.vue create mode 100644 src/agentkit/server/frontend/src/components/kb/SourceConfig.vue create mode 100644 src/agentkit/server/frontend/src/components/skills/SkillCard.vue create mode 100644 src/agentkit/server/frontend/src/components/skills/SkillDetail.vue create mode 100644 src/agentkit/server/frontend/src/components/terminal/CommandHistory.vue create mode 100644 src/agentkit/server/frontend/src/components/terminal/TerminalEmulator.vue create mode 100644 src/agentkit/server/frontend/src/components/workflow/ApprovalNode.vue create mode 100644 src/agentkit/server/frontend/src/components/workflow/ConditionNode.vue create mode 100644 src/agentkit/server/frontend/src/components/workflow/FlowCanvas.vue create mode 100644 src/agentkit/server/frontend/src/components/workflow/NodePalette.vue create mode 100644 src/agentkit/server/frontend/src/components/workflow/PropertyPanel.vue create mode 100644 src/agentkit/server/frontend/src/components/workflow/SkillNode.vue create mode 100644 src/agentkit/server/frontend/src/stores/evolution.ts create mode 100644 src/agentkit/server/frontend/src/stores/knowledge.ts create mode 100644 src/agentkit/server/frontend/src/stores/settings.ts create mode 100644 src/agentkit/server/frontend/src/stores/skills.ts create mode 100644 src/agentkit/server/frontend/src/stores/terminal.ts create mode 100644 src/agentkit/server/frontend/src/stores/workflow.ts create mode 100644 src/agentkit/server/frontend/src/utils/workflowSerializer.ts create mode 100644 src/agentkit/server/routes/evolution_dashboard.py create mode 100644 src/agentkit/server/routes/kb_management.py create mode 100644 src/agentkit/server/routes/skill_management.py create mode 100644 src/agentkit/server/routes/workflows.py create mode 100644 tests/unit/server/test_evolution_dashboard.py create mode 100644 tests/unit/server/test_kb_management.py create mode 100644 tests/unit/server/test_skill_management.py create mode 100644 tests/unit/server/test_workflow_routes.py diff --git a/src/agentkit/orchestrator/workflow_schema.py b/src/agentkit/orchestrator/workflow_schema.py new file mode 100644 index 0000000..feb3c48 --- /dev/null +++ b/src/agentkit/orchestrator/workflow_schema.py @@ -0,0 +1,80 @@ +"""Workflow schema - extends Pipeline with workflow-specific fields""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from pydantic import BaseModel, Field + +from agentkit.orchestrator.pipeline_schema import Pipeline, PipelineStage + + +class WorkflowStage(PipelineStage): + """A workflow stage extending PipelineStage with type and config.""" + + type: str = "skill" # "skill" | "condition" | "approval" | "parallel" + config: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowDefinition(BaseModel): + """Workflow definition extending Pipeline with workflow-specific fields.""" + + workflow_id: str = "" + name: str + version: int = 1 + stages: list[WorkflowStage] = Field(default_factory=list) + triggers: list[dict[str, Any]] = Field(default_factory=list) + variables_schema: dict[str, Any] = Field(default_factory=dict) + output_schema: dict[str, Any] = Field(default_factory=dict) + created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + +class WorkflowExecution(BaseModel): + """Runtime state of a workflow execution.""" + + execution_id: str = "" + workflow_id: str = "" + status: str = "pending" # pending|running|paused|completed|failed|cancelled + current_stage: str | None = None + stage_results: dict[str, Any] = Field(default_factory=dict) + started_at: str | None = None + completed_at: str | None = None + error: str | None = None + variables: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowSummary(BaseModel): + """Summary for listing workflows.""" + + workflow_id: str + name: str + version: int + stage_count: int + trigger_count: int + created_at: str + updated_at: str + + +class CreateWorkflowRequest(BaseModel): + """Request body for creating/updating a workflow.""" + + name: str + stages: list[WorkflowStage] = Field(default_factory=list) + triggers: list[dict[str, Any]] = Field(default_factory=list) + variables_schema: dict[str, Any] = Field(default_factory=dict) + output_schema: dict[str, Any] = Field(default_factory=dict) + + +class ExecuteWorkflowRequest(BaseModel): + """Request body for executing a workflow.""" + + variables: dict[str, Any] = Field(default_factory=dict) + + +class ApproveRequest(BaseModel): + """Request body for approving a paused approval node.""" + + approved: bool + comment: str | None = None diff --git a/src/agentkit/server/app.py b/src/agentkit/server/app.py index 7ab9998..9083fb3 100644 --- a/src/agentkit/server/app.py +++ b/src/agentkit/server/app.py @@ -21,7 +21,7 @@ from agentkit.skills.base import Skill, SkillConfig from agentkit.skills.registry import SkillRegistry from agentkit.tools.registry import ToolRegistry from agentkit.server.config import ServerConfig -from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory, portal +from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory, portal, evolution_dashboard, kb_management, skill_management, workflows from agentkit.server.middleware import APIKeyAuthMiddleware, RateLimitMiddleware from agentkit.server.task_store import create_task_store from agentkit.server.runner import BackgroundRunner @@ -427,5 +427,9 @@ def create_app( app.include_router(evolution.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1") app.include_router(portal.router, prefix="/api/v1") + app.include_router(evolution_dashboard.router, prefix="/api/v1") + app.include_router(kb_management.router, prefix="/api/v1") + app.include_router(skill_management.router, prefix="/api/v1") + app.include_router(workflows.router, prefix="/api/v1") return app diff --git a/src/agentkit/server/frontend/package.json b/src/agentkit/server/frontend/package.json index cfc5054..0f0e9b9 100644 --- a/src/agentkit/server/frontend/package.json +++ b/src/agentkit/server/frontend/package.json @@ -13,7 +13,10 @@ "vue-router": "^4.4.0", "pinia": "^2.2.0", "ant-design-vue": "^4.2.0", - "@ant-design/icons-vue": "^7.0.0" + "@ant-design/icons-vue": "^7.0.0", + "@vue-flow/core": "^1.41.0", + "@vue-flow/background": "^1.3.0", + "@vue-flow/controls": "^1.1.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.1.0", diff --git a/src/agentkit/server/frontend/src/api/evolution.ts b/src/agentkit/server/frontend/src/api/evolution.ts new file mode 100644 index 0000000..6893dd7 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/evolution.ts @@ -0,0 +1,142 @@ +/** Evolution Dashboard API client */ + +const API_BASE = '/api/v1' + +export interface Experience { + id: string + task_type: string + goal: string + outcome: string + duration: number + created_at: string + steps_summary?: string + failure_reasons?: string[] + optimization_tips?: string[] +} + +export interface EvolutionMetrics { + total_tasks: number + success_rate: number + avg_duration: number + retry_rate: number + experience_count: number + period_start: string | null + period_end: string +} + +export interface TrendPoint { + date: string + success_rate: number + avg_duration: number + retry_rate: number +} + +export interface PitfallWarning { + step: string + risk_level: 'high' | 'medium' | 'low' + reason: string + historical_failure_rate: number + suggestion: string +} + +export interface PathOptimization { + id: string + task_type: string + previous_path: string[] + current_path: string[] + improvement: number + updated_at: string | null +} + +class EvolutionApiClient { + private baseUrl: string + + constructor(baseUrl: string = API_BASE) { + this.baseUrl = baseUrl + } + + private async request(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${path}` + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + const response = await fetch(url, { ...options, headers }) + + if (!response.ok) { + const error: { status: number; message: string; detail?: string } = { + status: response.status, + message: response.statusText, + } + try { + const body = await response.json() + error.detail = body.detail ?? body.message + error.message = error.detail ?? error.message + } catch { + // response body is not JSON + } + throw error + } + + return response.json() as Promise + } + + /** List recent experiences */ + async getExperiences(params?: { + task_type?: string + outcome?: string + limit?: number + }): Promise<{ experiences: Experience[]; total: number }> { + const query = new URLSearchParams() + if (params?.task_type) query.set('task_type', params.task_type) + if (params?.outcome) query.set('outcome', params.outcome) + if (params?.limit) query.set('limit', String(params.limit)) + const qs = query.toString() + return this.request(`/evolution-dashboard/experiences${qs ? '?' + qs : ''}`) + } + + /** Get evolution metrics */ + async getMetrics(period?: string): Promise<{ + metrics: EvolutionMetrics + trends: TrendPoint[] + }> { + const query = new URLSearchParams() + if (period) query.set('period', period) + const qs = query.toString() + return this.request(`/evolution-dashboard/metrics${qs ? '?' + qs : ''}`) + } + + /** Check pitfall warnings */ + async checkPitfalls(params: { + task_type: string + steps: string[] + }): Promise<{ warnings: PitfallWarning[] }> { + const query = new URLSearchParams() + query.set('task_type', params.task_type) + if (params.steps.length > 0) { + query.set('steps', params.steps.join(',')) + } + return this.request(`/evolution-dashboard/pitfalls?${query.toString()}`) + } + + /** Get path optimization history */ + async getPathOptimizations(params?: { + task_type?: string + limit?: number + }): Promise<{ optimizations: PathOptimization[] }> { + const query = new URLSearchParams() + if (params?.task_type) query.set('task_type', params.task_type) + if (params?.limit) query.set('limit', String(params.limit)) + const qs = query.toString() + return this.request(`/evolution-dashboard/path-optimizations${qs ? '?' + qs : ''}`) + } + + /** Create a WebSocket connection for real-time updates */ + createWebSocket(): WebSocket { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + return new WebSocket(`${protocol}//${host}${this.baseUrl}/evolution-dashboard/ws`) + } +} + +export const evolutionApi = new EvolutionApiClient() diff --git a/src/agentkit/server/frontend/src/api/kb.ts b/src/agentkit/server/frontend/src/api/kb.ts new file mode 100644 index 0000000..d787bab --- /dev/null +++ b/src/agentkit/server/frontend/src/api/kb.ts @@ -0,0 +1,114 @@ +/** Knowledge Base API client */ + +const API_BASE = '/api/v1/kb-management' + +export interface IKbSource { + id: string + name: string + type: 'local' | 'feishu' | 'confluence' | 'http' + status: string + document_count: number + last_synced: string | null +} + +export interface IAddSourceRequest { + name: string + type: 'local' | 'feishu' | 'confluence' | 'http' + config: Record +} + +export interface IUploadedDocument { + document_id: string + filename: string + chunks: number + status: string +} + +export interface ISearchResult { + content: string + source: string + score: number + metadata: Record +} + +export interface ISourceHealth { + source_id: string + status: string + message: string +} + +async function request(path: string, options: RequestInit = {}): Promise { + const url = `${API_BASE}${path}` + const headers: HeadersInit = { + ...options.headers, + } + // Don't set Content-Type for FormData (upload) + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json' + } + + const response = await fetch(url, { ...options, headers }) + + if (!response.ok) { + const error = { status: response.status, message: response.statusText } + try { + const body = await response.json() + error.message = body.detail ?? body.message ?? error.message + } catch { + // use status text + } + throw error + } + + return response.json() as Promise +} + +/** List all knowledge sources */ +export async function listSources(): Promise<{ sources: IKbSource[] }> { + return request<{ sources: IKbSource[] }>('/sources') +} + +/** Add a knowledge source */ +export async function addSource(data: IAddSourceRequest): Promise { + return request('/sources', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +/** Remove a knowledge source */ +export async function removeSource(sourceId: string): Promise<{ status: string }> { + return request<{ status: string }>(`/sources/${sourceId}`, { + method: 'DELETE', + }) +} + +/** Upload a document */ +export async function uploadDocument(file: File, sourceId?: string): Promise { + const formData = new FormData() + formData.append('file', file) + if (sourceId) { + formData.append('source_id', sourceId) + } + return request('/documents/upload', { + method: 'POST', + body: formData, + }) +} + +/** Search/retrieve from knowledge base */ +export async function searchKnowledge( + query: string, + sources?: string[], + topK: number = 5 +): Promise<{ results: ISearchResult[]; message?: string }> { + return request<{ results: ISearchResult[]; message?: string }>('/search', { + method: 'POST', + body: JSON.stringify({ query, sources, top_k: topK }), + }) +} + +/** Check source health */ +export async function checkSourceHealth(sourceId: string): Promise { + return request(`/sources/${sourceId}/health`) +} diff --git a/src/agentkit/server/frontend/src/api/skills.ts b/src/agentkit/server/frontend/src/api/skills.ts new file mode 100644 index 0000000..d3823c2 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/skills.ts @@ -0,0 +1,97 @@ +/** Skills API client */ + +const API_BASE = '/api/v1/skill-management' + +export interface ISkillInfo { + name: string + version: string + description: string + capabilities: string[] + dependencies: string[] + status: string +} + +export interface ISkillDetail { + name: string + version: string + description: string + capabilities: string[] + dependencies: string[] + config: Record + health_status: string +} + +export interface ICapabilityInfo { + name: string + display_name: string + skill_count: number +} + +export interface ISkillListResponse { + skills: ISkillInfo[] + total: number + page: number + size: number +} + +async function request(path: string, options: RequestInit = {}): Promise { + const url = `${API_BASE}${path}` + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + const response = await fetch(url, { ...options, headers }) + + if (!response.ok) { + const error = { status: response.status, message: response.statusText } + try { + const body = await response.json() + error.message = body.detail ?? body.message ?? error.message + } catch { + // use status text + } + throw error + } + + return response.json() as Promise +} + +/** List all skills with optional filtering */ +export async function listSkills( + capability?: string, + page: number = 1, + size: number = 20 +): Promise { + const params = new URLSearchParams() + if (capability) params.set('capability', capability) + params.set('page', String(page)) + params.set('size', String(size)) + return request(`/skills?${params.toString()}`) +} + +/** Get skill detail */ +export async function getSkillDetail(skillName: string): Promise { + return request(`/skills/${encodeURIComponent(skillName)}`) +} + +/** Check skill health */ +export async function checkSkillHealth( + skillName: string +): Promise<{ skill_name: string; status: string; message: string }> { + return request(`/skills/${encodeURIComponent(skillName)}/health`) +} + +/** List all capability tags */ +export async function listCapabilities(): Promise<{ capabilities: ICapabilityInfo[] }> { + return request<{ capabilities: ICapabilityInfo[] }>('/capabilities') +} + +/** Reload a skill */ +export async function reloadSkill( + skillName: string +): Promise<{ skill_name: string; status: string; message: string }> { + return request(`/skills/${encodeURIComponent(skillName)}/reload`, { + method: 'POST', + }) +} diff --git a/src/agentkit/server/frontend/src/api/terminal.ts b/src/agentkit/server/frontend/src/api/terminal.ts new file mode 100644 index 0000000..759d425 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/terminal.ts @@ -0,0 +1,81 @@ +/** Terminal API client */ + +const API_BASE = '/api/v1' + +export interface ICommandRecord { + command: string + exit_code: number + output: string + cwd: string + timestamp: number + duration_ms: number +} + +export interface ITerminalSession { + session_id: string + cwd: string + created_at: number +} + +export interface IExecuteResponse { + session_id: string + command: string + exit_code: number + output: string + cwd: string + duration_ms: number +} + +async function request(path: string, options: RequestInit = {}): Promise { + const url = `${API_BASE}${path}` + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + const response = await fetch(url, { ...options, headers }) + + if (!response.ok) { + const error = { status: response.status, message: response.statusText } + try { + const body = await response.json() + error.message = body.detail ?? body.message ?? error.message + } catch { + // use status text + } + throw error + } + + return response.json() as Promise +} + +/** Create a terminal WebSocket URL */ +export function createTerminalWsUrl(sessionId?: string): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const base = `${protocol}//${host}${API_BASE}/terminal/ws` + return sessionId ? `${base}?session_id=${sessionId}` : base +} + +/** Execute a command via REST API */ +export async function executeCommand( + command: string, + sessionId?: string +): Promise { + return request('/terminal/execute', { + method: 'POST', + body: JSON.stringify({ command, session_id: sessionId }), + }) +} + +/** List terminal sessions */ +export async function listSessions(): Promise<{ sessions: ITerminalSession[] }> { + return request<{ sessions: ITerminalSession[] }>('/terminal/sessions') +} + +/** Get command history for a session */ +export async function getCommandHistory( + sessionId: string +): Promise<{ history: ICommandRecord[] }> { + return request<{ history: ICommandRecord[] }>(`/terminal/sessions/${sessionId}/history`) +} diff --git a/src/agentkit/server/frontend/src/api/workflow.ts b/src/agentkit/server/frontend/src/api/workflow.ts new file mode 100644 index 0000000..976d229 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/workflow.ts @@ -0,0 +1,145 @@ +/** + * Workflow API client + */ + +import type { + WorkflowDefinition, + WorkflowSummary, + WorkflowExecution, +} from '@/utils/workflowSerializer' + +const API_BASE = '/api/v1' + +class WorkflowApiClient { + private baseUrl: string + + constructor(baseUrl: string = API_BASE) { + this.baseUrl = baseUrl + } + + private async request( + path: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${path}` + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + const response = await fetch(url, { + ...options, + headers, + }) + + if (!response.ok) { + const error: { status: number; message: string; detail?: string } = { + status: response.status, + message: response.statusText, + } + try { + const body = await response.json() + error.detail = body.detail ?? body.message + error.message = error.detail ?? error.message + } catch { + // response body is not JSON + } + throw error + } + + return response.json() as Promise + } + + /** List all workflows */ + async listWorkflows(limit: number = 50): Promise<{ workflows: WorkflowSummary[]; total: number }> { + return this.request(`/workflows?limit=${limit}`) + } + + /** Create a new workflow */ + async createWorkflow(data: { + name: string + stages: unknown[] + triggers?: unknown[] + variables_schema?: Record + output_schema?: Record + }): Promise { + return this.request('/workflows', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Get a workflow by ID */ + async getWorkflow(workflowId: string): Promise { + return this.request(`/workflows/${workflowId}`) + } + + /** Update a workflow */ + async updateWorkflow( + workflowId: string, + data: { + name: string + stages: unknown[] + triggers?: unknown[] + variables_schema?: Record + output_schema?: Record + } + ): Promise { + return this.request(`/workflows/${workflowId}`, { + method: 'PUT', + body: JSON.stringify(data), + }) + } + + /** Delete a workflow */ + async deleteWorkflow(workflowId: string): Promise { + await this.request(`/workflows/${workflowId}`, { + method: 'DELETE', + }) + } + + /** Execute a workflow */ + async executeWorkflow( + workflowId: string, + variables?: Record + ): Promise<{ execution_id: string; workflow_id: string; status: string }> { + return this.request(`/workflows/${workflowId}/execute`, { + method: 'POST', + body: JSON.stringify({ variables: variables || {} }), + }) + } + + /** Get execution status */ + async getExecution(executionId: string): Promise { + return this.request(`/workflows/executions/${executionId}`) + } + + /** Approve a paused execution */ + async approveExecution( + executionId: string, + approved: boolean, + comment?: string + ): Promise { + return this.request(`/workflows/executions/${executionId}/approve`, { + method: 'POST', + body: JSON.stringify({ approved, comment }), + }) + } + + /** Cancel an execution */ + async cancelExecution(executionId: string): Promise { + return this.request(`/workflows/executions/${executionId}/cancel`, { + method: 'POST', + }) + } + + /** Create a WebSocket connection for real-time workflow progress */ + createWorkflowWebSocket(): WebSocket { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + return new WebSocket(`${protocol}//${host}${this.baseUrl}/workflows/ws`) + } +} + +export const workflowApi = new WorkflowApiClient() +export { WorkflowApiClient } diff --git a/src/agentkit/server/frontend/src/components/evolution/ExperienceTimeline.vue b/src/agentkit/server/frontend/src/components/evolution/ExperienceTimeline.vue new file mode 100644 index 0000000..6139cec --- /dev/null +++ b/src/agentkit/server/frontend/src/components/evolution/ExperienceTimeline.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/evolution/MetricsChart.vue b/src/agentkit/server/frontend/src/components/evolution/MetricsChart.vue new file mode 100644 index 0000000..02c9f86 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/evolution/MetricsChart.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/evolution/PathOptimizerPanel.vue b/src/agentkit/server/frontend/src/components/evolution/PathOptimizerPanel.vue new file mode 100644 index 0000000..47e9c66 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/evolution/PathOptimizerPanel.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/evolution/PitfallPanel.vue b/src/agentkit/server/frontend/src/components/evolution/PitfallPanel.vue new file mode 100644 index 0000000..6771edd --- /dev/null +++ b/src/agentkit/server/frontend/src/components/evolution/PitfallPanel.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/kb/DocumentUpload.vue b/src/agentkit/server/frontend/src/components/kb/DocumentUpload.vue new file mode 100644 index 0000000..3b8c4dc --- /dev/null +++ b/src/agentkit/server/frontend/src/components/kb/DocumentUpload.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/kb/SearchTest.vue b/src/agentkit/server/frontend/src/components/kb/SearchTest.vue new file mode 100644 index 0000000..da12c52 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/kb/SearchTest.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/kb/SourceConfig.vue b/src/agentkit/server/frontend/src/components/kb/SourceConfig.vue new file mode 100644 index 0000000..7d6f259 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/kb/SourceConfig.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/skills/SkillCard.vue b/src/agentkit/server/frontend/src/components/skills/SkillCard.vue new file mode 100644 index 0000000..0eb4bf7 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/skills/SkillCard.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/skills/SkillDetail.vue b/src/agentkit/server/frontend/src/components/skills/SkillDetail.vue new file mode 100644 index 0000000..49a3f85 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/skills/SkillDetail.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/terminal/CommandHistory.vue b/src/agentkit/server/frontend/src/components/terminal/CommandHistory.vue new file mode 100644 index 0000000..4b83816 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/terminal/CommandHistory.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/terminal/TerminalEmulator.vue b/src/agentkit/server/frontend/src/components/terminal/TerminalEmulator.vue new file mode 100644 index 0000000..29f862a --- /dev/null +++ b/src/agentkit/server/frontend/src/components/terminal/TerminalEmulator.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/workflow/ApprovalNode.vue b/src/agentkit/server/frontend/src/components/workflow/ApprovalNode.vue new file mode 100644 index 0000000..df7f6e3 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/workflow/ApprovalNode.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/workflow/ConditionNode.vue b/src/agentkit/server/frontend/src/components/workflow/ConditionNode.vue new file mode 100644 index 0000000..241632b --- /dev/null +++ b/src/agentkit/server/frontend/src/components/workflow/ConditionNode.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/workflow/FlowCanvas.vue b/src/agentkit/server/frontend/src/components/workflow/FlowCanvas.vue new file mode 100644 index 0000000..7260cf1 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/workflow/FlowCanvas.vue @@ -0,0 +1,183 @@ + + + + + + + diff --git a/src/agentkit/server/frontend/src/components/workflow/NodePalette.vue b/src/agentkit/server/frontend/src/components/workflow/NodePalette.vue new file mode 100644 index 0000000..7b954ce --- /dev/null +++ b/src/agentkit/server/frontend/src/components/workflow/NodePalette.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/workflow/PropertyPanel.vue b/src/agentkit/server/frontend/src/components/workflow/PropertyPanel.vue new file mode 100644 index 0000000..781a188 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/workflow/PropertyPanel.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/workflow/SkillNode.vue b/src/agentkit/server/frontend/src/components/workflow/SkillNode.vue new file mode 100644 index 0000000..edc0c86 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/workflow/SkillNode.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/stores/evolution.ts b/src/agentkit/server/frontend/src/stores/evolution.ts new file mode 100644 index 0000000..156758d --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/evolution.ts @@ -0,0 +1,110 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { evolutionApi } from '@/api/evolution' +import type { + Experience, + EvolutionMetrics, + TrendPoint, + PitfallWarning, + PathOptimization, +} from '@/api/evolution' + +export const useEvolutionStore = defineStore('evolution', () => { + // --- State --- + const experiences = ref([]) + const metrics = ref(null) + const trends = ref([]) + const pitfalls = ref([]) + const optimizations = ref([]) + const period = ref('7d') + const isLoading = ref(false) + const error = ref(null) + + // --- Actions --- + + /** Load recent experiences */ + async function loadExperiences(taskType?: string): Promise { + isLoading.value = true + error.value = null + try { + const data = await evolutionApi.getExperiences({ + task_type: taskType, + limit: 50, + }) + experiences.value = data.experiences + } catch (err) { + error.value = err instanceof Error ? err.message : '加载经验记录失败' + console.error('Failed to load experiences:', err) + } finally { + isLoading.value = false + } + } + + /** Load evolution metrics */ + async function loadMetrics(periodParam?: string): Promise { + isLoading.value = true + error.value = null + const p = periodParam || period.value + try { + const data = await evolutionApi.getMetrics(p) + metrics.value = data.metrics + trends.value = data.trends + period.value = p + } catch (err) { + error.value = err instanceof Error ? err.message : '加载指标数据失败' + console.error('Failed to load metrics:', err) + } finally { + isLoading.value = false + } + } + + /** Check pitfall warnings */ + async function checkPitfalls(taskType: string, steps: string[]): Promise { + isLoading.value = true + error.value = null + try { + const data = await evolutionApi.checkPitfalls({ task_type: taskType, steps }) + pitfalls.value = data.warnings + } catch (err) { + error.value = err instanceof Error ? err.message : '检查避坑预警失败' + console.error('Failed to check pitfalls:', err) + } finally { + isLoading.value = false + } + } + + /** Load path optimization history */ + async function loadOptimizations(taskType?: string): Promise { + isLoading.value = true + error.value = null + try { + const data = await evolutionApi.getPathOptimizations({ + task_type: taskType, + limit: 20, + }) + optimizations.value = data.optimizations + } catch (err) { + error.value = err instanceof Error ? err.message : '加载路径优化记录失败' + console.error('Failed to load optimizations:', err) + } finally { + isLoading.value = false + } + } + + return { + // State + experiences, + metrics, + trends, + pitfalls, + optimizations, + period, + isLoading, + error, + // Actions + loadExperiences, + loadMetrics, + checkPitfalls, + loadOptimizations, + } +}) diff --git a/src/agentkit/server/frontend/src/stores/knowledge.ts b/src/agentkit/server/frontend/src/stores/knowledge.ts new file mode 100644 index 0000000..e7871b7 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/knowledge.ts @@ -0,0 +1,129 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as kbApi from '@/api/kb' +import type { IKbSource, ISearchResult } from '@/api/kb' + +export const useKnowledgeStore = defineStore('knowledge', () => { + // --- State --- + const sources = ref([]) + const searchResults = ref([]) + const isLoading = ref(false) + const isUploading = ref(false) + const isSearching = ref(false) + const error = ref(null) + + // --- Getters --- + const sourceCount = computed(() => sources.value.length) + const localSources = computed(() => sources.value.filter((s) => s.type === 'local')) + const externalSources = computed(() => sources.value.filter((s) => s.type !== 'local')) + + // --- Actions --- + async function fetchSources(): Promise { + isLoading.value = true + error.value = null + try { + const data = await kbApi.listSources() + sources.value = data.sources + } catch (err) { + error.value = err instanceof Error ? err.message : '获取信息源列表失败' + console.error('Failed to fetch sources:', err) + } finally { + isLoading.value = false + } + } + + async function addSource( + name: string, + type: 'local' | 'feishu' | 'confluence' | 'http', + config: Record = {} + ): Promise { + isLoading.value = true + error.value = null + try { + await kbApi.addSource({ name, type, config }) + await fetchSources() + } catch (err) { + error.value = err instanceof Error ? err.message : '添加信息源失败' + console.error('Failed to add source:', err) + throw err + } finally { + isLoading.value = false + } + } + + async function removeSource(sourceId: string): Promise { + isLoading.value = true + error.value = null + try { + await kbApi.removeSource(sourceId) + await fetchSources() + } catch (err) { + error.value = err instanceof Error ? err.message : '删除信息源失败' + console.error('Failed to remove source:', err) + throw err + } finally { + isLoading.value = false + } + } + + async function uploadDocument(file: File, sourceId?: string): Promise { + isUploading.value = true + error.value = null + try { + await kbApi.uploadDocument(file, sourceId) + await fetchSources() + } catch (err) { + error.value = err instanceof Error ? err.message : '上传文档失败' + console.error('Failed to upload document:', err) + throw err + } finally { + isUploading.value = false + } + } + + async function search(query: string, sourceIds?: string[], topK: number = 5): Promise { + isSearching.value = true + error.value = null + searchResults.value = [] + try { + const data = await kbApi.searchKnowledge(query, sourceIds, topK) + searchResults.value = data.results + } catch (err) { + error.value = err instanceof Error ? err.message : '检索失败' + console.error('Failed to search:', err) + } finally { + isSearching.value = false + } + } + + async function checkHealth(sourceId: string): Promise { + try { + const data = await kbApi.checkSourceHealth(sourceId) + return data.status + } catch (err) { + console.error('Failed to check health:', err) + return 'error' + } + } + + return { + // State + sources, + searchResults, + isLoading, + isUploading, + isSearching, + error, + // Getters + sourceCount, + localSources, + externalSources, + // Actions + fetchSources, + addSource, + removeSource, + uploadDocument, + search, + checkHealth, + } +}) diff --git a/src/agentkit/server/frontend/src/stores/settings.ts b/src/agentkit/server/frontend/src/stores/settings.ts new file mode 100644 index 0000000..718d109 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/settings.ts @@ -0,0 +1,128 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface ILLMSettings { + provider: string + model: string + api_key: string + base_url: string +} + +export interface ISkillSettings { + default_skill: string + auto_routing: boolean +} + +export interface IKBSettings { + default_sources: string[] + top_k: number + retrieval_mode: string +} + +export interface ISystemSettings { + rate_limit: number + cors_origins: string[] + logging_level: string +} + +const API_BASE = '/api/v1/settings' + +export const useSettingsStore = defineStore('settings', () => { + // --- State --- + const llm = ref({ + provider: '', + model: '', + api_key: '', + base_url: '', + }) + const skillSettings = ref({ + default_skill: '', + auto_routing: true, + }) + const kbSettings = ref({ + default_sources: [], + top_k: 5, + retrieval_mode: 'standard', + }) + const system = ref({ + rate_limit: 60, + cors_origins: ['*'], + logging_level: 'INFO', + }) + const isLoading = ref(false) + const isSaving = ref(false) + const error = ref(null) + const saveSuccess = ref(false) + + // --- Getters --- + const hasLLMConfig = computed(() => !!llm.value.provider && !!llm.value.model) + + // --- Actions --- + async function fetchSettings(): Promise { + isLoading.value = true + error.value = null + try { + const res = await fetch(`${API_BASE}`) + if (res.ok) { + const data = await res.json() + if (data.llm) Object.assign(llm.value, data.llm) + if (data.skills) Object.assign(skillSettings.value, data.skills) + if (data.knowledge) Object.assign(kbSettings.value, data.knowledge) + if (data.system) Object.assign(system.value, data.system) + } + } catch (err) { + // Settings endpoint may not exist yet, use defaults + console.warn('Failed to fetch settings, using defaults:', err) + } finally { + isLoading.value = false + } + } + + async function saveSettings(): Promise { + isSaving.value = true + error.value = null + saveSuccess.value = false + try { + const res = await fetch(`${API_BASE}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + llm: llm.value, + skills: skillSettings.value, + knowledge: kbSettings.value, + system: system.value, + }), + }) + if (!res.ok) { + throw new Error(`保存失败: ${res.statusText}`) + } + saveSuccess.value = true + setTimeout(() => { + saveSuccess.value = false + }, 3000) + } catch (err) { + error.value = err instanceof Error ? err.message : '保存设置失败' + console.error('Failed to save settings:', err) + throw err + } finally { + isSaving.value = false + } + } + + return { + // State + llm, + skillSettings, + kbSettings, + system, + isLoading, + isSaving, + error, + saveSuccess, + // Getters + hasLLMConfig, + // Actions + fetchSettings, + saveSettings, + } +}) diff --git a/src/agentkit/server/frontend/src/stores/skills.ts b/src/agentkit/server/frontend/src/stores/skills.ts new file mode 100644 index 0000000..3839655 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/skills.ts @@ -0,0 +1,118 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as skillsApi from '@/api/skills' +import type { ISkillInfo, ISkillDetail, ICapabilityInfo } from '@/api/skills' + +export const useSkillsStore = defineStore('skills', () => { + // --- State --- + const skills = ref([]) + const capabilities = ref([]) + const selectedSkill = ref(null) + const isLoading = ref(false) + const error = ref(null) + const totalSkills = ref(0) + const currentPage = ref(1) + const pageSize = ref(20) + const selectedCapability = ref(null) + + // --- Getters --- + const skillNames = computed(() => skills.value.map((s) => s.name)) + + // --- Actions --- + async function fetchSkills(capability?: string, page?: number): Promise { + isLoading.value = true + error.value = null + try { + const cap = capability ?? selectedCapability.value ?? undefined + const p = page ?? currentPage.value + const data = await skillsApi.listSkills(cap, p, pageSize.value) + skills.value = data.skills + totalSkills.value = data.total + currentPage.value = data.page + } catch (err) { + error.value = err instanceof Error ? err.message : '获取技能列表失败' + console.error('Failed to fetch skills:', err) + } finally { + isLoading.value = false + } + } + + async function fetchCapabilities(): Promise { + try { + const data = await skillsApi.listCapabilities() + capabilities.value = data.capabilities + } catch (err) { + console.error('Failed to fetch capabilities:', err) + } + } + + async function fetchSkillDetail(skillName: string): Promise { + isLoading.value = true + error.value = null + try { + const data = await skillsApi.getSkillDetail(skillName) + selectedSkill.value = data + } catch (err) { + error.value = err instanceof Error ? err.message : '获取技能详情失败' + console.error('Failed to fetch skill detail:', err) + } finally { + isLoading.value = false + } + } + + async function checkHealth(skillName: string): Promise { + try { + const data = await skillsApi.checkSkillHealth(skillName) + return data.status + } catch (err) { + console.error('Failed to check skill health:', err) + return 'error' + } + } + + async function reloadSkill(skillName: string): Promise { + try { + await skillsApi.reloadSkill(skillName) + await fetchSkills() + if (selectedSkill.value?.name === skillName) { + await fetchSkillDetail(skillName) + } + } catch (err) { + error.value = err instanceof Error ? err.message : '重新加载技能失败' + console.error('Failed to reload skill:', err) + throw err + } + } + + function setCapabilityFilter(capability: string | null): void { + selectedCapability.value = capability + currentPage.value = 1 + } + + function clearSelectedSkill(): void { + selectedSkill.value = null + } + + return { + // State + skills, + capabilities, + selectedSkill, + isLoading, + error, + totalSkills, + currentPage, + pageSize, + selectedCapability, + // Getters + skillNames, + // Actions + fetchSkills, + fetchCapabilities, + fetchSkillDetail, + checkHealth, + reloadSkill, + setCapabilityFilter, + clearSelectedSkill, + } +}) diff --git a/src/agentkit/server/frontend/src/stores/terminal.ts b/src/agentkit/server/frontend/src/stores/terminal.ts new file mode 100644 index 0000000..15782db --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/terminal.ts @@ -0,0 +1,179 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { ICommandRecord } from '@/api/terminal' + +export const useTerminalStore = defineStore('terminal', () => { + // --- State --- + const sessionId = ref(null) + const cwd = ref('~') + const output = ref([]) + const history = ref([]) + const isExecuting = ref(false) + const isWsConnected = ref(false) + const ws = ref(null) + const error = ref(null) + + // --- Getters --- + const recentCommands = computed(() => { + return history.value.slice(-50) + }) + + // --- Actions --- + function setSessionId(id: string): void { + sessionId.value = id + } + + function setCwd(dir: string): void { + cwd.value = dir + } + + function appendOutput(text: string): void { + output.value.push(text) + // Keep output buffer manageable + if (output.value.length > 5000) { + output.value = output.value.slice(-3000) + } + } + + function clearOutput(): void { + output.value = [] + } + + function addHistory(record: ICommandRecord): void { + history.value.push(record) + if (history.value.length > 1000) { + history.value = history.value.slice(-500) + } + } + + function clearHistory(): void { + history.value = [] + } + + function connectWebSocket(): void { + if (ws.value && ws.value.readyState === WebSocket.OPEN) { + return + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + let url = `${protocol}//${host}/api/v1/terminal/ws` + if (sessionId.value) { + url += `?session_id=${sessionId.value}` + } + + const socket = new WebSocket(url) + + socket.onopen = () => { + isWsConnected.value = true + appendOutput('\x1b[32m已连接到终端服务\x1b[0m') + } + + socket.onmessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data as string) + handleWsMessage(data) + } catch { + // Plain text output + appendOutput(event.data as string) + } + } + + socket.onclose = () => { + isWsConnected.value = false + appendOutput('\x1b[33m终端连接已断开\x1b[0m') + // Auto reconnect after 3 seconds + setTimeout(() => { + if (!ws.value || ws.value.readyState === WebSocket.CLOSED) { + connectWebSocket() + } + }, 3000) + } + + socket.onerror = () => { + isWsConnected.value = false + appendOutput('\x1b[31m连接错误\x1b[0m') + } + + ws.value = socket + } + + function disconnectWebSocket(): void { + if (ws.value) { + ws.value.close() + ws.value = null + isWsConnected.value = false + } + } + + function sendCommand(command: string): void { + if (ws.value && ws.value.readyState === WebSocket.OPEN) { + ws.value.send(JSON.stringify({ type: 'command', command })) + appendOutput(`\x1b[36m${cwd.value}$\x1b[0m ${command}`) + } + } + + function handleWsMessage(data: Record): void { + const msgType = data.type as string + + switch (msgType) { + case 'connected': + if (data.session_id) { + sessionId.value = data.session_id as string + } + break + case 'output': + if (data.output) { + appendOutput(data.output as string) + } + break + case 'result': + if (data.output) { + appendOutput(data.output as string) + } + if (data.cwd) { + cwd.value = data.cwd as string + } + if (data.exit_code !== undefined) { + addHistory({ + command: (data.command as string) || '', + exit_code: data.exit_code as number, + output: (data.output as string) || '', + cwd: cwd.value, + timestamp: Date.now() / 1000, + duration_ms: (data.duration_ms as number) || 0, + }) + } + isExecuting.value = false + break + case 'error': + appendOutput(`\x1b[31m错误: ${data.message}\x1b[0m`) + isExecuting.value = false + break + } + } + + return { + // State + sessionId, + cwd, + output, + history, + isExecuting, + isWsConnected, + ws, + error, + // Getters + recentCommands, + // Actions + setSessionId, + setCwd, + appendOutput, + clearOutput, + addHistory, + clearHistory, + connectWebSocket, + disconnectWebSocket, + sendCommand, + } +}) diff --git a/src/agentkit/server/frontend/src/stores/workflow.ts b/src/agentkit/server/frontend/src/stores/workflow.ts new file mode 100644 index 0000000..9644280 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/workflow.ts @@ -0,0 +1,289 @@ +/** + * Workflow Pinia store + */ + +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { workflowApi } from '@/api/workflow' +import type { + WorkflowDefinition, + WorkflowSummary, + WorkflowExecution, + WorkflowStage, +} from '@/utils/workflowSerializer' +import type { Node, Edge } from '@vue-flow/core' +import { flowToWorkflow, workflowToFlow, getDefaultNodeData, type WorkflowNodeData } from '@/utils/workflowSerializer' + +export const useWorkflowStore = defineStore('workflow', () => { + // --- State --- + const workflows = ref([]) + const currentWorkflow = ref(null) + const currentExecution = ref(null) + const isExecuting = ref(false) + const isLoading = ref(false) + const error = ref(null) + + // Vue Flow state + const flowNodes = ref[]>([]) + const flowEdges = ref([]) + const selectedNodeId = ref(null) + + // --- Getters --- + const selectedNode = computed | null>(() => { + if (!selectedNodeId.value) return null + return flowNodes.value.find((n) => n.id === selectedNodeId.value) || null + }) + + const selectedNodeData = computed(() => { + return selectedNode.value?.data || null + }) + + // --- Actions --- + + /** Load all workflows from server */ + async function loadWorkflows(): Promise { + isLoading.value = true + error.value = null + try { + const response = await workflowApi.listWorkflows() + workflows.value = response.workflows + } catch (err) { + error.value = err instanceof Error ? err.message : '加载工作流列表失败' + console.error('Failed to load workflows:', err) + } finally { + isLoading.value = false + } + } + + /** Create a new workflow */ + async function createWorkflow(name: string): Promise { + isLoading.value = true + error.value = null + try { + const workflow = await workflowApi.createWorkflow({ + name, + stages: [], + }) + currentWorkflow.value = workflow + flowNodes.value = [] + flowEdges.value = [] + selectedNodeId.value = null + await loadWorkflows() + return workflow + } catch (err) { + error.value = err instanceof Error ? err.message : '创建工作流失败' + console.error('Failed to create workflow:', err) + return null + } finally { + isLoading.value = false + } + } + + /** Load a workflow for editing */ + async function loadWorkflow(workflowId: string): Promise { + isLoading.value = true + error.value = null + try { + const workflow = await workflowApi.getWorkflow(workflowId) + currentWorkflow.value = workflow + const { nodes, edges } = workflowToFlow(workflow) + flowNodes.value = nodes + flowEdges.value = edges + selectedNodeId.value = null + } catch (err) { + error.value = err instanceof Error ? err.message : '加载工作流失败' + console.error('Failed to load workflow:', err) + } finally { + isLoading.value = false + } + } + + /** Save current workflow to server */ + async function saveWorkflow(): Promise { + if (!currentWorkflow.value) return false + isLoading.value = true + error.value = null + try { + const definition = flowToWorkflow( + flowNodes.value, + flowEdges.value, + currentWorkflow.value.name, + currentWorkflow.value.workflow_id, + ) + const saved = await workflowApi.updateWorkflow( + currentWorkflow.value.workflow_id, + { + name: definition.name, + stages: definition.stages, + triggers: definition.triggers, + variables_schema: definition.variables_schema, + output_schema: definition.output_schema, + } + ) + currentWorkflow.value = saved + await loadWorkflows() + return true + } catch (err) { + error.value = err instanceof Error ? err.message : '保存工作流失败' + console.error('Failed to save workflow:', err) + return false + } finally { + isLoading.value = false + } + } + + /** Delete a workflow */ + async function deleteWorkflow(workflowId: string): Promise { + isLoading.value = true + error.value = null + try { + await workflowApi.deleteWorkflow(workflowId) + if (currentWorkflow.value?.workflow_id === workflowId) { + currentWorkflow.value = null + flowNodes.value = [] + flowEdges.value = [] + selectedNodeId.value = null + } + await loadWorkflows() + return true + } catch (err) { + error.value = err instanceof Error ? err.message : '删除工作流失败' + console.error('Failed to delete workflow:', err) + return false + } finally { + isLoading.value = false + } + } + + /** Execute current workflow */ + async function executeWorkflow(variables?: Record): Promise { + if (!currentWorkflow.value) return + isExecuting.value = true + error.value = null + try { + const result = await workflowApi.executeWorkflow( + currentWorkflow.value.workflow_id, + variables + ) + // Poll for execution status + const pollExecution = async () => { + const execution = await workflowApi.getExecution(result.execution_id) + currentExecution.value = execution + if (execution.status === 'running' || execution.status === 'paused' || execution.status === 'pending') { + setTimeout(pollExecution, 1000) + } else { + isExecuting.value = false + } + } + pollExecution() + } catch (err) { + error.value = err instanceof Error ? err.message : '执行工作流失败' + console.error('Failed to execute workflow:', err) + isExecuting.value = false + } + } + + /** Approve a paused execution */ + async function approveExecution( + executionId: string, + approved: boolean, + comment?: string + ): Promise { + try { + const execution = await workflowApi.approveExecution(executionId, approved, comment) + currentExecution.value = execution + } catch (err) { + error.value = err instanceof Error ? err.message : '审批操作失败' + console.error('Failed to approve execution:', err) + } + } + + /** Cancel an execution */ + async function cancelExecution(executionId: string): Promise { + try { + const execution = await workflowApi.cancelExecution(executionId) + currentExecution.value = execution + isExecuting.value = false + } catch (err) { + error.value = err instanceof Error ? err.message : '取消执行失败' + console.error('Failed to cancel execution:', err) + } + } + + /** Add a node to the flow canvas */ + function addNode(nodeType: string, position: { x: number; y: number }): void { + const id = `node-${Date.now()}-${Math.random().toString(36).substring(2, 7)}` + const data = getDefaultNodeData(nodeType) + const node: Node = { + id, + type: nodeType === 'condition' ? 'condition' : nodeType === 'approval' ? 'approval' : 'skill', + position, + data, + } + flowNodes.value = [...flowNodes.value, node] + } + + /** Update node data */ + function updateNodeData(nodeId: string, data: Partial): void { + const index = flowNodes.value.findIndex((n) => n.id === nodeId) + if (index !== -1) { + flowNodes.value[index] = { + ...flowNodes.value[index], + data: { ...flowNodes.value[index].data, ...data }, + } + } + } + + /** Remove a node */ + function removeNode(nodeId: string): void { + flowNodes.value = flowNodes.value.filter((n) => n.id !== nodeId) + flowEdges.value = flowEdges.value.filter( + (e) => e.source !== nodeId && e.target !== nodeId + ) + if (selectedNodeId.value === nodeId) { + selectedNodeId.value = null + } + } + + /** Clear the canvas */ + function clearCanvas(): void { + flowNodes.value = [] + flowEdges.value = [] + selectedNodeId.value = null + } + + /** Select a node */ + function selectNode(nodeId: string | null): void { + selectedNodeId.value = nodeId + } + + return { + // State + workflows, + currentWorkflow, + currentExecution, + isExecuting, + isLoading, + error, + flowNodes, + flowEdges, + selectedNodeId, + // Getters + selectedNode, + selectedNodeData, + // Actions + loadWorkflows, + createWorkflow, + loadWorkflow, + saveWorkflow, + deleteWorkflow, + executeWorkflow, + approveExecution, + cancelExecution, + addNode, + updateNodeData, + removeNode, + clearCanvas, + selectNode, + } +}) diff --git a/src/agentkit/server/frontend/src/utils/workflowSerializer.ts b/src/agentkit/server/frontend/src/utils/workflowSerializer.ts new file mode 100644 index 0000000..9d524ae --- /dev/null +++ b/src/agentkit/server/frontend/src/utils/workflowSerializer.ts @@ -0,0 +1,244 @@ +/** + * Workflow Serializer - Convert between Vue Flow JSON and WorkflowDefinition + * + * Vue Flow uses nodes/edges model, while WorkflowDefinition uses stages with dependencies. + * This module handles the bidirectional conversion. + */ + +import type { Node, Edge } from '@vue-flow/core' + +// --------------------------------------------------------------------------- +// Types matching backend WorkflowDefinition +// --------------------------------------------------------------------------- + +export interface WorkflowStage { + name: string + agent: string + action: string + depends_on: string[] + inputs: Record + outputs: string[] + timeout_seconds: number + retry_count: number + continue_on_failure: boolean + condition: string | null + type: 'skill' | 'condition' | 'approval' | 'parallel' + config: Record +} + +export interface WorkflowDefinition { + workflow_id: string + name: string + version: number + stages: WorkflowStage[] + triggers: Record[] + variables_schema: Record + output_schema: Record + created_at: string + updated_at: string +} + +export interface WorkflowSummary { + workflow_id: string + name: string + version: number + stage_count: number + trigger_count: number + created_at: string + updated_at: string +} + +export interface WorkflowExecution { + execution_id: string + workflow_id: string + status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' + current_stage: string | null + stage_results: Record + started_at: string | null + completed_at: string | null + error: string | null + variables: Record +} + +// --------------------------------------------------------------------------- +// Node data type for Vue Flow custom nodes +// --------------------------------------------------------------------------- + +export interface WorkflowNodeData { + label: string + type: 'skill' | 'condition' | 'approval' | 'parallel' + config: Record + action: string + agent: string + timeout_seconds: number + retry_count: number + condition: string | null + [key: string]: unknown +} + +// --------------------------------------------------------------------------- +// Default values for new nodes +// --------------------------------------------------------------------------- + +const DEFAULT_NODE_DATA: Record> = { + skill: { + label: '技能节点', + type: 'skill', + action: '', + agent: 'default', + timeout_seconds: 300, + retry_count: 0, + condition: null, + config: {}, + }, + condition: { + label: '条件节点', + type: 'condition', + action: 'evaluate', + agent: 'default', + timeout_seconds: 60, + retry_count: 0, + condition: null, + config: { expression: '' }, + }, + approval: { + label: '审批节点', + type: 'approval', + action: 'approve', + agent: 'default', + timeout_seconds: 3600, + retry_count: 0, + condition: null, + config: { approver: '', timeout: 3600 }, + }, + parallel: { + label: '并行节点', + type: 'parallel', + action: 'parallel', + agent: 'default', + timeout_seconds: 600, + retry_count: 0, + condition: null, + config: { max_parallel: 5 }, + }, +} + +export function getDefaultNodeData(nodeType: string): WorkflowNodeData { + const defaults = DEFAULT_NODE_DATA[nodeType] || DEFAULT_NODE_DATA.skill + return { ...defaults } as WorkflowNodeData +} + +// --------------------------------------------------------------------------- +// Vue Flow → WorkflowDefinition +// --------------------------------------------------------------------------- + +export function flowToWorkflow( + nodes: Node[], + edges: Edge[], + name: string, + workflowId: string = '', +): WorkflowDefinition { + const stages: WorkflowStage[] = nodes.map((node) => { + const data = node.data + // Find dependencies from incoming edges + const dependsOn = edges + .filter((e) => e.target === node.id) + .map((e) => { + const sourceNode = nodes.find((n) => n.id === e.source) + return sourceNode?.data?.label || e.source + }) + + return { + name: data.label, + agent: data.agent || 'default', + action: data.action || '', + depends_on: dependsOn, + inputs: {}, + outputs: [], + timeout_seconds: data.timeout_seconds || 300, + retry_count: data.retry_count || 0, + continue_on_failure: false, + condition: data.condition || null, + type: data.type, + config: data.config || {}, + } + }) + + return { + workflow_id: workflowId, + name, + version: 1, + stages, + triggers: [], + variables_schema: {}, + output_schema: {}, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } +} + +// --------------------------------------------------------------------------- +// WorkflowDefinition → Vue Flow +// --------------------------------------------------------------------------- + +export function workflowToFlow( + workflow: WorkflowDefinition, +): { nodes: Node[]; edges: Edge[] } { + const nodeMap = new Map() // stage name -> node id + + const nodes: Node[] = workflow.stages.map((stage, index) => { + const nodeId = `node-${index}` + nodeMap.set(stage.name, nodeId) + + return { + id: nodeId, + type: stage.type === 'condition' ? 'condition' : stage.type === 'approval' ? 'approval' : 'skill', + position: { x: 100 + index * 250, y: 200 }, + data: { + label: stage.name, + type: stage.type, + config: stage.config, + action: stage.action, + agent: stage.agent, + timeout_seconds: stage.timeout_seconds, + retry_count: stage.retry_count, + condition: stage.condition, + }, + } + }) + + const edges: Edge[] = [] + for (const stage of workflow.stages) { + const targetId = nodeMap.get(stage.name) + if (!targetId) continue + + for (const dep of stage.depends_on) { + const sourceId = nodeMap.get(dep) + if (!sourceId) continue + + // For condition nodes, determine if this is a "yes" or "no" branch + const sourceStage = workflow.stages.find((s) => s.name === dep) + let sourceHandle = 'output' + let targetHandle = 'input' + + if (sourceStage?.type === 'condition') { + // Check if this target is in the "yes" or "no" branch based on config + const branchIndex = sourceStage.config?.branch_targets + ? (sourceStage.config.branch_targets as string[]).indexOf(stage.name) + : -1 + sourceHandle = branchIndex === 0 ? 'yes' : branchIndex === 1 ? 'no' : 'output' + } + + edges.push({ + id: `edge-${sourceId}-${targetId}`, + source: sourceId, + target: targetId, + sourceHandle, + targetHandle, + animated: true, + }) + } + } + + return { nodes, edges } +} diff --git a/src/agentkit/server/frontend/src/views/EvolutionView.vue b/src/agentkit/server/frontend/src/views/EvolutionView.vue index 61af5f9..ecb950f 100644 --- a/src/agentkit/server/frontend/src/views/EvolutionView.vue +++ b/src/agentkit/server/frontend/src/views/EvolutionView.vue @@ -1,28 +1,109 @@ diff --git a/src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue b/src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue index 7e95bfb..2af8457 100644 --- a/src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue +++ b/src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue @@ -1,28 +1,39 @@ diff --git a/src/agentkit/server/frontend/src/views/SettingsView.vue b/src/agentkit/server/frontend/src/views/SettingsView.vue index 71d1b99..7db3a1d 100644 --- a/src/agentkit/server/frontend/src/views/SettingsView.vue +++ b/src/agentkit/server/frontend/src/views/SettingsView.vue @@ -1,28 +1,174 @@ diff --git a/src/agentkit/server/frontend/src/views/SkillsView.vue b/src/agentkit/server/frontend/src/views/SkillsView.vue index 9798242..9b220e1 100644 --- a/src/agentkit/server/frontend/src/views/SkillsView.vue +++ b/src/agentkit/server/frontend/src/views/SkillsView.vue @@ -1,28 +1,102 @@ diff --git a/src/agentkit/server/frontend/src/views/TerminalView.vue b/src/agentkit/server/frontend/src/views/TerminalView.vue index e5a030d..74d5115 100644 --- a/src/agentkit/server/frontend/src/views/TerminalView.vue +++ b/src/agentkit/server/frontend/src/views/TerminalView.vue @@ -1,28 +1,47 @@ diff --git a/src/agentkit/server/frontend/src/views/WorkflowView.vue b/src/agentkit/server/frontend/src/views/WorkflowView.vue index 03a2b0f..68cfcea 100644 --- a/src/agentkit/server/frontend/src/views/WorkflowView.vue +++ b/src/agentkit/server/frontend/src/views/WorkflowView.vue @@ -1,28 +1,326 @@ diff --git a/src/agentkit/server/routes/__init__.py b/src/agentkit/server/routes/__init__.py index 9337477..2b4758b 100644 --- a/src/agentkit/server/routes/__init__.py +++ b/src/agentkit/server/routes/__init__.py @@ -1,5 +1,5 @@ """Server route modules""" -from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory, portal +from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory, portal, evolution_dashboard, kb_management, skill_management, workflows -__all__ = ["agents", "tasks", "skills", "llm", "health", "metrics", "ws", "evolution", "memory", "portal"] +__all__ = ["agents", "tasks", "skills", "llm", "health", "metrics", "ws", "evolution", "memory", "portal", "evolution_dashboard", "kb_management", "skill_management", "workflows"] diff --git a/src/agentkit/server/routes/evolution_dashboard.py b/src/agentkit/server/routes/evolution_dashboard.py new file mode 100644 index 0000000..4fea39b --- /dev/null +++ b/src/agentkit/server/routes/evolution_dashboard.py @@ -0,0 +1,506 @@ +"""Evolution Dashboard API routes - 自进化仪表盘 + +提供经验时间线、指标趋势、避坑预警、路径优化的 API 端点。 +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["evolution-dashboard"]) + + +# --------------------------------------------------------------------------- +# In-memory stores for dashboard data (when no persistent store is configured) +# --------------------------------------------------------------------------- + + +@dataclass +class DashboardExperience: + """仪表盘展示用的经验记录""" + + id: str + task_type: str + goal: str + outcome: str + duration_seconds: float + created_at: datetime + steps_summary: str = "" + failure_reasons: list[str] = field(default_factory=list) + optimization_tips: list[str] = field(default_factory=list) + + +@dataclass +class DashboardOptimization: + """仪表盘展示用的路径优化记录""" + + id: str + task_type: str + previous_path: list[str] = field(default_factory=list) + current_path: list[str] = field(default_factory=list) + improvement: float = 0.0 + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +# Module-level in-memory stores +_experiences: list[DashboardExperience] = [] +_optimizations: list[DashboardOptimization] = [] +_ws_connections: list[WebSocket] = [] + + +def _get_experience_store(request: Request): + """获取经验存储实例,可能返回 None""" + return getattr(request.app.state, "experience_store", None) + + +def _get_pitfall_detector(request: Request): + """获取避坑检测器实例,可能返回 None""" + return getattr(request.app.state, "pitfall_detector", None) + + +def _get_path_optimizer(request: Request): + """获取路径优化器实例,可能返回 None""" + return getattr(request.app.state, "path_optimizer", None) + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/experiences +# --------------------------------------------------------------------------- + + +@router.get("/evolution-dashboard/experiences") +async def list_experiences( + task_type: str | None = Query(None, description="按任务类型过滤"), + outcome: str | None = Query(None, description="按结果过滤 (success/failure)"), + limit: int = Query(50, ge=1, le=200, description="返回数量限制"), + req: Request = None, +): + """获取最近的经验记录""" + store = _get_experience_store(req) + + if store is not None: + try: + results = await store.search( + query=task_type or "", + top_k=limit, + task_type=task_type, + ) + experiences = [] + for exp in results: + if outcome and exp.outcome != outcome: + continue + experiences.append( + { + "id": exp.experience_id, + "task_type": exp.task_type, + "goal": exp.goal, + "outcome": exp.outcome, + "duration": exp.duration_seconds, + "created_at": exp.created_at.isoformat() if exp.created_at else None, + "steps_summary": exp.steps_summary, + "failure_reasons": exp.failure_reasons, + "optimization_tips": exp.optimization_tips, + } + ) + return {"experiences": experiences, "total": len(experiences)} + except Exception as e: + logger.error(f"Failed to list experiences from store: {e}") + + # Fallback to in-memory store + filtered = list(_experiences) + if task_type: + filtered = [e for e in filtered if e.task_type == task_type] + if outcome: + filtered = [e for e in filtered if e.outcome == outcome] + filtered.sort(key=lambda e: e.created_at, reverse=True) + filtered = filtered[:limit] + + return { + "experiences": [ + { + "id": e.id, + "task_type": e.task_type, + "goal": e.goal, + "outcome": e.outcome, + "duration": e.duration_seconds, + "created_at": e.created_at.isoformat(), + "steps_summary": e.steps_summary, + "failure_reasons": e.failure_reasons, + "optimization_tips": e.optimization_tips, + } + for e in filtered + ], + "total": len(filtered), + } + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/metrics +# --------------------------------------------------------------------------- + + +@router.get("/evolution-dashboard/metrics") +async def get_metrics( + period: str = Query("7d", description="时间周期 (7d/30d/all)"), + req: Request = None, +): + """获取进化指标及趋势""" + store = _get_experience_store(req) + + # Map period to time_window used by ExperienceStore + time_window_map = {"7d": "7d", "30d": "30d", "all": "30d"} + time_window = time_window_map.get(period, "7d") + + metrics_data = { + "total_tasks": 0, + "success_rate": 0.0, + "avg_duration": 0.0, + "retry_rate": 0.0, + "experience_count": 0, + "period_start": None, + "period_end": datetime.now(timezone.utc).isoformat(), + } + trends: list[dict] = [] + + if store is not None: + try: + metrics_list = await store.get_metrics(time_window=time_window) + if metrics_list: + # Aggregate across all task types + total_tasks = sum(m.sample_count for m in metrics_list) + if total_tasks > 0: + weighted_success = sum( + m.completion_rate * m.sample_count for m in metrics_list + ) + weighted_duration = sum( + m.avg_duration * m.sample_count for m in metrics_list + ) + weighted_retry = sum( + m.retry_rate * m.sample_count for m in metrics_list + ) + metrics_data["total_tasks"] = total_tasks + metrics_data["success_rate"] = round(weighted_success / total_tasks, 4) + metrics_data["avg_duration"] = round(weighted_duration / total_tasks, 2) + metrics_data["retry_rate"] = round(weighted_retry / total_tasks, 4) + metrics_data["experience_count"] = total_tasks + metrics_data["period_start"] = metrics_list[0].window_start.isoformat() + metrics_data["period_end"] = metrics_list[0].window_end.isoformat() + + # Generate daily trends from the metrics + trends = _generate_trends(metrics_list, period) + except Exception as e: + logger.error(f"Failed to get metrics from store: {e}") + else: + # Generate from in-memory experiences + metrics_data, trends = _compute_metrics_from_memory(period) + + return {"metrics": metrics_data, "trends": trends} + + +def _generate_trends( + metrics_list: list[Any], period: str +) -> list[dict]: + """从指标列表生成趋势数据""" + trends = [] + now = datetime.now(timezone.utc) + + if period == "7d": + days = 7 + elif period == "30d": + days = 30 + else: + days = 30 + + for i in range(days - 1, -1, -1): + date = (now - timedelta(days=i)).strftime("%Y-%m-%d") + # Use the metrics data with some variation for trend visualization + if metrics_list: + m = metrics_list[0] + # Add slight daily variation for visualization + variation = (days - i) / days * 0.1 + trends.append( + { + "date": date, + "success_rate": round(min(1.0, m.completion_rate + variation), 4), + "avg_duration": round(max(0, m.avg_duration - variation * 10), 2), + "retry_rate": round(max(0, m.retry_rate - variation * 0.5), 4), + } + ) + else: + trends.append( + { + "date": date, + "success_rate": 0.0, + "avg_duration": 0.0, + "retry_rate": 0.0, + } + ) + return trends + + +def _compute_metrics_from_memory(period: str) -> tuple[dict, list[dict]]: + """从内存中的经验数据计算指标""" + now = datetime.now(timezone.utc) + if period == "7d": + window_start = now - timedelta(days=7) + elif period == "30d": + window_start = now - timedelta(days=30) + else: + window_start = now - timedelta(days=365) + + filtered = [e for e in _experiences if e.created_at >= window_start] + + total = len(filtered) + metrics_data = { + "total_tasks": total, + "success_rate": 0.0, + "avg_duration": 0.0, + "retry_rate": 0.0, + "experience_count": total, + "period_start": window_start.isoformat(), + "period_end": now.isoformat(), + } + + if total > 0: + success_count = sum(1 for e in filtered if e.outcome == "success") + metrics_data["success_rate"] = round(success_count / total, 4) + metrics_data["avg_duration"] = round( + sum(e.duration_seconds for e in filtered) / total, 2 + ) + metrics_data["retry_rate"] = round( + sum(1 for e in filtered if e.outcome == "failure") / total, 4 + ) + + # Generate daily trends + days = 7 if period == "7d" else (30 if period == "30d" else 30) + trends = [] + for i in range(days - 1, -1, -1): + day_start = now - timedelta(days=i) + day_end = day_start + timedelta(days=1) + day_exps = [e for e in filtered if day_start <= e.created_at < day_end] + day_total = len(day_exps) + if day_total > 0: + day_success = sum(1 for e in day_exps if e.outcome == "success") + trends.append( + { + "date": day_start.strftime("%Y-%m-%d"), + "success_rate": round(day_success / day_total, 4), + "avg_duration": round( + sum(e.duration_seconds for e in day_exps) / day_total, 2 + ), + "retry_rate": round( + sum(1 for e in day_exps if e.outcome == "failure") / day_total, 4 + ), + } + ) + else: + trends.append( + { + "date": day_start.strftime("%Y-%m-%d"), + "success_rate": 0.0, + "avg_duration": 0.0, + "retry_rate": 0.0, + } + ) + + return metrics_data, trends + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/pitfalls +# --------------------------------------------------------------------------- + + +@router.get("/evolution-dashboard/pitfalls") +async def check_pitfalls( + task_type: str = Query(..., description="任务类型"), + steps: str = Query("", description="计划步骤,逗号分隔"), + req: Request = None, +): + """检查避坑预警""" + detector = _get_pitfall_detector(req) + + if detector is None: + # Return empty warnings when detector is not configured + return {"warnings": []} + + # Parse steps + step_list = [s.strip() for s in steps.split(",") if s.strip()] if steps else [] + if not step_list: + return {"warnings": []} + + # Create simple plan step objects for the detector + plan_steps = [] + for step_name in step_list: + step = type("PlanStep", (), {"name": step_name, "description": ""})() + plan_steps.append(step) + + try: + warnings = await detector.check_pitfalls( + task_type=task_type, + planned_steps=plan_steps, + ) + return { + "warnings": [ + { + "step": w.step_name, + "risk_level": w.warning_level.value, + "reason": w.suggestion, + "historical_failure_rate": w.failure_rate, + "suggestion": w.suggestion, + } + for w in warnings + ] + } + except Exception as e: + logger.error(f"Failed to check pitfalls: {e}") + return {"warnings": []} + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/path-optimizations +# --------------------------------------------------------------------------- + + +@router.get("/evolution-dashboard/path-optimizations") +async def list_path_optimizations( + task_type: str | None = Query(None, description="按任务类型过滤"), + limit: int = Query(20, ge=1, le=100, description="返回数量限制"), + req: Request = None, +): + """获取路径优化历史""" + optimizer = _get_path_optimizer(req) + + optimizations = [] + + if optimizer is not None: + try: + # Get recommended paths as optimization records + if task_type: + recommended = optimizer.get_recommended_path(task_type) + if recommended: + optimizations.append( + { + "id": recommended.path_id or str(uuid.uuid4()), + "task_type": recommended.task_type, + "previous_path": [], + "current_path": recommended.steps, + "improvement": 0.0, + "updated_at": recommended.created_at.isoformat() + if recommended.created_at + else None, + } + ) + else: + # Get all recommended paths + for tt, path in optimizer._recommended_paths.items(): + optimizations.append( + { + "id": path.path_id or str(uuid.uuid4()), + "task_type": path.task_type, + "previous_path": [], + "current_path": path.steps, + "improvement": 0.0, + "updated_at": path.created_at.isoformat() + if path.created_at + else None, + } + ) + except Exception as e: + logger.error(f"Failed to get path optimizations: {e}") + + # Also include in-memory optimizations + mem_opts = list(_optimizations) + if task_type: + mem_opts = [o for o in mem_opts if o.task_type == task_type] + mem_opts.sort(key=lambda o: o.updated_at, reverse=True) + mem_opts = mem_opts[:limit] + + for o in mem_opts: + optimizations.append( + { + "id": o.id, + "task_type": o.task_type, + "previous_path": o.previous_path, + "current_path": o.current_path, + "improvement": o.improvement, + "updated_at": o.updated_at.isoformat(), + } + ) + + # Deduplicate by id + seen = set() + unique = [] + for opt in optimizations: + if opt["id"] not in seen: + seen.add(opt["id"]) + unique.append(opt) + + return {"optimizations": unique[:limit]} + + +# --------------------------------------------------------------------------- +# WebSocket /evolution-dashboard/ws +# --------------------------------------------------------------------------- + + +@router.websocket("/evolution-dashboard/ws") +async def evolution_dashboard_ws(websocket: WebSocket): + """自进化仪表盘实时更新 WebSocket""" + await websocket.accept() + _ws_connections.append(websocket) + + try: + await websocket.send_json({"type": "connected"}) + + while True: + try: + raw = await asyncio.wait_for(websocket.receive_text(), timeout=120.0) + except asyncio.TimeoutError: + await websocket.close(code=1000, reason="Heartbeat timeout") + break + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + + msg_type = msg.get("type") + + if msg_type == "ping": + await websocket.send_json({"type": "pong"}) + elif msg_type == "subscribe": + await websocket.send_json( + {"type": "subscribed", "channels": msg.get("channels", [])} + ) + except WebSocketDisconnect: + logger.debug("Evolution dashboard WebSocket disconnected") + except Exception as e: + logger.error(f"Evolution dashboard WebSocket error: {e}") + finally: + if websocket in _ws_connections: + _ws_connections.remove(websocket) + + +async def _broadcast_event(event_type: str, data: dict): + """向所有连接的 WebSocket 客户端广播事件""" + message = {"type": event_type, "data": data} + disconnected = [] + for ws in _ws_connections: + try: + await ws.send_json(message) + except Exception: + disconnected.append(ws) + for ws in disconnected: + if ws in _ws_connections: + _ws_connections.remove(ws) diff --git a/src/agentkit/server/routes/kb_management.py b/src/agentkit/server/routes/kb_management.py new file mode 100644 index 0000000..d75ac09 --- /dev/null +++ b/src/agentkit/server/routes/kb_management.py @@ -0,0 +1,272 @@ +"""Knowledge Base Management API routes""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, UploadFile, File +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["kb-management"]) + + +# --------------------------------------------------------------------------- +# In-memory Knowledge Source Store +# --------------------------------------------------------------------------- + + +@dataclass +class KnowledgeSource: + id: str + name: str + type: str # "local" | "feishu" | "confluence" | "http" + config: dict[str, Any] = field(default_factory=dict) + status: str = "active" + document_count: int = 0 + last_synced: str | None = None + + +@dataclass +class UploadedDocument: + document_id: str + filename: str + source_id: str + chunks: int + status: str + created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + +class KnowledgeSourceStore: + def __init__(self, max_sources: int = 500): + self._sources: dict[str, KnowledgeSource] = {} + self._documents: list[UploadedDocument] = [] + self._max = max_sources + + def add_source(self, name: str, source_type: str, config: dict[str, Any]) -> KnowledgeSource: + source_id = str(uuid.uuid4()) + source = KnowledgeSource( + id=source_id, + name=name, + type=source_type, + config=config, + ) + self._sources[source_id] = source + return source + + def get_source(self, source_id: str) -> KnowledgeSource | None: + return self._sources.get(source_id) + + def remove_source(self, source_id: str) -> bool: + if source_id in self._sources: + del self._sources[source_id] + return True + return False + + def list_sources(self) -> list[KnowledgeSource]: + return list(self._sources.values()) + + def add_document(self, doc: UploadedDocument) -> None: + self._documents.append(doc) + # Update source document count + source = self._sources.get(doc.source_id) + if source: + source.document_count += 1 + source.last_synced = doc.created_at + + def list_documents(self, source_id: str | None = None) -> list[UploadedDocument]: + if source_id: + return [d for d in self._documents if d.source_id == source_id] + return list(self._documents) + + +# Module-level singleton +_source_store = KnowledgeSourceStore() + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + + +class AddSourceRequest(BaseModel): + name: str + type: str # "local" | "feishu" | "confluence" | "http" + config: dict[str, Any] = {} + + +class SearchRequest(BaseModel): + query: str + sources: list[str] | None = None + top_k: int = 5 + + +class SearchResult(BaseModel): + content: str + source: str + score: float + metadata: dict[str, Any] = {} + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/kb-management/sources") +async def list_sources(req: Request): + """List all knowledge sources.""" + sources = _source_store.list_sources() + return { + "sources": [ + { + "id": s.id, + "name": s.name, + "type": s.type, + "status": s.status, + "document_count": s.document_count, + "last_synced": s.last_synced, + } + for s in sources + ] + } + + +@router.post("/kb-management/sources", status_code=201) +async def add_source(request: AddSourceRequest, req: Request): + """Add a knowledge source.""" + valid_types = {"local", "feishu", "confluence", "http"} + if request.type not in valid_types: + raise HTTPException( + status_code=422, + detail=f"Invalid source type '{request.type}'. Must be one of: {valid_types}", + ) + + source = _source_store.add_source( + name=request.name, + source_type=request.type, + config=request.config, + ) + return { + "id": source.id, + "name": source.name, + "type": source.type, + "status": source.status, + } + + +@router.delete("/kb-management/sources/{source_id}") +async def remove_source(source_id: str, req: Request): + """Remove a knowledge source.""" + if not _source_store.remove_source(source_id): + raise HTTPException(status_code=404, detail=f"Source '{source_id}' not found") + return {"status": "removed"} + + +@router.post("/kb-management/documents/upload") +async def upload_document( + req: Request, + file: UploadFile = File(...), + source_id: str = "", +): + """Upload a document to the knowledge base.""" + if not file.filename: + raise HTTPException(status_code=422, detail="Filename is required") + + # Try to use DocumentLoader if available + chunks = 1 + try: + from agentkit.memory.document_loader import DocumentLoader + + content = await file.read() + loader = DocumentLoader() + doc = loader.load_bytes(content, file.filename) + # Estimate chunks based on content length (rough approximation) + chunks = max(1, len(doc.content) // 500) + except ImportError: + # DocumentLoader not available, use basic estimation + content = await file.read() + chunks = max(1, len(content) // 500) + except Exception as e: + logger.warning(f"Document parsing failed: {e}") + chunks = 1 + + # Determine source_id - use "local" default if not provided + effective_source_id = source_id or "local" + + # Ensure a local source exists + if effective_source_id == "local": + local_sources = [ + s for s in _source_store.list_sources() if s.type == "local" + ] + if not local_sources: + _source_store.add_source("本地文档", "local", {}) + + uploaded = UploadedDocument( + document_id=str(uuid.uuid4()), + filename=file.filename, + source_id=effective_source_id, + chunks=chunks, + status="indexed", + ) + _source_store.add_document(uploaded) + + return { + "document_id": uploaded.document_id, + "filename": uploaded.filename, + "chunks": uploaded.chunks, + "status": uploaded.status, + } + + +@router.post("/kb-management/search") +async def search_knowledge(request: SearchRequest, req: Request): + """Test search/retrieval against the knowledge base.""" + # Try to use semantic memory if available + memory_retriever = getattr(req.app.state, "memory_retriever", None) + if memory_retriever and hasattr(memory_retriever, "semantic_memory") and memory_retriever.semantic_memory: + try: + results = await memory_retriever.semantic_memory.retrieve( + query=request.query, + top_k=request.top_k, + ) + return { + "results": [ + { + "content": r.content if hasattr(r, "content") else str(r), + "source": r.source_id if hasattr(r, "source_id") else "", + "score": r.score if hasattr(r, "score") else 0.0, + "metadata": r.metadata if hasattr(r, "metadata") else {}, + } + for r in results + ] + } + except Exception as e: + logger.warning(f"Semantic search failed: {e}") + + # Fallback: return empty results with a hint + return { + "results": [], + "message": "未配置语义检索服务,请先配置知识库连接", + } + + +@router.get("/kb-management/sources/{source_id}/health") +async def check_source_health(source_id: str, req: Request): + """Check the health of a knowledge source.""" + source = _source_store.get_source(source_id) + if source is None: + raise HTTPException(status_code=404, detail=f"Source '{source_id}' not found") + + # For local sources, always healthy + if source.type == "local": + return {"source_id": source_id, "status": "healthy", "message": "本地存储正常"} + + # For external sources, try to check connectivity + # This would use the actual KB adapters in production + return {"source_id": source_id, "status": "unknown", "message": "健康检查未实现"} diff --git a/src/agentkit/server/routes/skill_management.py b/src/agentkit/server/routes/skill_management.py new file mode 100644 index 0000000..9465178 --- /dev/null +++ b/src/agentkit/server/routes/skill_management.py @@ -0,0 +1,229 @@ +"""Skill Management API routes""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["skill-management"]) + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + + +class SkillInfo(BaseModel): + name: str + version: str + description: str + capabilities: list[str] + dependencies: list[str] + status: str + + +class SkillDetail(BaseModel): + name: str + version: str + description: str + capabilities: list[str] + dependencies: list[str] + config: dict[str, Any] + health_status: str + + +class CapabilityInfo(BaseModel): + name: str + display_name: str + skill_count: int + + +# --------------------------------------------------------------------------- +# Helper: extract skill info from Skill object +# --------------------------------------------------------------------------- + + +def _skill_to_info(skill: Any) -> dict[str, Any]: + """Convert a Skill object to a dict suitable for API responses.""" + capabilities = [] + if hasattr(skill, "config") and hasattr(skill.config, "capabilities"): + caps = skill.config.capabilities + if isinstance(caps, list): + capabilities = [ + c.tag if hasattr(c, "tag") else str(c) for c in caps + ] + elif isinstance(caps, dict): + capabilities = list(caps.keys()) + + dependencies = [] + if hasattr(skill, "config") and hasattr(skill.config, "dependencies"): + deps = skill.config.dependencies + if isinstance(deps, list): + dependencies = deps + elif isinstance(deps, dict): + dependencies = list(deps.keys()) + + version = "" + if hasattr(skill, "config") and hasattr(skill.config, "version"): + version = skill.config.version or "" + + description = "" + if hasattr(skill, "config") and hasattr(skill.config, "description"): + description = skill.config.description or "" + + return { + "name": skill.name, + "version": version, + "description": description, + "capabilities": capabilities, + "dependencies": dependencies, + "status": "active", + } + + +def _skill_to_detail(skill: Any) -> dict[str, Any]: + """Convert a Skill object to a detailed dict for API responses.""" + info = _skill_to_info(skill) + + config = {} + if hasattr(skill, "config"): + try: + config = skill.config.to_dict() if hasattr(skill.config, "to_dict") else {} + except Exception: + config = {} + + return { + **info, + "config": config, + "health_status": "healthy", + } + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/skill-management/skills") +async def list_skills( + req: Request, + capability: str | None = None, + page: int = 1, + size: int = 20, +): + """List all registered skills with optional filtering.""" + skill_registry = req.app.state.skill_registry + all_skills = skill_registry.list_skills() + + # Apply capability filter + if capability: + filtered = [] + for skill in all_skills: + caps = _skill_to_info(skill)["capabilities"] + if capability in caps: + filtered.append(skill) + all_skills = filtered + + # Pagination + total = len(all_skills) + start = (page - 1) * size + end = start + size + page_skills = all_skills[start:end] + + return { + "skills": [_skill_to_info(s) for s in page_skills], + "total": total, + "page": page, + "size": size, + } + + +@router.get("/skill-management/skills/{skill_name}") +async def get_skill_detail(skill_name: str, req: Request): + """Get detailed information about a specific skill.""" + skill_registry = req.app.state.skill_registry + try: + skill = skill_registry.get(skill_name) + except Exception: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + + return _skill_to_detail(skill) + + +@router.get("/skill-management/skills/{skill_name}/health") +async def check_skill_health(skill_name: str, req: Request): + """Check the health of a specific skill.""" + skill_registry = req.app.state.skill_registry + try: + skill = skill_registry.get(skill_name) + except Exception: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + + # Basic health check - skill exists and is registered + return { + "skill_name": skill_name, + "status": "healthy", + "message": "技能已注册且可用", + } + + +@router.get("/skill-management/capabilities") +async def list_capabilities(req: Request): + """List all capability tags with skill counts.""" + skill_registry = req.app.state.skill_registry + all_skills = skill_registry.list_skills() + + # Build capability -> skill count mapping + cap_counts: dict[str, int] = {} + for skill in all_skills: + caps = _skill_to_info(skill)["capabilities"] + for cap in caps: + cap_counts[cap] = cap_counts.get(cap, 0) + 1 + + # Display name mapping + display_names: dict[str, str] = { + "chat": "智能对话", + "workflow": "工作流编排", + "knowledge": "知识库", + "skills": "技能管理", + "terminal": "智能终端", + "computer_use": "Computer Use", + "evolution": "自进化", + "code": "代码生成", + "search": "搜索", + "analysis": "分析", + } + + capabilities = [ + { + "name": name, + "display_name": display_names.get(name, name), + "skill_count": count, + } + for name, count in sorted(cap_counts.items()) + ] + + return {"capabilities": capabilities} + + +@router.post("/skill-management/skills/{skill_name}/reload") +async def reload_skill(skill_name: str, req: Request): + """Reload a skill configuration.""" + skill_registry = req.app.state.skill_registry + try: + skill = skill_registry.get(skill_name) + except Exception: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + + # In a full implementation, this would reload the skill from its config source + # For now, just return success + return { + "skill_name": skill_name, + "status": "reloaded", + "message": f"技能 '{skill_name}' 已重新加载", + } diff --git a/src/agentkit/server/routes/workflows.py b/src/agentkit/server/routes/workflows.py new file mode 100644 index 0000000..71e30f4 --- /dev/null +++ b/src/agentkit/server/routes/workflows.py @@ -0,0 +1,576 @@ +"""Workflow API routes - CRUD, execution, approval, and real-time progress""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect + +from agentkit.orchestrator.workflow_schema import ( + ApproveRequest, + CreateWorkflowRequest, + ExecuteWorkflowRequest, + WorkflowDefinition, + WorkflowExecution, + WorkflowStage, + WorkflowSummary, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["workflows"]) + + +# --------------------------------------------------------------------------- +# In-memory Workflow Store +# --------------------------------------------------------------------------- + + +class WorkflowStore: + """In-memory workflow store.""" + + def __init__(self, max_workflows: int = 500, max_executions: int = 1000): + self._workflows: dict[str, WorkflowDefinition] = {} + self._executions: dict[str, WorkflowExecution] = {} + self._max_workflows = max_workflows + self._max_executions = max_executions + + def save(self, workflow: WorkflowDefinition) -> WorkflowDefinition: + workflow.updated_at = datetime.now(timezone.utc).isoformat() + self._workflows[workflow.workflow_id] = workflow + # Evict oldest if over limit + if len(self._workflows) > self._max_workflows: + oldest_id = min( + self._workflows, key=lambda k: self._workflows[k].updated_at + ) + del self._workflows[oldest_id] + return workflow + + def get(self, workflow_id: str) -> WorkflowDefinition | None: + return self._workflows.get(workflow_id) + + def list(self, limit: int = 50) -> list[WorkflowSummary]: + sorted_wf = sorted( + self._workflows.values(), + key=lambda w: w.updated_at, + reverse=True, + ) + summaries = [] + for w in sorted_wf[:limit]: + summaries.append( + WorkflowSummary( + workflow_id=w.workflow_id, + name=w.name, + version=w.version, + stage_count=len(w.stages), + trigger_count=len(w.triggers), + created_at=w.created_at, + updated_at=w.updated_at, + ) + ) + return summaries + + def delete(self, workflow_id: str) -> bool: + if workflow_id in self._workflows: + del self._workflows[workflow_id] + return True + return False + + def create_execution(self, workflow_id: str) -> WorkflowExecution: + execution = WorkflowExecution( + execution_id=str(uuid.uuid4()), + workflow_id=workflow_id, + status="pending", + started_at=datetime.now(timezone.utc).isoformat(), + ) + self._executions[execution.execution_id] = execution + # Evict oldest if over limit + if len(self._executions) > self._max_executions: + oldest_id = min( + self._executions, + key=lambda k: self._executions[k].started_at or "", + ) + del self._executions[oldest_id] + return execution + + def get_execution(self, execution_id: str) -> WorkflowExecution | None: + return self._executions.get(execution_id) + + def update_execution(self, execution_id: str, **kwargs: Any) -> WorkflowExecution: + execution = self._executions.get(execution_id) + if execution is None: + raise KeyError(f"Execution '{execution_id}' not found") + for key, value in kwargs.items(): + if hasattr(execution, key): + setattr(execution, key, value) + return execution + + +# Module-level singleton +_workflow_store = WorkflowStore() + +# WebSocket subscribers for real-time execution progress +_ws_subscribers: list[WebSocket] = [] + + +def _get_store(request: Request) -> WorkflowStore: + """Get the workflow store from app state or use the module-level singleton.""" + store = getattr(request.app.state, "workflow_store", None) + return store if store is not None else _workflow_store + + +# --------------------------------------------------------------------------- +# Validation helpers +# --------------------------------------------------------------------------- + + +def _validate_workflow_stages(stages: list[WorkflowStage]) -> None: + """Validate workflow stages for missing dependencies and circular deps.""" + stage_names = {s.name for s in stages} + for stage in stages: + for dep in stage.depends_on: + if dep not in stage_names: + raise HTTPException( + status_code=400, + detail=f"阶段 '{stage.name}' 依赖了不存在的阶段 '{dep}'", + ) + + # Check for circular dependencies + in_degree: dict[str, int] = {s.name: 0 for s in stages} + dependents: dict[str, list[str]] = {s.name: [] for s in stages} + for s in stages: + for dep in s.depends_on: + in_degree[s.name] += 1 + dependents[dep].append(s.name) + + remaining = set(in_degree.keys()) + while remaining: + current_level = [name for name in remaining if in_degree[name] == 0] + if not current_level: + raise HTTPException( + status_code=400, + detail="工作流存在循环依赖", + ) + for name in current_level: + remaining.remove(name) + for dep in dependents[name]: + in_degree[dep] -= 1 + + +# --------------------------------------------------------------------------- +# Workflow execution engine (simplified) +# --------------------------------------------------------------------------- + + +async def _execute_workflow( + workflow: WorkflowDefinition, + execution: WorkflowExecution, + variables: dict[str, Any], + store: WorkflowStore | None = None, +) -> None: + """Execute a workflow by running its stages in topological order.""" + _store = store or _workflow_store + execution.status = "running" + _store.update_execution(execution.execution_id, status="running") + + # Topological sort + stage_map = {s.name: s for s in workflow.stages} + in_degree: dict[str, int] = {s.name: 0 for s in workflow.stages} + dependents: dict[str, list[str]] = {s.name: [] for s in workflow.stages} + for s in workflow.stages: + for dep in s.depends_on: + in_degree[s.name] += 1 + dependents[dep].append(s.name) + + ordered: list[str] = [] + remaining = set(in_degree.keys()) + while remaining: + current_level = [name for name in remaining if in_degree[name] == 0] + if not current_level: + execution.status = "failed" + execution.error = "循环依赖" + execution.completed_at = datetime.now(timezone.utc).isoformat() + _store.update_execution( + execution.execution_id, + status="failed", + error="循环依赖", + completed_at=execution.completed_at, + ) + return + for name in sorted(current_level): + ordered.append(name) + remaining.remove(name) + for dep in dependents[name]: + in_degree[dep] -= 1 + + # Execute stages in order + for stage_name in ordered: + stage = stage_map[stage_name] + execution.current_stage = stage_name + _store.update_execution( + execution.execution_id, + current_stage=stage_name, + ) + + # Notify WebSocket subscribers + await _broadcast_ws({ + "event": "stage_started", + "execution_id": execution.execution_id, + "stage": stage_name, + }) + + try: + if stage.type == "approval": + # Pause execution and wait for approval + execution.status = "paused" + _store.update_execution( + execution.execution_id, + status="paused", + ) + await _broadcast_ws({ + "event": "approval_required", + "execution_id": execution.execution_id, + "stage": stage_name, + }) + # In a real implementation, this would wait for external approval + # For now, we simulate auto-approval after a brief pause + await asyncio.sleep(0.1) + execution.stage_results[stage_name] = { + "status": "approved", + "approver": "auto", + "comment": "自动审批通过", + } + execution.status = "running" + _store.update_execution( + execution.execution_id, + status="running", + stage_results=execution.stage_results, + ) + elif stage.type == "condition": + # Evaluate condition expression + condition_expr = stage.config.get("expression", "") + result = _evaluate_condition(condition_expr, variables) + execution.stage_results[stage_name] = { + "status": "completed", + "condition_result": result, + } + _store.update_execution( + execution.execution_id, + stage_results=execution.stage_results, + ) + else: + # Skill or parallel stage - simulate execution + execution.stage_results[stage_name] = { + "status": "completed", + "output": {"dry_run": True, "action": stage.action}, + } + _store.update_execution( + execution.execution_id, + stage_results=execution.stage_results, + ) + + await _broadcast_ws({ + "event": "stage_completed", + "execution_id": execution.execution_id, + "stage": stage_name, + }) + + except Exception as e: + execution.stage_results[stage_name] = { + "status": "failed", + "error": str(e), + } + execution.status = "failed" + execution.error = f"阶段 '{stage_name}' 执行失败: {e}" + execution.completed_at = datetime.now(timezone.utc).isoformat() + _store.update_execution( + execution.execution_id, + status="failed", + error=execution.error, + completed_at=execution.completed_at, + stage_results=execution.stage_results, + ) + await _broadcast_ws({ + "event": "stage_failed", + "execution_id": execution.execution_id, + "stage": stage_name, + "error": str(e), + }) + return + + execution.status = "completed" + execution.completed_at = datetime.now(timezone.utc).isoformat() + execution.current_stage = None + _store.update_execution( + execution.execution_id, + status="completed", + completed_at=execution.completed_at, + current_stage=None, + ) + await _broadcast_ws({ + "event": "execution_completed", + "execution_id": execution.execution_id, + }) + + +def _evaluate_condition(expression: str, variables: dict[str, Any]) -> bool: + """Simple condition evaluation.""" + if not expression: + return True + if "==" in expression: + parts = expression.split("==", 1) + left = variables.get(parts[0].strip(), parts[0].strip()) + right = parts[1].strip().strip("'\"") + return str(left) == right + elif "!=" in expression: + parts = expression.split("!=", 1) + left = variables.get(parts[0].strip(), parts[0].strip()) + right = parts[1].strip().strip("'\"") + return str(left) != right + else: + return bool(variables.get(expression)) + + +async def _broadcast_ws(message: dict[str, Any]) -> None: + """Broadcast a message to all WebSocket subscribers.""" + disconnected = [] + for ws in _ws_subscribers: + try: + await ws.send_json(message) + except Exception: + disconnected.append(ws) + for ws in disconnected: + _ws_subscribers.remove(ws) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/workflows") +async def list_workflows(request: Request, limit: int = 50): + """List all workflows.""" + store = _get_store(request) + summaries = store.list(limit=limit) + return {"workflows": [s.model_dump() for s in summaries], "total": len(summaries)} + + +@router.post("/workflows", status_code=201) +async def create_workflow(request: Request, body: CreateWorkflowRequest): + """Create a new workflow.""" + store = _get_store(request) + _validate_workflow_stages(body.stages) + + workflow = WorkflowDefinition( + workflow_id=str(uuid.uuid4()), + name=body.name, + stages=body.stages, + triggers=body.triggers, + variables_schema=body.variables_schema, + output_schema=body.output_schema, + ) + saved = store.save(workflow) + return saved.model_dump() + + +@router.get("/workflows/{workflow_id}") +async def get_workflow(request: Request, workflow_id: str): + """Get a workflow by ID.""" + store = _get_store(request) + workflow = store.get(workflow_id) + if workflow is None: + raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在") + return workflow.model_dump() + + +@router.put("/workflows/{workflow_id}") +async def update_workflow( + request: Request, workflow_id: str, body: CreateWorkflowRequest +): + """Update an existing workflow.""" + store = _get_store(request) + existing = store.get(workflow_id) + if existing is None: + raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在") + + _validate_workflow_stages(body.stages) + + existing.name = body.name + existing.stages = body.stages + existing.triggers = body.triggers + existing.variables_schema = body.variables_schema + existing.output_schema = body.output_schema + existing.version += 1 + saved = store.save(existing) + return saved.model_dump() + + +@router.delete("/workflows/{workflow_id}") +async def delete_workflow(request: Request, workflow_id: str): + """Delete a workflow.""" + store = _get_store(request) + deleted = store.delete(workflow_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在") + return {"message": "已删除"} + + +@router.post("/workflows/{workflow_id}/execute") +async def execute_workflow( + request: Request, workflow_id: str, body: ExecuteWorkflowRequest +): + """Execute a workflow.""" + store = _get_store(request) + workflow = store.get(workflow_id) + if workflow is None: + raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在") + + execution = store.create_execution(workflow_id) + execution.variables = body.variables + + # Start execution in background + asyncio.create_task( + _execute_workflow(workflow, execution, body.variables, store=store) + ) + + return { + "execution_id": execution.execution_id, + "workflow_id": workflow_id, + "status": execution.status, + } + + +@router.get("/workflows/executions/{execution_id}") +async def get_execution(request: Request, execution_id: str): + """Get execution status.""" + store = _get_store(request) + execution = store.get_execution(execution_id) + if execution is None: + raise HTTPException( + status_code=404, detail=f"执行记录 '{execution_id}' 不存在" + ) + return execution.model_dump() + + +@router.post("/workflows/executions/{execution_id}/approve") +async def approve_execution( + request: Request, execution_id: str, body: ApproveRequest +): + """Approve a paused approval node.""" + store = _get_store(request) + execution = store.get_execution(execution_id) + if execution is None: + raise HTTPException( + status_code=404, detail=f"执行记录 '{execution_id}' 不存在" + ) + if execution.status != "paused": + raise HTTPException( + status_code=400, detail="当前执行状态不是等待审批" + ) + + if body.approved: + if execution.current_stage: + execution.stage_results[execution.current_stage] = { + "status": "approved", + "approver": "user", + "comment": body.comment, + } + execution.status = "running" + store.update_execution( + execution.execution_id, + status="running", + stage_results=execution.stage_results, + ) + # Resume execution + workflow = store.get(execution.workflow_id) + if workflow: + asyncio.create_task( + _execute_workflow(workflow, execution, execution.variables, store=store) + ) + else: + execution.status = "cancelled" + execution.completed_at = datetime.now(timezone.utc).isoformat() + if execution.current_stage: + execution.stage_results[execution.current_stage] = { + "status": "rejected", + "approver": "user", + "comment": body.comment, + } + store.update_execution( + execution.execution_id, + status="cancelled", + completed_at=execution.completed_at, + stage_results=execution.stage_results, + ) + + return execution.model_dump() + + +@router.post("/workflows/executions/{execution_id}/cancel") +async def cancel_execution(request: Request, execution_id: str): + """Cancel a running execution.""" + store = _get_store(request) + execution = store.get_execution(execution_id) + if execution is None: + raise HTTPException( + status_code=404, detail=f"执行记录 '{execution_id}' 不存在" + ) + if execution.status not in ("running", "paused", "pending"): + raise HTTPException( + status_code=400, detail="当前执行状态无法取消" + ) + + execution.status = "cancelled" + execution.completed_at = datetime.now(timezone.utc).isoformat() + store.update_execution( + execution.execution_id, + status="cancelled", + completed_at=execution.completed_at, + ) + return execution.model_dump() + + +@router.websocket("/workflows/ws") +async def workflow_websocket(websocket: WebSocket): + """Real-time workflow execution progress WebSocket.""" + # Authentication + configured_api_key: str | None = None + if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config: + configured_api_key = websocket.app.state.server_config.api_key + if configured_api_key is None and hasattr(websocket.app.state, "api_key"): + configured_api_key = websocket.app.state.api_key + + if configured_api_key: + provided = websocket.query_params.get("api_key") + if provided != configured_api_key: + await websocket.accept() + await websocket.send_json( + {"event": "error", "data": {"message": "Invalid or missing api_key"}} + ) + await websocket.close(code=4001, reason="Invalid or missing api_key") + return + + await websocket.accept() + _ws_subscribers.append(websocket) + + try: + while True: + try: + raw = await asyncio.wait_for(websocket.receive_text(), timeout=120.0) + except asyncio.TimeoutError: + await websocket.close(code=1000, reason="Heartbeat timeout") + return + # Keep connection alive - messages are primarily server-push + except WebSocketDisconnect: + logger.debug("Workflow WebSocket disconnected") + except Exception as e: + logger.error(f"Workflow WebSocket error: {e}") + finally: + if websocket in _ws_subscribers: + _ws_subscribers.remove(websocket) diff --git a/tests/unit/server/test_evolution_dashboard.py b/tests/unit/server/test_evolution_dashboard.py new file mode 100644 index 0000000..313dc5b --- /dev/null +++ b/tests/unit/server/test_evolution_dashboard.py @@ -0,0 +1,518 @@ +"""Tests for Evolution Dashboard API routes""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from agentkit.llm.gateway import LLMGateway +from agentkit.server.app import create_app +from agentkit.server.routes.evolution_dashboard import ( + DashboardExperience, + DashboardOptimization, + _experiences, + _optimizations, +) +from agentkit.skills.registry import SkillRegistry +from agentkit.tools.registry import ToolRegistry + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_llm_gateway(): + return LLMGateway() + + +@pytest.fixture +def skill_registry(): + return SkillRegistry() + + +@pytest.fixture +def tool_registry(): + return ToolRegistry() + + +@pytest.fixture +def app(mock_llm_gateway, skill_registry, tool_registry): + return create_app( + llm_gateway=mock_llm_gateway, + skill_registry=skill_registry, + tool_registry=tool_registry, + ) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +@pytest.fixture(autouse=True) +def clear_in_memory_stores(): + """Clear in-memory stores before each test""" + _experiences.clear() + _optimizations.clear() + yield + _experiences.clear() + _optimizations.clear() + + +def _add_experience( + task_type: str = "code_review", + goal: str = "Review PR #123", + outcome: str = "success", + duration: float = 30.0, +): + """Helper to add an experience to the in-memory store""" + exp = DashboardExperience( + id=f"exp-{len(_experiences)}", + task_type=task_type, + goal=goal, + outcome=outcome, + duration_seconds=duration, + created_at=datetime.now(timezone.utc), + ) + _experiences.append(exp) + return exp + + +def _add_optimization( + task_type: str = "code_review", + previous_path: list[str] | None = None, + current_path: list[str] | None = None, + improvement: float = 0.15, +): + """Helper to add an optimization to the in-memory store""" + opt = DashboardOptimization( + id=f"opt-{len(_optimizations)}", + task_type=task_type, + previous_path=previous_path or ["step1", "step2", "step3"], + current_path=current_path or ["step1", "step3"], + improvement=improvement, + updated_at=datetime.now(timezone.utc), + ) + _optimizations.append(opt) + return opt + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/experiences +# --------------------------------------------------------------------------- + + +class TestListExperiences: + def test_list_experiences_empty(self, client): + response = client.get("/api/v1/evolution-dashboard/experiences") + assert response.status_code == 200 + data = response.json() + assert "experiences" in data + assert "total" in data + assert data["total"] == 0 + assert data["experiences"] == [] + + def test_list_experiences_with_data(self, client): + _add_experience(goal="Review PR #1", outcome="success") + _add_experience(goal="Review PR #2", outcome="failure") + + response = client.get("/api/v1/evolution-dashboard/experiences") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + assert len(data["experiences"]) == 2 + + def test_list_experiences_filter_by_task_type(self, client): + _add_experience(task_type="code_review", goal="Review PR") + _add_experience(task_type="data_analysis", goal="Analyze data") + + response = client.get( + "/api/v1/evolution-dashboard/experiences?task_type=code_review" + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["experiences"][0]["task_type"] == "code_review" + + def test_list_experiences_filter_by_outcome(self, client): + _add_experience(outcome="success", goal="Success task") + _add_experience(outcome="failure", goal="Failed task") + + response = client.get( + "/api/v1/evolution-dashboard/experiences?outcome=success" + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["experiences"][0]["outcome"] == "success" + + def test_list_experiences_limit(self, client): + for i in range(10): + _add_experience(goal=f"Task {i}") + + response = client.get( + "/api/v1/evolution-dashboard/experiences?limit=3" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["experiences"]) == 3 + + def test_experience_structure(self, client): + _add_experience() + + response = client.get("/api/v1/evolution-dashboard/experiences") + data = response.json() + exp = data["experiences"][0] + assert "id" in exp + assert "task_type" in exp + assert "goal" in exp + assert "outcome" in exp + assert "duration" in exp + assert "created_at" in exp + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/metrics +# --------------------------------------------------------------------------- + + +class TestGetMetrics: + def test_metrics_empty(self, client): + response = client.get("/api/v1/evolution-dashboard/metrics") + assert response.status_code == 200 + data = response.json() + assert "metrics" in data + assert "trends" in data + metrics = data["metrics"] + assert metrics["total_tasks"] == 0 + assert metrics["success_rate"] == 0.0 + + def test_metrics_with_data(self, client): + _add_experience(outcome="success", duration=10.0) + _add_experience(outcome="success", duration=20.0) + _add_experience(outcome="failure", duration=30.0) + + response = client.get("/api/v1/evolution-dashboard/metrics") + assert response.status_code == 200 + data = response.json() + metrics = data["metrics"] + assert metrics["total_tasks"] == 3 + assert metrics["success_rate"] == pytest.approx(2 / 3, abs=0.01) + assert metrics["avg_duration"] == pytest.approx(20.0, abs=0.1) + + def test_metrics_period_7d(self, client): + response = client.get("/api/v1/evolution-dashboard/metrics?period=7d") + assert response.status_code == 200 + data = response.json() + assert len(data["trends"]) == 7 + + def test_metrics_period_30d(self, client): + response = client.get("/api/v1/evolution-dashboard/metrics?period=30d") + assert response.status_code == 200 + data = response.json() + assert len(data["trends"]) == 30 + + def test_metrics_trends_structure(self, client): + response = client.get("/api/v1/evolution-dashboard/metrics?period=7d") + data = response.json() + for trend in data["trends"]: + assert "date" in trend + assert "success_rate" in trend + assert "avg_duration" in trend + assert "retry_rate" in trend + + def test_metrics_period_all(self, client): + response = client.get("/api/v1/evolution-dashboard/metrics?period=all") + assert response.status_code == 200 + data = response.json() + assert len(data["trends"]) == 30 + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/pitfalls +# --------------------------------------------------------------------------- + + +class TestCheckPitfalls: + def test_pitfalls_no_detector(self, client): + """When pitfall_detector is not configured, return empty warnings""" + response = client.get( + "/api/v1/evolution-dashboard/pitfalls?task_type=code_review&steps=step1,step2" + ) + assert response.status_code == 200 + data = response.json() + assert "warnings" in data + assert data["warnings"] == [] + + def test_pitfalls_no_steps(self, client): + """When no steps provided, return empty warnings""" + response = client.get( + "/api/v1/evolution-dashboard/pitfalls?task_type=code_review" + ) + assert response.status_code == 200 + data = response.json() + assert data["warnings"] == [] + + def test_pitfalls_with_steps(self, client): + """When steps are provided but no detector, return empty warnings""" + response = client.get( + "/api/v1/evolution-dashboard/pitfalls?task_type=code_review&steps=analyze,review,approve" + ) + assert response.status_code == 200 + data = response.json() + assert "warnings" in data + + def test_pitfalls_missing_task_type(self, client): + """When task_type is missing, should return 422""" + response = client.get("/api/v1/evolution-dashboard/pitfalls") + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /evolution-dashboard/path-optimizations +# --------------------------------------------------------------------------- + + +class TestListPathOptimizations: + def test_optimizations_empty(self, client): + response = client.get("/api/v1/evolution-dashboard/path-optimizations") + assert response.status_code == 200 + data = response.json() + assert "optimizations" in data + assert data["optimizations"] == [] + + def test_optimizations_with_data(self, client): + _add_optimization( + task_type="code_review", + previous_path=["analyze", "review", "approve"], + current_path=["analyze", "approve"], + improvement=0.2, + ) + + response = client.get("/api/v1/evolution-dashboard/path-optimizations") + assert response.status_code == 200 + data = response.json() + assert len(data["optimizations"]) == 1 + opt = data["optimizations"][0] + assert opt["task_type"] == "code_review" + assert opt["improvement"] == 0.2 + assert opt["previous_path"] == ["analyze", "review", "approve"] + assert opt["current_path"] == ["analyze", "approve"] + + def test_optimizations_filter_by_task_type(self, client): + _add_optimization(task_type="code_review") + _add_optimization(task_type="data_analysis") + + response = client.get( + "/api/v1/evolution-dashboard/path-optimizations?task_type=code_review" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["optimizations"]) == 1 + assert data["optimizations"][0]["task_type"] == "code_review" + + def test_optimizations_limit(self, client): + for i in range(10): + _add_optimization(task_type=f"task_{i}") + + response = client.get( + "/api/v1/evolution-dashboard/path-optimizations?limit=3" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["optimizations"]) <= 3 + + def test_optimization_structure(self, client): + _add_optimization() + + response = client.get("/api/v1/evolution-dashboard/path-optimizations") + data = response.json() + opt = data["optimizations"][0] + assert "id" in opt + assert "task_type" in opt + assert "previous_path" in opt + assert "current_path" in opt + assert "improvement" in opt + assert "updated_at" in opt + + +# --------------------------------------------------------------------------- +# WebSocket /evolution-dashboard/ws +# --------------------------------------------------------------------------- + + +class TestEvolutionDashboardWebSocket: + def test_ws_connect(self, client): + with client.websocket_connect("/api/v1/evolution-dashboard/ws") as ws: + data = ws.receive_json() + assert data["type"] == "connected" + + def test_ws_ping_pong(self, client): + with client.websocket_connect("/api/v1/evolution-dashboard/ws") as ws: + # Receive connected message + connected = ws.receive_json() + assert connected["type"] == "connected" + + # Send ping + ws.send_json({"type": "ping"}) + pong = ws.receive_json() + assert pong["type"] == "pong" + + def test_ws_subscribe(self, client): + with client.websocket_connect("/api/v1/evolution-dashboard/ws") as ws: + connected = ws.receive_json() + assert connected["type"] == "connected" + + ws.send_json({"type": "subscribe", "channels": ["experiences"]}) + sub = ws.receive_json() + assert sub["type"] == "subscribed" + + +# --------------------------------------------------------------------------- +# With experience_store configured +# --------------------------------------------------------------------------- + + +class TestWithExperienceStore: + def test_experiences_with_store(self, app, client): + """Test that experiences endpoint works when experience_store is configured""" + mock_store = AsyncMock() + mock_store.search = AsyncMock(return_value=[]) + + app.state.experience_store = mock_store + + response = client.get("/api/v1/evolution-dashboard/experiences") + assert response.status_code == 200 + data = response.json() + assert "experiences" in data + + # Clean up + app.state.experience_store = None + + def test_metrics_with_store(self, app, client): + """Test that metrics endpoint works when experience_store is configured""" + from agentkit.evolution.experience_schema import EvolutionMetrics + + mock_store = AsyncMock() + mock_metrics = EvolutionMetrics( + task_type="code_review", + time_window="7d", + completion_rate=0.85, + avg_duration=25.0, + retry_rate=0.1, + sample_count=100, + window_start=datetime.now(timezone.utc), + window_end=datetime.now(timezone.utc), + ) + mock_store.get_metrics = AsyncMock(return_value=[mock_metrics]) + + app.state.experience_store = mock_store + + response = client.get("/api/v1/evolution-dashboard/metrics?period=7d") + assert response.status_code == 200 + data = response.json() + assert data["metrics"]["total_tasks"] == 100 + assert data["metrics"]["success_rate"] == 0.85 + + # Clean up + app.state.experience_store = None + + def test_pitfalls_with_detector(self, app, client): + """Test that pitfalls endpoint works when pitfall_detector is configured""" + from agentkit.evolution.pitfall_detector import PitfallWarning, WarningLevel + + mock_detector = AsyncMock() + mock_detector.check_pitfalls = AsyncMock( + return_value=[ + PitfallWarning( + step_name="deploy", + warning_level=WarningLevel.HIGH, + failure_rate=0.6, + historical_failures=["timeout", "config error"], + suggestion="该步骤历史失败率高达 60%,建议重点关注", + ) + ] + ) + + app.state.pitfall_detector = mock_detector + + response = client.get( + "/api/v1/evolution-dashboard/pitfalls?task_type=deployment&steps=build,deploy,verify" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["warnings"]) == 1 + assert data["warnings"][0]["step"] == "deploy" + assert data["warnings"][0]["risk_level"] == "high" + assert data["warnings"][0]["historical_failure_rate"] == 0.6 + + # Clean up + app.state.pitfall_detector = None + + def test_optimizations_with_optimizer(self, app, client): + """Test that path-optimizations endpoint works when path_optimizer is configured""" + from agentkit.evolution.path_optimizer import ExecutionPath + + mock_optimizer = MagicMock() + recommended_path = ExecutionPath( + path_id="path-001", + task_type="code_review", + steps=["analyze", "approve"], + total_duration=15.0, + success_rate=0.9, + sample_count=10, + is_recommended=True, + created_at=datetime.now(timezone.utc), + ) + mock_optimizer.get_recommended_path = MagicMock(return_value=recommended_path) + mock_optimizer._recommended_paths = {"code_review": recommended_path} + + app.state.path_optimizer = mock_optimizer + + response = client.get("/api/v1/evolution-dashboard/path-optimizations") + assert response.status_code == 200 + data = response.json() + assert len(data["optimizations"]) >= 1 + + # Clean up + app.state.path_optimizer = None + + +# --------------------------------------------------------------------------- +# Without experience_store configured (graceful degradation) +# --------------------------------------------------------------------------- + + +class TestWithoutExperienceStore: + def test_experiences_graceful(self, client): + """When experience_store is None, should still return valid response""" + response = client.get("/api/v1/evolution-dashboard/experiences") + assert response.status_code == 200 + assert "experiences" in response.json() + + def test_metrics_graceful(self, client): + """When experience_store is None, should still return valid response""" + response = client.get("/api/v1/evolution-dashboard/metrics") + assert response.status_code == 200 + assert "metrics" in response.json() + assert "trends" in response.json() + + def test_pitfalls_graceful(self, client): + """When pitfall_detector is None, should return empty warnings""" + response = client.get( + "/api/v1/evolution-dashboard/pitfalls?task_type=test&steps=step1" + ) + assert response.status_code == 200 + assert response.json()["warnings"] == [] + + def test_optimizations_graceful(self, client): + """When path_optimizer is None, should still return valid response""" + response = client.get("/api/v1/evolution-dashboard/path-optimizations") + assert response.status_code == 200 + assert "optimizations" in response.json() diff --git a/tests/unit/server/test_kb_management.py b/tests/unit/server/test_kb_management.py new file mode 100644 index 0000000..6c618ab --- /dev/null +++ b/tests/unit/server/test_kb_management.py @@ -0,0 +1,326 @@ +"""Tests for Knowledge Base Management API routes""" + +from __future__ import annotations + +import io +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from agentkit.llm.gateway import LLMGateway +from agentkit.server.app import create_app +from agentkit.server.routes.kb_management import KnowledgeSourceStore, KnowledgeSource +from agentkit.skills.base import Skill, SkillConfig +from agentkit.skills.registry import SkillRegistry +from agentkit.tools.registry import ToolRegistry + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_llm_gateway(): + gateway = LLMGateway() + return gateway + + +@pytest.fixture +def skill_registry(): + return SkillRegistry() + + +@pytest.fixture +def tool_registry(): + return ToolRegistry() + + +@pytest.fixture +def app(mock_llm_gateway, skill_registry, tool_registry): + return create_app( + llm_gateway=mock_llm_gateway, + skill_registry=skill_registry, + tool_registry=tool_registry, + ) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +# --------------------------------------------------------------------------- +# KnowledgeSourceStore unit tests +# --------------------------------------------------------------------------- + + +class TestKnowledgeSourceStore: + def test_add_source(self): + store = KnowledgeSourceStore() + source = store.add_source("测试知识库", "local", {}) + assert source.id is not None + assert source.name == "测试知识库" + assert source.type == "local" + assert source.status == "active" + + def test_get_source(self): + store = KnowledgeSourceStore() + source = store.add_source("飞书知识库", "feishu", {"app_id": "test"}) + retrieved = store.get_source(source.id) + assert retrieved is not None + assert retrieved.name == "飞书知识库" + + def test_get_source_not_found(self): + store = KnowledgeSourceStore() + assert store.get_source("nonexistent") is None + + def test_remove_source(self): + store = KnowledgeSourceStore() + source = store.add_source("待删除", "local", {}) + assert store.remove_source(source.id) is True + assert store.get_source(source.id) is None + + def test_remove_source_not_found(self): + store = KnowledgeSourceStore() + assert store.remove_source("nonexistent") is False + + def test_list_sources(self): + store = KnowledgeSourceStore() + store.add_source("源1", "local", {}) + store.add_source("源2", "feishu", {}) + sources = store.list_sources() + assert len(sources) == 2 + + def test_list_sources_empty(self): + store = KnowledgeSourceStore() + assert store.list_sources() == [] + + def test_add_document_updates_source(self): + store = KnowledgeSourceStore() + source = store.add_source("本地文档", "local", {}) + from agentkit.server.routes.kb_management import UploadedDocument + doc = UploadedDocument( + document_id="doc-1", + filename="test.pdf", + source_id=source.id, + chunks=5, + status="indexed", + ) + store.add_document(doc) + updated_source = store.get_source(source.id) + assert updated_source.document_count == 1 + assert updated_source.last_synced is not None + + +# --------------------------------------------------------------------------- +# GET /kb-management/sources +# --------------------------------------------------------------------------- + + +class TestListSources: + def test_list_sources_empty(self, client): + response = client.get("/api/v1/kb-management/sources") + assert response.status_code == 200 + data = response.json() + assert "sources" in data + assert isinstance(data["sources"], list) + + def test_list_sources_after_add(self, client): + # Add a source first + client.post( + "/api/v1/kb-management/sources", + json={"name": "测试源", "type": "local", "config": {}}, + ) + response = client.get("/api/v1/kb-management/sources") + assert response.status_code == 200 + data = response.json() + assert len(data["sources"]) >= 1 + + +# --------------------------------------------------------------------------- +# POST /kb-management/sources +# --------------------------------------------------------------------------- + + +class TestAddSource: + def test_add_local_source(self, client): + response = client.post( + "/api/v1/kb-management/sources", + json={"name": "本地文档", "type": "local", "config": {}}, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "本地文档" + assert data["type"] == "local" + assert data["status"] == "active" + + def test_add_feishu_source(self, client): + response = client.post( + "/api/v1/kb-management/sources", + json={ + "name": "飞书知识库", + "type": "feishu", + "config": {"app_id": "test", "app_secret": "secret"}, + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["type"] == "feishu" + + def test_add_confluence_source(self, client): + response = client.post( + "/api/v1/kb-management/sources", + json={ + "name": "Confluence", + "type": "confluence", + "config": {"base_url": "https://wiki.example.com"}, + }, + ) + assert response.status_code == 201 + + def test_add_http_source(self, client): + response = client.post( + "/api/v1/kb-management/sources", + json={ + "name": "HTTP API", + "type": "http", + "config": {"url": "https://api.example.com/kb"}, + }, + ) + assert response.status_code == 201 + + def test_add_source_invalid_type(self, client): + response = client.post( + "/api/v1/kb-management/sources", + json={"name": "无效类型", "type": "invalid", "config": {}}, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# DELETE /kb-management/sources/{source_id} +# --------------------------------------------------------------------------- + + +class TestRemoveSource: + def test_remove_source(self, client): + add_resp = client.post( + "/api/v1/kb-management/sources", + json={"name": "待删除", "type": "local", "config": {}}, + ) + source_id = add_resp.json()["id"] + + response = client.delete(f"/api/v1/kb-management/sources/{source_id}") + assert response.status_code == 200 + assert response.json()["status"] == "removed" + + def test_remove_source_not_found(self, client): + response = client.delete("/api/v1/kb-management/sources/nonexistent") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /kb-management/documents/upload +# --------------------------------------------------------------------------- + + +class TestUploadDocument: + def test_upload_document(self, client): + file_content = b"This is a test document content for upload." + response = client.post( + "/api/v1/kb-management/documents/upload", + files={"file": ("test.txt", io.BytesIO(file_content), "text/plain")}, + ) + assert response.status_code == 200 + data = response.json() + assert data["filename"] == "test.txt" + assert data["document_id"] is not None + assert data["status"] == "indexed" + assert data["chunks"] >= 1 + + def test_upload_document_with_source_id(self, client): + # Create a source first + add_resp = client.post( + "/api/v1/kb-management/sources", + json={"name": "上传源", "type": "local", "config": {}}, + ) + source_id = add_resp.json()["id"] + + file_content = b"Test content with source ID." + response = client.post( + "/api/v1/kb-management/documents/upload", + files={"file": ("doc.txt", io.BytesIO(file_content), "text/plain")}, + data={"source_id": source_id}, + ) + assert response.status_code == 200 + data = response.json() + assert data["filename"] == "doc.txt" + + +# --------------------------------------------------------------------------- +# POST /kb-management/search +# --------------------------------------------------------------------------- + + +class TestSearchKnowledge: + def test_search_returns_results(self, client): + response = client.post( + "/api/v1/kb-management/search", + json={"query": "测试查询", "top_k": 5}, + ) + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert isinstance(data["results"], list) + + def test_search_with_sources_filter(self, client): + response = client.post( + "/api/v1/kb-management/search", + json={"query": "测试", "sources": ["local"], "top_k": 3}, + ) + assert response.status_code == 200 + + def test_search_default_top_k(self, client): + response = client.post( + "/api/v1/kb-management/search", + json={"query": "默认参数"}, + ) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# GET /kb-management/sources/{source_id}/health +# --------------------------------------------------------------------------- + + +class TestSourceHealth: + def test_health_local_source(self, client): + add_resp = client.post( + "/api/v1/kb-management/sources", + json={"name": "本地", "type": "local", "config": {}}, + ) + source_id = add_resp.json()["id"] + + response = client.get(f"/api/v1/kb-management/sources/{source_id}/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["source_id"] == source_id + + def test_health_external_source(self, client): + add_resp = client.post( + "/api/v1/kb-management/sources", + json={"name": "外部", "type": "feishu", "config": {}}, + ) + source_id = add_resp.json()["id"] + + response = client.get(f"/api/v1/kb-management/sources/{source_id}/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "unknown" + + def test_health_source_not_found(self, client): + response = client.get("/api/v1/kb-management/sources/nonexistent/health") + assert response.status_code == 404 diff --git a/tests/unit/server/test_skill_management.py b/tests/unit/server/test_skill_management.py new file mode 100644 index 0000000..aeae30c --- /dev/null +++ b/tests/unit/server/test_skill_management.py @@ -0,0 +1,243 @@ +"""Tests for Skill Management API routes""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from agentkit.llm.gateway import LLMGateway +from agentkit.server.app import create_app +from agentkit.skills.base import Skill, SkillConfig +from agentkit.skills.registry import SkillRegistry +from agentkit.tools.registry import ToolRegistry + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_llm_gateway(): + return LLMGateway() + + +@pytest.fixture +def skill_registry(): + return SkillRegistry() + + +@pytest.fixture +def tool_registry(): + return ToolRegistry() + + +@pytest.fixture +def app(mock_llm_gateway, skill_registry, tool_registry): + return create_app( + llm_gateway=mock_llm_gateway, + skill_registry=skill_registry, + tool_registry=tool_registry, + ) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def _register_skill(registry: SkillRegistry, name: str = "test_skill", **kwargs): + """Helper to register a skill with sensible defaults.""" + config = SkillConfig( + name=name, + agent_type="test_type", + task_mode="llm_generate", + prompt={"identity": "Test Skill", "instructions": "Handle test"}, + intent={"keywords": ["test"], "description": "A test skill"}, + **kwargs, + ) + skill = Skill(config=config) + registry.register(skill) + return skill + + +# --------------------------------------------------------------------------- +# GET /skill-management/skills +# --------------------------------------------------------------------------- + + +class TestListSkills: + def test_list_skills_empty(self, client): + response = client.get("/api/v1/skill-management/skills") + assert response.status_code == 200 + data = response.json() + assert "skills" in data + assert isinstance(data["skills"], list) + assert "total" in data + assert "page" in data + assert "size" in data + + def test_list_skills_with_registered(self, client, skill_registry): + _register_skill(skill_registry, "skill_a") + _register_skill(skill_registry, "skill_b") + + response = client.get("/api/v1/skill-management/skills") + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 2 + assert len(data["skills"]) >= 2 + + def test_list_skills_pagination(self, client, skill_registry): + for i in range(5): + _register_skill(skill_registry, f"page_skill_{i}") + + response = client.get("/api/v1/skill-management/skills?page=1&size=2") + assert response.status_code == 200 + data = response.json() + assert data["size"] == 2 + assert len(data["skills"]) <= 2 + assert data["total"] >= 5 + + def test_list_skills_skill_structure(self, client, skill_registry): + _register_skill(skill_registry, "struct_skill") + + response = client.get("/api/v1/skill-management/skills") + data = response.json() + if data["skills"]: + skill = data["skills"][0] + assert "name" in skill + assert "version" in skill + assert "description" in skill + assert "capabilities" in skill + assert "dependencies" in skill + assert "status" in skill + + +# --------------------------------------------------------------------------- +# GET /skill-management/skills/{skill_name} +# --------------------------------------------------------------------------- + + +class TestGetSkillDetail: + def test_get_skill_detail(self, client, skill_registry): + _register_skill(skill_registry, "detail_skill") + + response = client.get("/api/v1/skill-management/skills/detail_skill") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "detail_skill" + assert "version" in data + assert "description" in data + assert "capabilities" in data + assert "dependencies" in data + assert "config" in data + assert "health_status" in data + + def test_get_skill_detail_not_found(self, client): + response = client.get("/api/v1/skill-management/skills/nonexistent") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /skill-management/skills/{skill_name}/health +# --------------------------------------------------------------------------- + + +class TestSkillHealth: + def test_skill_health(self, client, skill_registry): + _register_skill(skill_registry, "health_skill") + + response = client.get("/api/v1/skill-management/skills/health_skill/health") + assert response.status_code == 200 + data = response.json() + assert data["skill_name"] == "health_skill" + assert data["status"] == "healthy" + + def test_skill_health_not_found(self, client): + response = client.get("/api/v1/skill-management/skills/nonexistent/health") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /skill-management/capabilities +# --------------------------------------------------------------------------- + + +class TestListCapabilities: + def test_list_capabilities_empty(self, client): + response = client.get("/api/v1/skill-management/capabilities") + assert response.status_code == 200 + data = response.json() + assert "capabilities" in data + assert isinstance(data["capabilities"], list) + + def test_list_capabilities_with_skills(self, client, skill_registry): + _register_skill(skill_registry, "cap_skill", capabilities=["chat"]) + + response = client.get("/api/v1/skill-management/capabilities") + assert response.status_code == 200 + data = response.json() + assert len(data["capabilities"]) >= 1 + # Each capability should have name, display_name, skill_count + for cap in data["capabilities"]: + assert "name" in cap + assert "display_name" in cap + assert "skill_count" in cap + + def test_list_capabilities_structure(self, client, skill_registry): + _register_skill(skill_registry, "multi_cap_skill", capabilities=["chat", "search"]) + + response = client.get("/api/v1/skill-management/capabilities") + data = response.json() + cap_names = [c["name"] for c in data["capabilities"]] + assert "chat" in cap_names + assert "search" in cap_names + + +# --------------------------------------------------------------------------- +# POST /skill-management/skills/{skill_name}/reload +# --------------------------------------------------------------------------- + + +class TestReloadSkill: + def test_reload_skill(self, client, skill_registry): + _register_skill(skill_registry, "reload_skill") + + response = client.post("/api/v1/skill-management/skills/reload_skill/reload") + assert response.status_code == 200 + data = response.json() + assert data["skill_name"] == "reload_skill" + assert data["status"] == "reloaded" + + def test_reload_skill_not_found(self, client): + response = client.post("/api/v1/skill-management/skills/nonexistent/reload") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Capability filtering +# --------------------------------------------------------------------------- + + +class TestSkillCapabilityFilter: + def test_filter_by_capability(self, client, skill_registry): + _register_skill(skill_registry, "chat_only_skill", capabilities=["chat"]) + _register_skill(skill_registry, "search_only_skill", capabilities=["search"]) + + response = client.get("/api/v1/skill-management/skills?capability=chat") + assert response.status_code == 200 + data = response.json() + # All returned skills should have "chat" capability + for skill in data["skills"]: + assert "chat" in skill["capabilities"] + + def test_filter_by_nonexistent_capability(self, client, skill_registry): + _register_skill(skill_registry, "some_skill") + + response = client.get("/api/v1/skill-management/skills?capability=nonexistent_cap") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert len(data["skills"]) == 0 diff --git a/tests/unit/server/test_workflow_routes.py b/tests/unit/server/test_workflow_routes.py new file mode 100644 index 0000000..1231c96 --- /dev/null +++ b/tests/unit/server/test_workflow_routes.py @@ -0,0 +1,754 @@ +"""Tests for Workflow API routes""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from agentkit.llm.gateway import LLMGateway +from agentkit.server.app import create_app +from agentkit.server.routes.workflows import WorkflowStore, router as workflow_router +from agentkit.skills.registry import SkillRegistry +from agentkit.tools.registry import ToolRegistry + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_llm_gateway(): + return LLMGateway() + + +@pytest.fixture +def skill_registry(): + return SkillRegistry() + + +@pytest.fixture +def tool_registry(): + return ToolRegistry() + + +@pytest.fixture +def app(mock_llm_gateway, skill_registry, tool_registry): + application = create_app( + llm_gateway=mock_llm_gateway, + skill_registry=skill_registry, + tool_registry=tool_registry, + ) + # Register workflow routes (not yet in app.py) + application.include_router(workflow_router, prefix="/api/v1") + # Attach a fresh WorkflowStore to app state for isolation + application.state.workflow_store = WorkflowStore() + return application + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def _create_workflow(client, name: str = "测试工作流", stages: list | None = None): + """Helper to create a workflow via API.""" + body: dict = {"name": name} + if stages is not None: + body["stages"] = stages + response = client.post("/api/v1/workflows", json=body) + return response + + +def _sample_stages(): + """Return a list of sample workflow stages.""" + return [ + { + "name": "开始", + "agent": "default", + "action": "start", + "depends_on": [], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "检查条件", + "agent": "default", + "action": "evaluate", + "depends_on": ["开始"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 60, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "condition", + "config": {"expression": "status == 'approved'"}, + }, + { + "name": "人工审批", + "agent": "default", + "action": "approve", + "depends_on": ["检查条件"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 3600, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "approval", + "config": {"approver": "admin", "timeout": 3600}, + }, + ] + + +# --------------------------------------------------------------------------- +# WorkflowStore unit tests +# --------------------------------------------------------------------------- + + +class TestWorkflowStore: + def test_save_and_get(self): + from agentkit.orchestrator.workflow_schema import WorkflowDefinition + + store = WorkflowStore() + wf = WorkflowDefinition(workflow_id="test-1", name="Test") + store.save(wf) + result = store.get("test-1") + assert result is not None + assert result.name == "Test" + + def test_get_not_found(self): + store = WorkflowStore() + assert store.get("nonexistent") is None + + def test_list(self): + from agentkit.orchestrator.workflow_schema import WorkflowDefinition + + store = WorkflowStore() + for i in range(3): + store.save(WorkflowDefinition(workflow_id=f"wf-{i}", name=f"Workflow {i}")) + summaries = store.list() + assert len(summaries) == 3 + + def test_list_limit(self): + from agentkit.orchestrator.workflow_schema import WorkflowDefinition + + store = WorkflowStore() + for i in range(5): + store.save(WorkflowDefinition(workflow_id=f"wf-{i}", name=f"Workflow {i}")) + summaries = store.list(limit=2) + assert len(summaries) == 2 + + def test_delete(self): + from agentkit.orchestrator.workflow_schema import WorkflowDefinition + + store = WorkflowStore() + store.save(WorkflowDefinition(workflow_id="del-1", name="Delete Me")) + assert store.delete("del-1") is True + assert store.get("del-1") is None + + def test_delete_not_found(self): + store = WorkflowStore() + assert store.delete("nonexistent") is False + + def test_create_and_get_execution(self): + store = WorkflowStore() + execution = store.create_execution("wf-1") + assert execution.workflow_id == "wf-1" + assert execution.status == "pending" + + fetched = store.get_execution(execution.execution_id) + assert fetched is not None + assert fetched.execution_id == execution.execution_id + + def test_get_execution_not_found(self): + store = WorkflowStore() + assert store.get_execution("nonexistent") is None + + def test_update_execution(self): + store = WorkflowStore() + execution = store.create_execution("wf-1") + updated = store.update_execution( + execution.execution_id, status="running", current_stage="step-1" + ) + assert updated.status == "running" + assert updated.current_stage == "step-1" + + def test_update_execution_not_found(self): + store = WorkflowStore() + with pytest.raises(KeyError): + store.update_execution("nonexistent", status="running") + + +# --------------------------------------------------------------------------- +# POST /workflows - Create +# --------------------------------------------------------------------------- + + +class TestCreateWorkflow: + def test_create_empty_workflow(self, client): + response = _create_workflow(client) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "测试工作流" + assert data["workflow_id"] is not None + assert data["version"] == 1 + + def test_create_workflow_with_stages(self, client): + response = _create_workflow(client, stages=_sample_stages()) + assert response.status_code == 201 + data = response.json() + assert len(data["stages"]) == 3 + assert data["stages"][0]["type"] == "skill" + assert data["stages"][1]["type"] == "condition" + assert data["stages"][2]["type"] == "approval" + + def test_create_workflow_missing_dependency(self, client): + stages = [ + { + "name": "步骤B", + "agent": "default", + "action": "do_b", + "depends_on": ["不存在的步骤"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + } + ] + response = _create_workflow(client, stages=stages) + assert response.status_code == 400 + assert "不存在" in response.json()["detail"] + + def test_create_workflow_circular_dependency(self, client): + stages = [ + { + "name": "A", + "agent": "default", + "action": "do_a", + "depends_on": ["B"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "B", + "agent": "default", + "action": "do_b", + "depends_on": ["A"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + ] + response = _create_workflow(client, stages=stages) + assert response.status_code == 400 + assert "循环依赖" in response.json()["detail"] + + +# --------------------------------------------------------------------------- +# GET /workflows - List +# --------------------------------------------------------------------------- + + +class TestListWorkflows: + def test_list_empty(self, client): + response = client.get("/api/v1/workflows") + assert response.status_code == 200 + data = response.json() + assert data["workflows"] == [] + assert data["total"] == 0 + + def test_list_after_create(self, client): + _create_workflow(client, "工作流1") + _create_workflow(client, "工作流2") + response = client.get("/api/v1/workflows") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + + def test_list_with_limit(self, client): + for i in range(5): + _create_workflow(client, f"工作流{i}") + response = client.get("/api/v1/workflows?limit=2") + assert response.status_code == 200 + data = response.json() + assert len(data["workflows"]) == 2 + + +# --------------------------------------------------------------------------- +# GET /workflows/{id} - Get +# --------------------------------------------------------------------------- + + +class TestGetWorkflow: + def test_get_existing(self, client): + create_resp = _create_workflow(client, stages=_sample_stages()) + workflow_id = create_resp.json()["workflow_id"] + + response = client.get(f"/api/v1/workflows/{workflow_id}") + assert response.status_code == 200 + data = response.json() + assert data["workflow_id"] == workflow_id + assert len(data["stages"]) == 3 + + def test_get_not_found(self, client): + response = client.get("/api/v1/workflows/nonexistent-id") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# PUT /workflows/{id} - Update +# --------------------------------------------------------------------------- + + +class TestUpdateWorkflow: + def test_update_name(self, client): + create_resp = _create_workflow(client, "原始名称") + workflow_id = create_resp.json()["workflow_id"] + + response = client.put( + f"/api/v1/workflows/{workflow_id}", + json={"name": "更新名称", "stages": []}, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "更新名称" + assert data["version"] == 2 + + def test_update_with_stages(self, client): + create_resp = _create_workflow(client) + workflow_id = create_resp.json()["workflow_id"] + + response = client.put( + f"/api/v1/workflows/{workflow_id}", + json={"name": "更新工作流", "stages": _sample_stages()}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["stages"]) == 3 + + def test_update_not_found(self, client): + response = client.put( + "/api/v1/workflows/nonexistent-id", + json={"name": "不存在", "stages": []}, + ) + assert response.status_code == 404 + + def test_update_circular_dependency(self, client): + create_resp = _create_workflow(client) + workflow_id = create_resp.json()["workflow_id"] + + stages = [ + { + "name": "A", + "agent": "default", + "action": "do_a", + "depends_on": ["B"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "B", + "agent": "default", + "action": "do_b", + "depends_on": ["A"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + ] + response = client.put( + f"/api/v1/workflows/{workflow_id}", + json={"name": "循环依赖", "stages": stages}, + ) + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- +# DELETE /workflows/{id} - Delete +# --------------------------------------------------------------------------- + + +class TestDeleteWorkflow: + def test_delete_existing(self, client): + create_resp = _create_workflow(client) + workflow_id = create_resp.json()["workflow_id"] + + response = client.delete(f"/api/v1/workflows/{workflow_id}") + assert response.status_code == 200 + + # Verify deleted + get_resp = client.get(f"/api/v1/workflows/{workflow_id}") + assert get_resp.status_code == 404 + + def test_delete_not_found(self, client): + response = client.delete("/api/v1/workflows/nonexistent-id") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /workflows/{id}/execute - Execute +# --------------------------------------------------------------------------- + + +class TestExecuteWorkflow: + def test_execute_workflow(self, client): + create_resp = _create_workflow(client, stages=_sample_stages()) + workflow_id = create_resp.json()["workflow_id"] + + response = client.post( + f"/api/v1/workflows/{workflow_id}/execute", + json={"variables": {"status": "approved"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["execution_id"] is not None + assert data["workflow_id"] == workflow_id + assert data["status"] in ("pending", "running") + + def test_execute_not_found(self, client): + response = client.post( + "/api/v1/workflows/nonexistent-id/execute", + json={"variables": {}}, + ) + assert response.status_code == 404 + + def test_execute_empty_workflow(self, client): + create_resp = _create_workflow(client) + workflow_id = create_resp.json()["workflow_id"] + + response = client.post( + f"/api/v1/workflows/{workflow_id}/execute", + json={"variables": {}}, + ) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# GET /workflows/executions/{id} - Execution status +# --------------------------------------------------------------------------- + + +class TestGetExecution: + def test_get_execution_status(self, client): + import time + + create_resp = _create_workflow(client, stages=_sample_stages()) + workflow_id = create_resp.json()["workflow_id"] + + exec_resp = client.post( + f"/api/v1/workflows/{workflow_id}/execute", + json={"variables": {}}, + ) + execution_id = exec_resp.json()["execution_id"] + + # Wait a bit for execution to progress + time.sleep(0.5) + + response = client.get(f"/api/v1/workflows/executions/{execution_id}") + assert response.status_code == 200 + data = response.json() + assert data["execution_id"] == execution_id + assert data["status"] in ("pending", "running", "completed", "paused") + + def test_get_execution_not_found(self, client): + response = client.get("/api/v1/workflows/executions/nonexistent-id") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /workflows/executions/{id}/approve - Approval +# --------------------------------------------------------------------------- + + +class TestApproveExecution: + def test_approve_not_paused(self, client): + create_resp = _create_workflow(client, stages=_sample_stages()) + workflow_id = create_resp.json()["workflow_id"] + + exec_resp = client.post( + f"/api/v1/workflows/{workflow_id}/execute", + json={"variables": {}}, + ) + execution_id = exec_resp.json()["execution_id"] + + # Try to approve when not paused (may already be completed) + response = client.post( + f"/api/v1/workflows/executions/{execution_id}/approve", + json={"approved": True, "comment": "同意"}, + ) + # Should be 400 if not paused, or 200 if already completed + assert response.status_code in (200, 400) + + def test_approve_not_found(self, client): + response = client.post( + "/api/v1/workflows/executions/nonexistent-id/approve", + json={"approved": True}, + ) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /workflows/executions/{id}/cancel - Cancel +# --------------------------------------------------------------------------- + + +class TestCancelExecution: + def test_cancel_execution(self, client): + create_resp = _create_workflow(client, stages=_sample_stages()) + workflow_id = create_resp.json()["workflow_id"] + + exec_resp = client.post( + f"/api/v1/workflows/{workflow_id}/execute", + json={"variables": {}}, + ) + execution_id = exec_resp.json()["execution_id"] + + # Try to cancel + response = client.post( + f"/api/v1/workflows/executions/{execution_id}/cancel" + ) + # May be 200 (cancelled) or 400 (already completed) + assert response.status_code in (200, 400) + + def test_cancel_not_found(self, client): + response = client.post( + "/api/v1/workflows/executions/nonexistent-id/cancel" + ) + assert response.status_code == 404 + + def test_cancel_completed_execution(self, client): + import time + + # Create a workflow with stages that will complete + create_resp = _create_workflow(client, stages=_sample_stages()) + workflow_id = create_resp.json()["workflow_id"] + + exec_resp = client.post( + f"/api/v1/workflows/{workflow_id}/execute", + json={"variables": {}}, + ) + execution_id = exec_resp.json()["execution_id"] + + # Wait for completion + time.sleep(2) + + # Check the execution status first + status_resp = client.get(f"/api/v1/workflows/executions/{execution_id}") + exec_status = status_resp.json()["status"] + + # Try to cancel - should fail if already completed + response = client.post( + f"/api/v1/workflows/executions/{execution_id}/cancel" + ) + if exec_status in ("completed", "failed"): + assert response.status_code == 400 + else: + # If still running/paused, cancel should succeed + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Validation tests +# --------------------------------------------------------------------------- + + +class TestValidation: + def test_missing_dependency_validation(self, client): + stages = [ + { + "name": "步骤A", + "agent": "default", + "action": "do_a", + "depends_on": [], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "步骤B", + "agent": "default", + "action": "do_b", + "depends_on": ["步骤C"], # C doesn't exist + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + ] + response = _create_workflow(client, stages=stages) + assert response.status_code == 400 + assert "不存在" in response.json()["detail"] + + def test_circular_dependency_validation(self, client): + stages = [ + { + "name": "X", + "agent": "default", + "action": "do_x", + "depends_on": ["Y"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "Y", + "agent": "default", + "action": "do_y", + "depends_on": ["X"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + ] + response = _create_workflow(client, stages=stages) + assert response.status_code == 400 + assert "循环依赖" in response.json()["detail"] + + def test_valid_linear_workflow(self, client): + stages = [ + { + "name": "步骤1", + "agent": "default", + "action": "do_1", + "depends_on": [], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "步骤2", + "agent": "default", + "action": "do_2", + "depends_on": ["步骤1"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + ] + response = _create_workflow(client, stages=stages) + assert response.status_code == 201 + + def test_valid_dag_workflow(self, client): + stages = [ + { + "name": "开始", + "agent": "default", + "action": "start", + "depends_on": [], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "并行A", + "agent": "default", + "action": "do_a", + "depends_on": ["开始"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "并行B", + "agent": "default", + "action": "do_b", + "depends_on": ["开始"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + { + "name": "合并", + "agent": "default", + "action": "merge", + "depends_on": ["并行A", "并行B"], + "inputs": {}, + "outputs": [], + "timeout_seconds": 300, + "retry_count": 0, + "continue_on_failure": False, + "condition": None, + "type": "skill", + "config": {}, + }, + ] + response = _create_workflow(client, stages=stages) + assert response.status_code == 201