fix(web): 智能体汇报展示层兜底与进度口径(FIX-7)

- 汇报正文里的 UUID、能力 ID、原因码展示时换成名称和人话,原文收进「正文原文」,历史不改
- 步骤进度统一 stepProgress:分子只数完成,跳过单独计(「4/6 · 跳过 2」)
- 进度行不再把失败的调用算进动作计数(3 次被拒的派发曾写成「派出了 5 个子会话」);
  实际结果行按会话树显示派出的子会话数
- 会话标题里的 ISO 时间与 download_failed 等码转人话

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 00:00:03 +08:00
1 parent 1b22bb5131
commit 417af9f253
9 files changed
+393 -27

No files matched your search

@@ -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 (
<div key={t.key} className={cn("w-full min-w-0", freshKeys.has(t.key) && anim)}>
<StreamingResponse
className="max-w-[72ch]"
status={streaming ? "streaming" : "complete"}
copyText={t.text}
copyText={human?.text ?? t.text}
announce={false}
showActions={false}
>
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
{streaming ? t.text : <Markdown text={t.text} />}
{human ? <Markdown text={human.text} /> : t.text}
</StreamingResponse>
{human?.replaced.length ? <ReplacedIds replaced={human.replaced} /> : null}
</div>
);
})}
@@ -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 (
<TechInfo
title="正文原文"
className="mt-1"
items={[
{
label: "换掉的标识",
value: (
<ul className="space-y-0.5">
{uniq.map((r) => (
<li key={r.from}>
{r.from} → {r.to || "(省略)"}
</li>
))}
</ul>
),
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({
<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" />}
<span className={cn("text-foreground/85", bad && "text-warning")}>实际结果:{resultText(report)}</span>
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
{u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
</p>
@@ -643,7 +681,7 @@ export function ResultLine({
items={[
{
label: "说明",
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务和整理记录。",
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。",
wide: true,
mono: false,
},
@@ -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");
});
});
@@ -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 (
<section aria-label="步骤进度" className="rounded-xl border border-border/70 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-meta">
@@ -67,11 +67,9 @@ export function RunPlan({
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
<span className="shrink-0 text-foreground tabular-nums">
步骤 {done}/{plan.steps.length}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"}
步骤 {progress.done}/{progress.total}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</span>
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
@@ -89,6 +87,7 @@ export function RunPlan({
<TodoList
title="步骤"
items={planTodoItems(plan.steps)}
count={progress.count}
defaultOpen
collapseOnComplete={!fill && !active}
maxHeightClassName={fill ? "max-h-none" : "max-h-56"}
@@ -39,7 +39,9 @@ import {
runPlanIds,
} from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.tsx";
import { shortGoalTitle } from "./agent-store.ts";
import { SubagentTree } from "./agent-subagent-tree.tsx";
import { stepProgress } from "./conversation-model.ts";
import {
NEW_RUN_COMMANDS,
PROPOSE_FIRST,
@@ -154,9 +156,8 @@ export function RunOverviewBar({
const [open, setOpen] = useState(false);
const r = snapshot.run;
const steps = live.plan?.steps ?? [];
const done = steps.filter((s) => 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")}
/>
<span className="shrink-0 text-foreground tabular-nums">
步骤 {done}/{steps.length}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{failed ? `失败:${failed.title}` : current ? `当前:${current.title}` : "全部完成"}
步骤 {progress.done}/{progress.total}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</span>
</>
) : (
<span className="min-w-0 flex-1 truncate text-muted-foreground">
@@ -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}%` }}
/>
</div>
) : null}
@@ -384,6 +383,12 @@ export function AgentRunPanel({
<AgentConversation
run={r}
live={live}
knownNames={[
...(run.data.children ?? []).map((c) => [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) => (
<ApprovalItem key={a.id} approval={a} />
))}
{finished ? <ResultLine run={r} report={report} live={live} /> : null}
{finished ? (
<ResultLine
run={r}
report={report}
live={live}
subagents={(run.data.children ?? []).filter((c) => c.parentRunId === r.id).length}
/>
) : null}
</>
}
/>
+16 -2
View File
@@ -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 "未命名会话";
@@ -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<ToolCallView, "capabilityId" | "status" | "outputSummary">[]): 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<ToolCallView, "capabilityId" | "stat
return groups.map(text).join(",");
}
// ── 步骤进度 ──
type StepLike = { status: "pending" | "running" | "done" | "skipped" | "failed"; title: string };
/**
* 步骤进度(概况条与展开的步骤清单共用一个口径):分子只数完成的步骤,跳过的单独计。
* 「4/6 · 跳过 2」;全部结束且有跳过时状态写「完成 4 · 跳过 2」,不再写「全部完成」。
* pct 按已结束(完成 + 跳过 + 失败)算进度条宽度。
*/
export function stepProgress(steps: readonly StepLike[]) {
const done = steps.filter((s) => 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<ToolCallView, "capabilityId">): string {
const a = activityOf(call.capabilityId);
@@ -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("全部完成");
});
});
@@ -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(`(?<![\\w.])(${CAP_IDS.join("|")})(?![\\w])(?:\\([^)\\n]*\\))?`, "g");
/** 完整 UUID,前面可带 `instance:` 这类前缀 */
const UUID_RE = new RegExp(`(?:\\b[a-z_]+:)?\\b(${UUID})\\b`, "gi");
/** 截断的 ID:`e6d79af3-…`、`0bd93cb0-…` */
const SHORT_TRUNC_RE = /\b([0-9a-f]{8})(?:-[0-9a-f]{0,4})*-?…/gi;
/** 裸 8 位十六进制:只有是已知 UUID 的前缀时才当 ID */
const SHORT_RE = /(?<![\w-])([0-9a-f]{8})(?![\w-])/gi;
/** 原因码:带下划线或冒号的小写码;单词码只认授权类(unauthorized / forbidden),其余单词可能是正文 */
const CODE_RE =
/(?<![\w.:/-])([a-z]+(?:_[a-z0-9]+)+(?::[a-z_]+)?|[a-z]+:[a-z]+(?:_[a-z0-9]+)+|unauthorized|forbidden)(?![\w-])/g;
export type NameIndex = Map<string, string>;
export type HumanizeResult = {
text: string;
/** 被替换的原文 → 显示的文字 */
replaced: { from: string; to: string }[];
};
const capTitle = (id: string) => (CAPABILITY_CATALOG as Record<string, { title?: string }>)[id]?.title ?? id;
function lookupShort(prefix: string, names: NameIndex, seen: Set<string>): { 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<string>,
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<string>([...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":"<uuid>" … "title":"…"`(同一层对象里、下一个 id 之前) */
const PAIR_RE = new RegExp(
`"(?:id|runId|accountId)"\\s*:\\s*"(${UUID})"(?:(?!"(?:id|runId)"\\s*:)[^{}])*?"(?:${NAME_KEYS})"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`,
"gi",
);
/** `"title":"…" … "id":"<uuid>"`(名字在前) */
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;
}
@@ -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({
<span className="sr-only">
已完成 {done} / 共 {items.length}
</span>
<span aria-hidden>
{done}/{items.length}
</span>
<span aria-hidden>{count ?? `${done}/${items.length}`}</span>
</span>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>