Files
sundynix-agentix/sundynix-desktop/frontend/src/studio/StudioView.tsx
T
Blizzard 760ca06667 feat(desktop/studio): Tier1 编排画布主题化 + 节点精修(产品灵魂打磨)
react-flow 此前 colorMode 写死 dark、背景/连线/控件全用库默认,换肤下不协调。改为:
- colorMode 绑定当前主题 → 控件/连线/手柄/选区自动随亮暗切换。
- Background 点阵色、MiniMap 遮罩随主题;隐藏库水印(proOptions.hideAttribution,更干净)。
- index.css 精修 react-flow:控件(细边/圆角/themed hover)、连线(中性描边+选中紫)、迷你图边框,对齐设计系统。
- TypedNode:卡面落到表面色(ink-850)、选中环用 brand 令牌(替硬编码紫)、加 hover 描边、手柄themed。
- 工具栏/调色板/检查器已用新 Button + 令牌类,自动一致。

tsc + 生产构建通过;桌面端已 HMR。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:47:48 +08:00

304 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
type Connection,
type Node,
type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Play, ShieldCheck, Sparkles, Trash2, Save, Workflow } from "lucide-react";
import { nodeTypes, type NodeStatus } from "./TypedNode";
import { Inspector } from "./Inspector";
import { NODE_KINDS, NODE_ORDER } from "./nodeCatalog";
import { exportDsl, validate, type Issue, type TaskDsl } from "../lib/dsl";
import type { RunPhase } from "../lib/run";
import { listKb, listChatModels, listAgents, saveAgent, deleteAgent, type Identity, type AgentInfo } from "../lib/api";
import { useTheme } from "../lib/theme";
import { Button, Input, cn, useToast } from "../ui";
let seq = 0;
// buildExample:一张可直接运行的示例图(输入 → 检索本人 default 库 → Agent → 输出)。
function buildExample(): { nodes: Node[]; edges: Edge[] } {
const mk = (id: string, kind: string, x: number, cfg: Record<string, unknown>): Node => ({
id,
type: "typed",
position: { x, y: 140 },
data: { kind, label: NODE_KINDS[kind].label, config: { ...NODE_KINDS[kind].defaults, ...cfg }, status: "idle" as NodeStatus },
});
return {
nodes: [
mk("ex-in", "input", 40, { text: "sundynix-agentix 的架构是怎样的?" }),
mk("ex-rag", "retriever", 300, { kb: "default", topK: 4 }),
mk("ex-agent", "agent", 560, { system: "你是知识库问答助手,依据检索到的资料严谨作答。" }),
mk("ex-out", "output", 820, {}),
],
edges: [
{ id: "ex-e1", source: "ex-in", target: "ex-rag" },
{ id: "ex-e2", source: "ex-rag", target: "ex-agent" },
{ id: "ex-e3", source: "ex-agent", target: "ex-out" },
],
};
}
// 编排 Studio:左(节点面板 + 我的编排) · 中画布 · 右检查器 · 顶工具栏。
export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity }) {
const toast = useToast();
const { theme } = useTheme();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selId, setSelId] = useState<string | null>(null);
const [issues, setIssues] = useState<Issue[] | null>(null);
const [kbOpts, setKbOpts] = useState<string[]>([]);
const [modelOpts, setModelOpts] = useState<string[]>([]);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [name, setName] = useState("");
useEffect(() => {
listKb(identity).then((ks) => setKbOpts(ks.map((k) => k.name))).catch(() => {});
listChatModels().then(setModelOpts).catch(() => {});
}, [identity]);
const refreshAgents = useCallback(() => {
listAgents(identity).then(setAgents).catch(() => {});
}, [identity]);
useEffect(() => refreshAgents(), [refreshAgents]);
const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]);
const onConnect = useCallback((c: Connection) => setEdges((es) => addEdge(c, es)), [setEdges]);
const addNode = useCallback(
(kind: string) => {
const id = `n${++seq}`;
const k = NODE_KINDS[kind];
setNodes((ns) => [
...ns,
{
id,
type: "typed",
position: { x: 120 + ((ns.length * 40) % 360), y: 60 + ns.length * 64 },
data: { kind, label: k.label, config: { ...k.defaults }, status: "idle" as NodeStatus },
},
]);
},
[setNodes],
);
const patchNode = useCallback(
(id: string, patch: Record<string, unknown>) =>
setNodes((ns) => ns.map((n) => (n.id === id ? { ...n, data: { ...n.data, ...patch } } : n))),
[setNodes],
);
const deleteNode = useCallback(
(id: string) => {
setNodes((ns) => ns.filter((n) => n.id !== id));
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
setSelId(null);
},
[setNodes, setEdges],
);
const loadGraph = useCallback(
(g: { nodes?: Node[]; edges?: Edge[] }) => {
// 防御:旧/损坏数据可能缺 position/type/data —— 全部兜底,避免 React Flow 崩成黑屏。
const safeNodes: Node[] = (g.nodes ?? [])
.filter((n) => n && n.id)
.map((n, i) => {
const d = (n.data ?? {}) as { kind?: string; label?: string; config?: Record<string, unknown> };
const kind = d.kind && NODE_KINDS[d.kind] ? d.kind : "output";
const pos = n.position && typeof n.position.x === "number" && typeof n.position.y === "number" ? n.position : { x: 100 + (i % 4) * 220, y: 80 + Math.floor(i / 4) * 120 };
return {
id: n.id,
type: "typed",
position: pos,
data: { kind, label: d.label || NODE_KINDS[kind].label, config: d.config ?? {}, status: "idle" as NodeStatus },
};
});
const ids = new Set(safeNodes.map((n) => n.id));
const safeEdges: Edge[] = (g.edges ?? [])
.filter((e) => e && e.source && e.target && ids.has(e.source) && ids.has(e.target))
.map((e, i) => ({ ...e, id: e.id || `e${i}` }));
setNodes(safeNodes);
setEdges(safeEdges);
setSelId(null);
setIssues(null);
for (const n of safeNodes) {
const m = /^n(\d+)$/.exec(n.id);
if (m) seq = Math.max(seq, Number(m[1]));
}
},
[setNodes, setEdges],
);
const clear = () => {
setNodes([]);
setEdges([]);
setSelId(null);
setIssues(null);
};
const save = async () => {
const nm = name.trim();
if (!nm) return toast.push("error", "先填编排名");
if (nodes.length === 0) return toast.push("error", "画布为空");
try {
await saveAgent(identity, nm, JSON.stringify({ nodes, edges }));
toast.push("success", `已保存编排「${nm}`);
refreshAgents();
} catch (e) {
toast.push("error", (e as Error).message);
}
};
const openAgent = (a: AgentInfo) => {
try {
loadGraph(JSON.parse(a.graph));
setName(a.name);
} catch {
toast.push("error", "编排数据损坏,无法载入");
}
};
const removeAgent = async (nm: string) => {
try {
await deleteAgent(identity, nm);
refreshAgents();
toast.push("success", `已删除「${nm}`);
} catch (e) {
toast.push("error", (e as Error).message);
}
};
useEffect(() => {
const status: NodeStatus =
phase === "streaming" || phase === "submitting" ? "running" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
setNodes((ns) => ns.map((n) => ({ ...n, data: { ...n.data, status } })));
}, [phase, setNodes]);
const run = useCallback(() => {
const found = validate(nodes, edges);
setIssues(found);
if (!found.some((i) => i.level === "error")) onRun(exportDsl(nodes, edges));
}, [nodes, edges, onRun]);
const selected = useMemo(() => nodes.find((n) => n.id === selId) ?? null, [nodes, selId]);
const running = phase === "submitting" || phase === "streaming";
return (
<div className="flex h-full">
{/* 左:节点面板 + 我的编排 */}
<div className="flex w-44 shrink-0 flex-col border-r border-line bg-ink-900">
<div className="overflow-auto p-2">
<div className="mb-1 px-1 text-[11px] font-semibold text-slate-500"></div>
{NODE_ORDER.map((kind) => {
const k = NODE_KINDS[kind];
return (
<button
key={kind}
onClick={() => addNode(kind)}
className={`mb-1 flex w-full items-center gap-2 rounded-md border border-l-[3px] border-line bg-ink-800 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-ink-700 ${k.accent}`}
title={k.desc}
>
{k.label}
</button>
);
})}
</div>
<div className="flex min-h-0 flex-1 flex-col border-t border-line p-2">
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] font-semibold text-slate-500">
<Workflow className="h-3.5 w-3.5" /> {agents.length > 0 && `(${agents.length})`}
</div>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-auto">
{agents.length === 0 && <li className="px-1 text-[11px] text-slate-600"></li>}
{agents.map((a) => (
<li key={a.name} className="group flex items-center gap-1 rounded hover:bg-ink-800">
<button onClick={() => openAgent(a)} className="flex-1 truncate px-2 py-1.5 text-left text-xs text-slate-300" title={`载入「${a.name}`}>
{a.name}
</button>
<button onClick={() => removeAgent(a.name)} className="px-1 text-slate-600 opacity-0 hover:text-danger group-hover:opacity-100" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
</div>
</div>
{/* 中画布 */}
<div className="relative flex-1 bg-ink-950">
<div className="absolute left-0 right-0 top-0 z-10 flex flex-wrap items-center gap-1.5 border-b border-line bg-ink-900/90 px-2 py-1.5 backdrop-blur">
<Button variant="primary" size="sm" icon={Play} onClick={run} disabled={running || nodes.length === 0}>
{running ? "运行中…" : "运行"}
</Button>
<Button size="sm" icon={ShieldCheck} onClick={() => setIssues(validate(nodes, edges))}>
</Button>
<span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" icon={Sparkles} onClick={() => loadGraph(buildExample())}>
</Button>
<Button size="sm" icon={Trash2} onClick={clear} disabled={nodes.length === 0}>
</Button>
<span className="mx-1 h-4 w-px bg-line" />
<Input className="h-8 w-32" value={name} onChange={(e) => setName(e.target.value)} placeholder="编排名" />
<Button size="sm" variant="primary" icon={Save} onClick={save} disabled={nodes.length === 0}>
</Button>
<span className="ml-auto text-[11px] text-slate-500">
{nodes.length} · {edges.length} 线
</span>
{issues && (
<span className="text-[11px]">
{issues.length === 0 ? <span className="text-success"> </span> : <span className="text-warn">{issues.length} </span>}
</span>
)}
</div>
<ReactFlow
colorMode={theme}
proOptions={{ hideAttribution: true }}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, n) => setSelId(n.id)}
onPaneClick={() => setSelId(null)}
deleteKeyCode={["Backspace", "Delete"]}
fitView
>
<Background color={theme === "dark" ? "#27272a" : "#e4e4e7"} gap={18} />
<Controls />
<MiniMap zoomable pannable maskColor={theme === "dark" ? "rgba(0,0,0,0.55)" : "rgba(0,0,0,0.08)"} className="!bg-ink-850" />
</ReactFlow>
{issues && issues.length > 0 && (
<div className="absolute bottom-2 left-2 max-w-md rounded-lg border border-line bg-ink-850 p-2 text-[11px] shadow-card">
{issues.map((i, idx) => (
<div key={idx} className={cn(i.level === "error" ? "text-danger" : "text-warn")}>
{i.level === "error" ? "✗" : "⚠"} {i.msg}
</div>
))}
</div>
)}
</div>
{/* 右检查器 */}
<div className="w-72 shrink-0 border-l border-line bg-ink-900">
<Inspector node={selected} onChange={patchNode} onDelete={deleteNode} dynamicOptions={dynamicOptions} />
</div>
</div>
);
}