预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。

- 装配只认 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:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
2026-10-23 00:13:08 +08:00
parent ed09e5c831
commit 9dc927282f
94 changed files with 11558 additions and 413 deletions
+95
View File
@@ -0,0 +1,95 @@
# 直聊(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. **展开判定**(纯函数,渲染与流式刷新共用):
```js
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)` 产出
```html
<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
- [x] `web/chat.js`:加 `thinkingToggles` Map、`thinkingOpenFor()`、`thinkingSummaryText()`
- [x] `web/chat.js`:`replyBodyHtml(body, key)` 改成 `<details>` 结构,字数进 summary
- [x] `web/chat.js`:`renderReplyCard()` 与 `paintStreamingReply()` 传 key
- [x] `web/chat.js`:委托 handler 处理 `summary.chat-think-summary`(preventDefault + 写 map + 就地开合)
- [x] `web/chat.js`:`pointerdown` 排除 `summary`;`removeWindow` / `retryReplies` 清理 map
- [x] `web/chat.css`:替换 `.chat-bubble-thinking` 为 `.chat-think*` 样式组
- [x] 手动回归:单卡 / 双卡、盲测、流式、复制、移除模型
## 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` 由服务端持久化)→ 仍是默认折叠。