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="智能体名称" + /> +
+ + + + + + +
+
+
+ + + + + + 拖拽节点 · 连线编排 · 双击编辑标签 + + +
+
+ ); +}