feat(desktop): MVP 驾驶舱外壳 + 类型化节点 Studio + 运行抽屉

按 desktop-ui-plan.md 落 MVP:五区外壳 + 编排 Studio + 底部抽屉 + 健康灯。

- shell: TopBar(垂直切换/健康灯[Gateway/DB 实时,余规划]/身份会话) +
  LeftNav(BUILD/RUN/MANAGE 分组,未就绪模块灰显) + BottomDrawer(输出/轨迹/工具调用/引用/评测)
- studio: 类型化节点目录(输入/检索RAG/Agent/工具/记忆/分支/并行/汇聚/渲染/输出,
  按类配色) + 自定义 TypedNode(状态徽标) + Inspector(按类型渲染配置表单) +
  校验(孤立节点/必填项) + 运行
- views: MemoryView(复用偏好面板) + Placeholder(规划中模块,露出 IA 与依赖)
- lib: run(运行状态机) + health(轮询 billing) + dsl(导出类型化 DSL + validate)
- 删旧 AgentCanvas(被 StudioView 取代)

验证: npm run build(tsc+vite)✓; 真实浏览器跑通——加类型化节点→校验(标出孤立)→运行
→SSE 注入画像(老王)+历史 流入抽屉, 健康灯 Gateway/DB 实时绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-10 15:00:32 +08:00
parent a8e210cd96
commit 61c1177eba
14 changed files with 850 additions and 146 deletions
+56 -60
View File
@@ -1,87 +1,83 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { AgentCanvas } from "./canvas/AgentCanvas"; import { TopBar } from "./shell/TopBar";
import { MemoryPanel } from "./panels/MemoryPanel"; import { LeftNav, type ViewKey } from "./shell/LeftNav";
import { BottomDrawer } from "./shell/BottomDrawer";
import { StudioView } from "./studio/StudioView";
import { MemoryView } from "./views/MemoryView";
import { Placeholder } from "./views/Placeholder";
import { submitTask, streamTokens, type Identity } from "./lib/api"; import { submitTask, streamTokens, type Identity } from "./lib/api";
import type { TaskDsl } from "./lib/dsl"; import type { TaskDsl } from "./lib/dsl";
import { emptyRun, type RunState } from "./lib/run";
const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = {
home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" },
kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" },
report: { title: "报告生成", desc: "模板库 · 大纲编辑 · 章节并行生成进度 · 实时预览(含引用) · 导出 docx/pdf。依赖 RAG 核心链 + UniOffice。" },
runs: { title: "运行 · 观测", desc: "实时执行 · 节点轨迹 · 工具调用 · 运行历史复盘。当前运行结果见底部抽屉。" },
market: { title: "市场 · Packs", desc: "垂直包(法律/医疗/金融) · Agent 模板 · 开通向导(建租户→入库→注册模板→应用配置)。依赖多租户 + Pack 格式。" },
admin: { title: "管理", desc: "租户/工作区 · 用户计费 · 护栏 · 模型与连接 · 设置。" },
};
// 顶层布局:左侧 React Flow 编排画布 + 右侧 身份 / 偏好记忆 / 运行输出。
export default function App() { export default function App() {
const [view, setView] = useState<ViewKey>("studio");
const [identity, setIdentity] = useState<Identity>({ userId: "wt", sessionId: "sess-ui" }); const [identity, setIdentity] = useState<Identity>({ userId: "wt", sessionId: "sess-ui" });
const [output, setOutput] = useState(""); const [run, setRun] = useState<RunState>(emptyRun);
const [status, setStatus] = useState("就绪");
const [running, setRunning] = useState(false);
const closeRef = useRef<(() => void) | null>(null); const closeRef = useRef<(() => void) | null>(null);
const onRun = useCallback( const onRun = useCallback(
async (dsl: TaskDsl) => { async (dsl: TaskDsl) => {
closeRef.current?.(); closeRef.current?.();
setOutput(""); const t0 = Date.now();
setRunning(true); setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }] });
setStatus("提交任务…");
try { try {
const taskId = await submitTask(dsl, identity); const taskId = await submitTask(dsl, identity);
setStatus(`流式中 · ${taskId}`); let first = true;
setRun((r) => ({
...r,
phase: "streaming",
taskId,
events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }],
}));
closeRef.current = streamTokens( closeRef.current = streamTokens(
taskId, taskId,
(t) => setOutput((o) => o + t), (tok) =>
() => { setRun((r) => {
setStatus("完成 ✓"); const ev = first ? [...r.events, { t: Date.now() - t0, label: "首 token" }] : r.events;
setRunning(false); first = false;
}, return { ...r, output: r.output + tok, events: ev };
() => { }),
setStatus("连接中断"); () =>
setRunning(false); setRun((r) => ({
}, ...r,
phase: "done",
events: [...r.events, { t: Date.now() - t0, label: "完成" }],
})),
() => setRun((r) => ({ ...r, phase: "error", error: "连接中断" })),
); );
} catch (e) { } catch (e) {
setStatus(`${(e as Error).message}`); setRun((r) => ({ ...r, phase: "error", error: (e as Error).message }));
setRunning(false);
} }
}, },
[identity], [identity],
); );
return ( return (
<div className="flex h-screen w-screen text-gray-900"> <div className="flex h-screen w-screen flex-col text-gray-900">
<main className="flex-1 border-r"> <TopBar identity={identity} setIdentity={setIdentity} />
<AgentCanvas onRun={onRun} running={running} /> <div className="flex min-h-0 flex-1">
</main> <LeftNav active={view} onSelect={setView} />
<aside className="flex w-[26rem] flex-col overflow-auto"> <main className="min-w-0 flex-1 overflow-hidden">
<section className="border-b p-4"> {view === "studio" ? (
<h2 className="mb-2 text-sm font-semibold text-gray-700"> / </h2> <StudioView onRun={onRun} phase={run.phase} />
<div className="flex gap-2"> ) : view === "memory" ? (
<label className="flex-1 text-xs text-gray-500"> <MemoryView identity={identity} />
) : (
<input <Placeholder {...(PLACEHOLDERS[view] ?? { title: "模块", desc: "规划中。" })} />
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900" )}
value={identity.userId} </main>
onChange={(e) => setIdentity((id) => ({ ...id, userId: e.target.value }))} </div>
/> <BottomDrawer run={run} />
</label>
<label className="flex-1 text-xs text-gray-500">
<input
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
value={identity.sessionId}
onChange={(e) => setIdentity((id) => ({ ...id, sessionId: e.target.value }))}
/>
</label>
</div>
</section>
<MemoryPanel identity={identity} />
<section className="flex flex-1 flex-col p-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-700">SSE Token </h2>
<span className="text-xs text-gray-400">{status}</span>
</div>
<pre className="flex-1 whitespace-pre-wrap rounded bg-gray-900 p-3 text-xs leading-relaxed text-emerald-300">
{output || "在左侧加节点 → 运行,模型会注入你的偏好与历史后流式作答。"}
</pre>
</section>
</aside>
</div> </div>
); );
} }
@@ -1,83 +0,0 @@
import { useCallback, useState } from "react";
import {
ReactFlow,
Background,
Controls,
addEdge,
useNodesState,
useEdgesState,
type Connection,
type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { exportDsl, type TaskDsl } from "../lib/dsl";
let seq = 0;
// React Flow Canvas —— Agent 编排:加节点、连线、导出 JSON DSL 并交给上层运行。
export function AgentCanvas({
onRun,
running,
}: {
onRun: (dsl: TaskDsl) => void;
running: boolean;
}) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [prompt, setPrompt] = useState("总结这段文本");
const onConnect = useCallback(
(c: Connection) => setEdges((eds) => addEdge(c, eds)),
[setEdges],
);
const addNode = useCallback(() => {
const id = `n${++seq}`;
setNodes((ns) => [
...ns,
{
id,
type: "default",
position: { x: 80 + ((seq * 40) % 320), y: 60 + seq * 70 },
data: { label: `${id}: ${prompt}`, prompt },
},
]);
}, [prompt, setNodes]);
const run = useCallback(() => onRun(exportDsl(nodes, edges)), [nodes, edges, onRun]);
return (
<div className="relative h-full w-full">
<div className="absolute left-0 right-0 top-0 z-10 flex items-center gap-2 border-b bg-white/90 p-2">
<input
className="flex-1 rounded border px-2 py-1 text-sm"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="节点提示词"
/>
<button onClick={addNode} className="rounded border px-3 py-1 text-sm hover:bg-gray-50">
Agent
</button>
<button
onClick={run}
disabled={running || nodes.length === 0}
className="rounded bg-violet-600 px-3 py-1 text-sm font-medium text-white disabled:opacity-40"
>
{running ? "运行中…" : "▶ 运行"}
</button>
</div>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Background />
<Controls />
</ReactFlow>
</div>
);
}
+42 -3
View File
@@ -1,17 +1,56 @@
import type { Edge, Node } from "@xyflow/react"; import type { Edge, Node } from "@xyflow/react";
import { NODE_KINDS } from "../studio/nodeCatalog";
// Task DSL —— React Flow 画布的可序列化表示,提交给 Gateway 解析组装。 // Task DSL —— React Flow 画布的可序列化表示,提交给 Gateway 解析组装。
export interface TaskDsl { export interface TaskDsl {
version: "1"; version: "1";
nodes: Array<{ id: string; type?: string; data: unknown }>; nodes: Array<{ id: string; kind: string; label?: string; config: unknown }>;
edges: Array<{ source: string; target: string }>; edges: Array<{ source: string; target: string }>;
} }
// exportDsl 把画布的节点/连线导出为 JSON DSL。 // exportDsl 把画布的节点/连线导出为类型化 JSON DSL。
export function exportDsl(nodes: Node[], edges: Edge[]): TaskDsl { export function exportDsl(nodes: Node[], edges: Edge[]): TaskDsl {
return { return {
version: "1", version: "1",
nodes: nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), nodes: nodes.map((n) => {
const d = n.data as { kind: string; label?: string; config?: unknown };
return { id: n.id, kind: d.kind, label: d.label, config: d.config ?? {} };
}),
edges: edges.map((e) => ({ source: e.source, target: e.target })), edges: edges.map((e) => ({ source: e.source, target: e.target })),
}; };
} }
export interface Issue {
level: "error" | "warn";
msg: string;
}
// validate 轻量校验(前端先行;真实 schema 校验应由后端兜底)。
export function validate(nodes: Node[], edges: Edge[]): Issue[] {
const issues: Issue[] = [];
if (nodes.length === 0) {
issues.push({ level: "error", msg: "画布为空:至少添加一个节点" });
return issues;
}
const connected = new Set<string>();
edges.forEach((e) => {
connected.add(e.source);
connected.add(e.target);
});
for (const n of nodes) {
const d = n.data as { kind: string; label?: string; config?: Record<string, unknown> };
const k = NODE_KINDS[d.kind];
if (nodes.length > 1 && !connected.has(n.id)) {
issues.push({ level: "warn", msg: `节点「${d.label || k?.label}」未连线(孤立)` });
}
k?.fields
.filter((f) => f.required)
.forEach((f) => {
const v = d.config?.[f.key];
if (v === undefined || v === "" || String(v).startsWith("(未")) {
issues.push({ level: "warn", msg: `节点「${d.label || k.label}」缺必填项:${f.label}` });
}
});
}
return issues;
}
@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
import { GATEWAY } from "./api";
export interface Health {
gateway: boolean;
persisted: boolean; // Postgres 是否在线(billing.persisted
}
// useHealth 轮询 Gateway 健康(billing 端点同时回报持久化是否就绪)。
// NATS/Milvus/Neo4j 暂未由网关透出,UI 以"未知"呈现(规划:加 /health 聚合)。
export function useHealth(intervalMs = 4000): Health {
const [h, setH] = useState<Health>({ gateway: false, persisted: false });
useEffect(() => {
let alive = true;
const ping = async () => {
try {
const res = await fetch(`${GATEWAY}/api/v1/billing`);
const data = (await res.json()) as { persisted?: boolean };
if (alive) setH({ gateway: res.ok, persisted: Boolean(data.persisted) });
} catch {
if (alive) setH({ gateway: false, persisted: false });
}
};
ping();
const id = setInterval(ping, intervalMs);
return () => {
alive = false;
clearInterval(id);
};
}, [intervalMs]);
return h;
}
+17
View File
@@ -0,0 +1,17 @@
// 运行状态 —— 跨 Studio 与底部抽屉共享。
export type RunPhase = "idle" | "submitting" | "streaming" | "done" | "error";
export interface RunEvent {
t: number; // 相对开始的毫秒
label: string;
}
export interface RunState {
phase: RunPhase;
taskId?: string;
output: string;
events: RunEvent[];
error?: string;
}
export const emptyRun: RunState = { phase: "idle", output: "", events: [] };
@@ -0,0 +1,87 @@
import { useState } from "react";
import type { RunState } from "../lib/run";
type Tab = "output" | "trace" | "tools" | "cite" | "eval";
const TABS: Array<{ key: Tab; label: string }> = [
{ key: "output", label: "输出" },
{ key: "trace", label: "轨迹" },
{ key: "tools", label: "工具调用" },
{ key: "cite", label: "引用" },
{ key: "eval", label: "评测" },
];
// 底部抽屉:运行输出 / 轨迹 / 工具调用 / 引用 / 评测(全局常驻)。
export function BottomDrawer({ run }: { run: RunState }) {
const [open, setOpen] = useState(true);
const [tab, setTab] = useState<Tab>("output");
return (
<div className="shrink-0 border-t bg-white">
<div className="flex items-center gap-1 border-b px-2">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => {
setTab(t.key);
setOpen(true);
}}
className={`px-3 py-1.5 text-xs ${
tab === t.key && open
? "border-b-2 border-violet-500 font-medium text-violet-700"
: "text-gray-500"
}`}
>
{t.label}
</button>
))}
<span className="ml-2 text-[11px] text-gray-400">
{run.phase === "streaming"
? "流式中…"
: run.phase === "done"
? "完成 ✓"
: run.phase === "error"
? `${run.error ?? "出错"}`
: run.phase === "submitting"
? "提交中…"
: "就绪"}
</span>
<button onClick={() => setOpen((o) => !o)} className="ml-auto px-2 text-xs text-gray-400">
{open ? "▾ 收起" : "▴ 展开"}
</button>
</div>
{open && (
<div className="h-40 overflow-auto p-3 text-xs">
{tab === "output" && (
<pre className="whitespace-pre-wrap leading-relaxed text-gray-800">
{run.output || "在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。"}
</pre>
)}
{tab === "trace" && (
<ul className="space-y-1 text-gray-600">
{run.events.length === 0 && <li className="text-gray-400"></li>}
{run.events.map((e, i) => (
<li key={i}>
<span className="text-gray-400">+{e.t}ms</span> · {e.label}
</li>
))}
<li className="mt-2 text-[11px] text-gray-400">
</li>
</ul>
)}
{tab === "tools" && (
<p className="text-gray-400">
sundynix.tools.* /
</p>
)}
{tab === "cite" && (
<p className="text-gray-400">RAG + + RAG </p>
)}
{tab === "eval" && (
<p className="text-gray-400"> / harness eval </p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,63 @@
export type ViewKey =
| "home"
| "studio"
| "kb"
| "report"
| "runs"
| "memory"
| "market"
| "admin";
interface Item {
key: ViewKey;
label: string;
icon: string;
group?: string;
ready?: boolean;
}
const ITEMS: Item[] = [
{ key: "home", label: "工作台", icon: "■" },
{ key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true },
{ key: "kb", label: "知识库", icon: "▣", group: "BUILD" },
{ key: "report", label: "报告", icon: "▤", group: "BUILD" },
{ key: "runs", label: "运行", icon: "▸", group: "RUN" },
{ key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true },
{ key: "market", label: "市场", icon: "⌧", group: "MANAGE" },
{ key: "admin", label: "管理", icon: "⚙", group: "MANAGE" },
];
// 左导航栏:模块切换,分组 BUILD / RUN / MANAGE;未就绪模块灰显(规划中)。
export function LeftNav({ active, onSelect }: { active: ViewKey; onSelect: (v: ViewKey) => void }) {
let lastGroup: string | undefined;
return (
<nav className="flex w-20 shrink-0 flex-col border-r bg-gray-50 py-2">
{ITEMS.map((it) => {
const header = it.group && it.group !== lastGroup ? it.group : null;
lastGroup = it.group ?? lastGroup;
return (
<div key={it.key}>
{header && (
<div className="mt-2 px-2 text-[9px] font-semibold tracking-wider text-gray-300">
{header}
</div>
)}
<button
onClick={() => onSelect(it.key)}
className={`flex w-full flex-col items-center gap-0.5 py-2 text-[11px] ${
active === it.key
? "bg-violet-50 font-medium text-violet-700"
: "text-gray-500 hover:bg-gray-100"
}`}
title={it.ready === false ? `${it.label}(规划中)` : it.label}
>
<span className="text-base leading-none">{it.icon}</span>
{it.label}
{it.ready === false && <span className="text-[8px] text-gray-300"></span>}
</button>
</div>
);
})}
</nav>
);
}
@@ -0,0 +1,54 @@
import type { Identity } from "../lib/api";
import { useHealth } from "../lib/health";
function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?: boolean }) {
const color = unknown ? "bg-gray-300" : on ? "bg-emerald-500" : "bg-rose-500";
return (
<span className="flex items-center gap-1 text-[11px] text-gray-500" title={label}>
<span className={`h-2 w-2 rounded-full ${color}`} />
{label}
</span>
);
}
// 顶栏:垂直切换 · 健康灯 · 身份/会话。
export function TopBar({
identity,
setIdentity,
}: {
identity: Identity;
setIdentity: (id: Identity) => void;
}) {
const h = useHealth();
return (
<header className="flex h-11 shrink-0 items-center gap-3 border-b bg-white px-3">
<span className="font-semibold text-gray-800">sundynix-agentix</span>
<select className="rounded border px-2 py-0.5 text-xs text-gray-700" defaultValue="通用版">
<option></option>
<option></option>
<option></option>
</select>
<div className="ml-2 flex items-center gap-3">
<Light on={h.gateway} label="Gateway" />
<Light on={h.persisted} label="DB" />
<Light unknown label="NATS" />
<Light unknown label="Milvus" />
<Light unknown label="Neo4j" />
</div>
<div className="ml-auto flex items-center gap-2">
<input
className="w-20 rounded border px-2 py-0.5 text-xs"
value={identity.userId}
onChange={(e) => setIdentity({ ...identity, userId: e.target.value })}
title="用户"
/>
<input
className="w-24 rounded border px-2 py-0.5 text-xs"
value={identity.sessionId}
onChange={(e) => setIdentity({ ...identity, sessionId: e.target.value })}
title="会话"
/>
</div>
</header>
);
}
@@ -0,0 +1,94 @@
import type { Node } from "@xyflow/react";
import { NODE_KINDS } from "./nodeCatalog";
// 右检查器:按选中节点的类型渲染配置表单;空选时显示图级提示。
export function Inspector({
node,
onChange,
onDelete,
}: {
node: Node | null;
onChange: (id: string, patch: Record<string, unknown>) => void;
onDelete: (id: string) => void;
}) {
if (!node) {
return (
<div className="p-4 text-xs text-gray-400">
/
<br />
线
</div>
);
}
const data = node.data as { kind: string; label?: string; config?: Record<string, unknown> };
const k = NODE_KINDS[data.kind] ?? NODE_KINDS.output;
const config = data.config ?? {};
const setConfig = (key: string, value: unknown) =>
onChange(node.id, { config: { ...config, [key]: value } });
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-3">
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${k.badge}`}>{k.label}</span>
<button onClick={() => onDelete(node.id)} className="text-xs text-rose-500 hover:underline">
</button>
</div>
<div className="flex flex-col gap-3 overflow-auto p-3">
<label className="text-xs text-gray-500">
<input
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
value={data.label ?? ""}
onChange={(e) => onChange(node.id, { label: e.target.value })}
/>
</label>
{k.fields.map((f) => {
const v = config[f.key];
return (
<label key={f.key} className="text-xs text-gray-500">
{f.label}
{f.required && <span className="text-rose-500"> *</span>}
{f.type === "select" ? (
<select
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
value={String(v ?? "")}
onChange={(e) => setConfig(f.key, e.target.value)}
>
{f.options?.map((o) => (
<option key={o}>{o}</option>
))}
</select>
) : f.type === "textarea" ? (
<textarea
className="mt-1 h-16 w-full resize-none rounded border px-2 py-1 text-sm text-gray-900"
value={String(v ?? "")}
placeholder={f.placeholder}
onChange={(e) => setConfig(f.key, e.target.value)}
/>
) : f.type === "checkbox" ? (
<input
type="checkbox"
className="ml-2 align-middle"
checked={Boolean(v)}
onChange={(e) => setConfig(f.key, e.target.checked)}
/>
) : (
<input
type={f.type === "number" ? "number" : "text"}
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
value={String(v ?? "")}
placeholder={f.placeholder}
onChange={(e) =>
setConfig(f.key, f.type === "number" ? Number(e.target.value) : e.target.value)
}
/>
)}
</label>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,176 @@
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 { 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";
let seq = 0;
// 编排 Studio:左节点面板 · 中画布 · 右检查器 · 顶工具栏。
export function StudioView({
onRun,
phase,
}: {
onRun: (dsl: TaskDsl) => void;
phase: RunPhase;
}) {
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 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],
);
// 运行状态映射到节点徽标:流式中全部 running,完成 done,空闲清除。
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="w-40 shrink-0 overflow-auto border-r bg-gray-50 p-2">
<div className="mb-1 px-1 text-[11px] font-semibold text-gray-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 border border-l-4 bg-white px-2 py-1.5 text-left text-xs hover:bg-gray-50 ${k.accent}`}
title={k.desc}
>
{k.label}
</button>
);
})}
</div>
{/* 中画布 */}
<div className="relative flex-1">
<div className="absolute left-0 right-0 top-0 z-10 flex items-center gap-2 border-b bg-white/90 px-2 py-1.5">
<button
onClick={run}
disabled={running || nodes.length === 0}
className="rounded bg-violet-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-40"
>
{running ? "运行中…" : "▶ 运行"}
</button>
<button
onClick={() => setIssues(validate(nodes, edges))}
className="rounded border px-3 py-1 text-xs hover:bg-gray-50"
>
</button>
<span className="ml-1 text-[11px] text-gray-400">
{nodes.length} · {edges.length} 线
</span>
{issues && (
<span className="ml-auto text-[11px]">
{issues.length === 0 ? (
<span className="text-emerald-600"> </span>
) : (
<span className="text-amber-600">{issues.length} </span>
)}
</span>
)}
</div>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, n) => setSelId(n.id)}
onPaneClick={() => setSelId(null)}
fitView
>
<Background />
<Controls />
<MiniMap zoomable pannable className="!bg-white" />
</ReactFlow>
{issues && issues.length > 0 && (
<div className="absolute bottom-2 left-2 max-w-md rounded border bg-white p-2 text-[11px] shadow">
{issues.map((i, idx) => (
<div key={idx} className={i.level === "error" ? "text-rose-600" : "text-amber-600"}>
{i.level === "error" ? "✗" : "⚠"} {i.msg}
</div>
))}
</div>
)}
</div>
{/* 右检查器 */}
<div className="w-72 shrink-0 border-l bg-white">
<Inspector node={selected} onChange={patchNode} onDelete={deleteNode} />
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { NODE_KINDS } from "./nodeCatalog";
export type NodeStatus = "idle" | "running" | "done" | "error";
// 自定义节点卡:左色条按类型、状态点、类型徽标 + 摘要。
export function TypedNode({ data, selected }: NodeProps) {
const d = data as { kind: string; label?: string; status?: NodeStatus; summary?: string };
const k = NODE_KINDS[d.kind] ?? NODE_KINDS.output;
const status = d.status ?? "idle";
const dot =
status === "running"
? "bg-amber-400 animate-pulse"
: status === "done"
? "bg-emerald-500"
: status === "error"
? "bg-rose-500"
: "bg-gray-300";
return (
<div
className={`min-w-[160px] rounded-md border border-l-4 bg-white shadow-sm ${k.accent} ${
selected ? "ring-2 ring-violet-400" : ""
}`}
>
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-gray-400" />
<div className="flex items-center justify-between gap-2 px-3 py-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${k.badge}`}>{k.label}</span>
<span className={`h-2 w-2 rounded-full ${dot}`} />
</div>
<div className="px-3 pb-2">
<div className="text-xs font-medium text-gray-800">{d.label || k.desc}</div>
{d.summary && <div className="mt-0.5 truncate text-[10px] text-gray-400">{d.summary}</div>}
</div>
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !bg-gray-400" />
</div>
);
}
export const nodeTypes = { typed: TypedNode };
@@ -0,0 +1,156 @@
// 节点类型目录 —— Studio 画布的"类型化节点"定义(决定面板、配色、检查器字段)。
// 与后端 DSL / Eino 图节点对齐:输入 / 检索(RAG) / Agent / 工具 / 记忆 / 分支 / 并行 / 汇聚 / 渲染 / 输出。
export type FieldType = "text" | "textarea" | "number" | "select" | "checkbox";
export interface Field {
key: string;
label: string;
type: FieldType;
options?: string[];
placeholder?: string;
required?: boolean;
}
export interface NodeKind {
kind: string;
label: string;
accent: string; // 左色条 / 标签配色(tailwind 类)
badge: string;
desc: string;
fields: Field[];
defaults: Record<string, unknown>;
}
export const NODE_KINDS: Record<string, NodeKind> = {
input: {
kind: "input",
label: "输入",
accent: "border-l-amber-500",
badge: "bg-amber-100 text-amber-700",
desc: "用户提问 / 文件 / 变量",
fields: [
{ key: "source", label: "来源", type: "select", options: ["用户输入", "本地文件", "变量"] },
{ key: "placeholder", label: "占位文案", type: "text", placeholder: "请输入…" },
],
defaults: { source: "用户输入", placeholder: "请输入…" },
},
retriever: {
kind: "retriever",
label: "检索 (RAG)",
accent: "border-l-sky-500",
badge: "bg-sky-100 text-sky-700",
desc: "绑定知识库做混合检索",
fields: [
{ key: "kb", label: "知识库", type: "select", options: ["(未建库)", "合同库", "判例库"], required: true },
{ key: "topK", label: "TopK", type: "number" },
{ key: "rerank", label: "重排 Rerank", type: "checkbox" },
],
defaults: { kb: "(未建库)", topK: 5, rerank: true },
},
agent: {
kind: "agent",
label: "Agent / LLM",
accent: "border-l-violet-500",
badge: "bg-violet-100 text-violet-700",
desc: "调模型生成",
fields: [
{ key: "model", label: "模型", type: "select", options: ["占位 Pool", "ollama:qwen", "vllm:custom"], required: true },
{ key: "system", label: "系统提示词", type: "textarea", placeholder: "你是…" },
{ key: "temperature", label: "温度", type: "number" },
],
defaults: { model: "占位 Pool", system: "", temperature: 0.7 },
},
tool: {
kind: "tool",
label: "工具",
accent: "border-l-blue-500",
badge: "bg-blue-100 text-blue-700",
desc: "绑定 MCP 工具",
fields: [
{
key: "tool",
label: "工具",
type: "select",
options: ["wiki_search", "parse_document", "render_doc", "memory_get", "external_api"],
required: true,
},
{ key: "args", label: "参数(JSON)", type: "textarea", placeholder: '{"q":"..."}' },
],
defaults: { tool: "wiki_search", args: "{}" },
},
memory: {
kind: "memory",
label: "记忆",
accent: "border-l-emerald-500",
badge: "bg-emerald-100 text-emerald-700",
desc: "注入画像 / 历史 / 语义",
fields: [
{ key: "profile", label: "注入画像", type: "checkbox" },
{ key: "history", label: "注入历史", type: "checkbox" },
{ key: "semantic", label: "语义召回", type: "checkbox" },
],
defaults: { profile: true, history: true, semantic: false },
},
branch: {
kind: "branch",
label: "分支",
accent: "border-l-rose-500",
badge: "bg-rose-100 text-rose-700",
desc: "条件路由",
fields: [{ key: "condition", label: "条件", type: "text", placeholder: "score > 0.8" }],
defaults: { condition: "" },
},
map: {
kind: "map",
label: "并行 / Map",
accent: "border-l-fuchsia-500",
badge: "bg-fuchsia-100 text-fuchsia-700",
desc: "fan-out(逐章节)",
fields: [{ key: "splitBy", label: "拆分依据", type: "text", placeholder: "sections" }],
defaults: { splitBy: "sections" },
},
aggregate: {
kind: "aggregate",
label: "汇聚",
accent: "border-l-fuchsia-700",
badge: "bg-fuchsia-100 text-fuchsia-800",
desc: "reduce / 合并",
fields: [{ key: "strategy", label: "合并策略", type: "select", options: ["拼接", "去重合并", "摘要"] }],
defaults: { strategy: "拼接" },
},
render: {
kind: "render",
label: "渲染",
accent: "border-l-cyan-600",
badge: "bg-cyan-100 text-cyan-700",
desc: "UniOffice 出 docx/pdf",
fields: [
{ key: "format", label: "格式", type: "select", options: ["docx", "pdf"] },
{ key: "template", label: "模板", type: "text", placeholder: "默认" },
],
defaults: { format: "docx", template: "默认" },
},
output: {
kind: "output",
label: "输出",
accent: "border-l-gray-500",
badge: "bg-gray-100 text-gray-700",
desc: "展示 / 导出 / 落盘",
fields: [{ key: "target", label: "目标", type: "select", options: ["屏幕", "本地文件"] }],
defaults: { target: "屏幕" },
},
};
export const NODE_ORDER = [
"input",
"retriever",
"agent",
"tool",
"memory",
"branch",
"map",
"aggregate",
"render",
"output",
];
@@ -0,0 +1,19 @@
import { MemoryPanel } from "../panels/MemoryPanel";
import type { Identity } from "../lib/api";
// 记忆模块:当前以偏好登记为主;规划做厚为 画像看/改/删 + 会话历史浏览。
export function MemoryView({ identity }: { identity: Identity }) {
return (
<div className="flex h-full">
<div className="w-96 border-r">
<MemoryPanel identity={identity} />
</div>
<div className="flex-1 p-6 text-xs leading-relaxed text-gray-400">
<div className="mb-1 text-sm font-semibold text-gray-600"> / </div>
/ +
<br />
memory_upsert sundynix_user_profile
</div>
</div>
);
}
@@ -0,0 +1,14 @@
// 规划中模块占位 —— 让信息架构可见,同时说明该模块的定位与依赖。
export function Placeholder({ title, desc }: { title: string; desc: string }) {
return (
<div className="flex h-full items-center justify-center">
<div className="max-w-md rounded-lg border border-dashed bg-gray-50 p-6 text-center">
<div className="mb-1 text-sm font-semibold text-gray-600">{title}</div>
<p className="text-xs leading-relaxed text-gray-400">{desc}</p>
<span className="mt-3 inline-block rounded bg-gray-200 px-2 py-0.5 text-[10px] text-gray-500">
</span>
</div>
</div>
);
}