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>
This commit is contained in:
Blizzard
2026-07-13 10:51:33 +08:00
parent bf5be08e96
commit addaa1b34f
16 changed files with 1077 additions and 46 deletions
@@ -49,7 +49,7 @@ function buildExample(): { nodes: Node[]; edges: Edge[] } {
}
// 编排 Studio:左(节点面板 + 我的编排) · 中画布 · 右检查器 · 顶工具栏。
export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity; readOnly?: boolean }) {
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>([]);
@@ -69,7 +69,8 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
const refreshAgents = useCallback(() => {
listAgents(identity).then(setAgents).catch(() => {});
}, [identity]);
useEffect(() => refreshAgents(), [refreshAgents]);
// 切换工作区(spaceId 变)后重新拉取该空间的编排(共享工作区隔离)。
useEffect(() => refreshAgents(), [refreshAgents, spaceId]);
const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]);
@@ -147,6 +148,7 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
};
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", "画布为空");
@@ -219,18 +221,21 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
</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})`}
<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.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}`}>
<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>
<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>
{(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>
@@ -255,7 +260,7 @@ export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun
</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}>
<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">