Files
Hermes-ui/docs/superpowers/plans/2026-04-23-thinking-block-collapse.md
T

1139 lines
33 KiB
Markdown
Raw Normal View History

# Think 块与正文分离、可折叠展示 — 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 识别 assistant 消息中 `<think>/<thinking>/<reasoning>` 标签,分离为可折叠思考块展示,不破坏历史数据。
**Architecture:** 新增纯函数解析器位于 `utils/`chat store 用运行时 Map 记录流式观察到的时间戳;`MessageItem.vue` 用两条独立响应链(parsed computed + duration interval)渲染折叠区。
**Tech Stack:** Vue 3 Composition API, TypeScript (strict), Pinia, Naive UI, Vitest, SCSS。
**Spec:** `docs/superpowers/specs/2026-04-23-thinking-block-collapse-design.md`
---
## 文件结构
| 路径 | 角色 |
|---|---|
| `packages/client/src/utils/thinking-parser.ts` | **新建** 纯函数:`parseThinking``detectThinkingBoundary``countThinkingChars` |
| `packages/client/src/stores/hermes/chat.ts` | **修改** 新增 `thinkingObservation` Map + getter + `switchSession` 清理 + `message.delta` 边界写入 |
| `packages/client/src/components/hermes/chat/MessageItem.vue` | **修改** 新增 `.thinking-block` 渲染区 + 两条 computed/interval |
| `packages/client/src/i18n/locales/{en,zh,de,es,fr,ja,ko,pt}.ts` | **修改** 新增 6 条 `chat.thinking*` key |
| `tests/client/thinking-parser.test.ts` | **新建** 解析器单测 |
| `tests/client/chat-store-thinking.test.ts` | **新建** store 观察态单测 |
---
## Task 1: 解析器骨架 + 第一个闭合标签测试
**Files:**
- Create: `tests/client/thinking-parser.test.ts`
- Create: `packages/client/src/utils/thinking-parser.ts`
- [ ] **Step 1.1: 写首个失败测试(单个闭合 think)**
```ts
// tests/client/thinking-parser.test.ts
import { describe, it, expect } from 'vitest'
import { parseThinking } from '@/utils/thinking-parser'
describe('parseThinking', () => {
it('splits a single closed <think> block from body', () => {
const r = parseThinking('<think>inner</think>body', { streaming: false })
expect(r.segments).toEqual(['inner'])
expect(r.body).toBe('body')
expect(r.pending).toBeNull()
expect(r.hasThinking).toBe(true)
})
})
```
- [ ] **Step 1.2: 运行测试确认失败**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: FAIL — `Cannot find module '@/utils/thinking-parser'`
- [ ] **Step 1.3: 实现最小骨架**
```ts
// packages/client/src/utils/thinking-parser.ts
export interface ParsedThinking {
segments: string[]
pending: string | null
body: string
hasThinking: boolean
}
export interface ParseOptions {
streaming: boolean
}
const TAG_RE = /<(think|thinking|reasoning)>([\s\S]*?)<\/\1>/gi
export function parseThinking(content: string, opts: ParseOptions): ParsedThinking {
const segments: string[] = []
let pending: string | null = null
let body = ''
let lastIndex = 0
TAG_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TAG_RE.exec(content)) !== null) {
body += content.slice(lastIndex, m.index)
segments.push(m[2])
lastIndex = m.index + m[0].length
}
const rest = content.slice(lastIndex)
const openRe = /<(think|thinking|reasoning)>([\s\S]*)$/i
const openMatch = rest.match(openRe)
if (openMatch) {
body += rest.slice(0, openMatch.index)
if (opts.streaming) {
pending = openMatch[2]
} else {
body += rest.slice(openMatch.index!)
}
} else {
body += rest
}
return {
segments,
pending,
body,
hasThinking: segments.length > 0 || pending !== null,
}
}
```
- [ ] **Step 1.4: 运行测试确认通过**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: PASS (1 test)
- [ ] **Step 1.5: 提交**
```bash
git add tests/client/thinking-parser.test.ts packages/client/src/utils/thinking-parser.ts
git commit -m "feat(thinking-parser): 首个闭合 <think> 标签拆分
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 2: 解析器:多段、变体标签、大小写、空输入
**Files:**
- Modify: `tests/client/thinking-parser.test.ts`
- [ ] **Step 2.1: 追加测试**
```ts
it('collects multiple closed blocks in order', () => {
const r = parseThinking('<think>a</think>mid<thinking>b</thinking>end', { streaming: false })
expect(r.segments).toEqual(['a', 'b'])
expect(r.body).toBe('midend')
})
it('supports <thinking> and <reasoning> variants', () => {
const r = parseThinking('<reasoning>r</reasoning>body', { streaming: false })
expect(r.segments).toEqual(['r'])
expect(r.body).toBe('body')
})
it('is case-insensitive on tag names', () => {
const r = parseThinking('<Think>x</Think><REASONING>y</REASONING>z', { streaming: false })
expect(r.segments).toEqual(['x', 'y'])
expect(r.body).toBe('z')
})
it('returns hasThinking=false and body unchanged for plain text', () => {
const r = parseThinking('hello world', { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe('hello world')
expect(r.segments).toEqual([])
})
it('returns hasThinking=false for empty content', () => {
const r = parseThinking('', { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe('')
})
```
- [ ] **Step 2.2: 运行**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: PASS (6 tests)
- [ ] **Step 2.3: 提交**
```bash
git add tests/client/thinking-parser.test.ts
git commit -m "test(thinking-parser): 覆盖多段/变体标签/大小写/空输入
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 3: 流式未闭合 + 终止态降级
**Files:**
- Modify: `tests/client/thinking-parser.test.ts`
- [ ] **Step 3.1: 追加测试**
```ts
it('treats trailing unclosed tag as pending when streaming', () => {
const r = parseThinking('body<think>in-progress', { streaming: true })
expect(r.pending).toBe('in-progress')
expect(r.body).toBe('body')
expect(r.segments).toEqual([])
expect(r.hasThinking).toBe(true)
})
it('degrades trailing unclosed tag to body when NOT streaming (terminal state)', () => {
const r = parseThinking('body<think>orphan', { streaming: false })
expect(r.pending).toBeNull()
expect(r.body).toBe('body<think>orphan')
expect(r.segments).toEqual([])
expect(r.hasThinking).toBe(false)
})
it('combines closed segments with trailing pending (streaming)', () => {
const r = parseThinking('<think>done</think>mid<thinking>now', { streaming: true })
expect(r.segments).toEqual(['done'])
expect(r.pending).toBe('now')
expect(r.body).toBe('mid')
})
```
- [ ] **Step 3.2: 运行 + 提交**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: PASS (9 tests)
```bash
git add tests/client/thinking-parser.test.ts
git commit -m "test(thinking-parser): 流式 pending 与终止态降级
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 4: 代码块保护(fenced + inline
**Files:**
- Modify: `tests/client/thinking-parser.test.ts`
- Modify: `packages/client/src/utils/thinking-parser.ts`
- [ ] **Step 4.1: 写失败测试**
```ts
it('does NOT recognize <think> inside fenced code block', () => {
const src = 'before\n```\n<think>fake</think>\n```\nafter'
const r = parseThinking(src, { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe(src)
})
it('does NOT recognize <think> inside tilde-fenced code block', () => {
const src = '~~~\n<think>fake</think>\n~~~'
const r = parseThinking(src, { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe(src)
})
it('does NOT recognize <think> inside inline code', () => {
const src = 'the tag `<think>x</think>` is a literal'
const r = parseThinking(src, { streaming: false })
expect(r.hasThinking).toBe(false)
expect(r.body).toBe(src)
})
it('parses real <think> outside code blocks even when code blocks contain fake ones', () => {
const src = '<think>real</think>text\n```\n<think>fake</think>\n```'
const r = parseThinking(src, { streaming: false })
expect(r.segments).toEqual(['real'])
expect(r.body).toBe('text\n```\n<think>fake</think>\n```')
})
```
- [ ] **Step 4.2: 运行确认失败**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: FAIL on 4 new tests
- [ ] **Step 4.3: 重构实现加入代码块保护**
替换 `packages/client/src/utils/thinking-parser.ts` 整个 `parseThinking` 相关部分为:
```ts
const PLACEHOLDER_PREFIX = '\u0000THKCODE'
const PLACEHOLDER_SUFFIX = '\u0000'
const FENCED_RE = /(```|~~~)([\s\S]*?)\1/g
const INLINE_CODE_RE = /`[^`\n]*`/g
function protectCodeBlocks(input: string): { masked: string; blocks: string[] } {
const blocks: string[] = []
let masked = input.replace(FENCED_RE, (m) => {
blocks.push(m)
return `${PLACEHOLDER_PREFIX}${blocks.length - 1}${PLACEHOLDER_SUFFIX}`
})
masked = masked.replace(INLINE_CODE_RE, (m) => {
blocks.push(m)
return `${PLACEHOLDER_PREFIX}${blocks.length - 1}${PLACEHOLDER_SUFFIX}`
})
return { masked, blocks }
}
function restoreCodeBlocks(text: string, blocks: string[]): string {
if (blocks.length === 0) return text
return text.replace(
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
(_, idx) => blocks[Number(idx)] ?? '',
)
}
export function parseThinking(content: string, opts: ParseOptions): ParsedThinking {
const { masked, blocks } = protectCodeBlocks(content)
const segments: string[] = []
let pending: string | null = null
let body = ''
let lastIndex = 0
TAG_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TAG_RE.exec(masked)) !== null) {
body += masked.slice(lastIndex, m.index)
segments.push(m[2])
lastIndex = m.index + m[0].length
}
const rest = masked.slice(lastIndex)
const openRe = /<(think|thinking|reasoning)>([\s\S]*)$/i
const openMatch = rest.match(openRe)
if (openMatch) {
body += rest.slice(0, openMatch.index)
if (opts.streaming) {
pending = openMatch[2]
} else {
body += rest.slice(openMatch.index!)
}
} else {
body += rest
}
return {
segments: segments.map(s => restoreCodeBlocks(s, blocks)),
pending: pending === null ? null : restoreCodeBlocks(pending, blocks),
body: restoreCodeBlocks(body, blocks),
hasThinking: segments.length > 0 || pending !== null,
}
}
```
- [ ] **Step 4.4: 运行确认全部通过**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: PASS (13 tests)
- [ ] **Step 4.5: 提交**
```bash
git add tests/client/thinking-parser.test.ts packages/client/src/utils/thinking-parser.ts
git commit -m "feat(thinking-parser): 代码块保护避免误识别伪标签
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 5: 同名嵌套 & chunk 边界 行为文档化
**Files:**
- Modify: `tests/client/thinking-parser.test.ts`
- [ ] **Step 5.1: 追加行为说明测试**
```ts
it('same-name nesting: inner tag absorbed into first segment (documented limitation)', () => {
const r = parseThinking('<think>a<think>b</think>c</think>', { streaming: false })
expect(r.segments).toEqual(['a<think>b'])
expect(r.body).toBe('c</think>')
})
it('handles chunk boundary: partial opening tag not yet identified', () => {
const mid = parseThinking('<thin', { streaming: true })
expect(mid.hasThinking).toBe(false)
expect(mid.body).toBe('<thin')
const after = parseThinking('<think>hi</think>done', { streaming: true })
expect(after.segments).toEqual(['hi'])
expect(after.body).toBe('done')
})
```
- [ ] **Step 5.2: 运行 + 提交**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: PASS (15 tests)
```bash
git add tests/client/thinking-parser.test.ts
git commit -m "test(thinking-parser): 同名嵌套与 chunk 边界行为
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 6: 字数计数 `countThinkingChars`
**Files:**
- Modify: `tests/client/thinking-parser.test.ts`
- Modify: `packages/client/src/utils/thinking-parser.ts`
- [ ] **Step 6.1: 写测试**
```ts
import { countThinkingChars } from '@/utils/thinking-parser'
describe('countThinkingChars', () => {
it('counts all segments + pending as Unicode chars', () => {
const n = countThinkingChars({
segments: ['abc', '你好'],
pending: '🎉!',
body: '',
hasThinking: true,
})
expect(n).toBe(7)
})
it('returns 0 when no thinking', () => {
expect(countThinkingChars({ segments: [], pending: null, body: 'x', hasThinking: false })).toBe(0)
})
})
```
- [ ] **Step 6.2: 实现**
`packages/client/src/utils/thinking-parser.ts` 末尾追加:
```ts
export function countThinkingChars(parsed: ParsedThinking): number {
const len = (s: string) => [...s].length
return parsed.segments.reduce((a, s) => a + len(s), 0) + len(parsed.pending || '')
}
```
- [ ] **Step 6.3: 运行 + 提交**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: PASS (17 tests)
```bash
git add tests/client/thinking-parser.test.ts packages/client/src/utils/thinking-parser.ts
git commit -m "feat(thinking-parser): countThinkingChars 辅助函数
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 7: 边界检测 `detectThinkingBoundary`
**Files:**
- Modify: `tests/client/thinking-parser.test.ts`
- Modify: `packages/client/src/utils/thinking-parser.ts`
- [ ] **Step 7.1: 测试**
```ts
import { detectThinkingBoundary } from '@/utils/thinking-parser'
describe('detectThinkingBoundary', () => {
it('detects first appearance of opening tag', () => {
const r = detectThinkingBoundary('', '<think>x')
expect(r.startedAtBoundary).toBe(true)
expect(r.endedAtBoundary).toBe(false)
})
it('detects first appearance of closing tag', () => {
const r = detectThinkingBoundary('<think>hi', '<think>hi</think>')
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(true)
})
it('detects both when both emerge in one delta', () => {
const r = detectThinkingBoundary('', '<think>x</think>')
expect(r.startedAtBoundary).toBe(true)
expect(r.endedAtBoundary).toBe(true)
})
it('reports no boundary when neither crossed', () => {
const r = detectThinkingBoundary('abc', 'abcdef')
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(false)
})
it('ignores fake tags inside code blocks', () => {
const r = detectThinkingBoundary('', '```\n<think>fake</think>\n```')
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(false)
})
it('is idempotent for repeated open/close after initial', () => {
const r = detectThinkingBoundary(
'<think>a</think><think>b',
'<think>a</think><think>b</think>',
)
expect(r.startedAtBoundary).toBe(false)
expect(r.endedAtBoundary).toBe(false)
})
})
```
- [ ] **Step 7.2: 实现**
`packages/client/src/utils/thinking-parser.ts` 末尾追加:
```ts
export interface ThinkingBoundary {
startedAtBoundary: boolean
endedAtBoundary: boolean
}
const ANY_OPEN_RE = /<(think|thinking|reasoning)>/i
const ANY_CLOSE_RE = /<\/(think|thinking|reasoning)>/i
export function detectThinkingBoundary(prev: string, next: string): ThinkingBoundary {
const prevMasked = protectCodeBlocks(prev).masked
const nextMasked = protectCodeBlocks(next).masked
return {
startedAtBoundary: !ANY_OPEN_RE.test(prevMasked) && ANY_OPEN_RE.test(nextMasked),
endedAtBoundary: !ANY_CLOSE_RE.test(prevMasked) && ANY_CLOSE_RE.test(nextMasked),
}
}
```
- [ ] **Step 7.3: 运行 + 提交**
Run: `npx vitest run tests/client/thinking-parser.test.ts`
Expected: PASS (23 tests)
```bash
git add tests/client/thinking-parser.test.ts packages/client/src/utils/thinking-parser.ts
git commit -m "feat(thinking-parser): detectThinkingBoundary 边界检测
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 8: chat store 集成 `thinkingObservation`
**Files:**
- Create: `tests/client/chat-store-thinking.test.ts`
- Modify: `packages/client/src/stores/hermes/chat.ts`
- [ ] **Step 8.1: 写 store 单测**
```ts
// tests/client/chat-store-thinking.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useChatStore } from '@/stores/hermes/chat'
describe('chat store thinkingObservation', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts empty', () => {
const store = useChatStore()
expect(store.getThinkingObservation('any-id')).toBeUndefined()
})
it('records startedAt when delta first introduces an opening tag', () => {
const store = useChatStore()
store.noteThinkingDelta('msg-1', '', '<think>hi')
const ob = store.getThinkingObservation('msg-1')
expect(ob).toBeDefined()
expect(typeof ob!.startedAt).toBe('number')
expect(ob!.endedAt).toBeUndefined()
})
it('records endedAt when delta first introduces closing tag', () => {
const store = useChatStore()
store.noteThinkingDelta('msg-1', '', '<think>hi')
store.noteThinkingDelta('msg-1', '<think>hi', '<think>hi</think>done')
const ob = store.getThinkingObservation('msg-1')
expect(ob!.startedAt).toBeDefined()
expect(typeof ob!.endedAt).toBe('number')
})
it('is idempotent for subsequent openings/closings', () => {
const store = useChatStore()
store.noteThinkingDelta('m', '', '<think>a</think>')
const first = store.getThinkingObservation('m')!
const firstStarted = first.startedAt
const firstEnded = first.endedAt
store.noteThinkingDelta(
'm',
'<think>a</think>',
'<think>a</think><think>b</think>',
)
const second = store.getThinkingObservation('m')!
expect(second.startedAt).toBe(firstStarted)
expect(second.endedAt).toBe(firstEnded)
})
it('is ignored when delta is inside a code block', () => {
const store = useChatStore()
store.noteThinkingDelta('m', '', '```\n<think>fake</think>\n```')
expect(store.getThinkingObservation('m')).toBeUndefined()
})
it('clears observations on clearThinkingObservationFor', () => {
const store = useChatStore()
store.noteThinkingDelta('m', '', '<think>hi</think>')
expect(store.getThinkingObservation('m')).toBeDefined()
store.clearThinkingObservationFor('any-session')
expect(store.getThinkingObservation('m')).toBeUndefined()
})
})
```
- [ ] **Step 8.2: 运行确认失败**
Run: `npx vitest run tests/client/chat-store-thinking.test.ts`
Expected: FAIL — 方法未定义
- [ ] **Step 8.3: 修改 chat.ts 导入 detectThinkingBoundary**
`packages/client/src/stores/hermes/chat.ts` import 区域追加一行:
```ts
import { detectThinkingBoundary } from '@/utils/thinking-parser'
```
- [ ] **Step 8.4: 在 store setup 函数内新增状态与方法**
定位 `defineStore('chat', () => { ... })` 内部(建议在已有 `const streamStates = ...` 等 ref 声明附近),追加:
```ts
// Transient observation of <think> boundaries during active streaming.
// Not persisted; cleared on session switch. See spec §5.3.
const thinkingObservation = new Map<string, { startedAt?: number; endedAt?: number }>()
function getThinkingObservation(messageId: string) {
return thinkingObservation.get(messageId)
}
function noteThinkingDelta(messageId: string, prevContent: string, nextContent: string) {
const { startedAtBoundary, endedAtBoundary } = detectThinkingBoundary(prevContent, nextContent)
if (!startedAtBoundary && !endedAtBoundary) return
const existing = thinkingObservation.get(messageId) || {}
if (startedAtBoundary && existing.startedAt === undefined) {
existing.startedAt = Date.now()
}
if (endedAtBoundary && existing.endedAt === undefined) {
existing.endedAt = Date.now()
}
thinkingObservation.set(messageId, existing)
}
function clearThinkingObservationFor(_sessionId: string) {
// messageId 与 sessionId 的关联未单独持有;方案是切会话时一律清空。
// 这符合 spec 定义:observation 是"当前会话范围内"的 transient 状态。
thinkingObservation.clear()
}
```
在 store `return { ... }` 块末尾新增导出:
```ts
getThinkingObservation,
noteThinkingDelta,
clearThinkingObservationFor,
```
- [ ] **Step 8.5: 运行测试确认通过**
Run: `npx vitest run tests/client/chat-store-thinking.test.ts`
Expected: PASS (6 tests)
- [ ] **Step 8.6: 提交**
```bash
git add packages/client/src/stores/hermes/chat.ts tests/client/chat-store-thinking.test.ts
git commit -m "feat(chat-store): 新增 thinkingObservation 运行时 Map
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 9: 接入 `message.delta` 与 `switchSession`
**Files:**
- Modify: `packages/client/src/stores/hermes/chat.ts`
- [ ] **Step 9.1: 定位 switchSession**
Run: `grep -n "async function switchSession\|function switchSession\|switchSession =\|function selectSession" packages/client/src/stores/hermes/chat.ts`
记下函数起始行号。
- [ ] **Step 9.2: 修改 message.delta 分支**
`packages/client/src/stores/hermes/chat.ts``case 'message.delta':` 分支(约 817-833 行)整体替换为:
```ts
case 'message.delta': {
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
if (last?.role === 'assistant' && last.isStreaming) {
const prev = last.content
const next = prev + (evt.delta || '')
noteThinkingDelta(last.id, prev, next)
last.content = next
} else {
const newId = uid()
const nextContent = evt.delta || ''
noteThinkingDelta(newId, '', nextContent)
addMessage(sid, {
id: newId,
role: 'assistant',
content: nextContent,
timestamp: Date.now(),
isStreaming: true,
})
}
schedulePersist()
break
}
```
- [ ] **Step 9.3: 在 switchSession 函数最开头加一行清理**
根据 Step 9.1 找到的 switchSession 函数入口(形如 `async function switchSession(sessionId: string) {`),在函数体第一行加入:
```ts
clearThinkingObservationFor(sessionId)
```
(参数名以实际函数签名为准。)
- [ ] **Step 9.4: 运行所有测试**
Run: `npm run test -- --run`
Expected: 全部通过(新增 + 原有)
Run: `npx vue-tsc -b --noEmit`
Expected: 通过
- [ ] **Step 9.5: 提交**
```bash
git add packages/client/src/stores/hermes/chat.ts
git commit -m "feat(chat-store): message.delta 写入 thinking 边界 + switchSession 清理
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 10: i18n 8 语言新增 thinking key
**Files:**
- Modify: `packages/client/src/i18n/locales/{en,zh,de,es,fr,ja,ko,pt}.ts`
在每个 locale 的 `chat: { ... }` 对象**末尾**(闭合 `},` 前)追加 6 条 key。各语言内容如下:
**zh.ts**
```ts
thinkingLabel: '思考过程',
thinkingInProgress: '思考中…',
thinkingShow: '展开思考过程',
thinkingHide: '收起思考过程',
thinkingDuration: '已观察 {duration}',
thinkingChars: '{count} 字',
```
**en.ts**
```ts
thinkingLabel: 'Thinking',
thinkingInProgress: 'Thinking…',
thinkingShow: 'Show thinking',
thinkingHide: 'Hide thinking',
thinkingDuration: 'Observed {duration}',
thinkingChars: '{count} chars',
```
**de.ts**
```ts
thinkingLabel: 'Denkprozess',
thinkingInProgress: 'Denkt…',
thinkingShow: 'Denkprozess anzeigen',
thinkingHide: 'Denkprozess ausblenden',
thinkingDuration: 'Beobachtet {duration}',
thinkingChars: '{count} Zeichen',
```
**es.ts**
```ts
thinkingLabel: 'Pensamiento',
thinkingInProgress: 'Pensando…',
thinkingShow: 'Mostrar pensamiento',
thinkingHide: 'Ocultar pensamiento',
thinkingDuration: 'Observado {duration}',
thinkingChars: '{count} caracteres',
```
**fr.ts**
```ts
thinkingLabel: 'Raisonnement',
thinkingInProgress: 'En réflexion…',
thinkingShow: 'Afficher le raisonnement',
thinkingHide: 'Masquer le raisonnement',
thinkingDuration: 'Observé {duration}',
thinkingChars: '{count} caractères',
```
**ja.ts**
```ts
thinkingLabel: '思考過程',
thinkingInProgress: '思考中…',
thinkingShow: '思考過程を表示',
thinkingHide: '思考過程を隠す',
thinkingDuration: '観測 {duration}',
thinkingChars: '{count} 文字',
```
**ko.ts**
```ts
thinkingLabel: '사고 과정',
thinkingInProgress: '사고 중…',
thinkingShow: '사고 과정 펼치기',
thinkingHide: '사고 과정 접기',
thinkingDuration: '관측 {duration}',
thinkingChars: '{count}자',
```
**pt.ts**
```ts
thinkingLabel: 'Raciocínio',
thinkingInProgress: 'Pensando…',
thinkingShow: 'Mostrar raciocínio',
thinkingHide: 'Ocultar raciocínio',
thinkingDuration: 'Observado {duration}',
thinkingChars: '{count} caracteres',
```
- [ ] **Step 10.1: 追加 8 个 locale 文件中的 key**(如上)
- [ ] **Step 10.2: Type-check**
Run: `npx vue-tsc -b --noEmit`
Expected: 通过
- [ ] **Step 10.3: 提交**
```bash
git add packages/client/src/i18n/locales/
git commit -m "i18n: 新增 thinking 块 6 条 key8 语言)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 11: `MessageItem.vue` 渲染 thinking 折叠区
**Files:**
- Modify: `packages/client/src/components/hermes/chat/MessageItem.vue`
- [ ] **Step 11.1: 补充 `<script setup>` 导入与状态**
在现有 import 后追加:
```ts
import { computed, onBeforeUnmount, ref, watchEffect } from 'vue'
import { parseThinking, countThinkingChars } from '@/utils/thinking-parser'
import { useChatStore } from '@/stores/hermes/chat'
import { useSettingsStore } from '@/stores/hermes/settings'
```
(注意:`computed, ref` 如已 import,不重复导入;实际只新增 `onBeforeUnmount, watchEffect`。)
`const timeStr = computed(...)` 附近追加:
```ts
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const parsedThinking = computed(() =>
parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming }),
)
const thinkingCharCount = computed(() => countThinkingChars(parsedThinking.value))
const thinkingOverride = ref<boolean | null>(null)
const thinkingExpanded = computed(() => {
if (props.message.isStreaming && parsedThinking.value.pending !== null) return true
if (thinkingOverride.value !== null) return thinkingOverride.value
return !!settingsStore.display.show_reasoning
})
function toggleThinking() {
thinkingOverride.value = !thinkingExpanded.value
}
const nowTick = ref(Date.now())
let tickTimer: number | null = null
function ensureTick() {
const ob = chatStore.getThinkingObservation(props.message.id)
const shouldTick = !!(
props.message.isStreaming &&
ob?.startedAt !== undefined &&
ob.endedAt === undefined
)
if (shouldTick && tickTimer === null) {
tickTimer = window.setInterval(() => {
nowTick.value = Date.now()
}, 1000)
} else if (!shouldTick && tickTimer !== null) {
window.clearInterval(tickTimer)
tickTimer = null
}
}
watchEffect(ensureTick)
onBeforeUnmount(() => {
if (tickTimer !== null) window.clearInterval(tickTimer)
})
const thinkingDurationMs = computed<number | null>(() => {
const ob = chatStore.getThinkingObservation(props.message.id)
if (!ob?.startedAt) return null
const end = ob.endedAt ?? (props.message.isStreaming ? nowTick.value : ob.startedAt)
return Math.max(0, end - ob.startedAt)
})
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const r = s % 60
return r === 0 ? `${m}m` : `${m}m ${r}s`
}
const thinkingFullText = computed(() => {
const parts = parsedThinking.value.segments.slice()
if (parsedThinking.value.pending) parts.push(parsedThinking.value.pending)
return parts.join('\n\n')
})
```
- [ ] **Step 11.2: 在 assistant 气泡模板中插入 thinking 区块**
`MessageItem.vue` `<template>` 中,找到:
```vue
<MarkdownRenderer
v-if="message.content"
:content="message.content"
/>
```
替换为:
```vue
<div
v-if="parsedThinking.hasThinking"
class="thinking-block"
:class="{ expanded: thinkingExpanded }"
>
<div class="thinking-header" @click="toggleThinking">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="thinking-chevron"
:class="{ rotated: thinkingExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="thinking-icon">💭</span>
<span class="thinking-label">
{{
message.isStreaming && parsedThinking.pending !== null
? t('chat.thinkingInProgress')
: t('chat.thinkingLabel')
}}
</span>
<span v-if="thinkingDurationMs !== null" class="thinking-meta">
· {{ t('chat.thinkingDuration', { duration: formatDuration(thinkingDurationMs) }) }}
</span>
<span class="thinking-meta">
· {{ t('chat.thinkingChars', { count: thinkingCharCount }) }}
</span>
</div>
<div v-if="thinkingExpanded" class="thinking-body">
<MarkdownRenderer :content="thinkingFullText" />
</div>
</div>
<MarkdownRenderer
v-if="parsedThinking.body"
:content="parsedThinking.body"
/>
```
- [ ] **Step 11.3: 追加 SCSS 样式**
`<style scoped lang="scss">` 区域,`.msg-attachment-file { ... }` 结束后追加:
```scss
.thinking-block {
margin-bottom: 8px;
padding: 4px 0;
border-bottom: 1px dashed $border-light;
.thinking-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-muted;
cursor: pointer;
padding: 2px 4px;
border-radius: $radius-sm;
user-select: none;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
}
.thinking-chevron {
flex-shrink: 0;
transition: transform 0.15s ease;
&.rotated {
transform: rotate(90deg);
}
}
.thinking-icon {
font-size: 11px;
flex-shrink: 0;
}
.thinking-label {
font-weight: 500;
flex-shrink: 0;
}
.thinking-meta {
color: $text-muted;
font-variant-numeric: tabular-nums;
}
.thinking-body {
margin-top: 6px;
padding: 6px 10px;
border-left: 2px solid $border-light;
font-size: 13px;
opacity: 0.85;
font-style: italic;
:deep(p) { margin: 0.3em 0; }
}
}
```
- [ ] **Step 11.4: Type-check + build**
Run: `npx vue-tsc -b --noEmit`
Expected: 通过
Run: `npm run test -- --run`
Expected: 全部通过
- [ ] **Step 11.5: 提交**
```bash
git add packages/client/src/components/hermes/chat/MessageItem.vue
git commit -m "feat(chat): MessageItem 渲染 thinking 折叠区
- 复用 tool-line 风格 chevron
- 两条响应链:parse computed + duration interval
- 流式+pending 强制展开
- show_reasoning 控制默认态
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
```
---
## Task 12: 完整构建 + 手动验证 + 推送
- [ ] **Step 12.1: 完整 build**
Run: `npm run build`
Expected: 构建通过,无 type 错误
- [ ] **Step 12.2: 完整测试**
Run: `npm run test -- --run`
Expected: 全部通过
- [ ] **Step 12.3: 手动验证清单(需用户配合)**
启动 dev 服务器(`npm run dev`)后逐项检查:
1. 用 DeepSeek R1 / GLM reasoner 等输出 `<think>...</think>` 的模型发起对话
2. **流式中**thinking 区展开实时滚动
3. **完成后**:按 `show_reasoning` 设置决定默认折叠;header 显示 `💭 思考过程 · 已观察 Xs · Y 字`
4. **设置切换**Display Settings → `show_reasoning` 开/关 → 新消息与刷新后应遵循
5. **手动展开/收起**:点击 chevron;刷新后回默认
6. **代码块保护**:让模型输出 "`<think>` 标签用法示例" 并包含 code block → code block 内 `<think>` **不被识别**
7. **老消息**:加载升级前的老会话(若有) → thinking 正确识别(只显示字数)
8. **切会话**:切到另一会话再回 → 耗时消息消失(observation 已清)
- [ ] **Step 12.4: 推送分支**
```bash
git push -u origin feat/thinking-block-collapse
```
---
## 自我检查清单(Plan Self-Review
- [x] Spec §4 识别规则 → Tasks 1-5
- [x] Spec §5.3 运行时 Map → Tasks 8-9
- [x] Spec §6.3 默认展开逻辑 → Task 11
- [x] Spec §6.4 两条响应链 → Task 11
- [x] Spec §6.5 终止态降级 → Task 3 测试 + parser 实现
- [x] Spec §6.6 i18n → Task 10
- [x] Spec §8 测试策略 → Tasks 1-9
- [x] Spec §9 兼容性 → Task 12.3 #7
**实施完成标准**:所有 Tasks checkbox 勾选;`npm run build``npm run test -- --run` 全绿;分支推送。