d7c6fab933
两个实测发现的问题: 1. 审批条偶发不弹、任务卡「运行中」:exec 的 await 事件走实时 core NATS(无回放), UI 的 EventSource 晚连一拍就漏掉 → 永远不弹审批。改为轮询持久化的任务状态 (GET /tasks/:id,每 1.5s),status==waiting 即弹审批条,不再依赖易抢跑的 exec 事件; exec 事件仅用于丰富摘要。新增 api.taskStatus + RunState.lifecycle/detail + App.tsx 轮询 + 终态停轮询;BottomDrawer 审批条触发改以 lifecycle==waiting 为准。 2. Studio 左侧调色板没有「人工审批」节点(只加了 NODE_KINDS,漏了 NODE_ORDER), 且其配色与「输入」同为 amber 难以区分:补进 NODE_ORDER(分支与并行之间), 配色改 orange(橙)与输入的 amber(琥珀金)区分。 验证:桌面端 live 重启确认审批条可靠弹出 + 批准/拒绝两条路径正常;左侧节点列表与 画布配色区分清晰。tsc 干净 + vitest 36 过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
9.5 KiB
TypeScript
209 lines
9.5 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { LayoutDashboard, Workflow, Database, FileText, Activity, Bookmark, Boxes, Settings } from "lucide-react";
|
|
|
|
import { TopBar } from "./shell/TopBar";
|
|
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
|
import { BottomDrawer } from "./shell/BottomDrawer";
|
|
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 { Home } from "./views/Home";
|
|
import { Placeholder } from "./views/Placeholder";
|
|
import { CommandPalette, type Command } from "./components/CommandPalette";
|
|
import { UpdateBanner } from "./components/UpdateBanner";
|
|
import { Login } from "./views/Login";
|
|
import { submitTask, streamTokens, streamExec, taskStatus, authMe, logout, type Identity, type AuthUser } from "./lib/api";
|
|
import type { TaskDsl } from "./lib/dsl";
|
|
import { emptyRun, type RunState } from "./lib/run";
|
|
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;
|
|
}
|
|
|
|
const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = {
|
|
home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" },
|
|
kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" },
|
|
report: { title: "报告生成", desc: "模板库 · 大纲编辑 · 章节并行生成进度 · 实时预览(含引用) · 导出 docx/pdf。依赖 RAG 核心链 + UniOffice。" },
|
|
runs: { title: "运行 · 观测", desc: "实时执行 · 节点轨迹 · 工具调用 · 运行历史复盘。当前运行结果见底部抽屉。" },
|
|
market: { title: "市场 · Packs", desc: "垂直包(法律/医疗/金融) · Agent 模板 · 开通向导(建租户→入库→注册模板→应用配置)。依赖多租户 + Pack 格式。" },
|
|
admin: { title: "管理", desc: "租户/工作区 · 用户计费 · 护栏 · 模型与连接 · 设置。" },
|
|
};
|
|
|
|
export default function App() {
|
|
const [view, setView] = useState<ViewKey>("home");
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
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 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);
|
|
}, []);
|
|
|
|
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: "market", label: "前往 · 市场 Packs", icon: Boxes, group: "页面", keywords: "market pack 垂直", run: go("market") },
|
|
{ id: "admin", label: "前往 · 管理", icon: Settings, group: "页面", keywords: "admin 租户 模型 设置", run: go("admin") },
|
|
{ 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") },
|
|
];
|
|
}, []);
|
|
|
|
const onRun = useCallback(
|
|
async (dsl: TaskDsl) => {
|
|
closeRef.current?.();
|
|
execCloseRef.current?.();
|
|
stopPoll();
|
|
const t0 = Date.now();
|
|
setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }], exec: [] });
|
|
try {
|
|
const taskId = await submitTask(dsl, identity);
|
|
let first = true;
|
|
setRun((r) => ({
|
|
...r,
|
|
phase: "streaming",
|
|
taskId,
|
|
events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }],
|
|
}));
|
|
// 轮询后端任务状态:可靠捕获 waiting(审批中断)—— exec 实时事件会抢跑,状态落 PG 不会丢。
|
|
const terminal = new Set(["done", "failed", "timeout", "rejected"]);
|
|
pollRef.current = window.setInterval(async () => {
|
|
try {
|
|
const s = await taskStatus(taskId);
|
|
setRun((r) => (r.taskId === taskId ? { ...r, lifecycle: s.status, detail: s.detail } : r));
|
|
if (terminal.has(s.status)) stopPoll();
|
|
} catch {
|
|
/* 忽略瞬时失败,下个 tick 再试 */
|
|
}
|
|
}, 1500);
|
|
// 执行轨迹(运行·观测):与 token 流并行订阅,逐节点点亮。
|
|
execCloseRef.current = streamExec(
|
|
taskId,
|
|
(ev) => setRun((r) => ({ ...r, exec: [...r.exec, ev] })),
|
|
() => {},
|
|
() => {},
|
|
);
|
|
closeRef.current = streamTokens(
|
|
taskId,
|
|
(tok) =>
|
|
setRun((r) => {
|
|
const ev = first ? [...r.events, { t: Date.now() - t0, label: "首 token" }] : r.events;
|
|
first = false;
|
|
return { ...r, output: r.output + tok, events: ev };
|
|
}),
|
|
() =>
|
|
setRun((r) => ({
|
|
...r,
|
|
phase: "done",
|
|
events: [...r.events, { t: Date.now() - t0, label: "完成" }],
|
|
})),
|
|
() => setRun((r) => ({ ...r, phase: "error", error: "连接中断" })),
|
|
);
|
|
} catch (e) {
|
|
setRun((r) => ({ ...r, phase: "error", error: (e as Error).message }));
|
|
}
|
|
},
|
|
[identity],
|
|
);
|
|
|
|
// 鉴权门:校验中显示占位;未登录显示登录页;登录后进入主应用。
|
|
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} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
|
|
<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={setView} />
|
|
) : view === "studio" ? (
|
|
<StudioView onRun={onRun} phase={run.phase} identity={identity} />
|
|
) : view === "kb" ? (
|
|
<KbView identity={identity} />
|
|
) : view === "report" ? (
|
|
<ReportView identity={identity} />
|
|
) : view === "runs" ? (
|
|
<RunsView run={run} />
|
|
) : view === "memory" ? (
|
|
<MemoryView identity={identity} />
|
|
) : (
|
|
<Placeholder {...(PLACEHOLDERS[view] ?? { title: "模块", desc: "规划中。" })} />
|
|
)}
|
|
</main>
|
|
</div>
|
|
<BottomDrawer run={run} />
|
|
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
|
</div>
|
|
</ToastProvider>
|
|
);
|
|
}
|