diff --git a/backend/python/main.py b/backend/python/main.py index 1c9f351..69d2e3a 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD from app.database import SessionLocal @@ -419,6 +419,7 @@ app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=[" app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) +app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"]) app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bf4fffa..4af19dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ const AdminAutomation = lazy(() => import('@/pages/admin/Automation')); const AdminDeveloper = lazy(() => import('@/pages/admin/Developer')); const AdminMcp = lazy(() => import('@/pages/admin/Mcp')); const AdminStorage = lazy(() => import('@/pages/admin/Storage')); +const AdminWorkflow = lazy(() => import('@/pages/admin/Workflow')); // Loading fallback const PageLoading: React.FC = () => ( @@ -113,6 +114,7 @@ const App: React.FC = () => { {/* AI能力 */} } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 2e1bf3c..e50ff32 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -686,4 +686,25 @@ export const evolutionAPI = { }, getReportDetail: (reportId: string) => get(`/evolution/reports/${reportId}`), + + // Workflow (Phase 3.6) + getWorkflows: (params?: Record) => + get('/admin/workflows/?' + new URLSearchParams(params || {}).toString()), + getWorkflow: (id: string) => + get(`/admin/workflows/${id}`), + getWorkflowStats: () => + get('/admin/workflows/stats'), + getWorkflowTemplates: () => + get('/admin/workflows/templates'), + createWorkflow: (data: any) => + post('/admin/workflows/', data), + updateWorkflow: (id: string, data: any) => + put(`/admin/workflows/${id}`, data), + deleteWorkflow: (id: string) => + del(`/admin/workflows/${id}`), + toggleWorkflow: (id: string, isActive: boolean) => + post(`/admin/workflows/${id}/toggle?is_active=${isActive}`), + executeWorkflow: (id: string, inputText: string) => + post(`/admin/workflows/${id}/execute`, { input_text: inputText }), + }; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 6034a89..f839239 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -34,6 +34,7 @@ const navGroups: NavGroup[] = [ items: [ { path: '/admin/models', icon: 'models', label: '模型控制台' }, { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, + { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, { path: '/admin/skills', icon: 'skills', label: '技能管理' }, { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, { path: '/admin/mcp', icon: 'mcp', label: 'MCP服务' }, @@ -73,6 +74,7 @@ const adminIconPaths: Record = { market: , logs: , agents: , + workflow: , skills: , knowledge: , // New icons for orphan pages diff --git a/frontend/src/pages/admin/Workflow.tsx b/frontend/src/pages/admin/Workflow.tsx new file mode 100644 index 0000000..ea0e394 --- /dev/null +++ b/frontend/src/pages/admin/Workflow.tsx @@ -0,0 +1,667 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; + +interface WorkflowNode { + id: string; + type: string; + position: { x: number; y: number }; + data: { label: string; config: Record }; +} + +interface WorkflowEdge { + id: string; + source: string; + target: string; + sourceHandle?: string | null; + targetHandle?: string | null; +} + +interface Workflow { + id: string; + name: string; + description: string; + agent_id: string | null; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + variables: Record; + status: string; + version: number; + created_at: string; + updated_at: string; +} + +interface NodeTemplate { + type: string; + label: string; + icon: string; + color: string; + description: string; + config_schema: Record; +} + +const NODE_COLORS: Record = { + start: "#3B6D11", + llm: "#534AB7", + knowledge: "#185FA5", + skill: "#0F6E56", + condition: "#BA7517", + http: "#D4537E", + code: "#5F5E5A", + end: "#A32D2D", +}; + +const NODE_LABELS: Record = { + start: "开始", + llm: "LLM 调用", + knowledge: "知识库检索", + skill: "Skill 调用", + condition: "条件分支", + http: "HTTP 请求", + code: "代码执行", + end: "结束", +}; + +export default function Workflow() { + const navigate = useNavigate(); + const [workflows, setWorkflows] = useState([]); + const [loading, setLoading] = useState(true); + const [view, setView] = useState<"list" | "editor">("list"); + const [currentWf, setCurrentWf] = useState(null); + const [templates, setTemplates] = useState([]); + const [options, setOptions] = useState>({}); + const [selectedNode, setSelectedNode] = useState(null); + const [draggingNode, setDraggingNode] = useState(null); + const [connectingFrom, setConnectingFrom] = useState(null); + const [executeResult, setExecuteResult] = useState(null); + const [executeInput, setExecuteInput] = useState(""); + const [showExecute, setShowExecute] = useState(false); + const [stats, setStats] = useState({ total: 0, draft: 0, published: 0, archived: 0 }); + const canvasRef = useRef(null); + const dragOffset = useRef({ x: 0, y: 0 }); + + const api = (window as any).api; + + // Load workflows + const loadWorkflows = useCallback(async () => { + setLoading(true); + try { + const res = await api.get("/admin/workflows/"); + if (res.code === 0) setWorkflows(res.data.items || []); + } catch (e) { console.error(e); } + try { + const s = await api.get("/admin/workflows/stats"); + if (s.code === 0) setStats(s.data); + } catch (e) { console.error(e); } + setLoading(false); + }, []); + + // Load templates + const loadTemplates = useCallback(async () => { + try { + const res = await api.get("/admin/workflows/templates"); + if (res.code === 0) { + setTemplates(Object.values(res.data.node_templates || {})); + setOptions(res.data.options || {}); + } + } catch (e) { console.error(e); } + }, []); + + useEffect(() => { loadWorkflows(); loadTemplates(); }, []); + + // Create new workflow + const handleCreate = async () => { + const name = prompt("工作流名称:", "新工作流"); + if (!name) return; + try { + const res = await api.post("/admin/workflows/", { + name, + description: "", + nodes: [ + { id: "start-1", type: "start", position: { x: 100, y: 200 }, data: { label: "开始", config: { input_variable: "input" } } }, + { id: "end-1", type: "end", position: { x: 500, y: 200 }, data: { label: "结束", config: { output_variable: "output" } } }, + ], + edges: [], + }); + if (res.code === 0) { + loadWorkflows(); + handleOpenEditor(res.data); + } + } catch (e) { console.error(e); } + }; + + // Open editor + const handleOpenEditor = (wf: Workflow) => { + setCurrentWf(wf); + setSelectedNode(null); + setView("editor"); + }; + + // Save workflow + const handleSave = async () => { + if (!currentWf) return; + try { + const res = await api.put(`/admin/workflows/${currentWf.id}`, { + name: currentWf.name, + description: currentWf.description, + nodes: currentWf.nodes, + edges: currentWf.edges, + variables: currentWf.variables, + }); + if (res.code === 0) { + setCurrentWf(res.data); + alert("保存成功"); + } + } catch (e) { console.error(e); } + }; + + // Delete workflow + const handleDelete = async (id: string) => { + if (!confirm("确认删除此工作流?")) return; + try { + await api.del(`/admin/workflows/${id}`); + loadWorkflows(); + } catch (e) { console.error(e); } + }; + + // Toggle publish + const handleToggle = async (wf: Workflow) => { + const isActive = wf.status !== "published"; + try { + await api.put(`/admin/workflows/${wf.id}/toggle?is_active=${isActive}`); + loadWorkflows(); + } catch (e) { console.error(e); } + }; + + // Add node to canvas + const handleAddNode = (template: NodeTemplate) => { + if (!currentWf) return; + const id = `${template.type}-${Date.now()}`; + const newNode: WorkflowNode = { + id, + type: template.type, + position: { x: 250 + Math.random() * 100, y: 150 + Math.random() * 100 }, + data: { + label: template.label, + config: Object.fromEntries( + Object.entries(template.config_schema).map(([k, v]: [string, any]) => [k, v.default ?? ""]) + ), + }, + }; + setCurrentWf({ ...currentWf, nodes: [...currentWf.nodes, newNode] }); + }; + + // Node drag + const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => { + e.stopPropagation(); + const node = currentWf!.nodes.find(n => n.id === nodeId); + if (!node || !canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + dragOffset.current = { + x: e.clientX - rect.left - node.position.x, + y: e.clientY - rect.top - node.position.y, + }; + setDraggingNode(nodeId); + setSelectedNode(node); + }; + + const handleCanvasMouseMove = (e: React.MouseEvent) => { + if (!draggingNode || !currentWf || !canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left - dragOffset.current.x; + const y = e.clientY - rect.top - dragOffset.current.y; + setCurrentWf({ + ...currentWf, + nodes: currentWf.nodes.map(n => n.id === draggingNode ? { ...n, position: { x, y } } : n), + }); + }; + + const handleCanvasMouseUp = () => { setDraggingNode(null); }; + + // Canvas click (deselect / cancel connecting) + const handleCanvasClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + setSelectedNode(null); + setConnectingFrom(null); + } + }; + + // Start connecting from a node + const handleStartConnect = (e: React.MouseEvent, nodeId: string) => { + e.stopPropagation(); + setConnectingFrom(nodeId); + }; + + // Complete connection to a node + const handleEndConnect = (e: React.MouseEvent, nodeId: string) => { + e.stopPropagation(); + if (!connectingFrom || !currentWf) return; + if (connectingFrom === nodeId) { setConnectingFrom(null); return; } + // Check if edge already exists + const exists = currentWf.edges.some(e => e.source === connectingFrom && e.target === nodeId); + if (exists) { setConnectingFrom(null); return; } + const newEdge: WorkflowEdge = { + id: `e-${Date.now()}`, + source: connectingFrom, + target: nodeId, + }; + setCurrentWf({ ...currentWf, edges: [...currentWf.edges, newEdge] }); + setConnectingFrom(null); + }; + + // Delete edge + const handleDeleteEdge = (edgeId: string) => { + if (!currentWf) return; + setCurrentWf({ ...currentWf, edges: currentWf.edges.filter(e => e.id !== edgeId) }); + }; + + // Delete selected node + const handleDeleteNode = () => { + if (!selectedNode || !currentWf) return; + const nodeId = selectedNode.id; + setCurrentWf({ + ...currentWf, + nodes: currentWf.nodes.filter(n => n.id !== nodeId), + edges: currentWf.edges.filter(e => e.source !== nodeId && e.target !== nodeId), + }); + setSelectedNode(null); + }; + + // Update node config + const handleConfigChange = (key: string, value: any) => { + if (!selectedNode || !currentWf) return; + const updatedNodes = currentWf.nodes.map(n => + n.id === selectedNode.id + ? { ...n, data: { ...n.data, label: n.data.label, config: { ...n.data.config, [key]: value } } } + : n + ); + setCurrentWf({ ...currentWf, nodes: updatedNodes }); + setSelectedNode(updatedNodes.find(n => n.id === selectedNode.id) || null); + }; + + // Execute workflow + const handleExecute = async () => { + if (!currentWf) return; + try { + const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, { + input_text: executeInput, + }); + if (res.code === 0) { + setExecuteResult(res.data); + } else { + alert("执行失败: " + (res.message || "未知错误")); + } + } catch (e) { console.error(e); alert("执行失败"); } + }; + + // SVG edge path (bezier curve) + const getEdgePath = (src: WorkflowNode, tgt: WorkflowNode) => { + const x1 = src.position.x + 90; + const y1 = src.position.y + 25; + const x2 = tgt.position.x; + const y2 = tgt.position.y + 25; + const dx = Math.abs(x2 - x1) * 0.5; + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; + }; + + // ============================================================ + // LIST VIEW + // ============================================================ + if (view === "list") { + return ( +
+
+
+

