Files
writing-agent/PLAN.md
T
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

7.8 KiB
Raw Blame History

直聊(chat.html)思维链默认折叠,点击展开

Context

直聊页面的多模型回复卡(.chat-reply-card)把模型的思维链整段铺在正文上方,一张卡的可视高度被思维链吃掉,正文(真正要看的内容)被挤到下面。截图现场:盲测模式下的「模型 1」卡,头部下面整块浅色区域就是思维链,之后才是终端流水线正文。

目标:思维链收成一行折叠条,默认收起,点一下展开;流式期间模型只吐思考时自动展开,正文一开始到达就自动收起。

已定决策(用户)

  1. 流式阶段:自动展开,正文开始后自动收起(不是"始终保持折叠")。
  2. 折叠条文案:带字数,形如 思考过程 · 1234 字。
  3. 右上角「复制」:仍然包含思维链(replyPlainText 不动)。

现状(已确认)

  • web/chat.js
    • replyBodyHtml(body) §902-908:思维链 <div class="chat-bubble-thinking"> + 正文,永远展开。
    • renderReplyCard() §893 调用它;paintStreamingReply() §1848-1857 每帧 textEl.innerHTML = replyBodyHtml(body),任何展开状态都会被重渲染冲掉,所以展开状态必须存在模块级集合里,不能只留在 DOM。
    • 流式钩子:onDelta 收到 event.field === "thinking" 累加 reply.thinking,否则累加 reply.content(§1952-1956、§2112-2116)。
    • 卡片内状态集合的既有惯例:revealedCards、expandedCards 都是 Set<${turnId}:${windowId}>,key 用 cardExpandKey(turnId, windowId)(§449);移除模型时按 key.endsWith(`:${windowId}`) 清理(§2479-2484)。
    • 卡片点击是委托在 arena 上的单一大 handler(§2790-2880),expand-card / reveal-card 都在里面处理,reveal-card 走 applyCardReveal() 就地改 DOM 不重渲染。
    • 拖动滚动:pointerdown 里 e.target.closest("button, a, textarea, input") 才跳过(§1129),summary 不在名单里 → 点折叠条会先进入拖动状态。
  • web/chat.css
    • §1155 .chat-bubble-thinking:浅底、muted、12px、margin: 0 0 8px。改完即无引用,直接替换。
    • §815-836 多卡并排时卡片是固定高度、.chat-reply-card-text 内部滚动;单卡时高度 auto。
  • web/agent-ui.js §93 renderThinkingBlock() 是主对话的同类实现(<details class="msg-thinking">,默认折叠),样式在 web/styles.css §2948 —— 但直聊页不加载 styles.css,不能直接复用 class,只复用手法。
  • 无前端单测,tests/ 全是 src/ 的 vitest;验证靠手动跑页面。

Approach

  1. 状态:新增 const thinkingToggles = new Map();,key 同 cardExpandKey,value 是用户手动选择(true 展开 / false 收起)。

  2. 展开判定(纯函数,渲染与流式刷新共用):

    function thinkingOpenFor(key, body) {
      const manual = thinkingToggles.get(key);
      if (manual !== undefined) return manual;           // 用户点过就听用户的
      const hasThinking = Boolean(String(body.thinking ?? "").trim());
      return hasThinking && !String(body.text ?? "").trim(); // 只有思考、还没正文 → 自动展开
    }
    
    • 流式:只有 thinking → 自动展开;content 一有内容 → 自动收起(就是选择的方案 B)。
    • 加载历史/结束后既有思考又有正文 → 默认收起。
    • 用户在流式中点开过 → 一直保持展开(用户意图优先)。
  3. 渲染:replyBodyHtml(body, key) 产出

    <details class="chat-think" open?>
      <summary class="chat-think-summary">思考过程 · N 字</summary>
      <div class="chat-think-body">…renderMarkdownToHtml(thinking)…</div>
    </details>
    

    字数取 thinking.trim().length(中文字符数即字数,流式时随内容增长)。renderReplyCard 和 paintStreamingReply 都传 cardExpandKey(turnId, windowId)。

  4. 点击展开:在 arena 的委托 handler 里,expand-card 之前加一段——命中 summary.chat-think-summary 时 e.preventDefault(),读 details.open 取反,写回 thinkingToggles 并直接改 details.open(不整卡重渲染,不打断选中)。键盘 Enter/Space 在 summary 上也会派发 click,一并生效。

  5. 别被拖动吞掉:pointerdown 的排除名单加 summary。

  6. 样式:把 .chat-bubble-thinking 换成 .chat-think 一组(虚线边框/muted/12px,与 .msg-thinking 同气质但用 chat.css 的 --st-* 变量),summary 保留原生三角、cursor: pointer、user-select: none、hover 提亮;.chat-arena.is-copy-off 下 summary 仍保持 cursor: pointer。展开后正文不再受 12px 之外的额外限制(不额外给 max-height,避免卡片内嵌套滚动条和现有 wheel 逻辑打架)。

  7. 清理:removeWindow 的清理循环里一并删 thinkingToggles 的 :windowId 项;retryReplies 清掉该卡 thinking 时同步删掉对应 toggle,避免重试后残留"强制展开"。

