feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -4,6 +4,7 @@ import { LayoutDashboard, Workflow, Database, FileText, Activity, Bookmark, Boxe
|
|||||||
import { TopBar } from "./shell/TopBar";
|
import { TopBar } from "./shell/TopBar";
|
||||||
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
||||||
import { ApprovalBar } from "./shell/ApprovalBar";
|
import { ApprovalBar } from "./shell/ApprovalBar";
|
||||||
|
import { SpaceMembers } from "./shell/SpaceMembers";
|
||||||
import { StudioView } from "./studio/StudioView";
|
import { StudioView } from "./studio/StudioView";
|
||||||
import { MemoryView } from "./views/MemoryView";
|
import { MemoryView } from "./views/MemoryView";
|
||||||
import { KbView } from "./views/KbView";
|
import { KbView } from "./views/KbView";
|
||||||
@@ -50,6 +51,7 @@ export default function App() {
|
|||||||
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
|
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
|
||||||
const [run, setRun] = useState<RunState>(emptyRun);
|
const [run, setRun] = useState<RunState>(emptyRun);
|
||||||
const [cmdOpen, setCmdOpen] = useState(false);
|
const [cmdOpen, setCmdOpen] = useState(false);
|
||||||
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
const closeRef = useRef<(() => void) | null>(null);
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const execCloseRef = useRef<(() => void) | null>(null);
|
const execCloseRef = useRef<(() => void) | null>(null);
|
||||||
@@ -298,7 +300,7 @@ export default function App() {
|
|||||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||||
/>
|
/>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenant?.role ?? ""} 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} onEnableTenantSpace={onEnableTenantSpace} onManageMembers={() => setMembersOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
|
||||||
<ApprovalBar run={run} />
|
<ApprovalBar run={run} />
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
@@ -323,6 +325,14 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
||||||
|
<SpaceMembers
|
||||||
|
open={membersOpen}
|
||||||
|
onClose={() => setMembersOpen(false)}
|
||||||
|
spaceId={space?.space?.id ?? ""}
|
||||||
|
spaceName={space?.space?.kind === "personal" ? "个人空间" : space?.space?.name ?? ""}
|
||||||
|
canManage={space?.role === "owner" || space?.role === "admin"}
|
||||||
|
selfUserId={user?.id ?? ""}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -212,6 +212,40 @@ export async function createSpace(name: string, kind = "project"): Promise<{ id:
|
|||||||
return (await res.json()) as { id: string };
|
return (await res.json()) as { id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 空间成员管理(邀请/列表/改角色/移除)----
|
||||||
|
export interface SpaceMemberInfo {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string; // owner/admin/member/viewer
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function spaceMembers(spaceId: string): Promise<SpaceMemberInfo[]> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/spaces/${spaceId}/members`, { headers: bearer() }));
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(d.error ?? `members failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const d = (await res.json()) as { members?: SpaceMemberInfo[] };
|
||||||
|
return d.members ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spaceMemberWrite(method: string, url: string, body?: unknown): Promise<void> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}${url}`, { method, headers: { "Content-Type": "application/json", ...bearer() }, body: body ? JSON.stringify(body) : undefined }));
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(d.error ?? `${method} ${url} failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addSpaceMember = (spaceId: string, email: string, role: string) =>
|
||||||
|
spaceMemberWrite("POST", `/api/v1/spaces/${spaceId}/members`, { email, role });
|
||||||
|
export const setSpaceMemberRole = (spaceId: string, userId: string, role: string) =>
|
||||||
|
spaceMemberWrite("PUT", `/api/v1/spaces/${spaceId}/members/${userId}`, { role });
|
||||||
|
export const removeSpaceMember = (spaceId: string, userId: string) =>
|
||||||
|
spaceMemberWrite("DELETE", `/api/v1/spaces/${spaceId}/members/${userId}`);
|
||||||
|
|
||||||
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
// ---- 我的用量明细(当前用户自己租户:余额 + 按天趋势 + 最近消耗)----
|
||||||
export interface UsageDay {
|
export interface UsageDay {
|
||||||
day: string; // YYYYMMDD
|
day: string; // YYYYMMDD
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { UserPlus, Trash2 } from "lucide-react";
|
||||||
|
import { Dialog, Button, Input, Select, Badge, useToast } from "../ui";
|
||||||
|
import { spaceMembers, addSpaceMember, setSpaceMemberRole, removeSpaceMember, type SpaceMemberInfo } from "../lib/api";
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读" };
|
||||||
|
const ROLE_TONE: Record<string, "brand" | "accent" | "neutral" | "warn"> = { owner: "brand", admin: "accent", member: "neutral", viewer: "warn" };
|
||||||
|
|
||||||
|
// SpaceMembers 空间成员管理弹窗:列成员;空间 admin/owner 可邀请/改角色/移除(owner 受保护)。
|
||||||
|
// 非管理员只读。作用于当前活跃空间(spaceId),个人空间不该打开此弹窗(调用方控制)。
|
||||||
|
export function SpaceMembers({ open, onClose, spaceId, spaceName, canManage, selfUserId }: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
spaceId: string;
|
||||||
|
spaceName: string;
|
||||||
|
canManage: boolean;
|
||||||
|
selfUserId: string;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [rows, setRows] = useState<SpaceMemberInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [inviteRole, setInviteRole] = useState("member");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!spaceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setRows(await spaceMembers(spaceId));
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [spaceId, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void refresh();
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
const invite = async () => {
|
||||||
|
const em = email.trim();
|
||||||
|
if (!em) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await addSpaceMember(spaceId, em, inviteRole);
|
||||||
|
setEmail("");
|
||||||
|
toast.push("success", `已邀请 ${em}`);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRole = async (uid: string, role: string) => {
|
||||||
|
try {
|
||||||
|
await setSpaceMemberRole(spaceId, uid, role);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (m: SpaceMemberInfo) => {
|
||||||
|
if (!window.confirm(`把 ${m.name || m.email} 移出「${spaceName}」?`)) return;
|
||||||
|
try {
|
||||||
|
await removeSpaceMember(spaceId, m.user_id);
|
||||||
|
toast.push("success", "已移除");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.push("error", (e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} title={`成员 · ${spaceName}`}>
|
||||||
|
{canManage && (
|
||||||
|
<div className="mb-3 flex items-end gap-2">
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="邀请已注册用户的邮箱"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && invite()}
|
||||||
|
/>
|
||||||
|
<Select className="w-24" value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
|
||||||
|
<option value="member">成员</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
<option value="viewer">只读</option>
|
||||||
|
</Select>
|
||||||
|
<Button variant="primary" size="sm" icon={UserPlus} onClick={invite} disabled={busy || !email.trim()}>
|
||||||
|
邀请
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!canManage && <p className="mb-2 text-[11px] text-slate-500">你在此空间为只读/成员,仅管理员可增删成员。</p>}
|
||||||
|
|
||||||
|
<ul className="max-h-72 space-y-1 overflow-auto">
|
||||||
|
{loading && <li className="px-1 py-2 text-[11px] text-slate-500">加载中…</li>}
|
||||||
|
{!loading && rows.length === 0 && <li className="px-1 py-2 text-[11px] text-slate-600">暂无成员</li>}
|
||||||
|
{rows.map((m) => {
|
||||||
|
const isOwner = m.role === "owner";
|
||||||
|
const isSelf = m.user_id === selfUserId;
|
||||||
|
return (
|
||||||
|
<li key={m.user_id} className="flex items-center gap-2 rounded-md border border-line bg-ink-800/60 px-2.5 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs text-slate-200">
|
||||||
|
{m.name || m.email} {isSelf && <span className="text-[10px] text-slate-500">(你)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[10px] text-slate-500">{m.email}</div>
|
||||||
|
</div>
|
||||||
|
{canManage && !isOwner ? (
|
||||||
|
<Select className="h-8 w-20 text-xs" value={m.role} onChange={(e) => changeRole(m.user_id, e.target.value)}>
|
||||||
|
<option value="member">成员</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
<option value="viewer">只读</option>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Badge tone={ROLE_TONE[m.role] ?? "neutral"}>{ROLE_LABEL[m.role] ?? m.role}</Badge>
|
||||||
|
)}
|
||||||
|
{canManage && !isOwner && (
|
||||||
|
<button onClick={() => remove(m)} className="text-slate-600 transition hover:text-danger" title="移出空间">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe } from "lucide-react";
|
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe, UserCog } from "lucide-react";
|
||||||
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
|
||||||
import { useHealth } from "../lib/health";
|
import { useHealth } from "../lib/health";
|
||||||
import { isMacDesktop } from "../lib/desktop";
|
import { isMacDesktop } from "../lib/desktop";
|
||||||
@@ -116,7 +116,7 @@ function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate, onEnableTenantSpa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||||
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, tenantRole, 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; onEnableTenantSpace?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, onManageMembers, tenantRole, 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; onEnableTenantSpace?: () => void; onManageMembers?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
|
||||||
const h = useHealth();
|
const h = useHealth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -161,6 +161,15 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spac
|
|||||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||||
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
|
||||||
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
|
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
|
||||||
|
{space?.space && space.space.kind !== "personal" && (
|
||||||
|
<button
|
||||||
|
onClick={onManageMembers}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<UserCog className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
|
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
|
|||||||
Reference in New Issue
Block a user