1 parent
96be3fdeb3
commit
1633d5c33e
3 files changed
+245
-1
No files matched your search
@@ -14,6 +14,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
@@ -22,7 +23,8 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.29",
|
"@types/react": "^18.3.29",
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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 (
|
||||||
|
<div className="agent-node skill-node">
|
||||||
|
<div className="node-icon">S</div>
|
||||||
|
<div className="node-label">{data.label}</div>
|
||||||
|
<div className="node-meta">{data.desc}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LLMNode({ data }: any) {
|
||||||
|
return (
|
||||||
|
<div className="agent-node llm-node">
|
||||||
|
<div className="node-icon">AI</div>
|
||||||
|
<div className="node-label">{data.label}</div>
|
||||||
|
<div className="node-meta">{data.model || 'deepseek-v4-flash'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MCPNode({ data }: any) {
|
||||||
|
return (
|
||||||
|
<div className="agent-node mcp-node">
|
||||||
|
<div className="node-icon">M</div>
|
||||||
|
<div className="node-label">{data.label}</div>
|
||||||
|
<div className="node-meta">{data.server}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IONode({ data }: any) {
|
||||||
|
return (
|
||||||
|
<div className={`agent-node io-node ${data.ioType}`}>
|
||||||
|
<div className="node-icon">{data.ioType === 'input' ? 'IN' : 'OUT'}</div>
|
||||||
|
<div className="node-label">{data.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any> = {
|
||||||
|
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 (
|
||||||
|
<div className="agent-canvas-page">
|
||||||
|
<div className="canvas-toolbar">
|
||||||
|
<input
|
||||||
|
className="agent-name-input"
|
||||||
|
value={agentName}
|
||||||
|
onChange={e => setAgentName(e.target.value)}
|
||||||
|
placeholder="智能体名称"
|
||||||
|
/>
|
||||||
|
<div className="toolbar-actions">
|
||||||
|
<button className="tb-btn" onClick={() => addNode('skillNode')}>+ 技能</button>
|
||||||
|
<button className="tb-btn" onClick={() => addNode('llmNode')}>+ LLM</button>
|
||||||
|
<button className="tb-btn" onClick={() => addNode('mcpNode')}>+ MCP</button>
|
||||||
|
<button className="tb-btn secondary" onClick={handleAutoLayout}>自动布局</button>
|
||||||
|
<button className="tb-btn secondary" onClick={handleClear}>清空</button>
|
||||||
|
<button className="tb-btn primary" onClick={handleSave}>
|
||||||
|
{saved ? '已保存 ✓' : '保存配置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="canvas-container">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
snapToGrid
|
||||||
|
snapGrid={[20, 20]}
|
||||||
|
>
|
||||||
|
<Controls />
|
||||||
|
<Background gap={20} size={1} />
|
||||||
|
<MiniMap nodeStrokeWidth={3} pannable zoomable />
|
||||||
|
<Panel position="bottom-center">
|
||||||
|
<span className="canvas-hint">拖拽节点 · 连线编排 · 双击编辑标签</span>
|
||||||
|
</Panel>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user