Files
sundynix-agentix/sundynix-desktop/frontend/src/studio/StudioView.tsx
T
Blizzard addaa1b34f feat(space): 共享工作区增量3a —— Agent 编排按 Space 共享(纯 PG 打样)
引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。

后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
  (如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
  owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
  索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)

桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
  重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除

验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:51:33 +08:00

313 lines
13 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, readOnly = false, spaceId = "", spaceReadOnly = false }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity; readOnly?: boolean; spaceId?: string; spaceReadOnly?: boolean }) {
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]);
// 切换工作区(spaceId 变)后重新拉取该空间的编排(共享工作区隔离)。
useEffect(() => refreshAgents(), [refreshAgents, spaceId]);
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 () => {
if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权保存编排");
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(() => {
if (readOnly) {
toast.push("error", "当前身份为只读成员(viewer),无权在此租户运行编排");
return;
}
const found = validate(nodes, edges);
setIssues(found);
if (!found.some((i) => i.level === "error")) onRun(exportDsl(nodes, edges));
}, [nodes, edges, onRun, readOnly, toast]);
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.creator ? ` · 由 ${a.creator} 创建` : ""}`}>
{a.name}
{a.creator && !a.mine && <span className="ml-1 text-[10px] text-slate-500">· {a.creator}</span>}
</button>
{(a.mine !== false && !spaceReadOnly) && (
<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 || readOnly} title={readOnly ? "只读成员(viewer)无权在此租户运行编排" : undefined}>
{running ? "运行中…" : readOnly ? "只读" : "运行"}
</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 || spaceReadOnly} title={spaceReadOnly ? "当前工作区你是只读成员(viewer)" : undefined}>
</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>
);
}