feat(web): 会话实际结果行只列系统核实的事实,不一致时标记(FIX-9)
去掉「说明」「汇总」与「未经核查」;没有动作时一行小字,有动作时列提交的下载与完成的整理并可点到记录, 汇报里的数字与系统记录不一致时行尾加「与汇报不一致」。统计行已有费用时不重复。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c67fd96639
commit
41a39f3cc8
3 files changed
+193
-62
No files matched your search
@@ -588,101 +588,134 @@ export function AgentConversation({
|
|||||||
|
|
||||||
// ── 实际结果 ──
|
// ── 实际结果 ──
|
||||||
|
|
||||||
/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
|
type ResultFact = { text: string; to?: string; tone?: "warning" };
|
||||||
export function resultText(report: GoalReport | null): string {
|
|
||||||
if (!report) return "汇总中…";
|
/**
|
||||||
|
* 实际结果一行:系统核实的事实(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本)。
|
||||||
|
* 没有动作时只有「没有提交下载或整理」;有动作时「提交了 2 个下载、完成 1 次整理」,只有一条记录的项直接链到它。
|
||||||
|
*/
|
||||||
|
export function resultFacts(report: GoalReport | null): ResultFact[] {
|
||||||
|
if (!report) return [{ text: "汇总中…" }];
|
||||||
const r = report;
|
const r = report;
|
||||||
switch (r.state) {
|
if (r.state === "no_actions") return [{ text: "没有提交下载或整理" }];
|
||||||
case "no_actions":
|
const downloads = Math.max(r.submitted, r.plans.length);
|
||||||
return "没有提交下载或整理";
|
const done = r.organization.filter((o) => o.status === "succeeded" || o.status === "partial");
|
||||||
case "waiting":
|
const facts: ResultFact[] = [];
|
||||||
return r.imported
|
if (downloads) {
|
||||||
? `已入库 ${r.imported} 个,${r.inProgress} 个还在进行`
|
const only = r.downloads.length === 1 ? r.downloads[0] : undefined;
|
||||||
: `${r.submitted || r.inProgress} 个下载已提交,还没入库`;
|
facts.push({
|
||||||
case "completed":
|
text: `提交了 ${downloads} 个下载`,
|
||||||
return `已入库 ${r.imported} 个`;
|
to: only ? `/console/downloads/${only.downloadTaskId}` : r.downloads.length ? "/console/downloads" : undefined,
|
||||||
case "partial":
|
});
|
||||||
return [
|
|
||||||
r.imported ? `已入库 ${r.imported} 个` : "",
|
|
||||||
r.inProgress ? `${r.inProgress} 个还在进行` : "",
|
|
||||||
r.failed ? `${r.failed} 个失败` : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(",");
|
|
||||||
case "failed":
|
|
||||||
return r.failed ? `${r.failed} 个没有成功` : "没有达成目标";
|
|
||||||
}
|
}
|
||||||
|
if (done.length) {
|
||||||
|
const only = done.length === 1 ? done[0] : undefined;
|
||||||
|
facts.push({
|
||||||
|
text: `完成 ${done.length} 次整理`,
|
||||||
|
to: only ? `/console/organize/${only.recordId}` : "/console/organize",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (r.imported && r.imported !== done.length) facts.push({ text: `已入库 ${r.imported} 个` });
|
||||||
|
if (r.inProgress) facts.push({ text: `${r.inProgress} 个还在进行` });
|
||||||
|
if (r.failed) facts.push({ text: `${r.failed} 个没有成功`, tone: "warning" });
|
||||||
|
return facts.length ? facts : [{ text: "没有提交下载或整理" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型汇报里写明的数字(只认「提交了 N 个」「整理了 N 个」这类明确说法;没写数字的不算) */
|
||||||
|
export function claimedCounts(text: string | null): { downloads?: number; organized?: number } {
|
||||||
|
if (!text) return {};
|
||||||
|
const out: { downloads?: number; organized?: number } = {};
|
||||||
|
const dl = text.match(/(?:提交|添加|推送|开始下载)了?\s*(\d+)\s*(?:个|项|部|集)/);
|
||||||
|
if (dl?.[1]) out.downloads = Number(dl[1]);
|
||||||
|
const org =
|
||||||
|
text.match(/整理了?\s*(\d+)\s*(?:个|次|项|部|集)/) ?? text.match(/(\d+)\s*(?:个|次|项)[^,。,.\n]{0,4}整理/);
|
||||||
|
if (org?.[1]) out.organized = Number(org[1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇报里的数字与系统记录不一致的地方;一致或汇报没写数字时为空 */
|
||||||
|
export function reportMismatch(report: GoalReport | null, text: string | null): string[] {
|
||||||
|
if (!report) return [];
|
||||||
|
const c = claimedCounts(text);
|
||||||
|
const downloads = Math.max(report.submitted, report.plans.length);
|
||||||
|
const organized = report.organization.filter((o) => o.status === "succeeded" || o.status === "partial").length;
|
||||||
|
const out: string[] = [];
|
||||||
|
if (c.downloads !== undefined && c.downloads !== downloads)
|
||||||
|
out.push(`汇报说提交了 ${c.downloads} 个下载,系统记录是 ${downloads} 个`);
|
||||||
|
if (c.organized !== undefined && c.organized !== organized)
|
||||||
|
out.push(`汇报说整理了 ${c.organized} 个,系统记录完成 ${organized} 次`);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回答下面一行实际结果:「没有提交下载或整理 · 41 秒 · 约 $0.01 · 技术信息 ›」。
|
* 回答下面一行实际结果:「没有提交下载或整理 · 41 秒」或「提交了 2 个下载、完成 1 次整理 · 41 秒」。
|
||||||
* 有下载任务 / 整理记录时给链接;模型、token、会话 ID 进技术信息。
|
* 数字与汇报不一致时行尾加「与汇报不一致」(悬停看说明,技术信息里也有);模型、token、会话 ID 进技术信息。
|
||||||
*/
|
*/
|
||||||
export function ResultLine({
|
export function ResultLine({
|
||||||
run,
|
run,
|
||||||
report,
|
report,
|
||||||
live,
|
live,
|
||||||
subagents = 0,
|
subagents = 0,
|
||||||
|
showCost = true,
|
||||||
}: {
|
}: {
|
||||||
run: AgentRunView;
|
run: AgentRunView;
|
||||||
report: GoalReport | null;
|
report: GoalReport | null;
|
||||||
live: AgentStreamState;
|
live: AgentStreamState;
|
||||||
/** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */
|
/** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */
|
||||||
subagents?: number;
|
subagents?: number;
|
||||||
|
/** 上方统计行已显示费用时传 false,不重复 */
|
||||||
|
showCost?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const u = { ...run.usage, ...(live.usage ?? {}) };
|
const u = { ...run.usage, ...(live.usage ?? {}) };
|
||||||
const durationMs =
|
const durationMs =
|
||||||
u.durationMs ??
|
u.durationMs ??
|
||||||
(run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null);
|
(run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null);
|
||||||
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||||
const links = report
|
const facts = resultFacts(report);
|
||||||
? [
|
|
||||||
...report.downloads.map((d) => ({
|
|
||||||
id: d.downloadTaskId,
|
|
||||||
label: d.name ?? "下载任务",
|
|
||||||
to: `/console/downloads/${d.downloadTaskId}`,
|
|
||||||
})),
|
|
||||||
...report.organization.map((o) => ({
|
|
||||||
id: o.recordId,
|
|
||||||
label: o.targetPath?.split("/").pop() || "整理记录",
|
|
||||||
to: `/console/organize/${o.recordId}`,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
const bad = report?.state === "failed" || report?.state === "partial";
|
|
||||||
const last = lastReportText(live.items);
|
const last = lastReportText(live.items);
|
||||||
const rawReport = last && humanizeReport(last).replaced.length ? last : null;
|
const rawReport = last && humanizeReport(last).replaced.length ? last : null;
|
||||||
|
const mismatch = reportMismatch(report, last ?? report?.modelText ?? null);
|
||||||
const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null;
|
const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null;
|
||||||
return (
|
return (
|
||||||
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
|
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
|
||||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-meta text-muted-foreground">
|
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-meta text-muted-foreground">
|
||||||
{report ? null : <LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />}
|
{report ? null : <LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />}
|
||||||
<span className={cn("text-foreground/85", bad && "text-warning")}>实际结果:{resultText(report)}</span>
|
<span className="min-w-0">
|
||||||
|
{facts.map((f, i) => (
|
||||||
|
<span key={f.text}>
|
||||||
|
{i ? "、" : ""}
|
||||||
|
{f.to ? (
|
||||||
|
<Link
|
||||||
|
to={f.to}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground/85 underline-offset-2 hover:underline",
|
||||||
|
f.tone === "warning" && "text-warning",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f.text}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={cn("text-foreground/85", f.tone === "warning" && "text-warning")}>{f.text}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
|
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
|
||||||
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
|
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
|
||||||
{u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
|
{showCost && u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
|
||||||
|
{mismatch.length ? (
|
||||||
|
<span
|
||||||
|
data-slot="report-mismatch"
|
||||||
|
title={mismatch.join("\n")}
|
||||||
|
className="rounded-tight border border-warning/40 px-1 text-warning"
|
||||||
|
>
|
||||||
|
与汇报不一致
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
{links.length ? (
|
|
||||||
<ul className="flex min-w-0 flex-wrap gap-x-3 gap-y-0.5 text-meta">
|
|
||||||
{links.slice(0, 4).map((l) => (
|
|
||||||
<li key={l.id} className="min-w-0 max-w-full">
|
|
||||||
<Link to={l.to} className="block truncate text-foreground/85 underline-offset-2 hover:underline">
|
|
||||||
{l.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{links.length > 4 ? <li className="text-muted-foreground">等 {links.length} 项</li> : null}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
{err ? <p className="text-caption break-words text-destructive">{err}</p> : null}
|
{err ? <p className="text-caption break-words text-destructive">{err}</p> : null}
|
||||||
<TechInfo
|
<TechInfo
|
||||||
items={[
|
items={[
|
||||||
{
|
|
||||||
label: "说明",
|
|
||||||
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。",
|
|
||||||
wide: true,
|
|
||||||
mono: false,
|
|
||||||
},
|
|
||||||
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
|
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
|
||||||
{
|
{
|
||||||
label: "token",
|
label: "token",
|
||||||
@@ -697,7 +730,7 @@ export function ResultLine({
|
|||||||
},
|
},
|
||||||
{ label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null },
|
{ label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null },
|
||||||
{ label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null },
|
{ label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null },
|
||||||
{ label: "汇总", value: report?.summary ?? null, wide: true, mono: false },
|
{ label: "与汇报不一致", value: mismatch.length ? mismatch.join(";") : null, wide: true, mono: false },
|
||||||
// 正文里的 ID / 能力名 / 原因码已换成人话;排查用的汇报原文只放这里
|
// 正文里的 ID / 能力名 / 原因码已换成人话;排查用的汇报原文只放这里
|
||||||
{ label: "原文", value: rawReport, wide: true, mono: false },
|
{ label: "原文", value: rawReport, wide: true, mono: false },
|
||||||
{ label: "会话 ID", value: run.id, wide: true },
|
{ label: "会话 ID", value: run.id, wide: true },
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ export function AgentRunPanel({
|
|||||||
report={report}
|
report={report}
|
||||||
live={live}
|
live={live}
|
||||||
subagents={(run.data.children ?? []).filter((c) => c.parentRunId === r.id).length}
|
subagents={(run.data.children ?? []).filter((c) => c.parentRunId === r.id).length}
|
||||||
|
showCost={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ import { createElement, type ReactNode } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { AgentRunView } from "~/api/agent.ts";
|
import type { AgentRunView, GoalReport } from "~/api/agent.ts";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { AgentConversation, contextLabel, DeniedLines, ResultLine, StepSummary } from "./agent-conversation.tsx";
|
import {
|
||||||
|
AgentConversation,
|
||||||
|
claimedCounts,
|
||||||
|
contextLabel,
|
||||||
|
DeniedLines,
|
||||||
|
ResultLine,
|
||||||
|
reportMismatch,
|
||||||
|
resultFacts,
|
||||||
|
StepSummary,
|
||||||
|
} from "./agent-conversation.tsx";
|
||||||
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
|
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
|
||||||
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
|
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
|
||||||
import { arrangeHistory } from "./agent-session-list.tsx";
|
import { arrangeHistory } from "./agent-session-list.tsx";
|
||||||
@@ -442,3 +451,91 @@ describe("会话统计与本轮状态(AG-4)", () => {
|
|||||||
expect(t).toContain("工具调用 1 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03");
|
expect(t).toContain("工具调用 1 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("实际结果行(FIX-9):只列系统核实的事实", () => {
|
||||||
|
const ID = (n: number) => `00000000-0000-4000-8000-00000000010${n}`;
|
||||||
|
const report = (o: Partial<GoalReport>): GoalReport => ({
|
||||||
|
state: "no_actions",
|
||||||
|
summary: "本次没有提交下载或整理,结论见模型汇报。",
|
||||||
|
submitted: 0,
|
||||||
|
imported: 0,
|
||||||
|
inProgress: 0,
|
||||||
|
failed: 0,
|
||||||
|
plans: [],
|
||||||
|
downloads: [],
|
||||||
|
organization: [],
|
||||||
|
modelText: null,
|
||||||
|
generatedAt: AT,
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
const done = report({
|
||||||
|
state: "completed",
|
||||||
|
summary: "已提交下载 2 个",
|
||||||
|
submitted: 2,
|
||||||
|
imported: 1,
|
||||||
|
inProgress: 1,
|
||||||
|
downloads: [
|
||||||
|
{ downloadTaskId: ID(1), planId: null, status: "completed", name: "A" },
|
||||||
|
{ downloadTaskId: ID(2), planId: null, status: "downloading", name: "B" },
|
||||||
|
],
|
||||||
|
organization: [
|
||||||
|
{ recordId: ID(3), status: "succeeded", downloadTaskId: ID(1), mediaEntityId: null, targetPath: "/m/A" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const withText = (t: string) =>
|
||||||
|
liveOf([
|
||||||
|
ev(0, { type: "run_started", goal: "g", resumed: false }),
|
||||||
|
ev(1, { type: "text_delta", delta: t }),
|
||||||
|
ev(2, { type: "run_completed", finalText: "", usage: { costUsd: 0.01 } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("没有动作:一行小字 + 时长 + 费用,不再有「说明」「汇总」「未经核查」", () => {
|
||||||
|
const html = render(
|
||||||
|
createElement(ResultLine, {
|
||||||
|
run: { ...RUN, usage: { costUsd: 0.01 } },
|
||||||
|
report: report({}),
|
||||||
|
live: withText("都正常。"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const t = text(html);
|
||||||
|
expect(t).toContain("没有提交下载或整理 · 41 秒 · 约 $0.01");
|
||||||
|
expect(t).not.toContain("说明");
|
||||||
|
expect(t).not.toContain("汇总");
|
||||||
|
expect(t).not.toContain("未经核查");
|
||||||
|
expect(t).not.toContain("实际结果:");
|
||||||
|
expect(t).not.toContain("结论见模型汇报");
|
||||||
|
});
|
||||||
|
it("统计行已显示费用时不重复", () => {
|
||||||
|
const t = text(
|
||||||
|
render(
|
||||||
|
createElement(ResultLine, {
|
||||||
|
run: { ...RUN, usage: { costUsd: 0.01 } },
|
||||||
|
report: report({}),
|
||||||
|
live: withText("x"),
|
||||||
|
showCost: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(t).toContain("没有提交下载或整理 · 41 秒");
|
||||||
|
expect(t).not.toContain("$0.01");
|
||||||
|
});
|
||||||
|
it("有动作:列事实,单条记录链到记录本身,多条链到列表", () => {
|
||||||
|
const html = render(
|
||||||
|
createElement(ResultLine, { run: RUN, report: done, live: withText("提交了 2 个下载,整理了 1 个。") }),
|
||||||
|
);
|
||||||
|
expect(text(html).replace(/\s*、\s*/g, "、")).toContain("提交了 2 个下载、完成 1 次整理、1 个还在进行 · 41 秒");
|
||||||
|
expect(html).toContain(`href="/console/organize/${ID(3)}"`);
|
||||||
|
expect(html).toContain('href="/console/downloads"');
|
||||||
|
expect(html).not.toContain("与汇报不一致");
|
||||||
|
});
|
||||||
|
it("数字与汇报不一致:行尾加标记,悬停和技术信息里说明", () => {
|
||||||
|
const html = render(createElement(ResultLine, { run: RUN, report: done, live: withText("已提交了 3 个下载。") }));
|
||||||
|
expect(html).toContain('data-slot="report-mismatch"');
|
||||||
|
expect(html).toContain("汇报说提交了 3 个下载,系统记录是 2 个");
|
||||||
|
});
|
||||||
|
it("汇报没写数字时不判不一致", () => {
|
||||||
|
expect(reportMismatch(done, "下载都安排好了。")).toEqual([]);
|
||||||
|
expect(claimedCounts("整理了 4 部电影")).toEqual({ organized: 4 });
|
||||||
|
expect(resultFacts(null)).toEqual([{ text: "汇总中…" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user