精简动作审查输入并支持审批来源定位 (#125)

* fix: 精简审查背景并区分人工消息与调度输入

* fix: 模型审查仅提交当前调用与选定背景

* feat: 审批来源定位原会话并移除 Worker 审查背景
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-09-15 23:51:37 +08:00
1 parent 0d6fa880b8
commit 04d25eb133
30 files changed
+1143 -465

No files matched your search

@@ -0,0 +1,138 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { Activity, InterceptExecution } from "@/lib/types";
// Resolve one exact persisted call, independently of transcript pagination.
export function useApprovalFocus({ taskId, conversationId }: { taskId?: string; conversationId?: number }) {
const [state, setState] = React.useState<{
id: number;
source?: InterceptExecution;
error?: string;
loading: boolean;
} | null>(null);
const [revision, setRevision] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
React.useEffect(() => {
const q = new URLSearchParams(window.location.search);
const id = Number(q.get("approval"));
const matches = conversationId ? q.get("c") === String(conversationId) : q.get("id") === taskId;
if (!matches || !Number.isSafeInteger(id) || id <= 0) {
setState(null);
return;
}
let cancelled = false;
setState({ id, loading: true });
api
.interceptExecution(id, conversationId)
.then((source) => {
if (cancelled) return;
if (
conversationId
? source.conversation_id !== conversationId
: source.task_id !== taskId || source.conversation_id != null
) {
throw new Error("审批来源与当前会话不一致");
}
setState({ id, source, loading: false });
})
.catch((e) => {
if (!cancelled) setState({ id, error: (e as Error).message || "无法定位原始执行", loading: false });
});
return () => {
cancelled = true;
};
}, [taskId, conversationId, revision]);
const close = React.useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.delete("approval");
window.history.replaceState(null, "", url);
setState(null);
}, []);
return { state, close, retry: () => setRevision((v) => v + 1) };
}
type HistoryPage = { items: Activity[]; hasMore: boolean };
// Fill the existing transcript continuously back to the call, preserving all
// intervening messages. Never splice an isolated call into a paginated history.
export function useApprovalHistory(
source: InterceptExecution | undefined,
loaded: boolean,
items: Activity[],
loadPage: (before: number) => Promise<HistoryPage>,
mergePage: (page: HistoryPage) => void,
) {
const itemsRef = React.useRef(items);
itemsRef.current = items;
const [result, setResult] = React.useState<{ source: InterceptExecution; error?: string }>();
React.useEffect(() => {
if (!source || !loaded) return;
let cancelled = false;
void (async () => {
let current = itemsRef.current;
let before = current[0]?.seq ?? 0;
while (!current.some((a) => a.seq === source.seq && a.kind === "tool_use")) {
const page = await loadPage(before);
if (cancelled) return;
if (!page.items.length || (before > 0 && page.items[0].seq >= before)) {
throw new Error("会话中未找到对应工具调用,记录可能已删除");
}
mergePage(page);
current = page.items;
before = current[0].seq;
if (!page.hasMore && !current.some((a) => a.seq === source.seq)) {
throw new Error("会话中未找到对应工具调用");
}
}
if (!cancelled) setResult({ source });
})().catch((error) => {
if (!cancelled) setResult({ source, error: (error as Error).message });
});
return () => {
cancelled = true;
};
}, [source, loaded, loadPage, mergePage]);
return {
ready: !!source && result?.source === source && !result.error,
error: result?.source === source ? result?.error : undefined,
};
}
export function ApprovalExecutionFocus({
focus,
history,
}: {
focus: ReturnType<typeof useApprovalFocus>;
history: ReturnType<typeof useApprovalHistory>;
}) {
const { state } = focus;
if (!state) return null;
const error = state.error || history.error;
return (
<div
role="status"
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2 text-xs"
>
<span className={error ? "text-destructive" : "text-muted-foreground"}>
{error
? `无法定位:${error}`
: history.ready
? `已展开审批 #${state.id} 对应的工具调用`
: `正在加载审批 #${state.id} 所在的对话位置…`}
</span>
<div className="flex gap-2">
{error ? (
<Button size="sm" variant="outline" onClick={focus.retry}>
重试定位
</Button>
) : null}
<Button size="sm" variant="ghost" onClick={focus.close}>
取消定位
</Button>
</div>
</div>
);
}
+111 -39
View File
@@ -50,6 +50,46 @@ function originLabel(row: InterceptApprovalRow) {
return "—";
}
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
const [locating, setLocating] = React.useState(false);
const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row);
const query = new URLSearchParams({ approval: String(row.id) });
let href: string | undefined;
if (row.conversation_id) {
query.set("c", String(row.conversation_id));
href = `/chat?${query}`;
} else if (row.task_id) {
query.set("id", row.task_id);
href = `/function/tasks/detail?${query}`;
}
return href ? (
<a
href={href}
className="text-primary underline-offset-4 hover:underline"
aria-label={`定位审批 #${row.id} 的来源:${label}`}
aria-busy={locating}
onClick={async (e) => {
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
if (locating) return;
setLocating(true);
try {
await api.interceptExecution(row.id, row.conversation_id ?? undefined);
window.location.assign(href);
} catch (error) {
toast.error((error as Error).message || "无法定位对应执行");
setLocating(false);
}
}}
>
{label}
</a>
) : (
<span>{label}</span>
);
}
function StatusBadge({ status }: { status: string }) {
const labels: Record<string, string> = { pending: "待审批", allowed: "已允许", denied: "已拒绝", timeout: "已超时" };
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
@@ -131,45 +171,73 @@ function ModelReviewContext({ input }: { input: InterceptReviewInput }) {
<div className="flex flex-col gap-2">
<h3 className="font-medium text-sm">模型审查上下文</h3>
<p className="text-muted-foreground text-xs">
以下为本次实际发送给审查模型的输入快照,包含当时可用的任务背景与执行记录。
{input.task ? "当前轮输入可能由调度器生成,不等同于原始用户消息。" : "当前轮输入为本次 Agent 收到的消息。"}
以下为本次实际发送给审查模型的输入快照。背景仅用于理解当前动作,裁决依据为审查策略。
{input.version < 4 ? "此记录使用旧版输入,保留当时实际发送的内容。" : null}
</p>
</div>
{input.turn_input ? (
<CodeBlock label="当前轮输入" text={input.turn_input} truncated={input.background_truncated} />
) : (
<p className="text-muted-foreground text-xs">本次审查输入未包含当前轮消息。</p>
)}
{input.task ? (
<>
<CodeBlock label="任务描述" text={input.task.description} truncated={input.task.truncated} />
<CodeBlock label="任务目标" text={input.task.goal} truncated={input.task.truncated} />
<CodeBlock label="任务操作约束" text={JSON.stringify(input.task.constraints, null, 2)} />
</>
) : null}
{input.worker_intent ? (
<CodeBlock label="Worker 意图" text={input.worker_intent} truncated={input.background_truncated} />
) : null}
{input.working_directory ? <CodeBlock label="工作目录" text={input.working_directory} /> : null}
<div className="flex min-w-0 flex-col gap-3">
<h4 className="font-medium text-muted-foreground text-xs">提供给模型的近期工具执行记录</h4>
{input.history?.length ? (
input.history.map((entry) => (
<div key={entry.tool_use_id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
<p className="break-words font-medium text-xs">
{entry.tool} · {entry.status === "succeeded" ? "成功" : "失败(可能有部分副作用)"}
</p>
<CodeBlock label="历史调用参数" text={entry.arguments_preview} truncated={entry.truncated} />
<CodeBlock label="历史执行结果" text={entry.result} truncated={entry.truncated} />
</div>
))
<CodeBlock
label="当前待审查调用"
text={JSON.stringify({ tool_name: input.tool_name, arguments: input.arguments }, null, 2)}
/>
{input.version >= 2 ? (
input.background ? (
<div className="flex min-w-0 flex-col gap-2">
<CodeBlock
label={input.background.source === "user_message" ? "背景 · 用户消息" : "背景 · Worker 意图摘要(旧版)"}
text={input.background.text}
truncated={input.background.truncated}
/>
<p className="text-muted-foreground text-xs">
{input.background.source === "user_message"
? "取自当前用户消息。"
: "这是旧版发送的 Worker 意图摘要;新版 Worker 审查不再发送此内容。"}
</p>
</div>
) : (
<p className="text-muted-foreground text-xs">本次未提供可配对的历史工具执行记录。</p>
)}
{input.history_truncated ? (
<p className="text-muted-foreground text-xs">历史为有限窗口,部分内容已截断。</p>
) : null}
</div>
<p className="text-muted-foreground text-xs">本次审查未附带背景消息。</p>
)
) : (
<>
{input.turn_input ? (
<CodeBlock label="当前轮输入(旧版)" text={input.turn_input} truncated={input.background_truncated} />
) : null}
{input.task ? (
<>
<CodeBlock label="任务描述(旧版)" text={input.task.description} truncated={input.task.truncated} />
<CodeBlock label="任务目标(旧版)" text={input.task.goal} truncated={input.task.truncated} />
<CodeBlock label="任务操作约束(旧版)" text={JSON.stringify(input.task.constraints, null, 2)} />
</>
) : null}
{input.worker_intent ? (
<CodeBlock label="Worker 意图(旧版)" text={input.worker_intent} truncated={input.background_truncated} />
) : null}
</>
)}
{input.working_directory ? <CodeBlock label="工作目录" text={input.working_directory} /> : null}
{input.version >= 3 ? (
<p className="text-muted-foreground text-xs">本次审查未发送历史调用或执行结果。</p>
) : (
<div className="flex min-w-0 flex-col gap-3">
<h4 className="font-medium text-muted-foreground text-xs">当时提供给模型的历史调用(旧版)</h4>
{input.history?.length ? (
input.history.map((entry) => (
<div key={entry.tool_use_id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
<p className="break-words font-medium text-xs">
{entry.tool} · {entry.status === "succeeded" ? "成功" : "失败(可能有部分副作用)"}
</p>
<CodeBlock label="历史调用参数" text={entry.arguments_preview} truncated={entry.truncated} />
<CodeBlock label="历史执行结果" text={entry.result} truncated={entry.truncated} />
</div>
))
) : (
<p className="text-muted-foreground text-xs">本次未提供可配对的历史工具执行记录。</p>
)}
{input.history_truncated ? (
<p className="text-muted-foreground text-xs">历史为有限窗口,部分内容已截断。</p>
) : null}
</div>
)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm" className="self-start">
@@ -299,7 +367,9 @@ export function ApprovalDetail({
{audit?.effective_action ? <p className="text-sm">最终动作:{actionLabels[audit.effective_action]}</p> : null}
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">来源</dt>
<dd className="break-words">{current.task_id ? `任务 ${current.task_id}` : originLabel(current)}</dd>
<dd className="break-words">
<ApprovalOrigin row={current} detail />
</dd>
<dt className="text-muted-foreground">申请时间</dt>
<dd>{fmtTime(row.created_at)}</dd>
<dt className="text-muted-foreground">决定时间</dt>
@@ -364,7 +434,9 @@ export function ApprovalDetail({
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" />
查看会话审计片段
{audit.model_input && audit.model_input.version >= 3
? "查看会话审计片段(未发送模型)"
: "查看会话审计片段"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
@@ -526,7 +598,7 @@ function ApprovalTable({
<TableCell className="hidden md:table-cell">
<div className="flex flex-col gap-1">
<span className="truncate" title={originLabel(row)}>
{originLabel(row)}
<ApprovalOrigin row={row} />
</span>
<span className="truncate text-muted-foreground text-xs">
{row.agent_name || row.conv_agent_key}
+74 -9
View File
@@ -355,12 +355,15 @@ function ToolBlock({
group,
getDetail,
showWorker,
focused = false,
}: {
group: Extract<Group, { type: "tool" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
focused?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(focused);
const targetRef = React.useRef<HTMLElement>(null);
const [detail, setDetail] = React.useState<string | null>(null);
// what we last loaded, keyed by the underlying step seqs. When the tool result
// arrives after we expanded mid-run (command only), this key changes and the
@@ -377,7 +380,7 @@ function ToolBlock({
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400";
const rawCmd =
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : use?.summary ?? "";
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : (use?.summary ?? "");
const cmd = toolInputText(toolName, rawCmd);
// status only — the full result lives behind the expand (【输出】), not previewed inline
const statusText = running ? "执行中…" : ok ? "✓" : "✕ 失败";
@@ -410,12 +413,53 @@ function ToolBlock({
};
}, [open, detailKey, use, result, getDetail, toolName]);
const scrolledRef = React.useRef(false);
React.useEffect(() => {
scrolledRef.current = false;
if (focused) setOpen(true);
}, [focused]);
React.useEffect(() => {
if (!focused || !open || detail === null || scrolledRef.current) return;
const el = targetRef.current;
const viewport = el?.closest('[data-slot="scroll-area-viewport"]');
if (!el || !viewport) return;
scrolledRef.current = true;
const center = () => {
const rect = el.getBoundingClientRect();
const bounds = viewport.getBoundingClientRect();
viewport.scrollTop += rect.top + rect.height / 2 - bounds.top - bounds.height / 2;
};
// User bubbles load their full text lazily. Allow their initial layout to
// settle, but stop anchoring immediately if the user interacts or after 2s.
const observer = new ResizeObserver(center);
observer.observe(el.parentElement ?? el);
observer.observe(viewport);
const stop = () => observer.disconnect();
viewport.addEventListener("wheel", stop, { passive: true, once: true });
viewport.addEventListener("touchstart", stop, { passive: true, once: true });
viewport.addEventListener("pointerdown", stop, { once: true });
const frame = requestAnimationFrame(center);
const timer = setTimeout(stop, 2000);
return () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
stop();
viewport.removeEventListener("wheel", stop);
viewport.removeEventListener("touchstart", stop);
viewport.removeEventListener("pointerdown", stop);
};
}, [focused, open, detail]);
function toggle() {
setOpen((o) => !o);
}
return (
<div className="text-xs">
<section
ref={targetRef}
aria-label={focused ? `定位的工具调用 #${use?.seq}` : undefined}
className={focused ? "rounded-lg border-2 border-primary bg-primary/5 p-3 text-xs" : "text-xs"}
>
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
@@ -431,7 +475,7 @@ function ToolBlock({
{detail ?? "加载中…"}
</pre>
)}
</div>
</section>
);
}
@@ -637,18 +681,19 @@ function ExecView({
taskId,
chat,
fetchDetail,
focusedSeq,
}: {
activity: Activity[];
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
focusedSeq?: number;
}) {
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
// its own (conversation-scoped) fetcher instead.
const getDetail = React.useCallback(
(seq: number) =>
fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? ""),
(seq: number) => (fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? "")),
[fetchDetail, taskId],
);
return (
@@ -665,7 +710,13 @@ function ExecView({
) : g.type === "answer" ? (
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
) : g.type === "tool" ? (
<ToolBlock key={"t" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
<ToolBlock
key={"t" + g.key}
group={g}
getDetail={getDetail}
showWorker={showWorker}
focused={focusedSeq != null && g.use?.seq === focusedSeq}
/>
) : g.type === "intercept" ? (
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
) : (
@@ -685,16 +736,30 @@ export function Transcript({
taskId,
chat,
fetchDetail,
focusedSeq,
}: {
activity: Activity[];
live?: boolean;
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
focusedSeq?: number;
}) {
const transcriptRef = React.useRef<HTMLDivElement>(null);
const [focusPadding, setFocusPadding] = React.useState(0);
React.useLayoutEffect(() => {
if (focusedSeq == null) { setFocusPadding(0); return; }
const viewport = transcriptRef.current?.closest('[data-slot="scroll-area-viewport"]');
if (!viewport) return;
const measure = () => setFocusPadding(viewport.clientHeight / 2);
measure();
const observer = new ResizeObserver(measure);
observer.observe(viewport);
return () => observer.disconnect();
}, [focusedSeq]);
return (
<div className="flex flex-col gap-1">
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} />
<div ref={transcriptRef} className="flex flex-col gap-1" style={focusPadding ? { paddingBlock: focusPadding } : undefined}>
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} focusedSeq={focusedSeq} />
{live && (
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
<span className="flex gap-1">