feat(hitl): 人工审批中断(Eino Phase D)—— 审批节点暂停→批准续跑/拒绝中止
专用「审批」节点方案,全栈打通。 后端: - contract:TaskWaiting/TaskRejected 状态 + ApprovalSubject/ApprovalDecision。 - bus:PublishApproval + WaitApproval(订阅决定主题,带超时);消费者 AckWait 30s→15min(阻塞等人审期间消息未 ack,否则重投成重复任务)。 - dispatcher:ApprovalWaiter 接口 + approvalNode——执行到审批节点发 await 事件 + 置 waiting,阻塞等决定。批准→回 running 放行下游;拒绝/超时→errRejected 哨兵→ 剪下游→rejected,优雅收尾不计熔断。超时安全默认拒绝。 taskExecTimeout 3→10min(审批5 < 执行10 < AckWait15)。 - 网关:POST /tasks/:id/approve(仅 waiting 态受理,幂等)。 桌面端: - Studio 新增「人工审批」节点(nodeCatalog,可填标题/说明)。 - run.ts pendingApproval() 从 exec 流派生待审中断 + waiting 节点状态。 - BottomDrawer ApprovalBar:琥珀审批条(摘要 + 批准/拒绝 + 备注,调 api.approveTask)。 - ExecTrace waiting 状态灯。 验证:后端 curl 实测 waiting→批准→running→done;waiting→拒绝→rejected(下游未跑)。 前端 tsc + vitest 36 过(pendingApproval 4 例 + waiting 状态)。全模块 build+vet+test 全绿。 EINO_ADOPTION Phase D 标记 HITL 完成。 未覆盖:compose 路径(EINO_COMPOSE,默认关)的 approval 节点;桌面端审批条未在 GUI 实点。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
|
||||
import { deriveNodes, type RunState } from "../lib/run";
|
||||
import { ChevronDown, ChevronUp, Wrench, ShieldCheck, Check, X } from "lucide-react";
|
||||
import { deriveNodes, pendingApproval, type RunState } from "../lib/run";
|
||||
import { ExecTrace } from "../components/ExecTrace";
|
||||
import { approveTask } from "../lib/api";
|
||||
import { Tabs, Badge, cn, type TabDef } from "../ui";
|
||||
|
||||
type Tab = "output" | "trace" | "tools" | "cite" | "eval";
|
||||
@@ -26,8 +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;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-line bg-ink-900">
|
||||
{approval && run.taskId && <ApprovalBar taskId={run.taskId} node={approval.node} title={approval.title} summary={approval.summary} />}
|
||||
<div className="flex items-center border-b border-line px-2">
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
@@ -60,6 +64,60 @@ export function BottomDrawer({ run }: { run: RunState }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ApprovalBar:HITL 人工审批中断条。任务停在审批节点时常驻顶部,展示待审摘要 + 批准/拒绝。
|
||||
// 决定经 approveTask 发回;dispatcher 续跑后 SSE 会推来 end/error 事件,本条随 pendingApproval 归 null 自动消失。
|
||||
function ApprovalBar({ taskId, node, title, summary }: { taskId: string; node: string; title: string; summary: string }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState<"approve" | "reject" | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const decide = async (approved: boolean) => {
|
||||
setBusy(approved ? "approve" : "reject");
|
||||
setErr("");
|
||||
try {
|
||||
await approveTask(taskId, approved, { node, note });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-[12px] text-amber-200">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-400" strokeWidth={2.2} />
|
||||
<span className="font-semibold">人工审批</span>
|
||||
<span className="text-amber-300/80">{title}</span>
|
||||
<Badge tone="warn">等待决定</Badge>
|
||||
</div>
|
||||
{summary && <pre className="max-h-20 overflow-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-amber-100/80">{summary}</pre>}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="备注(可选,拒绝原因等)"
|
||||
className="min-w-0 flex-1 rounded border border-line bg-ink-950/60 px-2 py-1 text-[11px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => decide(true)}
|
||||
disabled={busy !== null}
|
||||
className="flex items-center gap-1 rounded bg-success/20 px-2.5 py-1 text-[11px] font-medium text-success hover:bg-success/30 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" /> {busy === "approve" ? "提交中…" : "批准"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => decide(false)}
|
||||
disabled={busy !== null}
|
||||
className="flex items-center gap-1 rounded bg-danger/20 px-2.5 py-1 text-[11px] font-medium text-danger hover:bg-danger/30 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> {busy === "reject" ? "提交中…" : "拒绝"}
|
||||
</button>
|
||||
</div>
|
||||
{err && <p className="text-[11px] text-danger">提交失败:{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ToolCalls:从执行事件里筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。
|
||||
function ToolCalls({ run }: { run: RunState }) {
|
||||
const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool");
|
||||
|
||||
Reference in New Issue
Block a user