Files to modify

  • web/chat.js — 状态集合、thinkingOpenFor、replyBodyHtml、renderReplyCard、paintStreamingReply、委托 handler、pointerdown 排除、removeWindow / retryReplies 清理
  • web/chat.css — .chat-bubble-thinking → .chat-think / .chat-think-summary / .chat-think-body,copy-off 下的 cursor 覆盖

不动:replyPlainText(复制仍含思维链)、export.js、agent-ui.js(主对话早已折叠)。

边界 / 已知取舍

  • 删掉唯一一轮回复或整段会话时 thinkingToggles 可能留残留 key —— 与现有 revealedCards 一致,且 key 带 turnId 不会串到别的轮次。
  • 多条并排时卡片是固定高度,展开思维链后内容在 .chat-reply-card-text 内滚动,不重算 collapseReplyTracks()(重算会把整行高度重新洗一遍,副作用更大)。「展开」按钮的可见性仍按折叠前的测量结果,可接受。
  • 思维链全为空 → 不渲染折叠条(行为同现状)。

Steps

  • web/chat.js:加 thinkingToggles Map、thinkingOpenFor()、thinkingSummaryText()
  • web/chat.js:replyBodyHtml(body, key) 改成 <details> 结构,字数进 summary
  • web/chat.js:renderReplyCard() 与 paintStreamingReply() 传 key
  • web/chat.js:委托 handler 处理 summary.chat-think-summary(preventDefault + 写 map + 就地开合)
  • web/chat.js:pointerdown 排除 summary;removeWindow / retryReplies 清理 map
  • web/chat.css:替换 .chat-bubble-thinking 为 .chat-think* 样式组
  • 手动回归:单卡 / 双卡、盲测、流式、复制、移除模型

Verification

  1. 语法自检:node --check web/chat.js(浏览器 ESM,仅查语法;或 npx esbuild web/chat.js --bundle=false --loader=js > $null)。
  2. npm run web 打开 http://localhost:<port>/chat.html(端口见 server 启动日志):
    • 普通对话,模型有思维链 → 卡片顶部只有一行 思考过程 · N 字,正文直接从顶部开始;点开后思维链以 markdown 展开、再点收起。
    • 打开浏览器 DevTools Network 慢速/用长思考模型,观察流式:正文未到时折叠条自动展开、文本在增长;第一段正文到达后自动收起;此时再手动点开应一直保持展开直到本轮结束、且不被后续流式刷新冲掉。
    • 双模型并排 + 盲测(「模型 1」标签):折叠/展开两张卡互不影响;展开后卡片内部可滚动,「展开/收起」按钮行为不变。
    • 右上角「复制」→ 粘贴检查内容仍含思维链 + 正文。
    • 「移除」某模型、「重试」失败卡片后,展开状态不串卡。
    • 关闭复制模式(copy-off)点折叠条仍能正常开合、不触发拖动。
  3. 刷新页面重新加载历史会话(thinking 由服务端持久化)→ 仍是默认折叠。