* 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>
33 KiB
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)
// 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: 实现最小骨架
// 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: 提交
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: 追加测试
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: 提交
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: 追加测试
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)
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: 写失败测试
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 相关部分为:
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: 提交
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: 追加行为说明测试
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)
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: 写测试
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 末尾追加:
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)
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: 测试
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 末尾追加:
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)
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 单测
// 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 区域追加一行:
import { detectThinkingBoundary } from '@/utils/thinking-parser'
- Step 8.4: 在 store setup 函数内新增状态与方法
定位 defineStore('chat', () => { ... }) 内部(建议在已有 const streamStates = ... 等 ref 声明附近),追加:
// 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 { ... } 块末尾新增导出:
getThinkingObservation,
noteThinkingDelta,
clearThinkingObservationFor,
- Step 8.5: 运行测试确认通过
Run: npx vitest run tests/client/chat-store-thinking.test.ts
Expected: PASS (6 tests)
- Step 8.6: 提交
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 行)整体替换为:
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) {),在函数体第一行加入:
clearThinkingObservationFor(sessionId)
(参数名以实际函数签名为准。)
- Step 9.4: 运行所有测试
Run: npm run test -- --run
Expected: 全部通过(新增 + 原有)
Run: npx vue-tsc -b --noEmit
Expected: 通过
- Step 9.5: 提交
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
thinkingLabel: '思考过程',
thinkingInProgress: '思考中…',
thinkingShow: '展开思考过程',
thinkingHide: '收起思考过程',
thinkingDuration: '已观察 {duration}',
thinkingChars: '{count} 字',
en.ts
thinkingLabel: 'Thinking',
thinkingInProgress: 'Thinking…',
thinkingShow: 'Show thinking',
thinkingHide: 'Hide thinking',
thinkingDuration: 'Observed {duration}',
thinkingChars: '{count} chars',
de.ts
thinkingLabel: 'Denkprozess',
thinkingInProgress: 'Denkt…',
thinkingShow: 'Denkprozess anzeigen',
thinkingHide: 'Denkprozess ausblenden',
thinkingDuration: 'Beobachtet {duration}',
thinkingChars: '{count} Zeichen',
es.ts
thinkingLabel: 'Pensamiento',
thinkingInProgress: 'Pensando…',
thinkingShow: 'Mostrar pensamiento',
thinkingHide: 'Ocultar pensamiento',
thinkingDuration: 'Observado {duration}',
thinkingChars: '{count} caracteres',
fr.ts
thinkingLabel: 'Raisonnement',
thinkingInProgress: 'En réflexion…',
thinkingShow: 'Afficher le raisonnement',
thinkingHide: 'Masquer le raisonnement',
thinkingDuration: 'Observé {duration}',
thinkingChars: '{count} caractères',
ja.ts
thinkingLabel: '思考過程',
thinkingInProgress: '思考中…',
thinkingShow: '思考過程を表示',
thinkingHide: '思考過程を隠す',
thinkingDuration: '観測 {duration}',
thinkingChars: '{count} 文字',
ko.ts
thinkingLabel: '사고 과정',
thinkingInProgress: '사고 중…',
thinkingShow: '사고 과정 펼치기',
thinkingHide: '사고 과정 접기',
thinkingDuration: '관측 {duration}',
thinkingChars: '{count}자',
pt.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: 提交
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: 补充
<script setup>导入与状态
在现有 import 后追加:
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(...) 附近追加:
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> 中,找到:
<MarkdownRenderer
v-if="message.content"
:content="message.content"
/>
替换为:
<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 { ... } 结束后追加:
.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: 提交
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)后逐项检查:
- 用 DeepSeek R1 / GLM reasoner 等输出
<think>...</think>的模型发起对话 - 流式中:thinking 区展开实时滚动
- 完成后:按
show_reasoning设置决定默认折叠;header 显示💭 思考过程 · 已观察 Xs · Y 字 - 设置切换:Display Settings →
show_reasoning开/关 → 新消息与刷新后应遵循 - 手动展开/收起:点击 chevron;刷新后回默认
- 代码块保护:让模型输出 "
<think>标签用法示例" 并包含 code block → code block 内<think>不被识别 - 老消息:加载升级前的老会话(若有) → thinking 正确识别(只显示字数)
- 切会话:切到另一会话再回 → 耗时消息消失(observation 已清)
- Step 12.4: 推送分支
git push -u origin feat/thinking-block-collapse
自我检查清单(Plan Self-Review)
- Spec §4 识别规则 → Tasks 1-5
- Spec §5.3 运行时 Map → Tasks 8-9
- Spec §6.3 默认展开逻辑 → Task 11
- Spec §6.4 两条响应链 → Task 11
- Spec §6.5 终止态降级 → Task 3 测试 + parser 实现
- Spec §6.6 i18n → Task 10
- Spec §8 测试策略 → Tasks 1-9
- Spec §9 兼容性 → Task 12.3 #7
实施完成标准:所有 Tasks checkbox 勾选;npm run build 与 npm run test -- --run 全绿;分支推送。