feat(phase5): implement management pages, evolution dashboard, and workflow editor (U13b/U13c/U14)

This commit is contained in:
chiguyong 2026-06-10 01:29:01 +08:00
parent a1deeecede
commit c606ffa64a
47 changed files with 8727 additions and 100 deletions

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>
}
/** 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()

View File

@ -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<string, unknown>
}
export interface IUploadedDocument {
document_id: string
filename: string
chunks: number
status: string
}
export interface ISearchResult {
content: string
source: string
score: number
metadata: Record<string, unknown>
}
export interface ISourceHealth {
source_id: string
status: string
message: string
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>
}
/** 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<IKbSource> {
return request<IKbSource>('/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<IUploadedDocument> {
const formData = new FormData()
formData.append('file', file)
if (sourceId) {
formData.append('source_id', sourceId)
}
return request<IUploadedDocument>('/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<ISourceHealth> {
return request<ISourceHealth>(`/sources/${sourceId}/health`)
}

View File

@ -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<string, unknown>
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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>
}
/** List all skills with optional filtering */
export async function listSkills(
capability?: string,
page: number = 1,
size: number = 20
): Promise<ISkillListResponse> {
const params = new URLSearchParams()
if (capability) params.set('capability', capability)
params.set('page', String(page))
params.set('size', String(size))
return request<ISkillListResponse>(`/skills?${params.toString()}`)
}
/** Get skill detail */
export async function getSkillDetail(skillName: string): Promise<ISkillDetail> {
return request<ISkillDetail>(`/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',
})
}

View File

@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>
}
/** 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<IExecuteResponse> {
return request<IExecuteResponse>('/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`)
}

View File

