diff --git a/docs/superpowers/plans/2026-04-23-thinking-block-collapse.md b/docs/superpowers/plans/2026-04-23-thinking-block-collapse.md new file mode 100644 index 0000000..6702643 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-thinking-block-collapse.md @@ -0,0 +1,1138 @@ +# 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 消息中 `//` 标签,分离为可折叠思考块展示,不破坏历史数据。 + +**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 block from body', () => { + const r = parseThinking('innerbody', { 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): 首个闭合 标签拆分 + +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('amidbend', { streaming: false }) + expect(r.segments).toEqual(['a', 'b']) + expect(r.body).toBe('midend') + }) + + it('supports and variants', () => { + const r = parseThinking('rbody', { streaming: false }) + expect(r.segments).toEqual(['r']) + expect(r.body).toBe('body') + }) + + it('is case-insensitive on tag names', () => { + const r = parseThinking('xyz', { 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('bodyin-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('bodyorphan', { streaming: false }) + expect(r.pending).toBeNull() + expect(r.body).toBe('bodyorphan') + expect(r.segments).toEqual([]) + expect(r.hasThinking).toBe(false) + }) + + it('combines closed segments with trailing pending (streaming)', () => { + const r = parseThinking('donemidnow', { 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 inside fenced code block', () => { + const src = 'before\n```\nfake\n```\nafter' + const r = parseThinking(src, { streaming: false }) + expect(r.hasThinking).toBe(false) + expect(r.body).toBe(src) + }) + + it('does NOT recognize inside tilde-fenced code block', () => { + const src = '~~~\nfake\n~~~' + const r = parseThinking(src, { streaming: false }) + expect(r.hasThinking).toBe(false) + expect(r.body).toBe(src) + }) + + it('does NOT recognize inside inline code', () => { + const src = 'the tag `x` is a literal' + const r = parseThinking(src, { streaming: false }) + expect(r.hasThinking).toBe(false) + expect(r.body).toBe(src) + }) + + it('parses real outside code blocks even when code blocks contain fake ones', () => { + const src = 'realtext\n```\nfake\n```' + const r = parseThinking(src, { streaming: false }) + expect(r.segments).toEqual(['real']) + expect(r.body).toBe('text\n```\nfake\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('abc', { streaming: false }) + expect(r.segments).toEqual(['ab']) + expect(r.body).toBe('c') + }) + + it('handles chunk boundary: partial opening tag not yet identified', () => { + const mid = parseThinking('hidone', { 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('', 'x') + expect(r.startedAtBoundary).toBe(true) + expect(r.endedAtBoundary).toBe(false) + }) + + it('detects first appearance of closing tag', () => { + const r = detectThinkingBoundary('hi', 'hi') + expect(r.startedAtBoundary).toBe(false) + expect(r.endedAtBoundary).toBe(true) + }) + + it('detects both when both emerge in one delta', () => { + const r = detectThinkingBoundary('', 'x') + 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('', '```\nfake\n```') + expect(r.startedAtBoundary).toBe(false) + expect(r.endedAtBoundary).toBe(false) + }) + + it('is idempotent for repeated open/close after initial', () => { + const r = detectThinkingBoundary( + 'ab', + 'ab', + ) + 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', '', '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', '', 'hi') + store.noteThinkingDelta('msg-1', 'hi', 'hidone') + 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', '', 'a') + const first = store.getThinkingObservation('m')! + const firstStarted = first.startedAt + const firstEnded = first.endedAt + store.noteThinkingDelta( + 'm', + 'a', + 'ab', + ) + 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', '', '```\nfake\n```') + expect(store.getThinkingObservation('m')).toBeUndefined() + }) + + it('clears observations on clearThinkingObservationFor', () => { + const store = useChatStore() + store.noteThinkingDelta('m', '', 'hi') + 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 boundaries during active streaming. + // Not persisted; cleared on session switch. See spec §5.3. + const thinkingObservation = new Map() + + 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 条 key(8 语言) + +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: 补充 `