d14a10479e
759483b 把报告页剥成纯启动器时,Word/Markdown 导出搬去了运行页,但漏了两个:
printReportHtml(前端打印出 PDF,CJK 零字体依赖)和 notify(完成弹系统通知)
外部调用点归零,成了被孤立的活功能。
- 运行页报告正文面板补 PDF 按钮:previewRef 抓已渲染 DOM 送打印视图,
与剥离前同一条路径。
- 完成通知挪进 attachRun 的 token 流 done 回调并加 label 参数:原先只有
报告会通知,但「跑几分钟、人早切走了」对编排任务一样成立,且 attachRun
是所有运行的唯一汇合点,放这儿不会再漂移。恢复的待审任务也带主题。
验证:tsc 干净、68 个 vitest 全过、两函数调用点恢复非零。
⚠️ 未实机点验(需登录态):PDF 按钮实际点击出打印视图、通知实际弹出,
下次起服务后补验。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
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 { 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 { 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<ViewKey>("home");
|
||
// 从工作台「最近任务」点进来时要定位到具体那条运行,而不是只把页面切过去。
|
||
const [focusRun, setFocusRun] = useState<string | null>(null);
|
||
const goto = (v: ViewKey, taskId?: string) => { setView(v); setFocusRun(taskId ?? null); };
|
||
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);
|
||
const [cmdOpen, setCmdOpen] = useState(false);
|
||
const [membersOpen, setMembersOpen] = useState(false);
|
||
const closeRef = useRef<(() => void) | null>(null);
|
||
|
||
const execCloseRef = useRef<(() => void) | null>(null);
|
||
const pollRef = useRef<number | null>(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<Command[]>(() => {
|
||
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],
|
||
);
|
||
|
||
// 恢复在途待审任务:登录后若存在 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 <div className="flex h-screen w-screen items-center justify-center bg-ink-950 text-sm text-slate-500">加载中…</div>;
|
||
}
|
||
if (!user) {
|
||
return (
|
||
<ToastProvider>
|
||
<Login onAuthed={setUser} />
|
||
</ToastProvider>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ToastProvider>
|
||
<div className="relative flex h-screen w-screen flex-col bg-ink-950 text-slate-200">
|
||
{/* 顶部柔光,增加纵深 */}
|
||
<div
|
||
className="pointer-events-none absolute inset-x-0 top-0 h-64 opacity-60"
|
||
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} 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} />
|
||
<div className="relative flex min-h-0 flex-1">
|
||
<LeftNav active={view} onSelect={setView} />
|
||
<main className="min-w-0 flex-1 overflow-hidden">
|
||
{view === "home" ? (
|
||
<Home onSelect={goto} userName={user.name || user.email} spaceName={space?.space?.name} />
|
||
) : view === "studio" ? (
|
||
<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} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
|
||
) : view === "report" ? (
|
||
<ReportView onRunReport={onRunReport} running={run.phase === "submitting" || run.phase === "streaming"} />
|
||
) : view === "runs" ? (
|
||
<RunsView run={run} focusTaskId={focusRun} />
|
||
) : view === "memory" ? (
|
||
<MemoryView identity={identity} />
|
||
) : view === "usage" ? (
|
||
<UsageView />
|
||
) : null}
|
||
</main>
|
||
</div>
|
||
<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>
|
||
</ToastProvider>
|
||
);
|
||
}
|