diff --git a/frontend/package.json b/frontend/package.json
index e62840a..438c06a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
"license": "ISC",
"dependencies": {
"@types/qrcode": "^1.5.6",
+ "dagre": "^0.8.5",
"highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
@@ -22,7 +23,8 @@
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.30.3"
+ "react-router-dom": "^6.30.3",
+ "reactflow": "^11.11.4"
},
"devDependencies": {
"@types/react": "^18.3.29",
diff --git a/frontend/src/pages/front/AgentCanvas.css b/frontend/src/pages/front/AgentCanvas.css
new file mode 100644
index 0000000..e69ea40
--- /dev/null
+++ b/frontend/src/pages/front/AgentCanvas.css
@@ -0,0 +1,66 @@
+.agent-canvas-page {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 60px);
+ background: #0f1117;
+}
+.canvas-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 16px;
+ background: #1a1d27;
+ border-bottom: 1px solid rgba(255,255,255,0.06);
+ flex-shrink: 0;
+}
+.agent-name-input {
+ background: rgba(255,255,255,0.05);
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 6px;
+ color: #e0e0e0;
+ padding: 6px 12px;
+ font-size: 14px;
+ width: 200px;
+}
+.toolbar-actions { display: flex; gap: 8px; margin-left: auto; }
+.tb-btn {
+ padding: 6px 14px;
+ border-radius: 6px;
+ border: 1px solid rgba(255,255,255,0.12);
+ background: rgba(255,255,255,0.05);
+ color: #ccc;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.tb-btn:hover { background: rgba(255,255,255,0.1); }
+.tb-btn.primary { background: #5B5FED; border-color: #5B5FED; color: #fff; }
+.tb-btn.primary:hover { background: #4A4ECC; }
+.tb-btn.secondary { background: rgba(255,255,255,0.03); }
+.canvas-container { flex: 1; min-height: 0; }
+
+.agent-node {
+ padding: 10px 14px;
+ border-radius: 10px;
+ min-width: 140px;
+ font-size: 12px;
+ border: 2px solid rgba(255,255,255,0.1);
+ background: #1e2130;
+}
+.agent-node .node-icon {
+ width: 28px; height: 28px;
+ border-radius: 8px;
+ display: inline-flex; align-items: center; justify-content: center;
+ font-weight: 700; font-size: 13px; margin-bottom: 4px;
+}
+.skill-node .node-icon { background: rgba(91,95,237,0.2); color: #5B5FED; }
+.llm-node .node-icon { background: rgba(46,204,113,0.2); color: #2ecc71; }
+.mcp-node .node-icon { background: rgba(241,196,15,0.2); color: #f1c40f; }
+.io-node .node-icon { background: rgba(52,152,219,0.2); color: #3498db; }
+.io-node.output .node-icon { background: rgba(231,76,60,0.2); color: #e74c3c; }
+.node-label { font-weight: 600; color: #e0e0e0; margin: 2px 0; font-size: 13px; }
+.node-meta { color: #888; font-size: 11px; }
+.canvas-hint { color: #555; font-size: 12px; }
+
+.react-flow__controls button { background: #1a1d27; border-color: rgba(255,255,255,0.1); color: #ccc; }
+.react-flow__minimap { background: #1a1d27; }
diff --git a/frontend/src/pages/front/AgentCanvas.tsx b/frontend/src/pages/front/AgentCanvas.tsx
new file mode 100644
index 0000000..ba822e9
--- /dev/null
+++ b/frontend/src/pages/front/AgentCanvas.tsx
@@ -0,0 +1,176 @@
+import React, { useState, useCallback, useEffect, useMemo } from 'react';
+import ReactFlow, {
+ Node, Edge, Controls, Background, MiniMap,
+ useNodesState, useEdgesState, addEdge, Connection,
+ MarkerType, Panel, NodeTypes,
+} from 'reactflow';
+import 'reactflow/dist/style.css';
+import dagre from 'dagre';
+import './AgentCanvas.css';
+
+const nodeTypes: NodeTypes = {
+ skillNode: SkillNode,
+ llmNode: LLMNode,
+ mcpNode: MCPNode,
+ ioNode: IONode,
+};
+
+function SkillNode({ data }: any) {
+ return (
+
+
S
+
{data.label}
+
{data.desc}
+
+ );
+}
+
+function LLMNode({ data }: any) {
+ return (
+
+
AI
+
{data.label}
+
{data.model || 'deepseek-v4-flash'}
+
+ );
+}
+
+function MCPNode({ data }: any) {
+ return (
+
+
M
+
{data.label}
+
{data.server}
+
+ );
+}
+
+function IONode({ data }: any) {
+ return (
+
+
{data.ioType === 'input' ? 'IN' : 'OUT'}
+
{data.label}
+
+ );
+}
+
+const defaultNodes: Node[] = [
+ { id: 'input-1', type: 'ioNode', position: { x: 100, y: 250 }, data: { label: '用户输入', ioType: 'input' } },
+ { id: 'llm-1', type: 'llmNode', position: { x: 350, y: 200 }, data: { label: '推理节点', model: 'deepseek-v4-flash' } },
+ { id: 'skill-1', type: 'skillNode', position: { x: 350, y: 400 }, data: { label: 'PPT生成', desc: '文本→PPTX' } },
+ { id: 'output-1', type: 'ioNode', position: { x: 600, y: 250 }, data: { label: '输出结果', ioType: 'output' } },
+];
+
+const defaultEdges: Edge[] = [
+ { id: 'e1-2', source: 'input-1', target: 'llm-1', animated: true, markerEnd: { type: MarkerType.ArrowClosed } },
+ { id: 'e1-3', source: 'input-1', target: 'skill-1', animated: true, markerEnd: { type: MarkerType.ArrowClosed } },
+ { id: 'e2-4', source: 'llm-1', target: 'output-1', markerEnd: { type: MarkerType.ArrowClosed } },
+ { id: 'e3-4', source: 'skill-1', target: 'output-1', markerEnd: { type: MarkerType.ArrowClosed } },
+];
+
+function layoutDagre(nodes: Node[], edges: Edge[]) {
+ const g = new dagre.graphlib.Graph();
+ g.setDefaultEdgeLabel(() => ({}));
+ g.setGraph({ rankdir: 'LR', nodesep: 80, ranksep: 120 });
+ nodes.forEach(n => g.setNode(n.id, { width: 160, height: 80 }));
+ edges.forEach(e => g.setEdge(e.source, e.target));
+ dagre.layout(g);
+ return nodes.map(n => {
+ const pos = g.node(n.id);
+ return { ...n, position: { x: pos.x - 80, y: pos.y - 40 } };
+ });
+}
+
+export default function AgentCanvas() {
+ const [nodes, setNodes, onNodesChange] = useNodesState(layoutDagre(defaultNodes, defaultEdges));
+ const [edges, setEdges, onEdgesChange] = useEdgesState(defaultEdges);
+ const [agentName, setAgentName] = useState('新智能体');
+ const [saved, setSaved] = useState(false);
+
+ const onConnect = useCallback((params: Connection) =>
+ setEdges(eds => addEdge({ ...params, animated: true, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
+ [setEdges]
+ );
+
+ const addNode = useCallback((type: string) => {
+ const id = `${type}-${Date.now()}`;
+ const labels: Record = {
+ skillNode: { label: '新技能', desc: '技能描述' },
+ llmNode: { label: 'LLM节点', model: 'deepseek-v4-flash' },
+ mcpNode: { label: 'MCP工具', server: 'tool-server' },
+ };
+ const newNode: Node = {
+ id, type, position: { x: 300, y: 100 + nodes.length * 80 },
+ data: labels[type] || { label: id },
+ };
+ setNodes(nds => [...nds, newNode]);
+ }, [nodes.length, setNodes]);
+
+ const handleAutoLayout = useCallback(() => {
+ setNodes(nds => layoutDagre(nds, edges));
+ }, [edges, setNodes]);
+
+ const handleSave = useCallback(async () => {
+ const config = { name: agentName, nodes, edges, updatedAt: new Date().toISOString() };
+ try {
+ const token = localStorage.getItem('token');
+ await fetch('/app/api/agent/dags', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify(config),
+ });
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ } catch (e) {
+ console.error('Save failed', e);
+ }
+ }, [agentName, nodes, edges]);
+
+ const handleClear = useCallback(() => {
+ setNodes([]);
+ setEdges([]);
+ }, [setNodes, setEdges]);
+
+ return (
+
+
+
setAgentName(e.target.value)}
+ placeholder="智能体名称"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 拖拽节点 · 连线编排 · 双击编辑标签
+
+
+
+
+ );
+}