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:
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}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user