Agent 可视化编排

+

拖拽式 DAG 工作流编排引擎 · 支持LLM/知识库/Skill/条件分支/HTTP/代码节点

+
+ +
+ + {/* Stats */} +
+ {[ + { label: "总工作流", value: stats.total, color: "#534AB7" }, + { label: "草稿", value: stats.draft, color: "#BA7517" }, + { label: "已发布", value: stats.published, color: "#3B6D11" }, + { label: "已归档", value: stats.archived, color: "#5F5E5A" }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {/* Workflow list */} +
+ + + + {["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => ( + + ))} + + + + {loading ? ( + + ) : workflows.length === 0 ? ( + + ) : workflows.map(wf => ( + + + + + + + + + ))} + +
{h}
加载中...
暂无工作流,点击"新建工作流"开始
+
{wf.name}
+
{wf.description || "—"}
+
+ {wf.status === "published" ? "已发布" : wf.status === "archived" ? "已归档" : "草稿"} + {wf.nodes?.length || 0}v{wf.version}{wf.updated_at?.slice(0, 19).replace("T", " ") || "—"} + + + +
+
+
+ ); + } + + // ============================================================ + // EDITOR VIEW + // ============================================================ + if (!currentWf) return null; + + return ( +
+ {/* Toolbar */} +
+ + setCurrentWf({ ...currentWf, name: e.target.value })} + style={{ background: "transparent", border: "1px solid #333", borderRadius: "6px", padding: "4px 12px", color: "#fff", fontSize: "14px", width: "200px" }} + /> + v{currentWf.version} · {currentWf.nodes.length} 节点 · {currentWf.edges.length} 连线 +
+ + + {selectedNode && } +
+ + {/* Execute panel */} + {showExecute && ( +
+
+
+ +