61c1177eba
按 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>
177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|