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}`}