import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LayoutDashboard, Workflow, Database, FileText, Activity, Bookmark } from "lucide-react"; import { TopBar } from "./shell/TopBar"; import { LeftNav, type ViewKey } from "./shell/LeftNav"; import { ApprovalBar } from "./shell/ApprovalBar"; import { SpaceMembers } from "./shell/SpaceMembers"; import { InviteMembers } from "./shell/InviteMembers"; import { StudioView } from "./studio/StudioView"; import { MemoryView } from "./views/MemoryView"; import { KbView } from "./views/KbView"; import { ReportView } from "./views/ReportView"; import { RunsView } from "./views/RunsView"; import { UsageView } from "./views/UsageView"; import { Home } from "./views/Home"; import { CommandPalette, type Command } from "./components/CommandPalette"; import { UpdateBanner } from "./components/UpdateBanner"; import { Login } from "./views/Login"; import { VoiceDock } from "./shell/VoiceDock"; import { submitTask, generateReport, 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 { notify } from "./lib/desktop"; import { ToastProvider } from "./ui"; // 会话标识:本地持久化生成一次(多轮历史用,与鉴权身份分离)。 function getSessionId(): string { let s = localStorage.getItem("sdx_sess"); if (!s) { s = "sess-" + Math.random().toString(36).slice(2, 10); localStorage.setItem("sdx_sess", s); } return s; } export default function App() { const [view, setView] = useState("home"); // 从工作台「最近任务」点进来时要定位到具体那条运行,而不是只把页面切过去。 const [focusRun, setFocusRun] = useState(null); const goto = (v: ViewKey, taskId?: string) => { setView(v); setFocusRun(taskId ?? null); }; const [user, setUser] = useState(null); const [tenant, setTenant] = useState(null); const [tenants, setTenants] = useState([]); const [space, setSpace] = useState(null); const [spaces, setSpaces] = useState([]); const [authLoading, setAuthLoading] = useState(true); const identity = useMemo(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]); const [run, setRun] = useState(emptyRun); const [cmdOpen, setCmdOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const closeRef = useRef<(() => void) | null>(null); const execCloseRef = useRef<(() => void) | null>(null); const pollRef = useRef(null); const stopPoll = () => { if (pollRef.current != null) { window.clearInterval(pollRef.current); pollRef.current = null; } }; // 全局 ⌘K / Ctrl+K 唤起命令面板(键盘优先工作站入口)。 useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setCmdOpen((o) => !o); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // 启动校验现有令牌;并监听 401 登出事件(令牌失效 → 回登录页)。 useEffect(() => { authMe() .then(setUser) .catch(() => setUser(null)) .finally(() => setAuthLoading(false)); const onLogout = () => setUser(null); window.addEventListener("sdx:logout", onLogout); return () => window.removeEventListener("sdx:logout", onLogout); }, []); const onLogout = useCallback(() => { logout(); setUser(null); setTenant(null); }, []); // 租户 + 工作区(Space)上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时。 const refreshTenant = useCallback(() => { tenantCurrent() .then(setTenant) .catch(() => {}); 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 */ } }, [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], ); // 启用全员空间(整租户共享;幂等纳入全部成员)——须租户 admin。 const onEnableTenantSpace = useCallback(async () => { try { const { id } = await createSpace("全员空间", "tenant"); await switchSpace(id); refreshTenant(); } catch (e) { window.alert((e as Error).message); } }, [refreshTenant]); useEffect(() => { if (!user) { setTenant(null); setTenants([]); setSpace(null); setSpaces([]); return; } refreshTenant(); const t = window.setInterval(refreshTenant, 20000); return () => window.clearInterval(t); }, [user, refreshTenant]); // 运行结束余额会被扣,立即刷新一次(比等轮询更跟手)。 useEffect(() => { if (run.phase === "done") refreshTenant(); }, [run.phase, refreshTenant]); const commands = useMemo(() => { const go = (key: ViewKey) => () => setView(key); return [ { id: "home", label: "前往 · 工作台", icon: LayoutDashboard, group: "页面", keywords: "home dashboard 概览", run: go("home") }, { id: "studio", label: "前往 · 编排", icon: Workflow, group: "页面", keywords: "studio agent graph 编排 图", run: go("studio") }, { id: "kb", label: "前往 · 知识库", icon: Database, group: "页面", keywords: "kb rag 检索 图谱 入库", run: go("kb") }, { id: "report", label: "前往 · 报告生成", icon: FileText, group: "页面", keywords: "report 报告 word docx", run: go("report") }, { id: "runs", label: "前往 · 运行观测", icon: Activity, group: "页面", keywords: "runs trace 轨迹 观测", run: go("runs") }, { id: "memory", label: "前往 · 记忆", icon: Bookmark, group: "页面", keywords: "memory 画像 偏好", run: go("memory") }, { id: "act-report", label: "生成报告", icon: FileText, group: "动作", keywords: "新建 report 撰写", run: go("report") }, { id: "act-ingest", label: "入库知识", icon: Database, group: "动作", keywords: "上传 ingest 文件", run: go("kb") }, { id: "act-studio", label: "新建 Agent 编排", icon: Workflow, group: "动作", keywords: "new flow", run: go("studio") }, ]; }, []); // attachRun:给一个 task 挂上「状态轮询 + 执行轨迹流 + token 流」。新发起与恢复待审任务共用, // 故页面刷新/重开后能重新挂回在途任务(HITL 审批可能等数小时,必须可恢复,否则待审任务点不到批准)。 // label 用于完成时的系统通知文案(报告用主题,编排用泛称)。 const attachRun = useCallback((taskId: string, t0: number, label: string) => { let first = true; // 轮询后端任务状态:可靠捕获 waiting(审批中断)—— exec 实时事件会抢跑,状态落 PG 不会丢。 const terminal = new Set(["done", "failed", "timeout", "rejected"]); pollRef.current = window.setInterval(async () => { try { const s = await taskStatus(taskId); setRun((r) => { if (r.taskId !== taskId) return r; // 命中终态:同时把 phase 也置终态,否则 token 流没收到 done(如恢复的任务/外部置终态) // 时 phase 会永卡 streaming → 运行按钮永远禁用「运行中…」。 const phase = terminal.has(s.status) ? s.status === "failed" || s.status === "timeout" ? "error" : "done" : r.phase; return { ...r, lifecycle: s.status, detail: s.detail, phase }; }); if (terminal.has(s.status)) stopPoll(); } catch { /* 忽略瞬时失败,下个 tick 再试 */ } }, 1500); // 执行轨迹:与 token 流并行订阅;网关从 Redis 回放历史事件(含 await),故恢复时也能补回审批摘要。 execCloseRef.current = streamExec( taskId, (ev) => setRun((r) => (r.taskId === taskId ? { ...r, exec: [...r.exec, ev] } : r)), () => {}, () => {}, ); closeRef.current = streamTokens( taskId, (tok) => setRun((r) => { if (r.taskId !== taskId) return r; const ev = first ? [...r.events, { t: Date.now() - t0, label: "首 token" }] : r.events; first = false; return { ...r, output: r.output + tok, events: ev }; }), () => { // 系统通知:跑一次动辄几分钟,用户多半已经切走干别的了——不通知就只能自己回来刷。 // 浏览器预览下 notify 是空操作,桌面壳里才弹。 notify("运行完成", label); setRun((r) => (r.taskId === taskId ? { ...r, phase: "done", events: [...r.events, { t: Date.now() - t0, label: "完成" }] } : r)); }, () => setRun((r) => (r.taskId === taskId ? { ...r, phase: "error", error: "连接中断" } : r)), ); }, []); const onRun = useCallback( async (dsl: TaskDsl) => { closeRef.current?.(); execCloseRef.current?.(); stopPoll(); const t0 = Date.now(); setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }], exec: [] }); setView("runs"); // 发起即跳「运行 · 观测」,实时看轨迹/输出(观测统一收敛在此页) try { const taskId = await submitTask(dsl, identity); setRun((r) => ({ ...r, phase: "streaming", taskId, events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }], })); attachRun(taskId, t0, "编排任务已跑完"); } catch (e) { setRun((r) => ({ ...r, phase: "error", error: (e as Error).message })); } }, [identity, attachRun], ); // 报告生成走和编排任务同一条路:灌进全局运行态 + 跳「运行 · 观测」。 // 此前报告页自己维护一套 SSE/输出/轨迹本地 state,于是运行页看不到它、 // 切个页面这套 state 就没了 —— 报告明明在后端跑完了却永远找不回。 const onRunReport = useCallback( async (topic: string, kb?: string) => { closeRef.current?.(); execCloseRef.current?.(); stopPoll(); const t0 = Date.now(); setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交报告任务" }], exec: [] }); setFocusRun(null); setView("runs"); try { const taskId = await generateReport(identity, topic, kb); setRun((r) => ({ ...r, phase: "streaming", taskId, events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }], })); attachRun(taskId, t0, `报告已生成:${topic}`); } catch (e) { setRun((r) => ({ ...r, phase: "error", error: (e as Error).message })); } }, [identity, attachRun], ); // 语音任务:后端已在 ASR 转写后自行提交(走 SubmitTask 同一关卡),前端只需挂回它的运行流。 // 与 onRun 的区别:不再 submit,直接 attach(task 已在后端跑)——跳「运行·观测」看轨迹/输出。 const onVoiceTask = useCallback( (taskId: string) => { closeRef.current?.(); execCloseRef.current?.(); stopPoll(); const t0 = Date.now(); setRun({ phase: "streaming", taskId, output: "", events: [{ t: 0, label: `语音任务 ${taskId}` }], exec: [] }); setFocusRun(null); setView("runs"); attachRun(taskId, t0, "语音任务已跑完"); }, [attachRun], ); // 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现, // 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。 const restoredRef = useRef(false); useEffect(() => { if (!user || restoredRef.current) return; restoredRef.current = true; // 只恢复一次。App 是根组件不会真卸载,故不用 cancelled 守卫 (async () => { // (StrictMode 双调用 effect 时,cancelled 会把首次 async 的恢复误吞)。 try { const runs = await listRuns(20); const waiting = runs.find((r) => r.status === "waiting"); if (!waiting) return; const t0 = Date.now(); setRun((r) => r.taskId ? r // 已有 live run,不覆盖 : { phase: "streaming", taskId: waiting.task_id, output: "", events: [{ t: 0, label: "恢复待审任务" }], exec: [], lifecycle: "waiting" }, ); attachRun(waiting.task_id, t0, waiting.topic || "待审任务已跑完"); } catch { /* 列表拉取失败则跳过恢复,不影响正常使用 */ } })(); }, [user, attachRun]); // 鉴权门:校验中显示占位;未登录显示登录页;登录后进入主应用。 if (authLoading) { return
加载中…
; } if (!user) { return ( ); } return (
{/* 顶部柔光,增加纵深 */}
setMembersOpen(true)} onInviteMembers={() => setInviteOpen(true)} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
{view === "home" ? ( ) : view === "studio" ? ( ) : view === "kb" ? ( ) : view === "report" ? ( ) : view === "runs" ? ( ) : view === "memory" ? ( ) : view === "usage" ? ( ) : null}
setCmdOpen(false)} commands={commands} /> setMembersOpen(false)} spaceId={space?.space?.id ?? ""} spaceName={space?.space?.kind === "personal" ? "个人空间" : space?.space?.name ?? ""} canManage={space?.role === "owner" || space?.role === "admin"} selfUserId={user?.id ?? ""} /> setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} /> {/* JARVIS 界面动作(P2):服务端白名单过后,这里再按已知 ViewKey 核一次才执行(双保险)。 */} { const allowed: ViewKey[] = ["home", "studio", "kb", "report", "runs", "memory", "usage"]; if (allowed.includes(view as ViewKey)) goto(view as ViewKey, taskId); }} />
); }