@ -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<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
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<T>
}
/** 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<string, unknown>
output_schema?: Record<string, unknown>
}): Promise<WorkflowDefinition> {
return this.request('/workflows', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** Get a workflow by ID */
async getWorkflow(workflowId: string): Promise<WorkflowDefinition> {
return this.request(`/workflows/${workflowId}`)
}
/** Update a workflow */
async updateWorkflow(
workflowId: string,
data: {
name: string
stages: unknown[]
triggers?: unknown[]
variables_schema?: Record<string, unknown>
output_schema?: Record<string, unknown>
}
): Promise<WorkflowDefinition> {
return this.request(`/workflows/${workflowId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
/** Delete a workflow */
async deleteWorkflow(workflowId: string): Promise<void> {
await this.request(`/workflows/${workflowId}`, {
method: 'DELETE',
})
}
/** Execute a workflow */
async executeWorkflow(
workflowId: string,
variables?: Record<string, unknown>
): 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<WorkflowExecution> {
return this.request(`/workflows/executions/${executionId}`)
}
/** Approve a paused execution */
async approveExecution(
executionId: string,
approved: boolean,
comment?: string
): Promise<WorkflowExecution> {
return this.request(`/workflows/executions/${executionId}/approve`, {
method: 'POST',
body: JSON.stringify({ approved, comment }),
})
}
/** Cancel an execution */
async cancelExecution(executionId: string): Promise<WorkflowExecution> {
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 }

View File

@ -0,0 +1,239 @@
<template>
<div class="experience-timeline">
<div class="timeline-header">
<h3>经验时间线</h3>
<a-select
v-model:value="filterOutcome"
style="width: 120px"
size="small"
@change="onFilterChange"
>
<a-select-option value="">全部</a-select-option>
<a-select-option value="success">成功</a-select-option>
<a-select-option value="failure">失败</a-select-option>
</a-select>
</div>
<div v-if="experiences.length === 0" class="timeline-empty">
<a-empty description="暂无经验记录" />
</div>
<div v-else class="timeline-list">
<div
v-for="(exp, index) in experiences"
:key="exp.id"
class="timeline-item"
:class="{ 'timeline-item--left': index % 2 === 0, 'timeline-item--right': index % 2 !== 0 }"
>
<div class="timeline-dot" :class="`timeline-dot--${exp.outcome}`" />
<div class="timeline-card" @click="toggleExpand(exp.id)">
<div class="timeline-card__header">
<span class="timeline-card__goal">{{ exp.goal || exp.task_type }}</span>
<a-tag :color="exp.outcome === 'success' ? 'success' : 'error'" size="small">
{{ exp.outcome === 'success' ? '成功' : '失败' }}
</a-tag>
</div>
<div class="timeline-card__meta">
<span class="timeline-card__type">{{ exp.task_type }}</span>
<span class="timeline-card__duration">{{ formatDuration(exp.duration) }}</span>
<span class="timeline-card__time">{{ formatTime(exp.created_at) }}</span>
</div>
<div v-if="expandedId === exp.id" class="timeline-card__detail">
<div v-if="exp.steps_summary" class="detail-section">
<div class="detail-label">步骤摘要</div>
<div class="detail-text">{{ exp.steps_summary }}</div>
</div>
<div v-if="exp.failure_reasons?.length" class="detail-section">
<div class="detail-label">失败原因</div>
<ul class="detail-list">
<li v-for="(reason, i) in exp.failure_reasons" :key="i">{{ reason }}</li>
</ul>
</div>
<div v-if="exp.optimization_tips?.length" class="detail-section">
<div class="detail-label">优化建议</div>
<ul class="detail-list">
<li v-for="(tip, i) in exp.optimization_tips" :key="i">{{ tip }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Select as ASelect, SelectOption as ASelectOption, Tag as ATag, Empty as AEmpty } from 'ant-design-vue'
import type { Experience } from '@/api/evolution'
const props = defineProps<{
experiences: Experience[]
}>()
const emit = defineEmits<{
(e: 'filter', outcome: string): void
}>()
const expandedId = ref<string | null>(null)
const filterOutcome = ref('')
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
function onFilterChange() {
emit('filter', filterOutcome.value)
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds.toFixed(1)}`
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}分钟`
return `${(seconds / 3600).toFixed(1)}小时`
}
function formatTime(isoStr: string): string {
if (!isoStr) return ''
const d = new Date(isoStr)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
</script>
<style scoped>
.experience-timeline {
height: 100%;
display: flex;
flex-direction: column;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.timeline-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.timeline-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.timeline-list {
flex: 1;
overflow-y: auto;
position: relative;
padding-left: 20px;
}
.timeline-list::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: #e8e8e8;
}
.timeline-item {
position: relative;
margin-bottom: 12px;
}
.timeline-dot {
position: absolute;
left: -16px;
top: 12px;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid #fff;
z-index: 1;
}
.timeline-dot--success {
background: #52c41a;
}
.timeline-dot--failure {
background: #ff4d4f;
}
.timeline-card {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 10px 12px;
cursor: pointer;
transition: box-shadow 0.2s;
}
.timeline-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.timeline-card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.timeline-card__goal {
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.timeline-card__meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #8c8c8c;
}
.timeline-card__type {
color: #1890ff;
}
.timeline-card__detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.detail-section {
margin-bottom: 8px;
}
.detail-label {
font-size: 12px;
font-weight: 600;
color: #595959;
margin-bottom: 4px;
}
.detail-text {
font-size: 12px;
color: #8c8c8c;
line-height: 1.5;
}
.detail-list {
margin: 0;
padding-left: 16px;
font-size: 12px;
color: #8c8c8c;
line-height: 1.6;
}
</style>

View File

@ -0,0 +1,302 @@
<template>
<div class="metrics-chart">
<div class="chart-header">
<h3>指标趋势</h3>
<div class="period-selector">
<a-radio-group v-model:value="selectedPeriod" size="small" @change="onPeriodChange">
<a-radio-button value="7d">7</a-radio-button>
<a-radio-button value="30d">30</a-radio-button>
<a-radio-button value="all">全部</a-radio-button>
</a-radio-group>
</div>
</div>
<div v-if="trends.length === 0" class="chart-empty">
<a-empty description="暂无趋势数据" />
</div>
<div v-else class="chart-body">
<!-- Summary cards -->
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">成功率</div>
<div class="summary-value" style="color: #52c41a">
{{ metrics ? (metrics.success_rate * 100).toFixed(1) + '%' : '-' }}
</div>
</div>
<div class="summary-card">
<div class="summary-label">平均耗时</div>
<div class="summary-value" style="color: #1890ff">
{{ metrics ? formatDuration(metrics.avg_duration) : '-' }}
</div>
</div>
<div class="summary-card">
<div class="summary-label">重试率</div>
<div class="summary-value" style="color: #fa8c16">
{{ metrics ? (metrics.retry_rate * 100).toFixed(1) + '%' : '-' }}
</div>
</div>
</div>
<!-- SVG Chart -->
<div class="svg-chart-container">
<svg :width="chartWidth" :height="chartHeight" class="svg-chart">
<!-- Grid lines -->
<line
v-for="i in 5"
:key="'grid-' + i"
:x1="chartPadding.left"
:y1="chartPadding.top + (chartHeight - chartPadding.top - chartPadding.bottom) * (i - 1) / 4"
:x2="chartWidth - chartPadding.right"
:y2="chartPadding.top + (chartHeight - chartPadding.top - chartPadding.bottom) * (i - 1) / 4"
stroke="#f0f0f0"
stroke-width="1"
/>
<!-- Y-axis labels -->
<text
v-for="i in 5"
:key="'ylabel-' + i"
:x="chartPadding.left - 8"
:y="chartPadding.top + (chartHeight - chartPadding.top - chartPadding.bottom) * (i - 1) / 4 + 4"
text-anchor="end"
font-size="10"
fill="#8c8c8c"
>
{{ ((5 - i) * 25) }}%
</text>
<!-- Success rate line -->
<polyline
:points="successRatePoints"
fill="none"
stroke="#52c41a"
stroke-width="2"
stroke-linejoin="round"
/>
<!-- Avg duration line (normalized) -->
<polyline
:points="avgDurationPoints"
fill="none"
stroke="#1890ff"
stroke-width="2"
stroke-linejoin="round"
stroke-dasharray="4,2"
/>
<!-- Retry rate line -->
<polyline
:points="retryRatePoints"
fill="none"
stroke="#fa8c16"
stroke-width="2"
stroke-linejoin="round"
/>
<!-- Data points for success rate -->
<circle
v-for="(pt, i) in successRateCoords"
:key="'sr-' + i"
:cx="pt.x"
:cy="pt.y"
r="3"
fill="#52c41a"
/>
<!-- X-axis date labels (show every Nth) -->
<text
v-for="(pt, i) in xLabelCoords"
:key="'xlabel-' + i"
:x="pt.x"
:y="chartHeight - 4"
text-anchor="middle"
font-size="10"
fill="#8c8c8c"
>
{{ pt.label }}
</text>
</svg>
<!-- Legend -->
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot" style="background: #52c41a" />成功率</span>
<span class="legend-item"><span class="legend-dot" style="background: #1890ff" />平均耗时(归一化)</span>
<span class="legend-item"><span class="legend-dot" style="background: #fa8c16" />重试率</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RadioGroup as ARadioGroup, RadioButton as ARadioButton, Empty as AEmpty } from 'ant-design-vue'
import type { EvolutionMetrics, TrendPoint } from '@/api/evolution'
const props = defineProps<{
metrics: EvolutionMetrics | null
trends: TrendPoint[]
period: string
}>()
const emit = defineEmits<{
(e: 'period-change', period: string): void
}>()
const selectedPeriod = ref(props.period)
const chartWidth = 400
const chartHeight = 220
const chartPadding = { top: 20, right: 16, bottom: 30, left: 40 }
const plotWidth = computed(() => chartWidth - chartPadding.left - chartPadding.right)
const plotHeight = computed(() => chartHeight - chartPadding.top - chartPadding.bottom)
function onPeriodChange() {
emit('period-change', selectedPeriod.value)
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds.toFixed(1)}`
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}分钟`
return `${(seconds / 3600).toFixed(1)}小时`
}
function toX(index: number, total: number): number {
if (total <= 1) return chartPadding.left + plotWidth.value / 2
return chartPadding.left + (index / (total - 1)) * plotWidth.value
}
function toY(value: number): number {
// value is 0..1 range
const clamped = Math.max(0, Math.min(1, value))
return chartPadding.top + plotHeight.value * (1 - clamped)
}
const successRateCoords = computed(() => {
return props.trends.map((t, i) => ({
x: toX(i, props.trends.length),
y: toY(t.success_rate),
}))
})
const avgDurationCoords = computed(() => {
// Normalize avg_duration to 0..1 range
const maxDuration = Math.max(...props.trends.map(t => t.avg_duration), 1)
return props.trends.map((t, i) => ({
x: toX(i, props.trends.length),
y: toY(t.avg_duration / maxDuration),
}))
})
const retryRateCoords = computed(() => {
return props.trends.map((t, i) => ({
x: toX(i, props.trends.length),
y: toY(t.retry_rate),
}))
})
const successRatePoints = computed(() =>
successRateCoords.value.map(p => `${p.x},${p.y}`).join(' ')
)
const avgDurationPoints = computed(() =>
avgDurationCoords.value.map(p => `${p.x},${p.y}`).join(' ')
)
const retryRatePoints = computed(() =>
retryRateCoords.value.map(p => `${p.x},${p.y}`).join(' ')
)
const xLabelCoords = computed(() => {
const total = props.trends.length
// Show every Nth label depending on total points
const step = total <= 7 ? 1 : total <= 14 ? 2 : Math.ceil(total / 7)
return props.trends
.map((t, i) => ({ x: toX(i, total), label: t.date.slice(5), index: i }))
.filter((_, i) => i % step === 0 || i === total - 1)
})
</script>
<style scoped>
.metrics-chart {
height: 100%;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chart-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.chart-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.summary-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.summary-card {
flex: 1;
background: #fafafa;
border-radius: 6px;
padding: 8px 12px;
text-align: center;
}
.summary-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 2px;
}
.summary-value {
font-size: 18px;
font-weight: 600;
}
.chart-body {
flex: 1;
display: flex;
flex-direction: column;
}
.svg-chart-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.svg-chart {
max-width: 100%;
}
.chart-legend {
display: flex;
gap: 16px;
justify-content: center;
margin-top: 8px;
font-size: 12px;
color: #8c8c8c;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,203 @@
<template>
<div class="path-optimizer-panel">
<div class="optimizer-header">
<h3>路径优化</h3>
</div>
<div v-if="optimizations.length === 0" class="optimizer-empty">
<a-empty description="暂无优化记录" :image-style="{ height: '40px' }" />
</div>
<div v-else class="optimizer-list">
<div
v-for="opt in optimizations"
:key="opt.id"
class="optimizer-item"
>
<div class="optimizer-item__header" @click="toggleExpand(opt.id)">
<div class="optimizer-item__info">
<span class="optimizer-item__type">{{ opt.task_type }}</span>
<a-tag v-if="opt.improvement > 0" color="green" size="small">
改善 {{ (opt.improvement * 100).toFixed(1) }}%
</a-tag>
</div>
<span class="optimizer-item__time">
{{ formatTime(opt.updated_at) }}
</span>
</div>
<div v-if="expandedId === opt.id" class="optimizer-item__detail">
<div v-if="opt.previous_path.length > 0" class="path-comparison">
<div class="path-section">
<div class="path-label">优化前路径</div>
<div class="path-steps">
<div v-for="(step, i) in opt.previous_path" :key="'prev-' + i" class="path-step path-step--old">
{{ i + 1 }}. {{ step }}
</div>
</div>
</div>
<div class="path-arrow"></div>
<div class="path-section">
<div class="path-label">优化后路径</div>
<div class="path-steps">
<div v-for="(step, i) in opt.current_path" :key="'curr-' + i" class="path-step path-step--new">
{{ i + 1 }}. {{ step }}
</div>
</div>
</div>
</div>
<div v-else-if="opt.current_path.length > 0" class="path-current">
<div class="path-label">当前推荐路径</div>
<div class="path-steps">
<div v-for="(step, i) in opt.current_path" :key="'rec-' + i" class="path-step path-step--new">
{{ i + 1 }}. {{ step }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Tag as ATag, Empty as AEmpty } from 'ant-design-vue'
import type { PathOptimization } from '@/api/evolution'
const props = defineProps<{
optimizations: PathOptimization[]
}>()
const expandedId = ref<string | null>(null)
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
function formatTime(isoStr: string | null): string {
if (!isoStr) return ''
const d = new Date(isoStr)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
</script>
<style scoped>
.path-optimizer-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.optimizer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.optimizer-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.optimizer-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.optimizer-list {
flex: 1;
overflow-y: auto;
}
.optimizer-item {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
}
.optimizer-item__header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.optimizer-item__info {
display: flex;
align-items: center;
gap: 8px;
}
.optimizer-item__type {
font-size: 13px;
font-weight: 500;
color: #1890ff;
}
.optimizer-item__time {
font-size: 12px;
color: #8c8c8c;
}
.optimizer-item__detail {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f0f0f0;
}
.path-comparison {
display: flex;
align-items: flex-start;
gap: 8px;
}
.path-arrow {
font-size: 16px;
color: #52c41a;
padding-top: 20px;
flex-shrink: 0;
}
.path-section {
flex: 1;
min-width: 0;
}
.path-current {
width: 100%;
}
.path-label {
font-size: 12px;
font-weight: 600;
color: #595959;
margin-bottom: 4px;
}
.path-steps {
display: flex;
flex-direction: column;
gap: 2px;
}
.path-step {
font-size: 12px;
padding: 2px 6px;
border-radius: 3px;
line-height: 1.5;
}
.path-step--old {
background: #fff2f0;
color: #cf1322;
}
.path-step--new {
background: #f6ffed;
color: #389e0d;
}
</style>

View File

@ -0,0 +1,168 @@
<template>
<div class="pitfall-panel">
<div class="pitfall-header">
<h3>避坑预警</h3>
</div>
<div class="pitfall-input">
<a-input
v-model:value="taskTypeInput"
placeholder="输入任务类型"
size="small"
style="flex: 1"
@press-enter="onCheck"
/>
<a-button type="primary" size="small" :loading="loading" @click="onCheck">
检查
</a-button>
</div>
<div v-if="warnings.length === 0 && !loading" class="pitfall-empty">
<a-empty description="暂无预警信息" :image-style="{ height: '40px' }" />
</div>
<div v-else class="pitfall-list">
<div
v-for="(warning, index) in warnings"
:key="index"
class="pitfall-item"
>
<div class="pitfall-item__header">
<a-tag :color="riskColor(warning.risk_level)" size="small">
{{ riskLabel(warning.risk_level) }}
</a-tag>
<span class="pitfall-item__step">{{ warning.step }}</span>
</div>
<div class="pitfall-item__rate">
历史失败率: <strong>{{ (warning.historical_failure_rate * 100).toFixed(1) }}%</strong>
</div>
<div v-if="warning.reason" class="pitfall-item__reason">
{{ warning.reason }}
</div>
<div v-if="warning.suggestion && warning.suggestion !== warning.reason" class="pitfall-item__suggestion">
<span class="suggestion-icon">💡</span> {{ warning.suggestion }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Input as AInput, Button as AButton, Tag as ATag, Empty as AEmpty } from 'ant-design-vue'
import type { PitfallWarning } from '@/api/evolution'
const props = defineProps<{
warnings: PitfallWarning[]
loading?: boolean
}>()
const emit = defineEmits<{
(e: 'check', taskType: string): void
}>()
const taskTypeInput = ref('')
function onCheck() {
if (taskTypeInput.value.trim()) {
emit('check', taskTypeInput.value.trim())
}
}
function riskColor(level: string): string {
switch (level) {
case 'high': return 'red'
case 'medium': return 'orange'
case 'low': return 'blue'
default: return 'default'
}
}
function riskLabel(level: string): string {
switch (level) {
case 'high': return '高风险'
case 'medium': return '中风险'
case 'low': return '低风险'
default: return level
}
}
</script>
<style scoped>
.pitfall-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.pitfall-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.pitfall-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.pitfall-input {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.pitfall-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.pitfall-list {
flex: 1;
overflow-y: auto;
}
.pitfall-item {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
}
.pitfall-item__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.pitfall-item__step {
font-size: 13px;
font-weight: 500;
}
.pitfall-item__rate {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
}
.pitfall-item__reason {
font-size: 12px;
color: #595959;
line-height: 1.5;
margin-bottom: 4px;
}
.pitfall-item__suggestion {
font-size: 12px;
color: #1890ff;
line-height: 1.5;
}
.suggestion-icon {
font-size: 12px;
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="document-upload">
<a-upload-dragger
:multiple="true"
:custom-request="handleUpload"
:before-upload="beforeUpload"
:show-upload-list="false"
accept=".pdf,.docx,.doc,.md,.txt,.html,.csv,.json"
>
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
支持 PDFWordMarkdownHTML纯文本等格式
</p>
</a-upload-dragger>
<a-spin v-if="kbStore.isUploading" tip="正在上传处理..." class="upload-spin" />
<div v-if="uploadedFiles.length > 0" class="uploaded-list">
<a-divider>已上传文档</a-divider>
<a-list :data-source="uploadedFiles" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<span>{{ item.filename }}</span>
</template>
<template #description>
<a-tag :color="item.status === 'indexed' ? 'green' : 'orange'">
{{ item.status === 'indexed' ? '已索引' : '处理中' }}
</a-tag>
<span class="chunk-count">{{ item.chunks }} 个分块</span>
</template>
<template #avatar>
<FileOutlined />
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { InboxOutlined, FileOutlined } from '@ant-design/icons-vue'
import { useKnowledgeStore } from '@/stores/knowledge'
interface UploadedFile {
document_id: string
filename: string
chunks: number
status: string
}
const kbStore = useKnowledgeStore()
const uploadedFiles = ref<UploadedFile[]>([])
function beforeUpload(file: File): boolean {
const validExtensions = ['.pdf', '.docx', '.doc', '.md', '.txt', '.html', '.csv', '.json']
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!validExtensions.includes(ext)) {
message.error(`不支持的文件格式: ${ext}`)
return false
}
return true
}
async function handleUpload(options: { file: File; onSuccess?: () => void; onError?: (err: unknown) => void }): Promise<void> {
try {
await kbStore.uploadDocument(options.file)
uploadedFiles.value.unshift({
document_id: Date.now().toString(),
filename: options.file.name,
chunks: 1,
status: 'indexed',
})
message.success(`${options.file.name} 上传成功`)
options.onSuccess?.()
} catch (err) {
message.error(`${options.file.name} 上传失败`)
options.onError?.(err)
}
}
</script>
<style scoped>
.document-upload {
padding: 8px 0;
}
.upload-spin {
display: block;
text-align: center;
padding: 16px;
}
.uploaded-list {
margin-top: 16px;
}
.chunk-count {
margin-left: 8px;
color: #999;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="search-test">
<a-input-search
v-model:value="searchQuery"
placeholder="输入查询内容测试检索效果"
enter-button="检索"
size="large"
:loading="kbStore.isSearching"
@search="handleSearch"
/>
<div v-if="kbStore.searchResults.length > 0" class="search-results">
<a-divider>检索结果 ({{ kbStore.searchResults.length }})</a-divider>
<div
v-for="(result, index) in kbStore.searchResults"
:key="index"
class="search-result-item"
>
<div class="search-result-item__header">
<span class="search-result-item__index">#{{ index + 1 }}</span>
<a-tag color="blue">{{ result.source }}</a-tag>
<span class="search-result-item__score">
相关度: {{ (result.score * 100).toFixed(1) }}%
</span>
</div>
<div class="search-result-item__content">
{{ result.content }}
</div>
<div v-if="Object.keys(result.metadata).length > 0" class="search-result-item__meta">
<a-tag v-for="(value, key) in result.metadata" :key="String(key)" size="small">
{{ key }}: {{ value }}
</a-tag>
</div>
</div>
</div>
<a-empty
v-else-if="hasSearched && !kbStore.isSearching"
description="未找到相关结果"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useKnowledgeStore } from '@/stores/knowledge'
const kbStore = useKnowledgeStore()
const searchQuery = ref('')
const hasSearched = ref(false)
async function handleSearch(): Promise<void> {
if (!searchQuery.value.trim()) return
hasSearched.value = true
await kbStore.search(searchQuery.value)
}
</script>
<style scoped>
.search-test {
padding: 8px 0;
}
.search-results {
margin-top: 16px;
}
.search-result-item {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 12px;
}
.search-result-item__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.search-result-item__index {
font-weight: 600;
color: #1677ff;
}
.search-result-item__score {
margin-left: auto;
font-size: 12px;
color: #999;
}
.search-result-item__content {
font-size: 14px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.search-result-item__meta {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>

View File

@ -0,0 +1,200 @@
<template>
<div class="source-config">
<div class="source-config__header">
<a-button type="primary" @click="showAddModal = true">
<template #icon><PlusOutlined /></template>
添加信息源
</a-button>
</div>
<a-table
:data-source="kbStore.sources"
:columns="columns"
:loading="kbStore.isLoading"
row-key="id"
size="small"
:pagination="{ pageSize: 10 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="typeColorMap[record.type] || 'default'">
{{ typeLabelMap[record.type] || record.type }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
:text="record.status === 'active' ? '正常' : record.status"
/>
</template>
<template v-if="column.key === 'last_synced'">
{{ record.last_synced ? formatTime(record.last_synced) : '从未同步' }}
</template>
<template v-if="column.key === 'actions'">
<a-space>
<a-button type="link" size="small" @click="handleHealthCheck(record.id)">
健康检查
</a-button>
<a-popconfirm
title="确定删除此信息源?"
@confirm="handleRemove(record.id)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<a-modal
v-model:open="showAddModal"
title="添加信息源"
@ok="handleAddSource"
:confirm-loading="kbStore.isLoading"
>
<a-form :model="newSource" layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="newSource.name" placeholder="输入信息源名称" />
</a-form-item>
<a-form-item label="类型" required>
<a-select v-model:value="newSource.type" placeholder="选择信息源类型">
<a-select-option value="local">本地文档</a-select-option>
<a-select-option value="feishu">飞书知识库</a-select-option>
<a-select-option value="confluence">Confluence</a-select-option>
<a-select-option value="http">HTTP API</a-select-option>
</a-select>
</a-form-item>
<template v-if="newSource.type === 'feishu'">
<a-form-item label="App ID">
<a-input v-model:value="newSource.config.app_id" />
</a-form-item>
<a-form-item label="App Secret">
<a-input-password v-model:value="newSource.config.app_secret" />
</a-form-item>
<a-form-item label="知识库 ID">
<a-input v-model:value="newSource.config.wiki_space_id" />
</a-form-item>
</template>
<template v-if="newSource.type === 'confluence'">
<a-form-item label="Base URL">
<a-input v-model:value="newSource.config.base_url" placeholder="https://wiki.example.com" />
</a-form-item>
<a-form-item label="用户名">
<a-input v-model:value="newSource.config.username" />
</a-form-item>
<a-form-item label="API Token">
<a-input-password v-model:value="newSource.config.api_token" />
</a-form-item>
<a-form-item label="空间 Key">
<a-input v-model:value="newSource.config.space_key" />
</a-form-item>
</template>
<template v-if="newSource.type === 'http'">
<a-form-item label="API URL">
<a-input v-model:value="newSource.config.url" placeholder="https://api.example.com/kb" />
</a-form-item>
<a-form-item label="API Key">
<a-input-password v-model:value="newSource.config.api_key" />
</a-form-item>
</template>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { useKnowledgeStore } from '@/stores/knowledge'
const kbStore = useKnowledgeStore()
const showAddModal = ref(false)
const newSource = reactive({
name: '',
type: 'local' as 'local' | 'feishu' | 'confluence' | 'http',
config: {} as Record<string, string>,
})
const typeColorMap: Record<string, string> = {
local: 'blue',
feishu: 'green',
confluence: 'purple',
http: 'orange',
}
const typeLabelMap: Record<string, string> = {
local: '本地文档',
feishu: '飞书',
confluence: 'Confluence',
http: 'HTTP API',
}
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'type', key: 'type', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '文档数', dataIndex: 'document_count', key: 'document_count', width: 80 },
{ title: '最近同步', dataIndex: 'last_synced', key: 'last_synced', width: 160 },
{ title: '操作', key: 'actions', width: 180 },
]
function formatTime(isoStr: string): string {
try {
const d = new Date(isoStr)
return d.toLocaleString('zh-CN')
} catch {
return isoStr
}
}
async function handleAddSource(): Promise<void> {
if (!newSource.name) {
message.warning('请输入信息源名称')
return
}
try {
await kbStore.addSource(newSource.name, newSource.type, { ...newSource.config })
showAddModal.value = false
newSource.name = ''
newSource.type = 'local'
newSource.config = {}
message.success('信息源添加成功')
} catch {
message.error('添加信息源失败')
}
}
async function handleRemove(sourceId: string): Promise<void> {
try {
await kbStore.removeSource(sourceId)
message.success('信息源已删除')
} catch {
message.error('删除信息源失败')
}
}
async function handleHealthCheck(sourceId: string): Promise<void> {
const status = await kbStore.checkHealth(sourceId)
if (status === 'healthy') {
message.success('信息源状态正常')
} else if (status === 'unknown') {
message.warning('健康检查未实现')
} else {
message.error('信息源状态异常')
}
}
</script>
<style scoped>
.source-config {
padding: 8px 0;
}
.source-config__header {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="skill-card" @click="$emit('click')">
<a-card hoverable size="small">
<template #title>
<div class="skill-card__title">
<AppstoreOutlined class="skill-card__icon" />
<span>{{ skill.name }}</span>
</div>
</template>
<template #extra>
<a-badge
:status="skill.status === 'active' ? 'success' : 'default'"
:text="skill.status === 'active' ? '正常' : skill.status"
/>
</template>
<p class="skill-card__desc">{{ skill.description || '暂无描述' }}</p>
<div class="skill-card__tags">
<a-tag v-for="cap in skill.capabilities" :key="cap" size="small" color="blue">
{{ cap }}
</a-tag>
</div>
<div v-if="skill.dependencies.length > 0" class="skill-card__deps">
<span class="skill-card__deps-label">依赖:</span>
<a-tag v-for="dep in skill.dependencies.slice(0, 3)" :key="dep" size="small">
{{ dep }}
</a-tag>
<span v-if="skill.dependencies.length > 3" class="skill-card__more">
+{{ skill.dependencies.length - 3 }}
</span>
</div>
<div class="skill-card__footer">
<span v-if="skill.version" class="skill-card__version">v{{ skill.version }}</span>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { AppstoreOutlined } from '@ant-design/icons-vue'
import type { ISkillInfo } from '@/api/skills'
defineProps<{
skill: ISkillInfo
}>()
defineEmits<{
click: []
}>()
</script>
<style scoped>
.skill-card {
cursor: pointer;
}
.skill-card__title {
display: flex;
align-items: center;
gap: 6px;
}
.skill-card__icon {
color: #1677ff;
}
.skill-card__desc {
font-size: 13px;
color: #666;
margin-bottom: 8px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.skill-card__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.skill-card__deps {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.skill-card__deps-label {
font-size: 12px;
color: #999;
}
.skill-card__more {
font-size: 12px;
color: #999;
}
.skill-card__footer {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.skill-card__version {
font-size: 12px;
color: #999;
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<a-drawer
:open="visible"
:title="skill?.name || '技能详情'"
:width="560"
@close="$emit('close')"
>
<template v-if="skill">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="名称">{{ skill.name }}</a-descriptions-item>
<a-descriptions-item label="版本">{{ skill.version || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="skill.health_status === 'healthy' ? 'success' : 'warning'"
:text="skill.health_status === 'healthy' ? '正常' : skill.health_status"
/>
</a-descriptions-item>
<a-descriptions-item label="描述">
{{ skill.description || '暂无描述' }}
</a-descriptions-item>
</a-descriptions>
<a-divider orientation="left">能力标签</a-divider>
<div class="skill-detail__tags">
<a-tag v-for="cap in skill.capabilities" :key="cap" color="blue">{{ cap }}</a-tag>
<span v-if="skill.capabilities.length === 0" class="skill-detail__empty">暂无能力标签</span>
</div>
<a-divider orientation="left">依赖</a-divider>
<div class="skill-detail__tags">
<a-tag v-for="dep in skill.dependencies" :key="dep">{{ dep }}</a-tag>
<span v-if="skill.dependencies.length === 0" class="skill-detail__empty">无依赖</span>
</div>
<a-divider orientation="left">配置</a-divider>
<div v-if="Object.keys(skill.config).length > 0" class="skill-detail__config">
<pre>{{ JSON.stringify(skill.config, null, 2) }}</pre>
</div>
<div v-else class="skill-detail__empty">暂无配置信息</div>
<div class="skill-detail__actions">
<a-button type="primary" :loading="isReloading" @click="handleReload">
<template #icon><ReloadOutlined /></template>
重新加载
</a-button>
<a-button @click="handleHealthCheck">
<template #icon><HeartOutlined /></template>
健康检查
</a-button>
</div>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ReloadOutlined, HeartOutlined } from '@ant-design/icons-vue'
import { useSkillsStore } from '@/stores/skills'
import type { ISkillDetail } from '@/api/skills'
defineProps<{
visible: boolean
skill: ISkillDetail | null
}>()
defineEmits<{
close: []
}>()
const skillsStore = useSkillsStore()
const isReloading = ref(false)
async function handleReload(): Promise<void> {
if (!skillsStore.selectedSkill) return
isReloading.value = true
try {
await skillsStore.reloadSkill(skillsStore.selectedSkill.name)
message.success('技能已重新加载')
} catch {
message.error('重新加载失败')
} finally {
isReloading.value = false
}
}
async function handleHealthCheck(): Promise<void> {
if (!skillsStore.selectedSkill) return
const status = await skillsStore.checkHealth(skillsStore.selectedSkill.name)
if (status === 'healthy') {
message.success('技能状态正常')
} else {
message.error('技能状态异常')
}
}
</script>
<style scoped>
.skill-detail__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.skill-detail__empty {
color: #999;
font-size: 13px;
}
.skill-detail__config {
background: #f5f5f5;
border-radius: 6px;
padding: 12px;
overflow-x: auto;
}
.skill-detail__config pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
}
.skill-detail__actions {
margin-top: 24px;
display: flex;
gap: 12px;
}
</style>

View File

@ -0,0 +1,141 @@
<template>
<div class="command-history">
<div class="command-history__header">
<span>命令历史</span>
<a-button type="link" size="small" @click="terminalStore.clearHistory()">
清空
</a-button>
</div>
<div class="command-history__list">
<div
v-for="(record, index) in terminalStore.recentCommands"
:key="index"
class="command-history__item"
@click="$emit('select', record.command)"
>
<div class="command-history__item-header">
<span
class="command-history__exit-code"
:class="record.exit_code === 0 ? 'success' : 'error'"
>
{{ record.exit_code === 0 ? '✓' : '✗' }}
</span>
<span class="command-history__command">{{ record.command }}</span>
</div>
<div class="command-history__item-meta">
<span>{{ formatTime(record.timestamp) }}</span>
<span v-if="record.duration_ms" class="command-history__duration">
{{ record.duration_ms }}ms
</span>
</div>
</div>
<div v-if="terminalStore.recentCommands.length === 0" class="command-history__empty">
暂无命令历史
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTerminalStore } from '@/stores/terminal'
const terminalStore = useTerminalStore()
defineEmits<{
select: [command: string]
}>()
function formatTime(timestamp: number): string {
try {
const d = new Date(timestamp * 1000)
return d.toLocaleTimeString('zh-CN')
} catch {
return ''
}
}
</script>
<style scoped>
.command-history {
display: flex;
flex-direction: column;
height: 100%;
background: #fafafa;
border-left: 1px solid #f0f0f0;
}
.command-history__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
border-bottom: 1px solid #f0f0f0;
}
.command-history__list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.command-history__item {
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 4px;
transition: background 0.2s;
}
.command-history__item:hover {
background: #e6f4ff;
}
.command-history__item-header {
display: flex;
align-items: center;
gap: 6px;
}
.command-history__exit-code {
font-size: 12px;
font-weight: 600;
}
.command-history__exit-code.success {
color: #52c41a;
}
.command-history__exit-code.error {
color: #f5222d;
}
.command-history__command {
font-family: 'Menlo', 'Monaco', monospace;
font-size: 12px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-history__item-meta {
display: flex;
gap: 8px;
margin-top: 2px;
font-size: 11px;
color: #999;
}
.command-history__duration {
color: #1677ff;
}
.command-history__empty {
text-align: center;
padding: 24px;
color: #999;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,151 @@
<template>
<div class="terminal-emulator" ref="terminalContainer">
<div class="terminal-emulator__output" ref="outputContainer">
<div v-for="(line, index) in terminalStore.output" :key="index" class="terminal-line">
<span v-html="ansiToHtml(line)"></span>
</div>
<div v-if="terminalStore.output.length === 0" class="terminal-emulator__welcome">
Fischer AgentKit 智能终端 - 输入命令开始
</div>
</div>
<div class="terminal-emulator__input">
<span class="terminal-emulator__prompt">{{ terminalStore.cwd }}$</span>
<input
ref="inputRef"
v-model="commandInput"
class="terminal-emulator__input-field"
placeholder="输入命令..."
:disabled="terminalStore.isExecuting"
@keydown.enter="executeCommand"
@keydown.up.prevent="historyUp"
@keydown.down.prevent="historyDown"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue'
import { useTerminalStore } from '@/stores/terminal'
const terminalStore = useTerminalStore()
const commandInput = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const outputContainer = ref<HTMLElement | null>(null)
const historyIndex = ref(-1)
onMounted(() => {
terminalStore.connectWebSocket()
})
// Auto-scroll to bottom
watch(
() => terminalStore.output.length,
async () => {
await nextTick()
if (outputContainer.value) {
outputContainer.value.scrollTop = outputContainer.value.scrollHeight
}
}
)
function executeCommand(): void {
const cmd = commandInput.value.trim()
if (!cmd) return
terminalStore.sendCommand(cmd)
commandInput.value = ''
historyIndex.value = -1
}
function historyUp(): void {
const history = terminalStore.history
if (history.length === 0) return
if (historyIndex.value < history.length - 1) {
historyIndex.value++
commandInput.value = history[history.length - 1 - historyIndex.value].command
}
}
function historyDown(): void {
if (historyIndex.value > 0) {
historyIndex.value--
commandInput.value = terminalStore.history[terminalStore.history.length - 1 - historyIndex.value].command
} else {
historyIndex.value = -1
commandInput.value = ''
}
}
function ansiToHtml(text: string): string {
// Basic ANSI color code conversion
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\x1b\[32m/g, '<span style="color:#4caf50">')
.replace(/\x1b\[33m/g, '<span style="color:#ff9800">')
.replace(/\x1b\[31m/g, '<span style="color:#f44336">')
.replace(/\x1b\[36m/g, '<span style="color:#00bcd4">')
.replace(/\x1b\[0m/g, '</span>')
}
</script>
<style scoped>
.terminal-emulator {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
}
.terminal-emulator__output {
flex: 1;
overflow-y: auto;
padding: 12px;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
}
.terminal-emulator__welcome {
color: #888;
font-style: italic;
}
.terminal-emulator__input {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid #333;
background: #252526;
}
.terminal-emulator__prompt {
color: #4caf50;
margin-right: 8px;
font-size: 13px;
white-space: nowrap;
}
.terminal-emulator__input-field {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #d4d4d4;
font-family: inherit;
font-size: 13px;
}
.terminal-emulator__input-field::placeholder {
color: #555;
}
.terminal-line {
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<div class="approval-node" :class="{ selected: data.selected, paused: isPaused }">
<Handle type="target" :position="Position.Left" id="input" />
<div class="node-header">
<UserOutlined class="node-icon" />
<span class="node-title">{{ data.label }}</span>
<a-tag v-if="isPaused" color="orange" class="pause-tag">等待审批</a-tag>
</div>
<div class="node-body">
<div v-if="data.config?.approver" class="node-detail">
<span class="detail-label">审批人:</span>
<span class="detail-value">{{ data.config.approver }}</span>
</div>
<div v-if="data.config?.timeout" class="node-detail">
<span class="detail-label">超时:</span>
<span class="detail-value">{{ data.config.timeout }}s</span>
</div>
</div>
<Handle type="source" :position="Position.Right" id="output" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { UserOutlined } from '@ant-design/icons-vue'
import type { WorkflowNodeData } from '@/utils/workflowSerializer'
const props = defineProps<{
data: WorkflowNodeData
}>()
const isPaused = computed(() => {
return props.data.config?.status === 'paused'
})
</script>
<style scoped>
.approval-node {
background: #fff;
border: 2px solid #722ed1;
border-radius: 8px;
padding: 8px 12px;
min-width: 160px;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s;
}
.approval-node:hover {
box-shadow: 0 4px 12px rgba(114, 46, 209, 0.25);
}
.approval-node.selected {
border-color: #531dab;
box-shadow: 0 0 0 3px rgba(114, 46, 209, 0.2);
}
.approval-node.paused {
border-color: #fa8c16;
animation: pulse-border 2s ease-in-out infinite;
}
@keyframes pulse-border {
0%, 100% { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); }
50% { box-shadow: 0 0 0 4px rgba(250, 140, 22, 0.3); }
}
.node-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.node-icon {
color: #722ed1;
font-size: 14px;
}
.node-title {
font-weight: 600;
color: #333;
font-size: 13px;
}
.pause-tag {
font-size: 10px;
line-height: 16px;
padding: 0 4px;
margin-left: auto;
}
.node-body {
display: flex;
flex-direction: column;
gap: 2px;
}
.node-detail {
display: flex;
gap: 4px;
color: #666;
font-size: 11px;
}
.detail-label {
color: #999;
}
.detail-value {
color: #666;
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<div class="condition-node" :class="{ selected: data.selected }">
<Handle type="target" :position="Position.Left" id="input" />
<div class="diamond-shape">
<div class="diamond-content">
<BranchesOutlined class="node-icon" />
<span class="node-title">{{ data.label }}</span>
</div>
</div>
<div v-if="data.config?.expression" class="condition-expr">
{{ data.config.expression }}
</div>
<Handle type="source" :position="Position.Top" id="yes" class="handle-yes" />
<Handle type="source" :position="Position.Bottom" id="no" class="handle-no" />
<div class="handle-labels">
<span class="label-yes"></span>
<span class="label-no"></span>
</div>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { BranchesOutlined } from '@ant-design/icons-vue'
import type { WorkflowNodeData } from '@/utils/workflowSerializer'
defineProps<{
data: WorkflowNodeData
}>()
</script>
<style scoped>
.condition-node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
min-width: 140px;
}
.diamond-shape {
background: #fff;
border: 2px solid #faad14;
border-radius: 8px;
padding: 8px 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: none;
transition: box-shadow 0.2s;
}
.condition-node:hover .diamond-shape {
box-shadow: 0 4px 12px rgba(250, 173, 20, 0.25);
}
.condition-node.selected .diamond-shape {
border-color: #d48806;
box-shadow: 0 0 0 3px rgba(250, 173, 20, 0.2);
}
.diamond-content {
display: flex;
align-items: center;
gap: 6px;
}
.node-icon {
color: #faad14;
font-size: 14px;
}
.node-title {
font-weight: 600;
color: #333;
font-size: 13px;
}
.condition-expr {
margin-top: 4px;
padding: 2px 8px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 4px;
font-size: 11px;
color: #8c6900;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.handle-yes {
background: #52c41a;
}
.handle-no {
background: #ff4d4f;
}
.handle-labels {
display: flex;
justify-content: space-between;
width: 100%;
margin-top: 2px;
padding: 0 4px;
}
.label-yes {
color: #52c41a;
font-size: 11px;
font-weight: 600;
}
.label-no {
color: #ff4d4f;
font-size: 11px;
font-weight: 600;
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<div class="flow-canvas" ref="canvasRef" @drop="onDrop" @dragover="onDragOver">
<div class="canvas-toolbar">
<a-space>
<a-button size="small" @click="$emit('save')" :loading="saving">
<template #icon><SaveOutlined /></template>
保存
</a-button>
<a-button size="small" type="primary" @click="$emit('execute')" :loading="executing">
<template #icon><PlayCircleOutlined /></template>
执行
</a-button>
<a-button size="small" @click="onValidate">
<template #icon><CheckCircleOutlined /></template>
验证
</a-button>
<a-button size="small" danger @click="$emit('clear')">
<template #icon><DeleteOutlined /></template>
清空
</a-button>
</a-space>
</div>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
:default-edge-options="defaultEdgeOptions"
:snap-to-grid="true"
:snap-grid="[15, 15]"
fit-view-on-init
@node-click="onNodeClick"
@pane-click="onPaneClick"
@connect="onConnect"
>
<Background :gap="20" :size="1" />
<Controls />
<MiniMap />
</VueFlow>
</div>
</template>
<script setup lang="ts">
import { ref, markRaw } from 'vue'
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/core'
import {
SaveOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import SkillNode from './SkillNode.vue'
import ConditionNode from './ConditionNode.vue'
import ApprovalNode from './ApprovalNode.vue'
import type { WorkflowNodeData } from '@/utils/workflowSerializer'
const props = defineProps<{
nodes: any[]
edges: any[]
saving?: boolean
executing?: boolean
}>()
const emit = defineEmits<{
save: []
execute: []
clear: []
'update:nodes': [nodes: any[]]
'update:edges': [edges: any[]]
'node-select': [nodeId: string | null]
}>()
const canvasRef = ref<HTMLElement>()
const nodeTypes = {
skill: markRaw(SkillNode),
condition: markRaw(ConditionNode),
approval: markRaw(ApprovalNode),
}
const defaultEdgeOptions = {
animated: true,
style: { stroke: '#1890ff', strokeWidth: 2 },
}
function onDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
function onDrop(event: DragEvent) {
const nodeType = event.dataTransfer?.getData('application/vueflow')
if (!nodeType) return
const { left, top } = (canvasRef.value?.getBoundingClientRect() || { left: 0, top: 0 })
const position = {
x: event.clientX - left - 100,
y: event.clientY - top - 50,
}
emit('node-select', 'drop')
// The parent will handle adding the node via the store
// We emit a custom event that the parent can listen to
// For now, we'll use a direct approach
}
function onNodeClick({ node }: { node: any }) {
emit('node-select', node.id)
}
function onPaneClick() {
emit('node-select', null)
}
function onConnect(params: any) {
// Add new edge
const newEdge = {
id: `edge-${params.source}-${params.target}`,
source: params.source,
target: params.target,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
animated: true,
style: { stroke: '#1890ff', strokeWidth: 2 },
}
emit('update:edges', [...props.edges, newEdge])
}
function onValidate() {
// Basic validation
if (props.nodes.length === 0) {
message.warning('工作流为空,请添加节点')
return
}
// Check for nodes without connections (except the first)
const connectedSources = new Set(props.edges.map((e: any) => e.source))
const connectedTargets = new Set(props.edges.map((e: any) => e.target))
const allConnected = new Set([...connectedSources, ...connectedTargets])
const orphanNodes = props.nodes.filter((n: any) => !allConnected.has(n.id))
if (orphanNodes.length > 0 && props.nodes.length > 1) {
message.warning(`存在未连接的节点: ${orphanNodes.map((n: any) => n.data?.label).join(', ')}`)
return
}
message.success('工作流验证通过')
}
</script>
<style scoped>
.flow-canvas {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.canvas-toolbar {
padding: 8px 12px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
z-index: 10;
}
.flow-canvas :deep(.vue-flow) {
flex: 1;
}
</style>
<style>
/* Global styles for Vue Flow */
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
</style>

View File

@ -0,0 +1,137 @@
<template>
<div class="node-palette">
<div class="palette-title">节点类型</div>
<div class="palette-list">
<div
v-for="item in nodeTypes"
:key="item.type"
class="palette-item"
draggable="true"
@dragstart="onDragStart($event, item.type)"
>
<component :is="item.icon" class="item-icon" :style="{ color: item.color }" />
<div class="item-info">
<div class="item-name">{{ item.name }}</div>
<div class="item-desc">{{ item.desc }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ThunderboltOutlined,
BranchesOutlined,
UserOutlined,
ForkOutlined,
} from '@ant-design/icons-vue'
const nodeTypes = [
{
type: 'skill',
name: '技能节点',
desc: '引用已注册的技能',
icon: ThunderboltOutlined,
color: '#1890ff',
},
{
type: 'condition',
name: '条件节点',
desc: 'If/else 条件分支',
icon: BranchesOutlined,
color: '#faad14',
},
{
type: 'approval',
name: '审批节点',
desc: '人工审批关卡',
icon: UserOutlined,
color: '#722ed1',
},
{
type: 'parallel',
name: '并行节点',
desc: '并行执行组',
icon: ForkOutlined,
color: '#52c41a',
},
]
function onDragStart(event: DragEvent, nodeType: string) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', nodeType)
event.dataTransfer.effectAllowed = 'move'
}
}
</script>
<style scoped>
.node-palette {
width: 200px;
border-right: 1px solid #f0f0f0;
background: #fafafa;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.palette-title {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
color: #333;
border-bottom: 1px solid #f0f0f0;
}
.palette-list {
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.palette-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: grab;
transition: all 0.2s;
}
.palette-item:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
}
.palette-item:active {
cursor: grabbing;
}
.item-icon {
font-size: 20px;
flex-shrink: 0;
}
.item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.item-name {
font-size: 13px;
font-weight: 500;
color: #333;
}
.item-desc {
font-size: 11px;
color: #999;
}
</style>

View File

@ -0,0 +1,205 @@
<template>
<div class="property-panel">
<template v-if="nodeData">
<div class="panel-header">
<span class="panel-title">节点属性</span>
<a-button size="small" type="text" danger @click="$emit('delete-node', nodeData)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<div class="panel-body">
<!-- Common fields -->
<a-form layout="vertical" size="small">
<a-form-item label="节点名称">
<a-input
:value="nodeData.label"
@update:value="updateField('label', $event)"
/>
</a-form-item>
<a-form-item label="节点类型">
<a-tag :color="typeColor">{{ typeLabel }}</a-tag>
</a-form-item>
<!-- Skill node fields -->
<template v-if="nodeData.type === 'skill'">
<a-form-item label="技能动作">
<a-input
:value="nodeData.action"
@update:value="updateField('action', $event)"
placeholder="输入技能动作名称"
/>
</a-form-item>
<a-form-item label="Agent">
<a-input
:value="nodeData.agent"
@update:value="updateField('agent', $event)"
placeholder="default"
/>
</a-form-item>
<a-form-item label="超时时间(秒)">
<a-input-number
:value="nodeData.timeout_seconds"
@update:value="updateField('timeout_seconds', $event)"
:min="1"
:max="86400"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="重试次数">
<a-input-number
:value="nodeData.retry_count"
@update:value="updateField('retry_count', $event)"
:min="0"
:max="10"
style="width: 100%"
/>
</a-form-item>
</template>
<!-- Condition node fields -->
<template v-if="nodeData.type === 'condition'">
<a-form-item label="条件表达式">
<a-textarea
:value="nodeData.config?.expression || ''"
@update:value="updateConfig('expression', $event)"
placeholder="例: status == 'approved'"
:rows="3"
/>
</a-form-item>
<a-form-item label="超时时间(秒)">
<a-input-number
:value="nodeData.timeout_seconds"
@update:value="updateField('timeout_seconds', $event)"
:min="1"
:max="3600"
style="width: 100%"
/>
</a-form-item>
</template>
<!-- Approval node fields -->
<template v-if="nodeData.type === 'approval'">
<a-form-item label="审批人">
<a-input
:value="nodeData.config?.approver || ''"
@update:value="updateConfig('approver', $event)"
placeholder="输入审批人"
/>
</a-form-item>
<a-form-item label="审批超时(秒)">
<a-input-number
:value="nodeData.config?.timeout || 3600"
@update:value="updateConfig('timeout', $event)"
:min="60"
:max="86400"
style="width: 100%"
/>
</a-form-item>
</template>
<!-- Parallel node fields -->
<template v-if="nodeData.type === 'parallel'">
<a-form-item label="最大并行数">
<a-input-number
:value="nodeData.config?.max_parallel || 5"
@update:value="updateConfig('max_parallel', $event)"
:min="2"
:max="20"
style="width: 100%"
/>
</a-form-item>
</template>
</a-form>
</div>
</template>
<template v-else>
<div class="panel-empty">
<a-empty description="选择节点查看属性" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { DeleteOutlined } from '@ant-design/icons-vue'
import type { WorkflowNodeData } from '@/utils/workflowSerializer'
const props = defineProps<{
nodeData: WorkflowNodeData | null
}>()
const emit = defineEmits<{
'update-field': [field: string, value: unknown]
'update-config': [key: string, value: unknown]
'delete-node': [nodeData: WorkflowNodeData]
}>()
const typeLabel = computed(() => {
const labels: Record<string, string> = {
skill: '技能节点',
condition: '条件节点',
approval: '审批节点',
parallel: '并行节点',
}
return labels[props.nodeData?.type || 'skill'] || '未知'
})
const typeColor = computed(() => {
const colors: Record<string, string> = {
skill: 'blue',
condition: 'orange',
approval: 'purple',
parallel: 'green',
}
return colors[props.nodeData?.type || 'skill'] || 'default'
})
function updateField(field: string, value: unknown) {
emit('update-field', field, value)
}
function updateConfig(key: string, value: unknown) {
emit('update-config', key, value)
}
</script>
<style scoped>
.property-panel {
width: 300px;
border-left: 1px solid #f0f0f0;
background: #fff;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.panel-title {
font-weight: 600;
font-size: 14px;
color: #333;
}
.panel-body {
padding: 12px 16px;
}
.panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div class="skill-node" :class="{ selected: data.selected }">
<Handle type="target" :position="Position.Left" id="input" />
<div class="node-header">
<ThunderboltOutlined class="node-icon" />
<span class="node-title">{{ data.label }}</span>
</div>
<div class="node-body">
<div v-if="data.action" class="node-detail">
<span class="detail-label">动作:</span>
<span class="detail-value">{{ data.action }}</span>
</div>
<div v-if="data.timeout_seconds" class="node-detail">
<span class="detail-label">超时:</span>
<span class="detail-value">{{ data.timeout_seconds }}s</span>
</div>
</div>
<Handle type="source" :position="Position.Right" id="output" />
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { ThunderboltOutlined } from '@ant-design/icons-vue'
import type { WorkflowNodeData } from '@/utils/workflowSerializer'
defineProps<{
data: WorkflowNodeData
}>()
</script>
<style scoped>
.skill-node {
background: #fff;
border: 2px solid #1890ff;
border-radius: 8px;
padding: 8px 12px;
min-width: 160px;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s;
}
.skill-node:hover {
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.25);
}
.skill-node.selected {
border-color: #096dd9;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.2);
}
.node-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.node-icon {
color: #1890ff;
font-size: 14px;
}
.node-title {
font-weight: 600;
color: #333;
font-size: 13px;
}
.node-body {
display: flex;
flex-direction: column;
gap: 2px;
}
.node-detail {
display: flex;
gap: 4px;
color: #666;
font-size: 11px;
}
.detail-label {
color: #999;
}
.detail-value {
color: #666;
}
</style>

View File

@ -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<Experience[]>([])
const metrics = ref<EvolutionMetrics | null>(null)
const trends = ref<TrendPoint[]>([])
const pitfalls = ref<PitfallWarning[]>([])
const optimizations = ref<PathOptimization[]>([])
const period = ref<string>('7d')
const isLoading = ref(false)
const error = ref<string | null>(null)
// --- Actions ---
/** Load recent experiences */
async function loadExperiences(taskType?: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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,
}
})

View File

@ -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<IKbSource[]>([])
const searchResults = ref<ISearchResult[]>([])
const isLoading = ref(false)
const isUploading = ref(false)
const isSearching = ref(false)
const error = ref<string | null>(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<void> {
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<string, unknown> = {}
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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,
}
})

View File

@ -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<ILLMSettings>({
provider: '',
model: '',
api_key: '',
base_url: '',
})
const skillSettings = ref<ISkillSettings>({
default_skill: '',
auto_routing: true,
})
const kbSettings = ref<IKBSettings>({
default_sources: [],
top_k: 5,
retrieval_mode: 'standard',
})
const system = ref<ISystemSettings>({
rate_limit: 60,
cors_origins: ['*'],
logging_level: 'INFO',
})
const isLoading = ref(false)
const isSaving = ref(false)
const error = ref<string | null>(null)
const saveSuccess = ref(false)
// --- Getters ---
const hasLLMConfig = computed(() => !!llm.value.provider && !!llm.value.model)
// --- Actions ---
async function fetchSettings(): Promise<void> {
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<void> {
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,
}
})

View File

@ -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<ISkillInfo[]>([])
const capabilities = ref<ICapabilityInfo[]>([])
const selectedSkill = ref<ISkillDetail | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const totalSkills = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const selectedCapability = ref<string | null>(null)
// --- Getters ---
const skillNames = computed(() => skills.value.map((s) => s.name))
// --- Actions ---
async function fetchSkills(capability?: string, page?: number): Promise<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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,
}
})

View File

@ -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<string | null>(null)
const cwd = ref<string>('~')
const output = ref<string[]>([])
const history = ref<ICommandRecord[]>([])
const isExecuting = ref(false)
const isWsConnected = ref(false)
const ws = ref<WebSocket | null>(null)
const error = ref<string | null>(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<string, unknown>): 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,
}
})

View File

@ -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<WorkflowSummary[]>([])
const currentWorkflow = ref<WorkflowDefinition | null>(null)
const currentExecution = ref<WorkflowExecution | null>(null)
const isExecuting = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Vue Flow state
const flowNodes = ref<Node<WorkflowNodeData>[]>([])
const flowEdges = ref<Edge[]>([])
const selectedNodeId = ref<string | null>(null)
// --- Getters ---
const selectedNode = computed<Node<WorkflowNodeData> | null>(() => {
if (!selectedNodeId.value) return null
return flowNodes.value.find((n) => n.id === selectedNodeId.value) || null
})
const selectedNodeData = computed<WorkflowNodeData | null>(() => {
return selectedNode.value?.data || null
})
// --- Actions ---
/** Load all workflows from server */
async function loadWorkflows(): Promise<void> {
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<WorkflowDefinition | null> {
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<void> {
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<boolean> {
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<boolean> {
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<string, unknown>): Promise<void> {
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<void> {
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<void> {
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<WorkflowNodeData> = {
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<WorkflowNodeData>): 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,
}
})

View File

@ -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<string, unknown>
outputs: string[]
timeout_seconds: number
retry_count: number
continue_on_failure: boolean
condition: string | null
type: 'skill' | 'condition' | 'approval' | 'parallel'
config: Record<string, unknown>
}
export interface WorkflowDefinition {
workflow_id: string
name: string
version: number
stages: WorkflowStage[]
triggers: Record<string, unknown>[]
variables_schema: Record<string, unknown>
output_schema: Record<string, unknown>
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<string, unknown>
started_at: string | null
completed_at: string | null
error: string | null
variables: Record<string, unknown>
}
// ---------------------------------------------------------------------------
// Node data type for Vue Flow custom nodes
// ---------------------------------------------------------------------------
export interface WorkflowNodeData {
label: string
type: 'skill' | 'condition' | 'approval' | 'parallel'
config: Record<string, unknown>
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<string, Partial<WorkflowNodeData>> = {
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<WorkflowNodeData>[],
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<WorkflowNodeData>[]; edges: Edge[] } {
const nodeMap = new Map<string, string>() // stage name -> node id
const nodes: Node<WorkflowNodeData>[] = 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 }
}

View File

@ -1,28 +1,109 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(RiseOutlined)"
title="自进化"
sub-title="智能体自进化与优化,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
<div class="evolution-view">
<div class="evolution-view__header">
<h2>自进化仪表盘</h2>
<a-spin v-if="store.isLoading" size="small" />
</div>
<div class="evolution-view__grid">
<div class="evolution-view__panel">
<ExperienceTimeline
:experiences="store.experiences"
@filter="onExperienceFilter"
/>
</div>
<div class="evolution-view__panel">
<MetricsChart
:metrics="store.metrics"
:trends="store.trends"
:period="store.period"
@period-change="onPeriodChange"
/>
</div>
<div class="evolution-view__panel">
<PitfallPanel
:warnings="store.pitfalls"
:loading="store.isLoading"
@check="onPitfallCheck"
/>
</div>
<div class="evolution-view__panel">
<PathOptimizerPanel
:optimizations="store.optimizations"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { RiseOutlined } from '@ant-design/icons-vue'
import { onMounted } from 'vue'
import { Spin as ASpin } from 'ant-design-vue'
import { useEvolutionStore } from '@/stores/evolution'
import ExperienceTimeline from '@/components/evolution/ExperienceTimeline.vue'
import MetricsChart from '@/components/evolution/MetricsChart.vue'
import PitfallPanel from '@/components/evolution/PitfallPanel.vue'
import PathOptimizerPanel from '@/components/evolution/PathOptimizerPanel.vue'
const store = useEvolutionStore()
onMounted(() => {
store.loadExperiences()
store.loadMetrics()
store.loadOptimizations()
})
function onExperienceFilter(outcome: string) {
store.loadExperiences(outcome ? undefined : undefined)
}
function onPeriodChange(period: string) {
store.loadMetrics(period)
}
function onPitfallCheck(taskType: string) {
store.checkPitfalls(taskType, [])
}
</script>
<style scoped>
.placeholder-view {
.evolution-view {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
overflow: hidden;
}
.evolution-view__header {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
margin-bottom: 16px;
flex-shrink: 0;
}
.evolution-view__header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.evolution-view__grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 16px;
min-height: 0;
}
.evolution-view__panel {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

View File

@ -1,28 +1,39 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(BookOutlined)"
title="知识库"
sub-title="知识库管理与检索,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
<div class="kb-view">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="documents" tab="文档管理">
<DocumentUpload />
</a-tab-pane>
<a-tab-pane key="sources" tab="信息源配置">
<SourceConfig />
</a-tab-pane>
<a-tab-pane key="search" tab="检索测试">
<SearchTest />
</a-tab-pane>
</a-tabs>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { BookOutlined } from '@ant-design/icons-vue'
import { ref, onMounted } from 'vue'
import { useKnowledgeStore } from '@/stores/knowledge'
import DocumentUpload from '@/components/kb/DocumentUpload.vue'
import SourceConfig from '@/components/kb/SourceConfig.vue'
import SearchTest from '@/components/kb/SearchTest.vue'
const kbStore = useKnowledgeStore()
const activeTab = ref('documents')
onMounted(() => {
kbStore.fetchSources()
})
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
.kb-view {
height: 100%;
padding: 16px 24px;
overflow-y: auto;
background: #fff;
}
</style>

View File

@ -1,28 +1,174 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(SettingOutlined)"
title="设置"
sub-title="系统配置与偏好设置,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
<div class="settings-view">
<a-form layout="vertical" class="settings-form">
<!-- LLM 配置 -->
<a-divider orientation="left">LLM 配置</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="Provider">
<a-select v-model:value="settingsStore.llm.provider" placeholder="选择 LLM 提供商">
<a-select-option value="anthropic">Anthropic</a-select-option>
<a-select-option value="openai">OpenAI</a-select-option>
<a-select-option value="gemini">Gemini</a-select-option>
<a-select-option value="custom">自定义</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="模型">
<a-input v-model:value="settingsStore.llm.model" placeholder="例如: claude-sonnet-4-20250514" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="API Key">
<a-input-password v-model:value="settingsStore.llm.api_key" placeholder="输入 API Key" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="Base URL">
<a-input v-model:value="settingsStore.llm.base_url" placeholder="自定义 API 地址(可选)" />
</a-form-item>
</a-col>
</a-row>
<!-- 技能配置 -->
<a-divider orientation="left">技能配置</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="默认技能">
<a-input v-model:value="settingsStore.skillSettings.default_skill" placeholder="留空则使用自动路由" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="自动路由">
<a-switch v-model:checked="settingsStore.skillSettings.auto_routing" />
<span style="margin-left: 8px; color: #999">启用后自动将消息路由到最匹配的技能</span>
</a-form-item>
</a-col>
</a-row>
<!-- 知识库配置 -->
<a-divider orientation="left">知识库配置</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="默认信息源">
<a-select
v-model:value="settingsStore.kbSettings.default_sources"
mode="multiple"
placeholder="选择默认信息源"
>
<a-select-option value="local">本地文档</a-select-option>
<a-select-option value="feishu">飞书</a-select-option>
<a-select-option value="confluence">Confluence</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="检索数量 (Top K)">
<a-input-number v-model:value="settingsStore.kbSettings.top_k" :min="1" :max="50" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="检索模式">
<a-select v-model:value="settingsStore.kbSettings.retrieval_mode">
<a-select-option value="standard">标准</a-select-option>
<a-select-option value="rerank">重排序</a-select-option>
<a-select-option value="compression">压缩</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<!-- 系统配置 -->
<a-divider orientation="left">系统配置</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="速率限制 (次/分钟)">
<a-input-number v-model:value="settingsStore.system.rate_limit" :min="1" :max="1000" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="日志级别">
<a-select v-model:value="settingsStore.system.logging_level">
<a-select-option value="DEBUG">DEBUG</a-select-option>
<a-select-option value="INFO">INFO</a-select-option>
<a-select-option value="WARNING">WARNING</a-select-option>
<a-select-option value="ERROR">ERROR</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="CORS 来源">
<a-select
v-model:value="settingsStore.system.cors_origins"
mode="tags"
placeholder="输入 CORS 来源"
/>
</a-form-item>
</a-col>
</a-row>
<div class="settings-form__actions">
<a-button type="primary" :loading="settingsStore.isSaving" @click="handleSave">
保存设置
</a-button>
<a-alert
v-if="settingsStore.saveSuccess"
message="设置已保存"
type="success"
show-icon
style="margin-left: 12px"
/>
<a-alert
v-if="settingsStore.error"
:message="settingsStore.error"
type="error"
show-icon
style="margin-left: 12px"
/>
</div>
</a-form>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { SettingOutlined } from '@ant-design/icons-vue'
import { onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useSettingsStore } from '@/stores/settings'
const settingsStore = useSettingsStore()
onMounted(() => {
settingsStore.fetchSettings()
})
async function handleSave(): Promise<void> {
try {
await settingsStore.saveSettings()
message.success('设置已保存')
} catch {
message.error('保存设置失败')
}
}
</script>
<style scoped>
.placeholder-view {
.settings-view {
height: 100%;
padding: 16px 24px;
overflow-y: auto;
background: #fff;
}
.settings-form {
max-width: 900px;
}
.settings-form__actions {
margin-top: 24px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -1,28 +1,102 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(AppstoreOutlined)"
title="技能"
sub-title="技能注册与管理,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
<div class="skills-view">
<div class="skills-view__header">
<a-select
v-model:value="selectedCapability"
placeholder="按能力标签筛选"
allow-clear
style="width: 200px"
@change="handleCapabilityChange"
>
<a-select-option v-for="cap in skillsStore.capabilities" :key="cap.name" :value="cap.name">
{{ cap.display_name }} ({{ cap.skill_count }})
</a-select-option>
</a-select>
<a-button @click="handleRefresh" :loading="skillsStore.isLoading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<a-spin :spinning="skillsStore.isLoading">
<div class="skills-view__grid">
<SkillCard
v-for="skill in skillsStore.skills"
:key="skill.name"
:skill="skill"
@click="handleSkillClick(skill.name)"
/>
</div>
<a-empty v-if="!skillsStore.isLoading && skillsStore.skills.length === 0" description="暂无已注册技能" />
</a-spin>
<SkillDetail
:visible="showDetail"
:skill="skillsStore.selectedSkill"
@close="handleDetailClose"
/>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { AppstoreOutlined } from '@ant-design/icons-vue'
import { ref, onMounted } from 'vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { useSkillsStore } from '@/stores/skills'
import SkillCard from '@/components/skills/SkillCard.vue'
import SkillDetail from '@/components/skills/SkillDetail.vue'
const skillsStore = useSkillsStore()
const selectedCapability = ref<string | undefined>(undefined)
const showDetail = ref(false)
onMounted(async () => {
await Promise.all([
skillsStore.fetchSkills(),
skillsStore.fetchCapabilities(),
])
})
async function handleCapabilityChange(value: string | undefined): Promise<void> {
skillsStore.setCapabilityFilter(value ?? null)
await skillsStore.fetchSkills()
}
async function handleRefresh(): Promise<void> {
await Promise.all([
skillsStore.fetchSkills(),
skillsStore.fetchCapabilities(),
])
}
async function handleSkillClick(skillName: string): Promise<void> {
await skillsStore.fetchSkillDetail(skillName)
showDetail.value = true
}
function handleDetailClose(): void {
showDetail.value = false
skillsStore.clearSelectedSkill()
}
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
.skills-view {
height: 100%;
padding: 16px 24px;
overflow-y: auto;
background: #fff;
}
.skills-view__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.skills-view__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
</style>

View File

@ -1,28 +1,47 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(CodeOutlined)"
title="终端"
sub-title="远程终端与命令执行,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
<div class="terminal-view">
<div class="terminal-view__main">
<TerminalEmulator />
</div>
<div class="terminal-view__sidebar">
<CommandHistory @select="handleHistorySelect" />
</div>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { CodeOutlined } from '@ant-design/icons-vue'
import { onUnmounted } from 'vue'
import { useTerminalStore } from '@/stores/terminal'
import TerminalEmulator from '@/components/terminal/TerminalEmulator.vue'
import CommandHistory from '@/components/terminal/CommandHistory.vue'
const terminalStore = useTerminalStore()
onUnmounted(() => {
terminalStore.disconnectWebSocket()
})
function handleHistorySelect(command: string): void {
// When a history item is clicked, send it as a new command
terminalStore.sendCommand(command)
}
</script>
<style scoped>
.placeholder-view {
.terminal-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
overflow: hidden;
}
.terminal-view__main {
flex: 1;
overflow: hidden;
padding: 16px;
}
.terminal-view__sidebar {
width: 280px;
overflow: hidden;
}
</style>

View File

@ -1,28 +1,326 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(ApartmentOutlined)"
title="工作流"
sub-title="可视化工作流编排与管理,即将上线"
<div class="workflow-editor">
<!-- Workflow list sidebar -->
<div v-if="!currentWorkflow" class="workflow-list">
<div class="list-header">
<h3>工作流列表</h3>
<a-button type="primary" size="small" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建
</a-button>
</div>
<a-spin :spinning="isLoading">
<div class="list-body">
<div
v-for="wf in workflows"
:key="wf.workflow_id"
class="workflow-item"
@click="handleSelectWorkflow(wf.workflow_id)"
>
<div class="item-name">{{ wf.name }}</div>
<div class="item-meta">
<span>{{ wf.stage_count }} 个阶段</span>
<span>v{{ wf.version }}</span>
</div>
<a-button
size="small"
type="text"
danger
@click.stop="handleDelete(wf.workflow_id)"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<a-empty v-if="workflows.length === 0" description="暂无工作流" />
</div>
</a-spin>
</div>
<!-- Workflow editor canvas -->
<div v-else class="editor-main">
<NodePalette />
<FlowCanvas
:nodes="flowNodes"
:edges="flowEdges"
:saving="isLoading"
:executing="isExecuting"
@save="handleSave"
@execute="handleExecute"
@clear="handleClear"
@node-select="handleNodeSelect"
@update:edges="flowEdges = $event"
/>
<PropertyPanel
:node-data="selectedNodeData"
@update-field="handleUpdateField"
@update-config="handleUpdateConfig"
@delete-node="handleDeleteNode"
/>
</div>
<!-- Back button when editing -->
<div v-if="currentWorkflow" class="back-bar">
<a-button size="small" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<span class="workflow-name">{{ currentWorkflow.name }}</span>
<a-tag v-if="currentExecution" :color="executionStatusColor">
{{ executionStatusLabel }}
</a-tag>
</div>
<!-- Execution dialog -->
<a-modal
v-model:open="showExecuteDialog"
title="执行工作流"
@ok="confirmExecute"
ok-text="执行"
cancel-text="取消"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
<a-form layout="vertical">
<a-form-item label="输入变量 (JSON)">
<a-textarea
v-model:value="executeVariables"
placeholder='{"key": "value"}'
:rows="4"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { ApartmentOutlined } from '@ant-design/icons-vue'
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
DeleteOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons-vue'
import { useWorkflowStore } from '@/stores/workflow'
import NodePalette from '@/components/workflow/NodePalette.vue'
import FlowCanvas from '@/components/workflow/FlowCanvas.vue'
import PropertyPanel from '@/components/workflow/PropertyPanel.vue'
const store = useWorkflowStore()
const showExecuteDialog = ref(false)
const executeVariables = ref('{}')
// Computed from store
const workflows = computed(() => store.workflows)
const currentWorkflow = computed(() => store.currentWorkflow)
const currentExecution = computed(() => store.currentExecution)
const isExecuting = computed(() => store.isExecuting)
const isLoading = computed(() => store.isLoading)
const flowNodes = computed({
get: () => store.flowNodes,
set: (val) => { store.flowNodes = val },
})
const flowEdges = computed({
get: () => store.flowEdges,
set: (val) => { store.flowEdges = val },
})
const selectedNodeData = computed(() => store.selectedNodeData)
const executionStatusColor = computed(() => {
const colors: Record<string, string> = {
pending: 'default',
running: 'processing',
paused: 'warning',
completed: 'success',
failed: 'error',
cancelled: 'default',
}
return colors[currentExecution.value?.status || 'pending'] || 'default'
})
const executionStatusLabel = computed(() => {
const labels: Record<string, string> = {
pending: '等待中',
running: '运行中',
paused: '已暂停',
completed: '已完成',
failed: '已失败',
cancelled: '已取消',
}
return labels[currentExecution.value?.status || 'pending'] || '未知'
})
onMounted(() => {
store.loadWorkflows()
})
async function handleCreate() {
const name = `工作流 ${workflows.value.length + 1}`
const wf = await store.createWorkflow(name)
if (wf) {
message.success('工作流已创建')
}
}
async function handleSelectWorkflow(workflowId: string) {
await store.loadWorkflow(workflowId)
}
async function handleDelete(workflowId: string) {
const success = await store.deleteWorkflow(workflowId)
if (success) {
message.success('工作流已删除')
}
}
async function handleSave() {
const success = await store.saveWorkflow()
if (success) {
message.success('工作流已保存')
} else {
message.error('保存失败')
}
}
function handleExecute() {
showExecuteDialog.value = true
}
async function confirmExecute() {
showExecuteDialog.value = false
try {
const vars = JSON.parse(executeVariables.value)
await store.executeWorkflow(vars)
message.success('工作流开始执行')
} catch {
message.error('变量格式错误,请输入有效的 JSON')
}
}
function handleClear() {
store.clearCanvas()
}
function handleNodeSelect(nodeId: string | null) {
store.selectNode(nodeId)
}
function handleUpdateField(field: string, value: unknown) {
if (store.selectedNodeId) {
store.updateNodeData(store.selectedNodeId, { [field]: value })
}
}
function handleUpdateConfig(key: string, value: unknown) {
if (store.selectedNodeId && store.selectedNodeData) {
store.updateNodeData(store.selectedNodeId, {
config: { ...store.selectedNodeData.config, [key]: value },
})
}
}
function handleDeleteNode() {
if (store.selectedNodeId) {
store.removeNode(store.selectedNodeId)
}
}
function handleBack() {
store.currentWorkflow = null
store.flowNodes = []
store.flowEdges = []
store.selectedNodeId = null
store.currentExecution = null
store.loadWorkflows()
}
</script>
<style scoped>
.placeholder-view {
.workflow-editor {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.workflow-list {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
.list-header {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
margin-bottom: 16px;
}
.list-header h3 {
margin: 0;
font-size: 16px;
}
.list-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.workflow-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
gap: 12px;
}
.workflow-item:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.12);
}
.item-name {
font-weight: 500;
font-size: 14px;
flex: 1;
}
.item-meta {
display: flex;
gap: 8px;
font-size: 12px;
color: #999;
}
.editor-main {
display: flex;
flex: 1;
height: 100%;
overflow: hidden;
}
.back-bar {
position: absolute;
top: 0;
left: 200px;
right: 300px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid #f0f0f0;
z-index: 20;
}
.workflow-name {
font-weight: 600;
font-size: 14px;
color: #333;
}
</style>

View File

@ -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"]

View File

@ -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)

View File

@ -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": "健康检查未实现"}

View File

@ -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}' 已重新加载",
}

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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