addaa1b34f
引入 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>
313 lines
13 KiB
TypeScript
313 lines
13 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 { 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>
|
||
);
|
||
}
|