1 parent
96be3fdeb3
commit
1633d5c33e
3 files changed
+245
-1
No files matched your search
@@ -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