feat(chat): 支持思考块实时流式与历史展示 (#191)
* feat: 添加文件下载功能,支持多 Terminal Backend 实现基于 FileProvider 抽象的文件下载能力,支持 local、Docker、SSH、 Singularity 四种 backend。 主要变更: - 新增 FileProvider 接口及四种后端实现(含 SSH 命令注入防护) - 新增 GET /api/hermes/download 下载路由(含 MIME 类型检测) - 前端 Markdown 文件链接拦截下载 + 附件下载按钮 - 中英文 i18n 翻译 - 更新 README、CLAUDE.md 和设计文档 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: 添加文件浏览器与下载功能,支持目录浏览、文件编辑、预览和上传 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: add prepare script so 'npm install git+url' auto-builds dist/ Allows installing this package directly from git without a pre-built dist/. When cloned via npm, prepare runs 'npm run build' if dist/ is missing, producing the artifacts declared in the files[] field before packing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use clipboard fallback for non-secure HTTP contexts navigator.clipboard is undefined on HTTP intranet deployments (only available in secure contexts). The previous synchronous calls threw silently and the success toast still fired, making 'copy' actions appear broken. - Add packages/client/src/utils/clipboard.ts with execCommand fallback via a hidden textarea - Use the helper in FileContextMenu (copy file path), CodexLoginModal (copy user code), NousLoginModal (copy user code), ChatPanel (copy session id) - Each call now awaits the result and shows success/failure based on the actual outcome Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * i18n: backfill files/download translations for de, es, fr, ja, ko, pt Add nav.files, files.* (39 keys), and download.* (9 keys) so the file browser UI is fully localized in these six locales instead of falling back to English. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(files): close preview when navigating or affected file changes Opening a preview and then navigating directories, deleting the previewed file, or renaming it left the preview pane stuck on stale content because previewFile was never cleared. - stores/hermes/files.ts: - fetchEntries clears previewFile on path change (in-place refresh keeps the preview). - deleteEntry / renameEntry clear preview/editor state when the affected entry matches the previewed/edited file or its parent. - Add isAffected(target, changed, isDir) helper. - components/hermes/files/FilePreview.vue: replace the misleading common.cancel close button with a dedicated files.closePreview key plus an X icon and quaternary style. - i18n: add files.closePreview to all 8 locales. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: 清理已完成功能的计划与设计文档 文件浏览器与文件下载功能均已被上游合并,对应的开发计划 与设计稿不再需要在 fork 中保留: - plans/2025-07-20-file-browser.md - plans/2026-04-20-file-download.md - specs/2025-07-20-file-browser-design.md - specs/2026-04-20-file-download-design.md 清理后本 fork 与 upstream/main 代码层面完全对齐。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: 添加 thinking 块分离与折叠展示设计稿(#164) 针对上游 issue #164,设计 assistant 消息中 <think>/<thinking>/<reasoning> 标签的识别、分离与可折叠展示方案。 关键决策(经 rubber-duck 审查修订): - 不修改 Message.content 与持久化字段,确保 localStorage 向前兼容 - 耗时摘要改为纯运行时派生(store 内 Map),避免刷新/重连丢失 - 首版即实现代码块保护,避免误识别 - 流结束时未闭合标签降级为正文,防止吞答案 - 解析 computed 与 duration interval 分离,规避性能风险 - 解析器放置 packages/client/src/utils/ 避免反向依赖 - 显式不支持同名嵌套(罕见场景文档化) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: 添加 thinking 块分离与折叠实施计划(#164) 12 Task TDD 计划: - Task 1-7:utils/thinking-parser.ts 纯函数模块 + 单元测试 - Task 8-9:chat store thinkingObservation Map 接入 SSE - Task 10:8 语言 i18n 新增 6 条 key - Task 11:MessageItem.vue 渲染折叠 UI + SCSS - Task 12:构建/测试/手动验证/推送 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(thinking-parser): 首个闭合 <think> 标签拆分 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(thinking-parser): 覆盖多段/变体标签/大小写/空输入 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(thinking-parser): 流式 pending 与终止态降级 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(thinking-parser): 代码块保护避免误识别伪标签 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(thinking-parser): 同名嵌套与 chunk 边界行为 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(thinking-parser): countThinkingChars 辅助函数 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(thinking-parser): detectThinkingBoundary 边界检测 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(chat-store): 新增 thinkingObservation 运行时 Map Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(chat-store): message.delta 写入 thinking 边界 + switchSession 清理 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * i18n: 新增 thinking 块 6 条 key(8 语言) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(chat): MessageItem 渲染 thinking 折叠区 - 复用 tool-line 风格 chevron - 两条响应链:parse computed + duration interval - 流式+pending 强制展开 - show_reasoning 控制默认态 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(chat): 支持思考块实时流式与历史展示 - 扩展 Message 接口增加 reasoning 字段,mapHermesMessages 从 HermesMessage.reasoning 透传历史会话的思考内容。 - RunEvent 类型新增 text 字段,chat store 处理三个新 SSE 事件: reasoning.delta / thinking.delta / reasoning.available。 - 思考时长观察:仅在 reasoning.delta 累积时记录起始时间戳, reasoning.available 时记录结束时间戳;无实时 delta 时不显示时长。 - MessageItem 采用双源渲染(reasoning 字段优先,<think> 标签作 fallback),duration > 0 才展示耗时。 - 新增 3 条单测覆盖三个 SSE 事件;测试 32/32 通过。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(chat): reasoning 块不再短暂展示正文 根因:上游 hermes-agent run_agent.py:11275 在每次模型响应结束时用 assistant content[:500] 作为 reasoning.available 的 preview 负载, 致使 Web UI 把正文写入 last.reasoning,思考块短暂显示正文直到会话 轮询/刷新从 session DB 读回正确的 reasoning 字段。 修复: - reasoning.available 事件不再写入 last.reasoning,仅用于标记计时 结束(noteReasoningEnd);真实推理由 reasoning.delta 或会话 DB 提供 - 新增 scrubBuggyReasoningInCache:hydration 时治愈 localStorage 里 已被污染的 assistant 消息(reasoning == content 或前缀时丢弃) - 两个 cache 加载入口(loadSessions / switchSession)均接入 scrubber 测试:新增 4 条单测,全套 280/280 通过。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
# Think 块与正文分离、可折叠展示 — 设计稿
|
||||
|
||||
- **Issue**: 上游 #164 —【Feature】think 块与正文区分开
|
||||
- **日期**: 2026-04-23
|
||||
- **分支**: `feat/thinking-block-collapse`
|
||||
- **状态**: 设计稿(含 rubber-duck 审查反馈修订)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 assistant 回复中,思考链(reasoning/think)内容直接以 `<think>...</think>` 等原始标签形式嵌在 `Message.content` 里,由 `MarkdownRenderer` 原样渲染。用户反馈:
|
||||
|
||||
1. think 块与正文混在一起,大段文本难以快速定位正文;
|
||||
2. 正文输出完成后,think 内容无法单独收起/查看;
|
||||
3. 已存在的 `settings.display.show_reasoning` 开关目前未真正影响渲染。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- assistant 消息中,**识别并分离** think 块与正文;
|
||||
- think 块以**可折叠 header** 形式展示,复用项目已有的 `tool-line` 折叠样式;
|
||||
- 折叠 header 显示**字数摘要**,正在流式观察到的消息额外显示**观察到的耗时**;
|
||||
- 默认展开/收起由 `settings.display.show_reasoning` 控制,每条消息可独立覆盖(运行时 transient 状态);
|
||||
- 流式中未闭合标签**容错解析**;流结束时仍未闭合则**降级保留为正文**;
|
||||
- 代码块/内联 code 中出现的伪 `<think>` 标签**不识别**;
|
||||
- 不改动上游 SSE 协议、不修改 `Message.content`、不破坏 localStorage 旧数据。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不修改后端 gateway 协议,不新增 SSE 事件类型;
|
||||
- 不持久化 thinking 耗时摘要 — 历史/刷新后恢复的消息仅显示字数;
|
||||
- 不持久化每条消息的手动折叠状态 — transient,刷新后回默认;
|
||||
- 不支持同名嵌套标签(`<think><think>...</think>...</think>` 内层按纯文本处理)。
|
||||
|
||||
## 4. 识别规则
|
||||
|
||||
### 4.1 标签范围
|
||||
|
||||
正则匹配以下三类标签(大小写不敏感):
|
||||
|
||||
```
|
||||
<think>...</think>
|
||||
<thinking>...</thinking>
|
||||
<reasoning>...</reasoning>
|
||||
```
|
||||
|
||||
> 选择理由:覆盖 DeepSeek R1、GLM reasoner、通义 Qwen reasoning、Claude thinking 等主流推理模型。
|
||||
|
||||
### 4.2 代码块保护(首版必做)
|
||||
|
||||
解析前先将 markdown 代码块内容替换为占位符,避免误识别:
|
||||
|
||||
1. **Fenced code block**:匹配 `` ```lang\n...\n``` ``(含 `~~~` 变体);
|
||||
2. **Inline code**:匹配 `` `...` ``(单反引号,非转义);
|
||||
|
||||
替换为 `\u0000CODE_N\u0000` 占位符 → 对剩余文本执行 4.3 → 解析完成后把占位符原位还原回 `body` 与 `segments`(segments 内本不应出现 code,但为简单起见统一还原,不影响结果)。
|
||||
|
||||
### 4.3 解析算法
|
||||
|
||||
对剥离代码块后的一条 assistant `content`:
|
||||
|
||||
1. 非贪婪匹配所有 `<(think|thinking|reasoning)>[\s\S]*?</\1>`(大小写不敏感);
|
||||
2. 同名嵌套**不支持**:内层同名开始标签被外层 `</>` 先闭合吞掉;解析器不处理 dangling 内层 `</tag>`,会作为正文残留(罕见场景可接受);
|
||||
3. 若还残留一个**未闭合**的 `<think|thinking|reasoning>`:
|
||||
- **流式中**(调用方传 `streaming=true`)→ 从该标签起到末尾视为 `pending` thinking;
|
||||
- **非流式**(`streaming=false`)→ **降级**:视为正文保留(含标签字符原样),`pending=null`;
|
||||
4. 其余纯文本按顺序拼接为 `body`;
|
||||
5. 还原所有代码块占位符。
|
||||
|
||||
### 4.4 TypeScript 签名
|
||||
|
||||
文件位置:**`packages/client/src/utils/thinking-parser.ts`**(中性 utils 目录,避免 store → components 反向依赖)
|
||||
|
||||
```ts
|
||||
export interface ParsedThinking {
|
||||
/** 所有已闭合 thinking 片段纯文本(不含标签) */
|
||||
segments: string[]
|
||||
/** 流式中未闭合的 thinking;非流式时始终为 null */
|
||||
pending: string | null
|
||||
/** 正文(已剔除 thinking) */
|
||||
body: string
|
||||
/** 是否存在任何 thinking 内容(segments 非空 或 pending 非空) */
|
||||
hasThinking: boolean
|
||||
}
|
||||
|
||||
export function parseThinking(content: string, opts: { streaming: boolean }): ParsedThinking
|
||||
|
||||
/** 检测 content 从 prev 变到 next 期间,是否跨越了"首次出现开始/结束标签"的边界 */
|
||||
export function detectThinkingBoundary(prev: string, next: string): {
|
||||
startedAtBoundary: boolean
|
||||
endedAtBoundary: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
### 5.1 `Message.content` 保持不变
|
||||
|
||||
原始字符串原样存储 & 持久化。localStorage / sessions export 向前兼容。
|
||||
|
||||
### 5.2 不新增持久化字段
|
||||
|
||||
**采纳 rubber-duck #4 审查反馈**:不在 `Message` 接口上新增 `thinkingStartedAt/EndedAt` 字段。理由:
|
||||
|
||||
- `mapHermesMessages()` 只映射服务端已知字段,新字段会被刷新/重连覆盖丢失;
|
||||
- `switchSession` / `startPolling` / `refreshActiveSession` 会用 server 数据覆盖本地消息;
|
||||
- thinking 耗时的语义本就是"前端观察到的 wall-clock 时间",非模型真实思考时间,持久化反而误导。
|
||||
|
||||
### 5.3 运行时观察态(store 内 Map)
|
||||
|
||||
在 `stores/hermes/chat.ts` 新增:
|
||||
|
||||
```ts
|
||||
/** Map<messageId, { startedAt, endedAt }>,仅记录本次会话流式期间观察到的时间戳 */
|
||||
const thinkingObservation = reactive(new Map<string, { startedAt?: number; endedAt?: number }>())
|
||||
```
|
||||
|
||||
- 在 `message.delta` 事件处理中调用 `detectThinkingBoundary(prev, next)`,首次 started 写入 `startedAt`,首次 ended 写入 `endedAt`;
|
||||
- `run.completed` / `run.failed` 后不清除该 entry(以便流式结束后仍能展示"本次会话的观察耗时",直到用户刷新或切换会话);
|
||||
- `switchSession` 时清空 Map(跨会话不保留);
|
||||
- 历史消息、刷新后恢复的消息、polling 拉取的消息均**无** entry,不显示耗时,仅显示字数。
|
||||
|
||||
## 6. UI 设计
|
||||
|
||||
### 6.1 位置
|
||||
|
||||
assistant 气泡**内部顶部**,`MarkdownRenderer`(渲染 body)**之前**,独立渲染一个 thinking 折叠区。只有 `parsedThinking.hasThinking === true` 时才渲染。
|
||||
|
||||
### 6.2 视觉样式
|
||||
|
||||
复用现有 `tool-line` 折叠样式:
|
||||
|
||||
```
|
||||
▸ 💭 思考过程 · 412 字 (历史消息,仅字数)
|
||||
▸ 💭 思考过程 · 已观察 3s · 412 字 (本次会话流式完成的消息)
|
||||
▾ 💭 思考中… · 128 字 (流式进行中)
|
||||
```
|
||||
|
||||
展开后:
|
||||
|
||||
```
|
||||
▾ 💭 ...
|
||||
┌─────────────────────────
|
||||
│ thinking 内容(Markdown 渲染,
|
||||
│ 字体略小、弱对比色)
|
||||
└─────────────────────────
|
||||
```
|
||||
|
||||
新增 SCSS 类 `.thinking-block`,复用 `.tool-line` / `.tool-details` 的布局,文本弱化(opacity 0.85 + italic 可选)。
|
||||
|
||||
### 6.3 默认展开状态
|
||||
|
||||
- **流式进行中**(`message.isStreaming && parsedThinking.pending`)→ **强制展开**;
|
||||
- **非流式**:
|
||||
- `settings.display.show_reasoning === true` → 默认展开;
|
||||
- `settings.display.show_reasoning === false` → 默认收起;
|
||||
- 用户手动点击 chevron 切换后,以组件内 `ref<boolean | null>(null)` 记录覆盖态(null = 跟随默认)。**Transient**:刷新 / 切会话 / 重挂载后回默认。
|
||||
|
||||
### 6.4 Header 摘要计算
|
||||
|
||||
两条独立响应链避免性能问题(采纳 rubber-duck #7):
|
||||
|
||||
```ts
|
||||
// 仅依赖 content 变化,重解析
|
||||
const parsed = computed(() => parseThinking(message.content, { streaming: message.isStreaming }))
|
||||
|
||||
// 字数:Unicode 字符数
|
||||
const thinkingChars = computed(() => {
|
||||
const len = (s: string) => [...s].length
|
||||
return parsed.value.segments.reduce((a, s) => a + len(s), 0) + len(parsed.value.pending || '')
|
||||
})
|
||||
|
||||
// 耗时:仅活跃 streaming 消息开秒表;非活跃时取定值或不显示
|
||||
const observation = chatStore.getThinkingObservation(message.id) // 可能为 undefined
|
||||
const liveNowTick = /* useInterval(1000) 仅在 isStreaming 时启用 */
|
||||
const durationMs = computed(() => {
|
||||
if (!observation?.startedAt) return null
|
||||
const end = observation.endedAt ?? (message.isStreaming ? liveNowTick.value : observation.startedAt)
|
||||
return end - observation.startedAt
|
||||
})
|
||||
```
|
||||
|
||||
- 字数计算开销小,随 content 变化;
|
||||
- duration interval 仅在 `message.isStreaming && observation?.startedAt && !observation?.endedAt` 时启用,非活跃消息不耗 CPU。
|
||||
|
||||
### 6.5 终止态降级(采纳 rubber-duck #2)
|
||||
|
||||
当 SSE `run.completed` / `run.failed` 触发后:
|
||||
|
||||
- 消息 `isStreaming` 变为 `false`;
|
||||
- 解析时传入 `streaming: false`;
|
||||
- `parseThinking` 中未闭合的 `<think>` 不再视为 pending,**保留为正文的一部分**;
|
||||
- 避免"答案被永久折叠看不见"。
|
||||
|
||||
### 6.6 i18n 新增 key(8 语言)
|
||||
|
||||
```
|
||||
chat.thinkingLabel "思考过程" / "Thinking"
|
||||
chat.thinkingInProgress "思考中…" / "Thinking…"
|
||||
chat.thinkingShow "展开思考过程"
|
||||
chat.thinkingHide "收起思考过程"
|
||||
chat.thinkingDuration "已观察 {duration}" / "Observed {duration}"
|
||||
chat.thinkingChars "{count} 字" / "{count} chars"
|
||||
```
|
||||
|
||||
## 7. 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `packages/client/src/utils/thinking-parser.ts` | **新增** — 纯函数解析器 + 边界检测 |
|
||||
| `packages/client/src/components/hermes/chat/MessageItem.vue` | 新增 thinking 折叠区渲染,computed 拆分 |
|
||||
| `packages/client/src/stores/hermes/chat.ts` | 新增 `thinkingObservation` Map;`message.delta` 中写入边界;`switchSession` 清理;导出 `getThinkingObservation(messageId)` |
|
||||
| `packages/client/src/i18n/locales/{en,zh,de,es,fr,ja,ko,pt}.ts` | 新增 6 条 i18n key |
|
||||
| `tests/client/utils/thinking-parser.test.ts` | **新增** — 解析器单元测试 |
|
||||
| `tests/client/stores/chat-thinking-boundary.test.ts` | **新增** — 边界检测 / switchSession 清理测试 |
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
### 8.1 解析器(必测,覆盖边界)
|
||||
|
||||
- 单个闭合 `<think>...</think>` → segments=[...], body=''
|
||||
- 多个闭合片段按顺序
|
||||
- 未闭合 `<think>x`,`streaming=true` → pending='x'
|
||||
- 未闭合 `<think>x`,`streaming=false`(终止态降级)→ body 原样保留 `<think>x`,pending=null
|
||||
- `<thinking>` / `<reasoning>` 变体
|
||||
- 大小写变体 `<Think>`, `<REASONING>`
|
||||
- **同名嵌套** `<think>a<think>b</think>c</think>` → segments=['a<think>b'], body='c</think>'(明确文档化此行为)
|
||||
- **Fenced code block 保护** `\`\`\`\n<think>not real</think>\n\`\`\`` → 不识别
|
||||
- **Inline code 保护** `` `<think>` `` → 不识别
|
||||
- 空 content → hasThinking=false
|
||||
- 纯正文 → hasThinking=false, body 原样
|
||||
- Chunk 边界场景(前半 `<thin`,后半 `k>hi</think>`)→ 基于累积 content 正确解析
|
||||
|
||||
### 8.2 边界检测(必测)
|
||||
|
||||
- `detectThinkingBoundary('', '<think>hi')` → startedAtBoundary=true
|
||||
- `detectThinkingBoundary('<think>hi', '<think>hi</think>')` → endedAtBoundary=true
|
||||
- `detectThinkingBoundary('abc', 'abcdef')` → both false
|
||||
- 代码块里的伪标签不触发边界
|
||||
|
||||
### 8.3 Store 行为(必测)
|
||||
|
||||
- `message.delta` 首次出现开始标签 → `thinkingObservation` Map 写入 startedAt
|
||||
- 首次结束标签 → 写入 endedAt
|
||||
- `switchSession` → Map 清空
|
||||
- `refreshActiveSession` 覆盖消息后,Map 已写入的 entry 保留(即仅 switchSession 清理)
|
||||
|
||||
### 8.4 组件(若已有 Vue test-utils 基建)
|
||||
|
||||
- 无 thinking 时 `.thinking-block` 不渲染
|
||||
- `show_reasoning=true` 默认展开;`=false` 默认收起
|
||||
- 流式且有 pending 时强制展开(忽略 show_reasoning)
|
||||
- 点击切换不改设置
|
||||
- 有 observation 显示 duration,无 observation 仅显示字数
|
||||
|
||||
## 9. 兼容性与迁移
|
||||
|
||||
### 9.1 数据层(完全兼容)
|
||||
|
||||
- **`Message.content` 字段未变**:仍是原始字符串(含 `<think>...</think>` 等标签);
|
||||
- **`Message` 接口无新持久化字段**(采纳 rubber-duck #4);
|
||||
- **localStorage 旧数据**:无 schema 迁移,原样可读;
|
||||
- **Sessions export/import JSON 格式**:无变化;
|
||||
- **上游 hermes CLI `sessions export`**:读取的仍是 content 字符串,无副作用。
|
||||
|
||||
### 9.2 渲染行为变化(正是需求本身)
|
||||
|
||||
**升级到含本功能的版本后,老消息的视觉表现会发生变化**,这是功能预期效果:
|
||||
|
||||
| 场景 | 旧版渲染 | 新版渲染 |
|
||||
|---|---|---|
|
||||
| `<think>x</think>body` | think 标签原样出现在正文中(或被 Markdown 当作 HTML 忽略) | 识别为独立可折叠块 + 正文 |
|
||||
| 仅 `<think>x</think>` 无正文 | 整条消息显示 think 内容(含标签) | 仅显示折叠 thinking,正文为空 |
|
||||
| 代码块中演示 `<think>` 字面量 | 同样原样显示 | **不识别**,保持原样 |
|
||||
|
||||
**历史消息在新版下的限制**:
|
||||
|
||||
- 无 `thinkingObservation` entry → **不显示耗时**,header 文案降级为 `💭 思考过程 · X 字`;
|
||||
- 该限制是刻意设计:耗时语义为"本次会话前端观察到的 wall-clock 时间",历史消息无法回溯,显示任何数字都会误导。
|
||||
|
||||
### 9.3 边界情况
|
||||
|
||||
- **老消息中 `<think>` 未闭合**(极罕见,如旧版前端流式中断未完整保存)→ §6.5 终止态降级保留为正文,不会吞答案;
|
||||
- **同名嵌套 / 代码块伪标签 / chunk 边界** → §4 解析规则已明确处理。
|
||||
|
||||
### 9.4 未来扩展
|
||||
|
||||
若上游新增独立 `reasoning.delta` SSE 事件,可在 chat store 将 delta 单独拼接到 segments 虚拟字段,UI 层无需变动(解析器仍兼容标签形式)。
|
||||
|
||||
## 10. 风险与决议
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| 超长 content regex 性能 | `computed` 缓存;代码块替换 + 一次正则扫描级别 |
|
||||
| 代码块内伪标签误识别 | 首版即做代码块保护(4.2)|
|
||||
| 流式结束时标签未闭合,正文被吞 | 终止态降级(6.5)保留为正文 |
|
||||
| 嵌套标签误匹配 | 显式不支持,文档化;实际场景极罕见 |
|
||||
| 刷新后时间戳丢失 | 纯运行时派生,不持久化;历史消息仅显示字数 |
|
||||
| 多段 reasoning 的耗时失真 | 按"首个 started / 最后 ended"聚合;字数累加 |
|
||||
| duration 秒表造成非活跃消息 CPU 占用 | interval 只在 `isStreaming && hasStartedAt && !hasEndedAt` 时启动 |
|
||||
|
||||
## 11. 实施阶段(交给 writing-plans 细化)
|
||||
|
||||
1. 实现 `utils/thinking-parser.ts` + 解析器单测(TDD)
|
||||
2. 实现边界检测 + switchSession 清理 + store 单测
|
||||
3. `MessageItem.vue` 集成折叠 UI(两条响应链 + transient 状态)
|
||||
4. SCSS 复用 tool-line 样式
|
||||
5. i18n 8 语言
|
||||
6. 集成自测(DeepSeek R1 / GLM 真实对话)+ 手动验证刷新、切会话场景
|
||||
7. `npm run build` + `npm run test` 验证
|
||||
Reference in New Issue
Block a user