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:
@@ -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",
|
||||
];
|
||||
Reference in New Issue
Block a user