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:
@@ -15,7 +15,7 @@ import { Placeholder } from "./views/Placeholder";
|
||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||
import { UpdateBanner } from "./components/UpdateBanner";
|
||||
import { Login } from "./views/Login";
|
||||
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, type Identity, type AuthUser, type TenantCtx, type MyTenant } from "./lib/api";
|
||||
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
|
||||
import type { TaskDsl } from "./lib/dsl";
|
||||
import { emptyRun, type RunState } from "./lib/run";
|
||||
import { ToastProvider } from "./ui";
|
||||
@@ -44,6 +44,8 @@ export default function App() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [tenant, setTenant] = useState<TenantCtx | null>(null);
|
||||
const [tenants, setTenants] = useState<MyTenant[]>([]);
|
||||
const [space, setSpace] = useState<SpaceCtx | null>(null);
|
||||
const [spaces, setSpaces] = useState<MySpace[]>([]);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
|
||||
const [run, setRun] = useState<RunState>(emptyRun);
|
||||
@@ -88,7 +90,7 @@ export default function App() {
|
||||
setTenant(null);
|
||||
}, []);
|
||||
|
||||
// 租户上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时(顶栏余额芯片用)。
|
||||
// 租户 + 工作区(Space)上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时。
|
||||
const refreshTenant = useCallback(() => {
|
||||
tenantCurrent()
|
||||
.then(setTenant)
|
||||
@@ -96,11 +98,28 @@ export default function App() {
|
||||
myTenants()
|
||||
.then((r) => setTenants(r.tenants))
|
||||
.catch(() => {});
|
||||
spaceCurrent()
|
||||
.then(setSpace)
|
||||
.catch(() => {});
|
||||
mySpaces()
|
||||
.then((r) => setSpaces(r.spaces))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const onSwitchTenant = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await switchTenant(id);
|
||||
refreshTenant(); // 切租户会重置活跃空间到该租户个人空间,一并刷新
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[refreshTenant],
|
||||
);
|
||||
const onSwitchSpace = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await switchSpace(id);
|
||||
refreshTenant();
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -108,10 +127,24 @@ export default function App() {
|
||||
},
|
||||
[refreshTenant],
|
||||
);
|
||||
const onCreateSpace = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
const { id } = await createSpace(name, "project");
|
||||
await switchSpace(id); // 建完即切入
|
||||
refreshTenant();
|
||||
} catch (e) {
|
||||
window.alert((e as Error).message);
|
||||
}
|
||||
},
|
||||
[refreshTenant],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setTenant(null);
|
||||
setTenants([]);
|
||||
setSpace(null);
|
||||
setSpaces([]);
|
||||
return;
|
||||
}
|
||||
refreshTenant();
|
||||
@@ -255,7 +288,7 @@ export default function App() {
|
||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||
/>
|
||||
<UpdateBanner />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||
<ApprovalBar run={run} />
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<LeftNav active={view} onSelect={setView} />
|
||||
@@ -263,7 +296,7 @@ export default function App() {
|
||||
{view === "home" ? (
|
||||
<Home onSelect={setView} />
|
||||
) : view === "studio" ? (
|
||||
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} />
|
||||
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
||||
) : view === "kb" ? (
|
||||
<KbView identity={identity} />
|
||||
) : view === "report" ? (
|
||||
|
||||
@@ -165,6 +165,53 @@ export async function switchTenant(tenantId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 共享工作区(Space,增量3):活跃空间上下文 + 切换 + 列表 + 建 ----
|
||||
export interface SpaceCtx {
|
||||
space: { id: string; name: string; kind: string; creator: string; archived: boolean } | null;
|
||||
role: string; // 当前用户在此空间的角色(owner/admin/member/viewer)
|
||||
}
|
||||
export interface MySpace {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
kind: string; // personal / project / tenant
|
||||
creator: string;
|
||||
archived: boolean;
|
||||
role: string;
|
||||
members: number;
|
||||
}
|
||||
|
||||
export async function spaceCurrent(): Promise<SpaceCtx | null> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces/current`, { headers: bearer() }));
|
||||
if (!res.ok) return null;
|
||||
const d = (await res.json()) as Partial<SpaceCtx>;
|
||||
return { space: d.space ?? null, role: d.role ?? "" };
|
||||
}
|
||||
|
||||
export async function mySpaces(): Promise<{ spaces: MySpace[]; active: string }> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/spaces`, { headers: bearer() }));
|
||||
if (!res.ok) return { spaces: [], active: "" };
|
||||
const d = (await res.json()) as { spaces?: MySpace[]; active?: string };
|
||||
return { spaces: d.spaces ?? [], active: d.active ?? "" };
|
||||
}
|
||||
|
||||
export async function switchSpace(spaceId: string): Promise<void> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/space`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ space_id: spaceId }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `switch space failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSpace(name: string, kind = "project"): Promise<{ id: string }> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces`, { method: "POST", headers: { "Content-Type": "application/json", ...bearer() }, body: JSON.stringify({ name, kind }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `create space failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as { id: string };
|
||||
}
|
||||
|
||||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||
export interface UsageDay {
|
||||
day: string; // YYYYMMDD
|
||||
@@ -341,6 +388,9 @@ export interface AgentInfo {
|
||||
name: string;
|
||||
graph: string; // {nodes,edges} JSON
|
||||
updated_at?: string;
|
||||
owner?: string; // 创建人 user.id(共享工作区)
|
||||
creator?: string; // 创建人名字/邮箱(显示用)
|
||||
mine?: boolean; // 是否本人创建(决定能否删/覆盖)
|
||||
}
|
||||
|
||||
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2 } from "lucide-react";
|
||||
import type { AuthUser, TenantCtx, MyTenant } from "../lib/api";
|
||||
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus } from "lucide-react";
|
||||
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
||||
import { useHealth } from "../lib/health";
|
||||
import { isMacDesktop } from "../lib/desktop";
|
||||
import { useTheme } from "../lib/theme";
|
||||
@@ -65,8 +65,45 @@ function TenantSwitcher({ tenants, activeId, onSwitch }: { tenants: MyTenant[];
|
||||
);
|
||||
}
|
||||
|
||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||
// 工作区切换器:切换活跃 Space(共享工作区 — 个人/项目/全员)。含"新建项目空间"。
|
||||
// 个人空间恒有,故 ≥1 即显示(让用户随时能建/进项目协作空间)。
|
||||
function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate }: { spaces: MySpace[]; activeId?: string; onSwitch?: (id: string) => void; onCreate?: (name: string) => void }) {
|
||||
if (spaces.length === 0) return null;
|
||||
const label = (s: MySpace) => (s.kind === "personal" ? "个人空间" : s.name) + (s.kind !== "personal" ? ` · ${s.members}人` : "");
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={NODRAG}>
|
||||
<div className="relative">
|
||||
<UsersIcon className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<select
|
||||
value={activeId ?? ""}
|
||||
onChange={(e) => onSwitch?.(e.target.value)}
|
||||
title="切换当前工作区(共享资源作用域)"
|
||||
className="appearance-none rounded-md border border-line bg-ink-800 py-1 pl-7 pr-7 text-xs text-slate-300 focus:border-brand focus:outline-none"
|
||||
>
|
||||
{spaces.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{label(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = window.prompt("新建项目空间名称(团队成员可共享此空间的编排/知识库)");
|
||||
if (name && name.trim()) onCreate?.(name.trim());
|
||||
}}
|
||||
title="新建项目空间"
|
||||
className="flex items-center rounded-md border border-line bg-ink-800 px-1.5 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||
const h = useHealth();
|
||||
const { theme, toggle } = useTheme();
|
||||
return (
|
||||
@@ -110,6 +147,7 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, onLogout, o
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
||||
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} />
|
||||
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
|
||||
<button
|
||||
onClick={toggle}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user