Files
sundynix-agentix/sundynix-desktop/frontend/src/App.tsx
T
Blizzard d14a10479e fix(desktop): 补回报告页剥离时漏搬的 PDF 导出与完成系统通知
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>
2026-07-16 16:28:28 +08:00

364 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}