From d7c6fab93301dac8a98ab354b65e2fb211dc2d2b Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 24 Jun 2026 14:51:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(hitl):=20=E5=AE=A1=E6=89=B9=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=8F=AF=E9=9D=A0=E5=8C=96=EF=BC=88=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E5=85=9C=E5=BA=95=EF=BC=89+=20=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E8=8A=82=E7=82=B9=E5=85=A5=E8=B0=83=E8=89=B2=E6=9D=BF?= =?UTF-8?q?=E5=B9=B6=E6=94=B9=E6=A9=99=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个实测发现的问题: 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) --- sundynix-desktop/frontend/src/App.tsx | 21 ++++++++++++++++++- sundynix-desktop/frontend/src/lib/api.ts | 9 ++++++++ sundynix-desktop/frontend/src/lib/run.ts | 2 ++ .../frontend/src/shell/BottomDrawer.tsx | 6 +++++- .../frontend/src/studio/nodeCatalog.ts | 5 +++-- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 7171ff2..0c550de 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -14,7 +14,7 @@ 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, authMe, logout, type Identity, type AuthUser } from "./lib/api"; +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"; @@ -48,6 +48,13 @@ export default function App() { 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(() => { @@ -98,6 +105,7 @@ export default function App() { async (dsl: TaskDsl) => { closeRef.current?.(); execCloseRef.current?.(); + stopPoll(); const t0 = Date.now(); setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }], exec: [] }); try { @@ -109,6 +117,17 @@ export default function App() { 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, diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index ad0e9e6..ba182ac 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -109,6 +109,15 @@ export async function submitTask(dsl: TaskDsl, id: Identity): Promise { return data.task_id; } +// taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。 +// 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。 +export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> { + const res = guard401(await fetch(`${GATEWAY}/api/v1/tasks/${taskId}`, { headers: bearer() })); + if (!res.ok) throw new Error(`status failed: ${res.status}`); + const d = (await res.json()) as { status?: string; detail?: string }; + return { status: d.status ?? "", detail: d.detail ?? "" }; +} + // approveTask: POST /api/v1/tasks/:id/approve —— HITL 人工审批决定(批准放行 / 拒绝中止)。 export async function approveTask(taskId: string, approved: boolean, opts?: { node?: string; note?: string }): Promise { const res = guard401( diff --git a/sundynix-desktop/frontend/src/lib/run.ts b/sundynix-desktop/frontend/src/lib/run.ts index fbb0211..0acf644 100644 --- a/sundynix-desktop/frontend/src/lib/run.ts +++ b/sundynix-desktop/frontend/src/lib/run.ts @@ -15,6 +15,8 @@ export interface RunState { events: RunEvent[]; exec: ExecEvent[]; // 后端回流的节点级执行轨迹(运行·观测) error?: string; + lifecycle?: string; // 轮询到的后端任务状态(waiting 时弹审批;done/rejected 等终态) + detail?: string; // 状态附带说明(如审批标题) } export const emptyRun: RunState = { phase: "idle", output: "", events: [], exec: [] }; diff --git a/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx b/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx index e79ddb7..4bdea0a 100644 --- a/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx +++ b/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx @@ -27,7 +27,11 @@ export function BottomDrawer({ run }: { run: RunState }) { const statusText = run.phase === "streaming" ? "流式中…" : run.phase === "done" ? "完成 ✓" : run.phase === "error" ? `✗ ${run.error ?? "出错"}` : run.phase === "submitting" ? "提交中…" : "就绪"; - const approval = run.taskId ? pendingApproval(run.exec) : null; + // 审批条触发以「后端状态 waiting」为准(可靠,落 PG);exec 的 await 事件仅用于丰富摘要(可能抢跑丢失)。 + const approval = + run.taskId && run.lifecycle === "waiting" + ? pendingApproval(run.exec) ?? { node: "", title: run.detail || "人工审批", summary: "" } + : null; return (
diff --git a/sundynix-desktop/frontend/src/studio/nodeCatalog.ts b/sundynix-desktop/frontend/src/studio/nodeCatalog.ts index e320146..c280829 100644 --- a/sundynix-desktop/frontend/src/studio/nodeCatalog.ts +++ b/sundynix-desktop/frontend/src/studio/nodeCatalog.ts @@ -105,8 +105,8 @@ export const NODE_KINDS: Record = { approval: { kind: "approval", label: "人工审批", - accent: "border-l-amber-500", - badge: "bg-amber-100 text-amber-700", + accent: "border-l-orange-500", + badge: "bg-orange-100 text-orange-700", desc: "HITL:暂停等人工批准", fields: [ { key: "title", label: "审批标题", type: "text", placeholder: "如:高危操作审批" }, @@ -162,6 +162,7 @@ export const NODE_ORDER = [ "tool", "memory", "branch", + "approval", "map", "aggregate", "render",