diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx
index bed5752e..4f5d71e0 100644
--- a/apps/web/app/components/agent/agent-conversation.tsx
+++ b/apps/web/app/components/agent/agent-conversation.tsx
@@ -60,6 +60,7 @@ import {
runningText,
toolSummary,
} from "./conversation-model.ts";
+import { collectNames, humanizeReport } from "./report-humanize.ts";
import { ToolCallIo } from "./tool-call-card-io.tsx";
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
@@ -439,6 +440,7 @@ export function AgentConversation({
userMessages = [],
audit,
after,
+ knownNames,
}: {
run: AgentRunView;
live: AgentStreamState;
@@ -446,6 +448,8 @@ export function AgentConversation({
audit?: InvocationAudit[];
/** 流末尾追加的内容(其它审批卡、实际结果行) */
after?: ReactNode;
+ /** 工具结果之外已知的「ID → 名字」(子会话),汇报兜底换名用 */
+ knownNames?: [string, string][];
}) {
const active = isRunActive(run.status) && !live.finished;
const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued";
@@ -460,6 +464,7 @@ export function AgentConversation({
const lastKey = live.items.at(-1)?.key;
const tail = live.items.at(-1);
const blocks = buildConversation(live.items, userMessages);
+ const names = collectNames(live.items, knownNames);
const anim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none";
const lastBlock = blocks.at(-1);
@@ -501,18 +506,21 @@ export function AgentConversation({
) : null}
{p.body.map((t) => {
const streaming = streamingText && t.key === lastKey;
+ // 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息
+ const human = streaming ? null : humanizeReport(t.text, names);
return (
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
- {streaming ? t.text : }
+ {human ? : t.text}
+ {human?.replaced.length ? : null}
);
})}
@@ -556,6 +564,32 @@ export function AgentConversation({
);
}
+/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */
+function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) {
+ const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()];
+ return (
+
+ {uniq.map((r) => (
+
+ {r.from} → {r.to || "(省略)"}
+
+ ))}
+
+ ),
+ wide: true,
+ },
+ ]}
+ />
+ );
+}
+
// ── 实际结果 ──
/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
@@ -592,10 +626,13 @@ export function ResultLine({
run,
report,
live,
+ subagents = 0,
}: {
run: AgentRunView;
report: GoalReport | null;
live: AgentStreamState;
+ /** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */
+ subagents?: number;
}) {
const u = { ...run.usage, ...(live.usage ?? {}) };
const durationMs =
@@ -623,6 +660,7 @@ export function ResultLine({
{report ? null : }
实际结果:{resultText(report)}
+ {subagents ? · 派出 {subagents} 个子会话 : null}
{durationMs ? · {durationLabel(durationMs)} : null}
{u.costUsd ? · 约 {formatUsd(u.costUsd)} : null}
@@ -643,7 +681,7 @@ export function ResultLine({
items={[
{
label: "说明",
- value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务和整理记录。",
+ value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。",
wide: true,
mono: false,
},
diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts
index 2a59cf51..f5f868cb 100644
--- a/apps/web/app/components/agent/agent-helpers.test.ts
+++ b/apps/web/app/components/agent/agent-helpers.test.ts
@@ -155,4 +155,16 @@ describe("shortGoalTitle(会话短标题)", () => {
expect(shortGoalTitle("啊".repeat(60))).toBe(`${"啊".repeat(40)}…`);
expect(shortGoalTitle(" ")).toBe("未命名会话");
});
+ it("ISO 时间与待处理类型码转人话(FIX-7)", () => {
+ const iso = "2026-09-28T18:53:50.468Z";
+ const d = new Date(iso);
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ expect(shortGoalTitle(`巡检待处理(${iso}):有 1 条未关闭的待处理事项。逐条查看`)).toBe(
+ `巡检待处理(${d.getMonth() + 1}月${d.getDate()}日 ${hm}):有 1 条未关闭的待处理事项`,
+ );
+ const t = shortGoalTitle(
+ "调查待处理事项 728960d1-b892-4ae7-8de3-baf61f8abbd7(download_failed):下载异常:Dune.Part.Two。摘要",
+ );
+ expect(t).toBe("调查待处理事项(下载失败):下载异常:Dune.Part.Two");
+ });
});
diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx
index df091959..99329205 100644
--- a/apps/web/app/components/agent/agent-interaction.tsx
+++ b/apps/web/app/components/agent/agent-interaction.tsx
@@ -17,6 +17,7 @@ import { Button } from "~/components/ui/button.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
+import { stepProgress } from "./conversation-model.ts";
import type { ApprovalView, OptionsView } from "./use-agent-stream.ts";
// 自动化优先的会话交互(AG-2):步骤清单(agent.plan)、方案选择(agent.propose_options)、步骤处的审批卡。
@@ -54,11 +55,10 @@ export function RunPlan({
fill?: boolean;
}) {
if (!plan?.steps.length) return null;
- const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
- const current = plan.steps.find((s) => s.status === "running") ?? plan.steps.find((s) => s.status === "pending");
- const failed = plan.steps.some((s) => s.status === "failed");
+ const progress = stepProgress(plan.steps);
+ const failed = progress.failed > 0;
if (compact) {
- const pct = Math.round((done / plan.steps.length) * 100);
+ const pct = progress.pct;
return (
@@ -67,11 +67,9 @@ export function RunPlan({
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
- 步骤 {done}/{plan.steps.length}
-
-
- {current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"}
+ 步骤 {progress.done}/{progress.total}
+ {progress.status}
s.status === "done" || s.status === "skipped").length;
- const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
- const failed = steps.find((s) => s.status === "failed");
+ const progress = stepProgress(steps);
+ const failed = progress.failedStep;
const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent;
if (!steps.length && !family && !isRunActive(status)) return null;
const u = { ...r.usage, ...(live.usage ?? {}) };
@@ -177,11 +178,9 @@ export function RunOverviewBar({
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
- 步骤 {done}/{steps.length}
-
-
- {failed ? `失败:${failed.title}` : current ? `当前:${current.title}` : "全部完成"}
+ 步骤 {progress.done}/{progress.total}
+ {progress.status}
>
) : (
@@ -197,7 +196,7 @@ export function RunOverviewBar({
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
failed ? "bg-destructive" : "bg-foreground/60",
)}
- style={{ width: `${Math.round((done / steps.length) * 100)}%` }}
+ style={{ width: `${progress.pct}%` }}
/>
) : null}
@@ -384,6 +383,12 @@ export function AgentRunPanel({
[c.id, shortGoalTitle(c.goal, 24)] as [string, string]),
+ ...(run.data.parent
+ ? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]]
+ : []),
+ ]}
audit={audit.data}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
@@ -393,7 +398,14 @@ export function AgentRunPanel({
.map((a) => (
))}
- {finished ? : null}
+ {finished ? (
+ c.parentRunId === r.id).length}
+ />
+ ) : null}
>
}
/>
diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts
index e9df70d3..78919117 100644
--- a/apps/web/app/components/agent/agent-store.ts
+++ b/apps/web/app/components/agent/agent-store.ts
@@ -1,4 +1,4 @@
-import { CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
+import { CAPABILITY_CATALOG, type KnownCapabilityId, reasonText } from "@umt/contracts";
import { useSyncExternalStore } from "react";
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
@@ -159,6 +159,16 @@ const capabilityTitle = (id: string): string | null =>
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
const CAP_RE = /\b[a-z][a-z_]*(?:\.[a-z_]+)+\b/g;
+const ISO_RE = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?/g;
+const CODE_RE = /\b[a-z]+(?:_[a-z]+)+\b/g;
+
+/** 目标里的 ISO 时间(巡检模板的 {{now}})→「9月28日 18:53」,按浏览器时区 */
+function shortTime(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
/**
* 会话标题:从目标生成短标题(DEAI-2)。去掉上下文前缀、UUID、JSON 参数,能力 ID 换成中文名,
@@ -170,10 +180,14 @@ export function shortGoalTitle(goal: string, max = 40): string {
t = t.replace(UUID_RE, "");
t = t.replace(/\b[0-9a-f]{8}(?:-[0-9a-f]{0,4})*…?/gi, ""); // 截断的短 ID
t = t.replace(CAP_RE, (id) => capabilityTitle(id) ?? id);
+ t = t.replace(ISO_RE, shortTime);
+ // 待处理类型、原因码(download_failed)换成人话
+ t = t.replace(CODE_RE, (c) => reasonText(c)?.text ?? c);
+ t = t.replace(/[((]\s*([^()()]*[\p{Script=Han}][^()()]*)\s*[))]/gu, "($1)");
t = t.split(/[\n。!?!?]/)[0] ?? "";
t = t
.replace(/\s+/g, " ")
- .replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、)])/gu, "")
+ .replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、()])/gu, "")
.replace(/[,,::;;、\s]*(?:参数|args?)?[::]?\s*$/i, "")
.trim();
if (!t) return "未命名会话";
diff --git a/apps/web/app/components/agent/conversation-model.ts b/apps/web/app/components/agent/conversation-model.ts
index 139bab68..286a643d 100644
--- a/apps/web/app/components/agent/conversation-model.ts
+++ b/apps/web/app/components/agent/conversation-model.ts
@@ -62,14 +62,15 @@ const joinNouns = (xs: string[]) =>
/**
* 「查了 1 个站点、2 个下载任务和媒体库,搜了 3 次资源」:按能力元数据的 verb / noun 计数,
- * 同一动作的对象合并,动作按第一次出现排序;被拒绝和隐藏的调用不计入。超过 4 种动作只写前 3 种。
+ * 同一动作的对象合并,动作按第一次出现排序;被拒绝、失败和隐藏的调用不计入(失败的在进度行另计「N 项失败」,
+ * 否则 3 次被预算拒绝的派发 + 2 次成功会写成「派出了 5 个子会话」)。超过 4 种动作只写前 3 种。
*/
export function activitySummary(calls: Pick[]): string {
const groups: Group[] = [];
for (const c of calls) {
const a = activityOf(c.capabilityId);
if (a.hidden) continue;
- if (c.status === "denied" || authDenial(c)) continue;
+ if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue;
let g = groups.find((x) => x.verb === a.verb);
if (!g) {
g = { verb: a.verb, nouns: [] };
@@ -85,6 +86,34 @@ export function activitySummary(calls: Pick s.status === "done").length;
+ const skipped = steps.filter((s) => s.status === "skipped").length;
+ const failedStep = steps.find((s) => s.status === "failed") ?? null;
+ const failed = steps.filter((s) => s.status === "failed").length;
+ const total = steps.length;
+ const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending") ?? null;
+ const count = `${done}/${total}${skipped ? ` · 跳过 ${skipped}` : ""}`;
+ const status = failedStep
+ ? `失败:${failedStep.title}`
+ : current
+ ? `当前:${current.title}`
+ : skipped
+ ? `完成 ${done} · 跳过 ${skipped}`
+ : "全部完成";
+ const pct = total ? Math.round(((done + skipped + failed) / total) * 100) : 0;
+ return { done, skipped, failed, total, current, failedStep, count, status, pct };
+}
+
/** 正在进行的那一步:「正在检测站点」 */
export function runningText(call: Pick): string {
const a = activityOf(call.capabilityId);
diff --git a/apps/web/app/components/agent/report-humanize.test.ts b/apps/web/app/components/agent/report-humanize.test.ts
new file mode 100644
index 00000000..812db7f2
--- /dev/null
+++ b/apps/web/app/components/agent/report-humanize.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest";
+import { activitySummary, stepProgress } from "./conversation-model.ts";
+import { collectNames, humanizeReport } from "./report-humanize.ts";
+import type { StreamItem, ToolCallView } from "./use-agent-stream.ts";
+
+// FIX-7:旧会话汇报的展示层兜底 + 进度口径。样本取自用户库会话 82338768 / a855fb03 的最终汇报(只读核对)。
+
+const ENTITY = "74beab51-45e5-4177-832d-199beb8c75fa";
+const SITE = "2ff30e4b-4578-4338-bece-047ef9f6e9cc";
+const VOLUME = "be751d0b-152c-4f72-8182-9797452fbe68";
+
+const call = (capabilityId: string, outputSummary: string | null, status: ToolCallView["status"] = "succeeded") =>
+ ({
+ kind: "tool",
+ key: `x${Math.random()}`,
+ call: {
+ toolCallId: `c${Math.random()}`,
+ capabilityId,
+ status,
+ inputSummary: "{}",
+ outputSummary,
+ reason: null,
+ startedAt: "2026-09-28T17:00:00.000Z",
+ endedAt: "2026-09-28T17:00:01.000Z",
+ },
+ }) as unknown as StreamItem;
+
+describe("humanizeReport(汇报兜底)", () => {
+ const names = collectNames([
+ call(
+ "catalog.lookup",
+ `{"local":[{"entity":{"id":"${ENTITY}","createdAt":"2026-09-28T16:35:21.283Z","kind":"movie","title":"沙丘2","year":2024`,
+ ),
+ call("site.list", `{"items":[{"id":"${SITE}","key":"audience","name":"观众(mock)","enabled":true}]}`),
+ ]);
+
+ it("从工具结果收集 ID → 名字,截断的 JSON 也能配对", () => {
+ expect(names.get(ENTITY)).toBe("沙丘2");
+ expect(names.get(SITE)).toBe("观众(mock)");
+ });
+
+ it("UUID 换名字;认不出的缩成「…」;原文进 replaced", () => {
+ const r = humanizeReport(`目录定位:实体 ID \`${ENTITY}\`,缓存卷 \`instance:${VOLUME}\` 使用率未知`, names);
+ expect(r.text).toBe("目录定位:实体 ID 「沙丘2」,缓存卷 … 使用率未知");
+ expect(r.replaced.map((x) => x.from)).toEqual([ENTITY, `instance:${VOLUME}`]);
+ });
+
+ it("名字已写在前面时不重复", () => {
+ const r = humanizeReport(`站点「观众(mock)」\`${SITE}\` 登录失效`, names);
+ expect(r.text).toBe("站点「观众(mock)」 登录失效");
+ });
+
+ it("能力 ID(带参数)换成能力标题,原因码换成人话", () => {
+ const r = humanizeReport(
+ "`site.health_check` 被拒;`download.inspect` 无法调用(返回 `invalid_input`);`download.plan(slot 37746be7, release 0bd93cb0)` 返回 plan=null,拒绝原因 `cache:volume_state_unknown`",
+ names,
+ );
+ expect(r.text).toBe(
+ "检测站点 被拒;查看下载任务 无法调用(返回 参数不对);生成下载计划 返回 plan=null,拒绝原因 下载盘用量未知",
+ );
+ expect(r.text).not.toMatch(/site\.health_check|invalid_input|volume_state_unknown/);
+ expect(humanizeReport("被规则拒绝(unauthorized),未重试", names).text).toBe("被规则拒绝(需要你批准),未重试");
+ });
+
+ it("截断的短 ID 缩成「…」;不是 ID 的 8 位十六进制与普通英文不动", () => {
+ const r = humanizeReport(
+ `仅 2 条候选——\`e6d79af3-…\` 720p;哈希 deadbeef;${ENTITY.slice(0, 8)} 已找到;Dune Part Two`,
+ names,
+ );
+ expect(r.text).toBe("仅 2 条候选——… 720p;哈希 deadbeef;「沙丘2」 已找到;Dune Part Two");
+ });
+
+ it("代码块与链接地址原样保留;不认识的码不动", () => {
+ const text = `看[详情](/console/library/${ENTITY})\n\`\`\`\n${ENTITY}\n\`\`\`\nsome_new_code`;
+ const r = humanizeReport(text, names);
+ expect(r.text).toBe(text);
+ expect(r.replaced).toEqual([]);
+ });
+});
+
+describe("activitySummary(系统事实计数)", () => {
+ it("失败的派发不计入「派出了 N 个子会话」", () => {
+ const calls = [
+ ...Array.from({ length: 3 }, () => ({
+ capabilityId: "agent.spawn",
+ status: "failed" as const,
+ outputSummary: '{"error":{"code":"budget","message":"超过剩余预算"}}',
+ })),
+ ...Array.from({ length: 2 }, () => ({
+ capabilityId: "agent.spawn",
+ status: "succeeded" as const,
+ outputSummary: "{}",
+ })),
+ ];
+ expect(activitySummary(calls)).toBe("派出了 2 个子会话");
+ });
+});
+
+describe("stepProgress(步骤口径)", () => {
+ const s = (status: "pending" | "running" | "done" | "skipped" | "failed", title = "步骤") => ({ status, title });
+ it("跳过的单独计:4 完成 + 2 跳过不再写「6/6 全部完成」", () => {
+ const p = stepProgress([s("done"), s("done"), s("done"), s("done"), s("skipped"), s("skipped")]);
+ expect(p.count).toBe("4/6 · 跳过 2");
+ expect(p.status).toBe("完成 4 · 跳过 2");
+ expect(p.pct).toBe(100);
+ });
+ it("没有跳过时照旧", () => {
+ const p = stepProgress([s("done"), s("running", "核对下载任务"), s("pending")]);
+ expect(p.count).toBe("1/3");
+ expect(p.status).toBe("当前:核对下载任务");
+ expect(stepProgress([s("done"), s("done")]).status).toBe("全部完成");
+ });
+});
diff --git a/apps/web/app/components/agent/report-humanize.ts b/apps/web/app/components/agent/report-humanize.ts
new file mode 100644
index 00000000..1536b14e
--- /dev/null
+++ b/apps/web/app/components/agent/report-humanize.ts
@@ -0,0 +1,148 @@
+import { CAPABILITY_CATALOG, CAPABILITY_IDS, reasonText } from "@umt/contracts";
+import type { StreamItem } from "./use-agent-stream.ts";
+
+// 智能体汇报的展示层兜底(FIX-7):旧会话或不守汇报格式的模型会在正文里写 UUID、能力 ID(site.health_check)、
+// 原因码(invalid_input、cache:volume_state_unknown)。展示时把它们换成名称 / 人话,历史数据不改;
+// 换掉的原文交给「技术信息」。能认出名字的 UUID 换成名字,认不出的缩成「…」。
+
+const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
+const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+const CAP_IDS = [...CAPABILITY_IDS].sort((a, b) => b.length - a.length).map(escapeRe);
+
+/** 能力 ID(可带调用参数 `(…)`) */
+const CAP_RE = new RegExp(`(?;
+
+export type HumanizeResult = {
+ text: string;
+ /** 被替换的原文 → 显示的文字 */
+ replaced: { from: string; to: string }[];
+};
+
+const capTitle = (id: string) => (CAPABILITY_CATALOG as Record)[id]?.title ?? id;
+
+function lookupShort(prefix: string, names: NameIndex, seen: Set): { name: string | null; known: boolean } {
+ const p = prefix.toLowerCase();
+ for (const [id, name] of names) if (id.startsWith(p)) return { name, known: true };
+ for (const id of seen) if (id.startsWith(p)) return { name: null, known: true };
+ return { name: null, known: false };
+}
+
+/** 一段不含代码块 / 行内代码的文字 */
+function humanizePlain(
+ text: string,
+ names: NameIndex,
+ seen: Set,
+ replaced: HumanizeResult["replaced"],
+ /** 前文(判断名字是否已经写在前面) */
+ prior = "",
+): string {
+ const note = (from: string, to: string) => {
+ replaced.push({ from, to });
+ return to;
+ };
+ // 名字已经写在前面(「站点「观众」2ff30e4b-…」)时,ID 直接去掉
+ const nameOrDrop = (from: string, name: string, before: string) =>
+ note(from, (prior + before).slice(-name.length - 4).includes(name) ? "" : `「${name}」`);
+
+ let out = text.replace(CAP_RE, (m, id: string) => note(m, capTitle(id)));
+ out = out.replace(UUID_RE, (m, id: string, offset: number, all: string) => {
+ const name = names.get(id.toLowerCase());
+ return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…");
+ });
+ out = out.replace(SHORT_TRUNC_RE, (m, p: string, offset: number, all: string) => {
+ const { name } = lookupShort(p, names, seen);
+ return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…");
+ });
+ out = out.replace(SHORT_RE, (m, p: string, offset: number, all: string) => {
+ const { name, known } = lookupShort(p, names, seen);
+ if (!known) return m;
+ return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…");
+ });
+ out = out.replace(CODE_RE, (m, code: string) => {
+ const r = reasonText(code);
+ return r ? note(m, r.text) : m;
+ });
+ return out;
+}
+
+/**
+ * 纯函数(单测覆盖):汇报正文里的 UUID / 能力 ID / 原因码 → 名称 / 人话。
+ * 代码块原样保留;行内代码里的内容被替换时去掉反引号,没有可换的保持原样;链接地址不动。
+ */
+export function humanizeReport(text: string, names: NameIndex = new Map()): HumanizeResult {
+ const replaced: HumanizeResult["replaced"] = [];
+ const seen = new Set([...text.matchAll(new RegExp(UUID, "gi"))].map((m) => m[0].toLowerCase()));
+ for (const id of names.keys()) seen.add(id);
+ const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`|\]\([^)\s]*\))/g);
+ const out: string[] = [];
+ parts.forEach((part, i) => {
+ const prior = out.join("");
+ if (i % 2 === 0) out.push(humanizePlain(part, names, seen, replaced, prior));
+ else if (part.startsWith("```") || part.startsWith("](")) out.push(part);
+ else {
+ const n = replaced.length;
+ const h = humanizePlain(part.slice(1, -1), names, seen, replaced, prior);
+ out.push(replaced.length > n ? h : part);
+ }
+ });
+ const cleaned = out
+ .join("")
+ // 换掉 ID 后留下的空括号、重复省略号
+ .replace(/[((]\s*[))]/g, "")
+ .replace(/…(?:\s*[、,,]\s*…)+/g, "…");
+ return { text: cleaned, replaced };
+}
+
+const NAME_KEYS = "title|name|label|displayName";
+/** `"id":"" … "title":"…"`(同一层对象里、下一个 id 之前) */
+const PAIR_RE = new RegExp(
+ `"(?:id|runId|accountId)"\\s*:\\s*"(${UUID})"(?:(?!"(?:id|runId)"\\s*:)[^{}])*?"(?:${NAME_KEYS})"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`,
+ "gi",
+);
+/** `"title":"…" … "id":""`(名字在前) */
+const PAIR_REV_RE = new RegExp(
+ `"(?:${NAME_KEYS})"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"[^{}]*?"id"\\s*:\\s*"(${UUID})"`,
+ "gi",
+);
+
+const unescapeJson = (s: string) => {
+ try {
+ return JSON.parse(`"${s}"`) as string;
+ } catch {
+ return s;
+ }
+};
+
+/**
+ * 从会话里的工具输入输出收集「ID → 名字」(作品、站点、订阅、待处理事项、资源…),
+ * 截断的 JSON 也能按字面配对。extra(子会话等)优先。
+ */
+export function collectNames(items: StreamItem[], extra: [string, string][] = []): NameIndex {
+ const names: NameIndex = new Map();
+ const add = (id: string, name: string) => {
+ const k = id.toLowerCase();
+ const v = unescapeJson(name).trim();
+ if (v && !names.has(k) && v.length <= 80) names.set(k, v);
+ };
+ for (const [id, name] of extra) add(id, name);
+ for (const it of items) {
+ if (it.kind !== "tool") continue;
+ for (const raw of [it.call.outputSummary, it.call.inputSummary]) {
+ if (!raw) continue;
+ for (const m of raw.matchAll(PAIR_RE)) if (m[1] && m[2]) add(m[1], m[2]);
+ for (const m of raw.matchAll(PAIR_REV_RE)) if (m[1] && m[2]) add(m[2], m[1]);
+ }
+ }
+ return names;
+}
diff --git a/apps/web/app/components/agentui/todo-list.tsx b/apps/web/app/components/agentui/todo-list.tsx
index f81dafc1..e929db60 100644
--- a/apps/web/app/components/agentui/todo-list.tsx
+++ b/apps/web/app/components/agentui/todo-list.tsx
@@ -99,6 +99,8 @@ export interface TodoListProps {
maxHeightClassName?: string;
emptyText?: ReactNode;
className?: string;
+ /** 表头计数(缺省「完成数/总数」);智能体步骤清单传「4/6 · 跳过 2」,与概况条同一口径 */
+ count?: string;
}
export function TodoList({
@@ -109,6 +111,7 @@ export function TodoList({
maxHeightClassName = "max-h-64",
emptyText = "还没有步骤",
className,
+ count,
}: TodoListProps) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
@@ -142,9 +145,7 @@ export function TodoList({
已完成 {done} / 共 {items.length}
-
- {done}/{items.length}
-
+ {count ?? `${done}/${items.length}`}