Files
sundynix-agentix/sundynix-desktop/frontend/src/App.tsx
T

392 lines
17 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 { 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<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 [inviteOpen, setInviteOpen] = 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],
);
// 语音任务:后端已在 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 <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)} onInviteMembers={() => setInviteOpen(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 ?? ""}
/>
<InviteMembers open={inviteOpen} onClose={() => setInviteOpen(false)} tenantName={tenant?.tenant?.name ?? ""} />
{/* JARVIS 界面动作(P2):服务端白名单过后,这里再按已知 ViewKey 核一次才执行(双保险)。 */}
<VoiceDock
onTask={onVoiceTask}
onNavigate={(view, taskId) => {
const allowed: ViewKey[] = ["home", "studio", "kb", "report", "runs", "memory", "usage"];
if (allowed.includes(view as ViewKey)) goto(view as ViewKey, taskId);
}}
/>
</div>
</ToastProvider>
);
}