Skip to content

[Feature] asktool 转录工具行特化为「回答摘要卡」 #1482

Description

@Tioit-Wang

feat(transcript): 把 asktool 工具行特化为「回答摘要卡」

状态:待实现(未开工)
设计来源:asktool-row-demos/index.html 方案 A(回答摘要卡),已确认两项细节:选项默认全部可见、行头文案为 提问 · N 个问题 · 已作答 n / N
目标结构:asktool-row-demos/target-structure.html
建议决策号:D650(当前最大为 D649)
建议分支:feat/asktool-transcript-card


1. 现状(可复现)

一次 asktool 调用在对话列表(转录)里长这样:

调用  {"questions":[{"question":"你希望我优先优化哪一部分?","options":[{"label":"转录列表性能","descripti…
  └─ 输出
     └─ <pre>你希望我优先优化哪一部分?:工具行视觉\n---\n验收时想看到哪些内容?:本地 demo 预览、截图对比</pre>
  └─ questions
     └─ <pre>{ "questions": [ … 完整 JSON … ] }</pre>

链路与根因(均已在当前 main 上核对):

环节 位置 行为
工具语义 packages/agent-runtime/src/runtime.ts:473 ASK_TOOL_NAME = "asktool" 工具名固定为 asktool
参数形状 packages/shared/src/types/agent.ts:238 { questions: [{ question, options: Array<string | {label, description}>, multiSelect? }] }
结果文本 packages/shared/src/types/agent.ts:270 formatAskToolOutput() ${question}:${answers[i]?.join("、") ?? ""},多题用 \n---\n 连接;null = 该题被跳过或整单被拒绝
动作归类 apps/desktop/src/lib/tool-display.ts:96 getToolAction() asktool 落到 "use" → 行头文案 chat.toolUsed = “调用”(features/chat/transcript/shared.tsx:326)
行头摘要 apps/desktop/src/lib/tool-display.ts:177 getToolSummary() SUMMARY_KEYS.use 不含 questions → 回退 JSON.stringify(args) 压成单行、截断 220 字
展开体 apps/desktop/src/lib/tool-presentation.ts:694 buildToolPresentation() resultBlocks() 无 asktool 分支 → 落到 codeBlock("output", payload)(同文件 :680);且 wantArgs 因 action === "use" 为真,追加 recordBlocks(remaining, "input") → 数组里的对象再被 safeJson 成 JSON 代码块(同文件 :423)
渲染 apps/desktop/src/features/chat/transcript/ToolRow.tsx:173 HostToolRow → apps/desktop/src/components/ToolDetails.tsx 通用 “输出 / 输入” 两块
已有友好卡 apps/desktop/src/components/AskToolCard.tsx + docs/spec/04-ux/11-asktool-question-card.md 输入框上方的待答卡已经友好;转录里的历史行没有对应表达

问题归纳:

  1. 折叠态读不出“它问了我什么、我答了什么”,只有 220 字的 JSON 前缀。
  2. 展开态是两段 JSON + 一段用 \n---\n 拼的文本,问题/选项/我的回答混在一起,看不出答的是哪一题。
  3. “未作答 / 跳过 / 全部拒绝”在结果文本里都是空串,渲染后完全一样,无法区分。
  4. 行头图标是通用扳手(ToolActionIcon("use")),与“提问”语义无关。

2. 目标

转录里的 asktool 行变成一张只读的「回答摘要卡」:

  • 折叠态:提问 · 2 个问题 · 已作答 2 / 2(一眼知道问了几题、答了几题)。
  • 展开态:逐题一行 —— 题号 + 问题文本 + 我的回答(✓)+ 该题的全部选项(选中项高亮),自定义答案单独标注来源。
  • 不丢信息:解析失败、老消息、导入消息一律回退到现状呈现。

3. 已确认的设计决策

编号 决策 理由
A-1 展开体采用回答摘要卡(逐题一行),不使用选项卡片网格 与转录其它工具行同节奏,长会话里安静;选项仍在同一行内可见
A-2 选项默认全部可见(不做“其他选项 N 个”折叠) 回答要有上下文:能看见当时还有哪些选择
A-3 行头文案 提问 · {{total}} 个问题 · 已作答 {{answered}} / {{total}};未作答时 · 未作答;运行中 · 等待回答 语序即结论,不需要额外 chip
A-4 只读:转录卡不提供再次提交 唯一提交面仍是 AskToolCard(输入框停靠区),避免二次提交与状态歧义
A-5 解析失败 → 回退现状(输出 + questions 两个代码块) 不引入“信息黑洞”风险

| A-6 | 卡片卡内滚动兜底:.asktool-history { max-height: min(320px, 45vh); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable } | 超长内容不许把转录撑成几屏;.tool-row-content(Bash 输出)已有 260px 上限的先例,问答卡给 320px |
| A-7 | 单个超长文本(> 2000 字)不裁剪、不分页,改用限高 + 渐隐遮罩 + 可滚动 + 键盘可聚焦(tabindex="0" + role="group" + aria-label) | 信息必须完整;LargeTextPreview 是分页组件(chat.largeTextPage = “第 N / M 部分”),为几十 KB 的 Bash 输出设计,问答卡里分页阅读是错的 |
| A-8 | 选项不设数量上限(题干“全部可见”的自然结论),高度交给 A-6 的卡内滚动 | 一旦设上限就必须隐藏信息,与 A-5/R5“不丢信息”冲突;极端数量(单题 > 50 项)先观察,出现真实问题再加收口 |
| A-9 | 窄宽(≤ 720px):选项允许换行、卡片内边距收到 8px 10px;工作面板最窄 320px 下不得出现横向滚动 | 工作面板宽度可拖到 320px,长英文/URL 类选项极易溢出 |

4. 详细设计

4.1 新增纯函数模块 apps/desktop/src/lib/ask-tool-history.ts

import type { AskToolQuestion } from "@pi-desktop/shared";
import type { ToolPresentationMessage } from "./tool-presentation";

/** asktool 行:题目恒来自调用参数,答案恒来自结果文本。 */
export type AskToolHistory = {
  questions: AskToolQuestion[];
  /** 与 questions 等长;null = 该题被跳过或整单被拒绝。 */
  answers: Array<string[] | null>;
  /** 调用仍在进行(结果文本还没到)。 */
  pending: boolean;
};

export function isAskToolName(toolName?: string): boolean;
export function askToolHistory(message: ToolPresentationMessage): AskToolHistory | null;

/** 行头文案选择(纯函数返回 i18n key + 参数,翻译留在渲染层)。 */
export function askToolHeadline(history: AskToolHistory, status?: string):
  | { key: "chat.askToolRow.summaryPending"; params: { total: number } }
  | { key: "chat.askToolRow.summaryUnanswered"; params: { total: number } }
  | { key: "chat.askToolRow.summaryAnswered"; params: { total: number; answered: number } };

解析规则(每条都要有对应用例):

  1. 名字判定:复用 tool-display.ts 里已有的 bareToolName()(需从私有函数改为导出),去掉 provider / plugin 前缀后等于 asktool 才算;因此 plugin:asktool 之类不会被误判,plugin_asktool 也不会命中非 asktool 工具。
  2. 题目:读 message.toolArgs.questions,逐条用 normalizeAskToolOption()(packages/shared/src/types/agent.ts:211,已存在)归一化选项,丢弃非法项;有效题目 ≥ 1 才继续,否则返回 null(例如流式中的半截 JSON)。
  3. 结果文本:envelopeTextOf(message)(tool-presentation.ts:178,已存在,能同时处理裸字符串与 {content, details} 信封)→ 取不到就 pending = true,answers 全 null。
  4. 逐题匹配:文本先 replace(/\r\n/g, "\n"),按 \n---\n 切成段;对第 i 题,必须存在一段以 `${question}:` 开头(startsWith,不能 split(":") —— 题目文本本身允许含全角冒号),取其后的子串作为答案。
    • 子串为空 → answers[i] = null(跳过 / 拒绝)。
    • 否则 answers[i] = 子串.split("、")(与 formatAskToolOutput 的 join("、") 严格对称)。
    • 任何一题匹配不到 → 整体返回 null(宁可回退,也不要渲染半截卡片)。
  5. 长度校验:answers.length === questions.length,否则 null。
  6. 自定义答案无需额外字段:!questions[i].options.some(o => label(o) === value) 即为自定义(渲染层判定,避免解析器承担展示语义)。

4.2 展示块 apps/desktop/src/lib/tool-presentation.ts

  • ToolBlockRole 增加 "ask"。
  • ToolBlock 增加一个成员:
| (BlockBase & {
    kind: "ask";
    questions: AskToolQuestion[];
    answers: Array<string[] | null>;
  })
  • buildToolPresentation():在现有 resultBlocks() 调用之前/之后加早返回(与 delegate 的 mapped 思路一致):
const ask = isAskToolName(message.toolName) ? askToolHistory(message) : null;
if (ask) return [{ kind: "ask", role: "ask", questions: ask.questions, answers: ask.answers }];

这条早返回同时关掉了 wantArgs(action === "use")追加的裸 JSON —— 这正是本次的核心收益。解析为 null 时完全走原路径,零行为变化。

  • hasToolDetails() 不需要改:ask 块非空即可展开,caret 自然出现。

4.3 行头 apps/desktop/src/features/chat/transcript/ToolRow.tsx

在 HostToolRow 里,与 lifecycle / roster 同一层:

const askHistory = isAskToolName(message.toolName) ? askToolHistory(message) : null;
const askHead = askHistory ? askToolHeadline(askHistory, status) : null;
const rawName = ...;                                   // 不变
const summary = askHead ? t(askHead.key, askHead.params) : lifecycle ? rosterSummary : argSummary;
const headLabel = askHead ? t("chat.askToolRow.name") : actionLabel;   // "提问" 取代 “调用”
  • 图标、aria-label、status-* class、ToolChips、折叠/展开机制全部不动:图标保持 ToolActionIcon("use")(本次不引入新图标,避免连带影响其它 use 类工具)。
  • argSummary 仍然计算,只是 asktool 行不使用它,其它工具行零影响。
  • 不给 ask 行加 chip(A-3 的文案已表达状态),避免一个状态说两遍。

4.4 卡片组件 apps/desktop/src/components/AskToolHistoryCard.tsx(新增)

放在 components/ 而不是 features/chat/transcript/:它由 components/ToolDetails.tsx 渲染,与 PermissionCard 共用同一层;但不复用 AskToolCard.tsx(那是待答交互卡,含提交状态机)。

export function AskToolHistoryCard({
  questions,
  answers,
}: { questions: AskToolQuestion[]; answers: Array<string[] | null> }) { … }
  • packages/shared 已有 askToolOptionLabel / askToolOptionDescription,直接用来归一化展示。
  • 问题文本复用 AskToolRichText(components/AskToolRichText.ts):composer 卡已经允许题目/选项里的 Markdown,转录卡必须同样渲染,否则同一个问题两处显示不同。
  • 全部为静态节点(<ol>/<li>/<p>/<span>),没有按钮、没有 aria-pressed/role="radio" —— 只读语义,键盘只需要折叠开关。

4.5 apps/desktop/src/components/ToolDetails.tsx

  • BLOCK_LABEL_KEYS 增加 ask: "chat.askToolRow.heading"。
  • blockCopyText() 增加 case "ask":返回 formatAskToolOutput(questions, answers.map(a => a ?? []))(packages/shared 已有函数),即“复制后能贴回给模型”的那份文本。
  • BlockBody() 增加 case "ask": return <AskToolHistoryCard ... />。

4.6 样式 apps/desktop/src/styles/asktool-history.css(新增,由组件 import)

只用既有 --ds-* 令牌,不新增颜色常量(pnpm --filter @pi-desktop/desktop lint 会跑 scripts/check-style-tokens.mjs)。取色与 composer 卡的已选态严格一致:

  • 卡片底 --ds-tile;选项底 --ds-tile-deep
  • 选中项 color-mix(in oklab, var(--ds-accent) 10%, var(--ds-tile-deep)),文字升到 --ds-text-primary
  • 勾标记 15×15、--radius-2xs,选中底色 color-mix(in oklab, var(--ds-accent) 18%, transparent)
  • 自定义答案 chip:1px dashed var(--ds-border-strong),选中时改实线
  • 字号梯级:问题 --text-md(13px)/medium,回答与选项 --text-sm-plus(12.5px),来源标记与题号 --text-xs(11px)
  • 完整可复制 CSS 见 target-structure.html 的第 3 节

4.7 i18n(9 个 locale:de, en, es, fr, ko, pt-BR, tr, zh-CN, zh-TW)

packages/i18n/src/locales/<locale>/index.ts 的 chat 段新增 askToolRow:

key zh-CN en
chat.askToolRow.name 提问 Asked
chat.askToolRow.summaryAnswered {{total}} 个问题 · 已作答 {{answered}} / {{total}} {{total}} questions · answered {{answered}} / {{total}}
chat.askToolRow.summaryUnanswered {{total}} 个问题 · 未作答 {{total}} questions · unanswered
chat.askToolRow.summaryPending {{total}} 个问题 · 等待回答 {{total}} questions · waiting for an answer
chat.askToolRow.heading 问答 Questions
chat.askToolRow.kindSingle 单选 Single choice
chat.askToolRow.kindMulti 多选 Multiple choice
chat.askToolRow.answerCustom 输入的其他答案 Typed answer
chat.askToolRow.answerSkipped 已跳过 Skipped
chat.askToolRow.answerDeclined 已全部拒绝 Declined
chat.askToolRow.answerPending 等待回答 Waiting
  • 复制按钮沿用现有 chat.copy / chat.copied。
  • en 若要求严格复数,按仓库既有 queued_one / queued_other 的写法补 _one / _other 变体(其余语言可只用单形)。

4.8 测试 apps/desktop/test/asktool-transcript-card.test.mjs(新增)

按仓库既有模式(node --test + helpers/ts-import-hooks.mjs 直接 import TS,renderToStaticMarkup 渲染组件,参考 test/plan-history.test.mjs、test/asktool-rich-options.test.mjs):

# 用例 断言
T1 两题全答(含一个自定义答案) askToolHistory() 返回 2 题;answers[0] = 自定义文本;answers[1] = 两个选项值
T2 第二题跳过 answers[1] === null;头部走 summaryAnswered(answered = 1)
T3 整单拒绝 两个 null → 头部走 summaryUnanswered;卡片回答行显示 answerDeclined
T4 题目文本内含全角冒号 仍能正确切出答案(startsWith 匹配,不 split(":"))
T5 结果文本被截断 / 与题目对不上 返回 null,且 buildToolPresentation() 回到 输出 + input 两个块(回归保护)
T6 toolName: "Read" / "plugin:other" isAskToolName() 为假,其它工具行零变化
T7 运行中(无结果文本,questions 已完整) pending === true,头部 summaryPending,卡片回答行显示 answerPending
T8 渲染 卡片里 .asktool-history-item 数 == 题目数;.asktool-history-option.is-selected 数 == 答案值总数;展开体 不含 "questions" 裸 JSON;<pre> 数与题目数不相等(确认没有走代码块路径)
T9 只读语义 卡片内 querySelector("button, input, [role=radio], [role=checkbox]") 为空
T10 超长题目(> 2000 字) isLongText() 为真;该题文本节点带 is-long + tabindex="0" + role="group" + aria-label;文本内容完整(textContent.length 等于原串长度,证明没有截断);padding-bottom: 18px,滚到底后最后一行不被渐隐遮住(可用 Range.getBoundingClientRect().bottom 与容器 bottom - 18 比较)
T11 30 个选项的题 30 个 .asktool-history-option 全部渲染(不设上限),且 .asktool-history 的 scrollHeight > clientHeight、scrollWidth <= clientWidth(只有纵向滚动)
T12 长 token 不溢出 选项/题目含 80 字符无空格英文串时,scrollWidth <= clientWidth(overflow-wrap: anywhere 生效)
T13 窄宽 320px 容器宽度设 320px 后 .asktool-history 仍无横向滚动;选项 white-space 计算值为 normal(命中 @media (max-width: 720px))

4.9 规格与决策(行为可见变更,必须同步)

  • docs/spec/04-ux/11-asktool-question-card.md + docs/zh-CN/spec/04-ux/11-asktool-question-card.md:新增「转录中的问答卡」一节 —— 只读、逐题一行、选项默认全部可见、行头文案、pending/skipped/declined 三种措辞、解析失败回退。
  • docs/spec/08-meta/decisions-log.md(英文表格式,追加一行 | D650 | … |)+ docs/zh-CN/spec/08-meta/decisions-log.md(中文日期式小节)。
  • 可选:packages/shared/src/changelog*.ts(9 个语言文件)在进入发布版本时追一行 UI 改进文案。

4.10 超长内容展示(验收重点)

「友好卡片」最容易在长度上翻车:一屏一行工具行变成一屏一张卡。规则如下(全部为纯 CSS,不引入新的 JS 状态):

/* 1) 卡内滚动兜底:内容再长,转录行高度也有上限。 */
.asktool-history {
  max-height: min(320px, 45vh);
  overflow: auto;
  overscroll-behavior: contain;   /* 滚动不穿透到转录 */
  scrollbar-gutter: stable;       /* 出滚动条时不跳宽 */
}

/* 2) 单个超长文本:限高 + 底部渐隐,聚焦后可用键盘滚动读完。
 *    padding-bottom 必须 >= 渐隐带高度(18px),否则滚到底时最后一行被遮罩压住。 */
.asktool-history-question.is-long,
.asktool-history-answer-text.is-long {
  max-height: 12rem;
  overflow: auto;
  padding-bottom: 18px;
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent);
}

/* 3) 长 token(URL / 长英文串 / 无空格中文混排)必须能断行。 */
.asktool-history-question,
.asktool-history-answer-text,
.asktool-history-option { overflow-wrap: anywhere; }

/* 4) 窄宽:选项换行 + 收边距。 */
@media (max-width: 720px) {
  .asktool-history { padding: 8px 10px 10px; }
  .asktool-history-option { white-space: normal; }
}

判定 .is-long 的阈值:文本 length > 2000 或含 ≥ 8 个换行 —— 放在 AskToolHistoryCard 内部的一个纯函数里(isLongText(text)),便于单测;阈值收紧/放宽只改一处。

配套说明(写进规格,避免后人“顺手优化”掉):

  • 不做 line-clamp 截断:正文一律完整渲染,长度问题只由“限高 + 滚动”解决;截断会违反 R5。
  • 不引入 LargeTextPreview:它是分页组件(apps/desktop/src/components/LargeTextPreview.tsx,chat.largeTextPage / largeTextPagination 系列文案),面向 Bash 的几十 KB 输出;问答分页会让“第 1/3 部分”出现在一个问题中间。
  • 不在卡内做 <details> 展开:保持“零提交控件 + 零状态”,长内容用滚动读完;<details> 会带来键盘焦点顺序与只读语义的额外争议。
  • 运行中不自动滚动:ToolRow 里现有的自动滚到底只作用于 .tool-row-content(status === "running"),与本卡的手动滚动互不影响;历史卡一旦自动滚动会打断用户阅读。
  • 多题不设上限:题与题之间的分隔线(border-top)保证长列表里仍能分辨每题边界。
  • 渐隐带必须留出等高 padding-bottom:mask-image 把底部 18px 渐变到透明,若滚动容器没有 padding-bottom: 18px,滚到底时最后一行会停在渐隐带里(读到半行就没了)。这不是可选的“美化参数”,而是可事后回归的正确性条件 —— 见 R12 / T10,反例与正例并在 asktool-row-demos/target-structure.html §7.2 对照。
  • 渐隐带只在 .is-long 上出现:普通长度的问答(占绝大多数)不加遮罩,避免“短文本也被淡掉尾巴”的观感。
  • 滚动容器不嵌套陷阱:.asktool-history 与 .is-long 各有一层滚动,滚轮在 .is-long 上先滚它自己、到底后由 overscroll-behavior: contain 交给父层(不会滚到转录外层)。

5. 验收标准

  • R1 展开后不再出现裸 JSON(除非解析失败回退)。
  • R2 折叠态文案为 提问 · 2 个问题 · 已作答 2 / 2(状态不同则按 A-3 的三种变体)。
  • R3 展开态每题一行:题号 + 问题(Markdown 渲染)+ 回答(✓)+ 全部选项(选中高亮、自定义来源标注)。
  • R4 四种状态可视区分:运行中 / 已作答 / 部分跳过 / 全部拒绝。
  • R5 解析失败、老会话、导入消息 → 回退现状;不抛错、不丢字段。
  • R6 只读:卡片内无按钮/表单控件;折叠开关仍可键盘操作;aria-label 与工具行其它行保持同构。
  • R7 窄宽(转录 320px)无横向滚动、无裁剪;长选项自动换行。
  • R8 pnpm --filter @pi-desktop/desktop typecheck、pnpm -r --if-present test、pnpm lint、pnpm --filter @pi-desktop/desktop test 全绿;apps/desktop/test/asktool-transcript-card.test.mjs 通过。
  • R9 9 个 locale key 齐备、de/en/es/fr/ko/pt-BR/tr/zh-CN/zh-TW 无遗漏(漏 key 会在 zh-CN 之外显示英文 fallback)。
  • R10 规格与决策日志已同步(§4.9)。
  • R11 超长内容不把转录行撑长:.asktool-history 高度不超过 min(320px, 45vh),超出部分在卡内滚动(overscroll-behavior: contain)。
  • R12 单个 > 2000 字的问题/答案不截断、可键盘聚焦并滚动读完;底部渐隐提示还有内容,且 padding-bottom ≥ 渐隐带高度(18px),滚到底时最后一行不被遮罩压住。
  • R13 选项数量不设上限,全部可见且只产生纵向滚动;长 token(URL / 无空格长串)不横向溢出。
  • R14 320px 宽下无横向滚动、无裁剪;选项标签可换行。

6. 影响面与风险

风险 等级 应对
结果文本解析脆弱(题目含 :、、 混用、截断) 中 startsWith 前缀匹配 + 长度校验 + 任一题失败即整体回退;T4/T5 覆盖
运行中参数是半截 JSON 低 有效题目 < 1 直接回退到现有 JSON 摘要;≥1 时头部显示 “等待回答”
超长选项文本撑高卡片 低 选项 chip 允许换行、不加 max-height;问题/选项 Markdown 复用 AskToolRichText 的既有安全边界(不产生链接/图片/原始 HTML)
子智能体内被调用的 asktool 行 低 同一渲染路径,自动生效;Task 拓扑行(variant === "topology")不进入特化分支,保持 host row
插件声明的 toolCard 槽位 低 ToolRow 里 useSlotEntryForKey("toolCard", toolName) 判断在 host row 之前,保持不动;asktool 不是插件工具,不受影响
与 composer 卡重复/冲突 低 转录卡严格只读,不持有 draft 状态,不发 resolveAsk

7. 非目标

  • 不改工具协议、参数/结果形状、IPC、事件、存储(纯渲染层特化)。
  • 不改 AskToolCard.tsx(输入框停靠区)的交互与视觉。
  • 不改其它工具行的动作归类、图标、摘要策略。
  • 不为 asktool 增加专用图标(如需另开 issue)。
  • 不适配 Task 拓扑行 / 权限卡。

8. 参考

  • 视觉参考:asktool-row-demos/index.html(页签「A · 问答摘要卡」;「现状(对照)」页签即本文 §1)
  • 目标结构:asktool-row-demos/target-structure.html
  • 既有规格:docs/spec/04-ux/11-asktool-question-card.md
  • 设计评审产物(本地产物,未入库,实现者可按需入库或忽略):
    • asktool-row-demos/index.html:四方案对比 demo(A/B/C/D + 现状对照;A 为最终选择)
    • asktool-row-demos/target-structure.html:目标 DOM 结构 + 可复制 CSS + 状态矩阵 + 超长内容压力用例;§7.2 是「渐隐吃掉最后一行」的错误 / 正确对照(两卡内容相同、都已被脚本滚到最底,左卡 clearance = -16px、右卡 +1px),可作为实现时的可视判据。
    • 若要让它们随实现一起入库,建议提交到 docs/design/asktool-transcript-card/。
  • 既有实现:apps/desktop/src/components/AskToolCard.tsx、apps/desktop/src/lib/tool-presentation.ts、apps/desktop/src/components/ToolDetails.tsx、apps/desktop/src/features/chat/transcript/ToolRow.tsx

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions