From 41a39f3cc87b8b14d12a0137a3c4131fb954a0be Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 19:26:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=BC=9A=E8=AF=9D=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E7=BB=93=E6=9E=9C=E8=A1=8C=E5=8F=AA=E5=88=97=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=A0=B8=E5=AE=9E=E7=9A=84=E4=BA=8B=E5=AE=9E,?= =?UTF-8?q?=E4=B8=8D=E4=B8=80=E8=87=B4=E6=97=B6=E6=A0=87=E8=AE=B0(FIX-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉「说明」「汇总」与「未经核查」;没有动作时一行小字,有动作时列提交的下载与完成的整理并可点到记录, 汇报里的数字与系统记录不一致时行尾加「与汇报不一致」。统计行已有费用时不重复。 Co-Authored-By: Claude Opus 5.5 --- .../components/agent/agent-conversation.tsx | 153 +++++++++++------- .../app/components/agent/agent-run-panel.tsx | 1 + apps/web/app/components/agent/agui3.test.ts | 101 +++++++++++- 3 files changed, 193 insertions(+), 62 deletions(-) diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 3bb8e1d8..7b4a5b87 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -588,101 +588,134 @@ export function AgentConversation({ // ── 实际结果 ── -/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */ -export function resultText(report: GoalReport | null): string { - if (!report) return "汇总中…"; +type ResultFact = { text: string; to?: string; tone?: "warning" }; + +/** + * 实际结果一行:系统核实的事实(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本)。 + * 没有动作时只有「没有提交下载或整理」;有动作时「提交了 2 个下载、完成 1 次整理」,只有一条记录的项直接链到它。 + */ +export function resultFacts(report: GoalReport | null): ResultFact[] { + if (!report) return [{ text: "汇总中…" }]; const r = report; - switch (r.state) { - case "no_actions": - return "没有提交下载或整理"; - case "waiting": - return r.imported - ? `已入库 ${r.imported} 个,${r.inProgress} 个还在进行` - : `${r.submitted || r.inProgress} 个下载已提交,还没入库`; - case "completed": - return `已入库 ${r.imported} 个`; - 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 (r.state === "no_actions") return [{ text: "没有提交下载或整理" }]; + const downloads = Math.max(r.submitted, r.plans.length); + const done = r.organization.filter((o) => o.status === "succeeded" || o.status === "partial"); + const facts: ResultFact[] = []; + if (downloads) { + const only = r.downloads.length === 1 ? r.downloads[0] : undefined; + facts.push({ + text: `提交了 ${downloads} 个下载`, + to: only ? `/console/downloads/${only.downloadTaskId}` : r.downloads.length ? "/console/downloads" : undefined, + }); } + 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 · 技术信息 ›」。 - * 有下载任务 / 整理记录时给链接;模型、token、会话 ID 进技术信息。 + * 回答下面一行实际结果:「没有提交下载或整理 · 41 秒」或「提交了 2 个下载、完成 1 次整理 · 41 秒」。 + * 数字与汇报不一致时行尾加「与汇报不一致」(悬停看说明,技术信息里也有);模型、token、会话 ID 进技术信息。 */ export function ResultLine({ run, report, live, subagents = 0, + showCost = true, }: { run: AgentRunView; report: GoalReport | null; live: AgentStreamState; /** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */ subagents?: number; + /** 上方统计行已显示费用时传 false,不重复 */ + showCost?: boolean; }) { const u = { ...run.usage, ...(live.usage ?? {}) }; const durationMs = u.durationMs ?? (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 links = 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 facts = resultFacts(report); const last = lastReportText(live.items); 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; return (

{report ? null : } - 实际结果:{resultText(report)} + + {facts.map((f, i) => ( + + {i ? "、" : ""} + {f.to ? ( + + {f.text} + + ) : ( + {f.text} + )} + + ))} + {subagents ? · 派出 {subagents} 个子会话 : null} {durationMs ? · {durationLabel(durationMs)} : null} - {u.costUsd ? · 约 {formatUsd(u.costUsd)} : null} + {showCost && u.costUsd ? · 约 {formatUsd(u.costUsd)} : null} + {mismatch.length ? ( + + 与汇报不一致 + + ) : null}

- {links.length ? ( -
    - {links.slice(0, 4).map((l) => ( -
  • - - {l.label} - -
  • - ))} - {links.length > 4 ?
  • 等 {links.length} 项
  • : null} -
- ) : null} {err ?

{err}

: null} c.parentRunId === r.id).length} + showCost={false} /> ) : null} diff --git a/apps/web/app/components/agent/agui3.test.ts b/apps/web/app/components/agent/agui3.test.ts index 0d8b7568..bda0c88f 100644 --- a/apps/web/app/components/agent/agui3.test.ts +++ b/apps/web/app/components/agent/agui3.test.ts @@ -4,9 +4,18 @@ import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; 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 { 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 { goalFromInput, RunStatsLine } from "./agent-run-panel.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"); }); }); + +describe("实际结果行(FIX-9):只列系统核实的事实", () => { + const ID = (n: number) => `00000000-0000-4000-8000-00000000010${n}`; + const report = (o: Partial): 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: "汇总中…" }]); + }); +});