From 26ab5a6657baff66a9c9c8456e0003e6ca9d1a5c Mon Sep 17 00:00:00 2001 From: web4zn Date: Fri, 8 May 2026 21:53:23 +0800 Subject: [PATCH 1/5] docs: sync specs with actual code, remove 5 stale spec dirs Removed incremental-mindmap-generation, mindmap-corpus, mindmap-content-selection, mindmap-streaming-preview, configurable-mindmap-depth. Updated mindmap-data, mindmap-generation, mindmap-panel-layout, mindmap-canvas-rendering, mindmap-tree-view, chat-interface, conversation-management, brand-identity, model-provider to match current code. Ultraworked with Sisyphus Co-authored-by: Sisyphus --- openspec/specs/brand-identity/spec.md | 16 +-- openspec/specs/chat-interface/spec.md | 16 ++- .../specs/configurable-mindmap-depth/spec.md | 46 ------- .../specs/conversation-management/spec.md | 26 ++-- .../incremental-mindmap-generation/spec.md | 87 ------------- .../specs/mindmap-canvas-rendering/spec.md | 2 +- .../specs/mindmap-content-selection/spec.md | 1 - openspec/specs/mindmap-corpus/spec.md | 106 ---------------- openspec/specs/mindmap-data/spec.md | 47 +++---- openspec/specs/mindmap-generation/spec.md | 116 +++++------------- openspec/specs/mindmap-panel-layout/spec.md | 57 ++++----- .../specs/mindmap-streaming-preview/spec.md | 41 ------- openspec/specs/mindmap-tree-view/spec.md | 4 +- openspec/specs/model-provider/spec.md | 13 +- 14 files changed, 106 insertions(+), 472 deletions(-) delete mode 100644 openspec/specs/configurable-mindmap-depth/spec.md delete mode 100644 openspec/specs/incremental-mindmap-generation/spec.md delete mode 100644 openspec/specs/mindmap-content-selection/spec.md delete mode 100644 openspec/specs/mindmap-corpus/spec.md delete mode 100644 openspec/specs/mindmap-streaming-preview/spec.md diff --git a/openspec/specs/brand-identity/spec.md b/openspec/specs/brand-identity/spec.md index 3e39bd1..ef65636 100644 --- a/openspec/specs/brand-identity/spec.md +++ b/openspec/specs/brand-identity/spec.md @@ -1,26 +1,26 @@ ## ADDED Requirements ### Requirement: Icon system consistency -全站 SHALL 使用 Lucide React 图标组件替代所有 emoji 图标。图标 SHALL 使用一致的尺寸(默认 16px、20px、24px 三级)和颜色(继承当前文本颜色)。不允许混用 emoji 和 SVG 图标。 +全站 SHALL 使用 Lucide React 图标组件替代所有 emoji 图标。图标 SHALL 使用一致的尺寸(默认 `w-4 h-4`,小号 `w-3 h-3`)。不允许混用 emoji 和 SVG 图标。 -#### Scenario: All emoji replaced with Lucide icons +#### Scenario: All icons use Lucide - **WHEN** 用户浏览应用的所有界面元素 - **THEN** 任何功能图标均使用 Lucide 组件渲染,无 emoji 图标残留 #### Scenario: Icon sizing consistency - **WHEN** 在不同 UI 上下文中渲染图标 -- **THEN** 图标尺寸符合 16px(inline)/ 20px(button)/ 24px(standalone)三级规范 +- **THEN** 图标尺寸遵循 `w-4 h-4`(按钮/操作)或 `w-3 h-3`(inline/装饰)两级规范 ### Requirement: Color system -应用 SHALL 定义清晰的色彩层级。侧边栏 SHALL 使用深色背景(跟随主题),内容区 SHALL 保持浅色/干净背景。强调色 SHALL 用于关键操作(发送、激活态)。 +应用 SHALL 定义清晰的色彩层级。侧边栏 SHALL 使用主题自适应背景(`bg-sidebar`),内容区 SHALL 保持干净背景。强调色 SHALL 用于关键操作(发送、激活态)。 -#### Scenario: Sidebar dark theme (light mode) +#### Scenario: Sidebar theme adaptive - **WHEN** 系统为浅色主题 -- **THEN** 侧边栏使用深灰/半透明背景(如 bg-neutral-900/80),与浅色内容区形成对比 +- **THEN** 侧边栏使用浅色背景,与内容区通过分隔线区分 -#### Scenario: Sidebar dark theme (dark mode) +#### Scenario: Sidebar theme adaptive (dark mode) - **WHEN** 系统为深色主题 -- **THEN** 侧边栏使用更深的黑色背景(如 bg-black/60) +- **THEN** 侧边栏使用深色背景 ### Requirement: Typography hierarchy 消息内容 SHALL 使用 14px(sm)字号,标题使用 16px(base)及以上。行高 SHALL 为 1.6 以优化长文本可读性。代码块 SHALL 使用等宽字体。 diff --git a/openspec/specs/chat-interface/spec.md b/openspec/specs/chat-interface/spec.md index 2bb1953..6faf653 100644 --- a/openspec/specs/chat-interface/spec.md +++ b/openspec/specs/chat-interface/spec.md @@ -22,6 +22,8 @@ ### Requirement: Streaming response display 系统 SHALL 以流式方式显示 LLM 的响应内容,逐字或逐 token 渲染到对话区域。流式输出期间 SHALL 显示加载状态指示器(如光标闪烁)。当流式传输完成时,系统 SHALL 停止加载指示器。 +> 注:当前 ChatPage.doSend 使用非流式 `chat()`(`stream: false`),流式生成函数 `streamChat` 已存在于 `llm-client.ts` 但未被调用。此 requirement 描述的是目标行为。 + #### Scenario: Streaming response rendering - **WHEN** LLM 返回流式响应 - **THEN** 系统逐 token 将内容渲染到消息气泡中,用户可实时看到生成过程 @@ -30,16 +32,12 @@ - **WHEN** 流式响应传输完成 - **THEN** 系统移除加载指示器,完整消息渲染为最终格式 -#### Scenario: Stream interruption -- **WHEN** 流式响应因网络错误中断 -- **THEN** 系统保留已接收的部分内容,显示错误提示,并提供"重新生成"选项 - ### Requirement: Markdown rendering -系统 SHALL 将 LLM 响应中的 Markdown 语法正确渲染为富文本。支持的标准 SHALL 包括:标题、加粗、斜体、代码块(带语法高亮)、行内代码、列表(有序/无序)、表格、链接、引用块。 +系统 SHALL 将 LLM 响应中的 Markdown 语法正确渲染为富文本。支持的标准 SHALL 包括:标题、加粗、斜体、代码块、行内代码、列表(有序/无序)、表格、链接、引用块。 -#### Scenario: Code block with syntax highlighting +#### Scenario: Code block rendering - **WHEN** LLM 返回包含代码块的 Markdown 响应 -- **THEN** 系统渲染代码块并应用语法高亮,代码块包含语言标签和复制按钮 +- **THEN** 系统渲染代码块(使用 `@tailwindcss/typography` 样式),代码块包含语言标签 #### Scenario: Table rendering - **WHEN** LLM 返回包含 Markdown 表格的响应 @@ -120,11 +118,11 @@ #### Scenario: Wide screen centering without panel - **WHEN** 用户在大屏幕(≥1024px)上使用应用,图谱面板隐藏 -- **THEN** 消息列表和输入栏在水平方向居中,最大宽度为 max-w-3xl,两侧保留空白 +- **THEN** 消息列表和输入栏在水平方向居中,最大宽度为 max-w-lg,两侧保留空白 #### Scenario: Wide screen centering with panel - **WHEN** 用户在大屏幕(≥1024px)上使用应用,图谱面板可见 -- **THEN** 消息列表和输入栏在聊天区剩余空间内居中,最大宽度适配至 max-w-2xl 或更窄以保持可读性 +- **THEN** 消息列表和输入栏在聊天区剩余空间内显示,面板占 flex-1 #### Scenario: Narrow screen full width - **WHEN** 用户在小屏幕(<768px)上使用应用 diff --git a/openspec/specs/configurable-mindmap-depth/spec.md b/openspec/specs/configurable-mindmap-depth/spec.md deleted file mode 100644 index b179d6a..0000000 --- a/openspec/specs/configurable-mindmap-depth/spec.md +++ /dev/null @@ -1,46 +0,0 @@ -## ADDED Requirements - -### Requirement: Configurable mindmap depth -系统 SHALL 支持用户为每个图谱配置生成深度。`maxDepth` 字段 SHALL 接受 3-5 的整数或 0(自动模式)。默认值 SHALL 为 3。图谱设置 UI SHALL 提供深度选择器,选项为 3 层、4 层、5 层、自动。 - -#### Scenario: Set depth to 4 -- **WHEN** 用户在图谱设置中将最大深度设为 4 -- **THEN** 生成 prompt 告知模型最大深度为 4 层,解析器接受最多 4 层标题 - -#### Scenario: Set depth to auto -- **WHEN** 用户在图谱设置中选择「自动」模式(maxDepth = 0) -- **THEN** 生成 prompt 不指定具体深度限制,告知模型根据内容密度自行判断,解析器安全上限为 6 层 - -#### Scenario: Default depth for existing mindmap -- **WHEN** 旧图谱的 `maxDepth` 为 undefined -- **THEN** 系统使用默认深度 3 层 - -### Requirement: Depth parameter propagation -生成链条中的所有函数 SHALL 接受 `maxDepth` 参数: -- `buildSystemPrompt(maxDepth)`:prompt 中替换硬编码的 3 -- `parseMarkdownToTree(markdown, sourceMap?, maxDepth?)`:正则和 stack 判断参数化 -- `jsonNodeToMindMapNode(item, sourceMap?, depth, maxDepth?)`:递归深度判断参数化 -- `generateMindmap` 从 MindMap.maxDepth 获取值传递给上述函数 - -#### Scenario: Prompt reflects configured depth -- **WHEN** 图谱 `maxDepth` 为 4,触发生成 -- **THEN** system prompt 包含「最大深度为 4 层(# / ## / ### / ####)」 - -#### Scenario: Parser respects configured depth -- **WHEN** 图谱 `maxDepth` 为 4,LLM 返回包含 #### 的内容 -- **THEN** 解析器正确处理 #### 作为第四层节点 - -#### Scenario: Parser enforces safety cap in auto mode -- **WHEN** 图谱为自动模式,LLM 返回超过 6 层的内容 -- **THEN** 解析器忽略第 7 层及更深层标题 - -### Requirement: Depth selection UI -系统 SHALL 在图谱设置弹窗和工具栏中提供深度选择 UI。工具栏 SHALL 在「更新图谱」按钮旁显示紧凑下拉,选项包括:3 层、4 层、5 层、「自动(模型判断)」。切换时 SHALL 即时保存,无需进入设置弹窗。设置弹窗中 SHALL 同步显示当前选择。 - -#### Scenario: Quick switch depth from toolbar -- **WHEN** 用户在工具栏下拉选择「4 层」 -- **THEN** 图谱 `maxDepth` 立即更新为 4,下次生成使用 4 层深度 - -#### Scenario: Toolbar and settings dialog in sync -- **WHEN** 用户在工具栏将深度从 3 改为 4,随后打开设置弹窗 -- **THEN** 设置弹窗中显示当前深度为 4 diff --git a/openspec/specs/conversation-management/spec.md b/openspec/specs/conversation-management/spec.md index 88bf20a..497aad1 100644 --- a/openspec/specs/conversation-management/spec.md +++ b/openspec/specs/conversation-management/spec.md @@ -12,16 +12,12 @@ - **THEN** 系统先创建新图谱,再创建新会话 ### Requirement: Switch conversation -系统 SHALL 提供会话列表侧边栏,用户可点击切换到不同的会话。侧边栏 SHALL 使用深色背景,会话项使用 Lucide 图标操作按钮。切换会话时 SHALL 保存当前会话状态,加载目标会话的消息历史。 +系统 SHALL 提供会话列表侧边栏,用户可点击切换到不同的会话。侧边栏 SHALL 使用主题自适应背景,会话项使用 Lucide 图标操作按钮。切换会话时 SHALL 保存当前会话状态,加载目标会话的消息历史。 #### Scenario: Switch between conversations - **WHEN** 用户在侧边栏点击另一个会话 - **THEN** 当前会话状态保存,目标会话的消息历史加载到对话区域,模型选择器切换到该会话关联的模型,被选中的会话项高亮显示 -#### Scenario: Switch during generation -- **WHEN** 用户在 AI 响应生成期间切换到另一个会话 -- **THEN** 当前会话的生成继续在后台进行,用户切换回时显示完整响应 - ### Requirement: Delete conversation 系统 SHALL 允许用户删除会话。删除前 SHALL 显示确认对话框。删除后 SHALL 不可恢复。 @@ -31,7 +27,7 @@ #### Scenario: Delete current conversation - **WHEN** 用户删除当前正在查看的会话 -- **THEN** 系统自动切换到列表中的下一个会话,若无其他会话则创建新的空白会话 +- **THEN** 系统自动切换到列表中的下一个会话,若无其他会话则 `activeConversationId` 设为 null ### Requirement: Conversation title auto-generation 系统 SHALL 根据用户第一条消息的内容自动生成会话标题。标题 SHALL 截取消息前 20 个字符,超出部分用省略号替代。用户 SHALL 可手动编辑会话标题。 @@ -63,11 +59,11 @@ - **THEN** 所有会话和历史消息从 IndexedDB 完整恢复 ### Requirement: Conversation search -系统 SHALL 提供会话搜索功能,用户可根据关键词搜索会话标题和消息内容。 +系统 SHALL 提供会话搜索功能,用户可根据关键词搜索会话标题。 #### Scenario: Search by keyword - **WHEN** 用户在搜索框输入关键词 -- **THEN** 会话列表过滤为包含该关键词的会话(匹配标题或消息内容),高亮匹配文本 +- **THEN** 会话列表过滤为标题包含该关键词的会话 #### Scenario: Clear search - **WHEN** 用户清空搜索框 @@ -80,16 +76,12 @@ - **WHEN** 用户选择导出会话 - **THEN** 系统生成 Markdown 格式的文件并触发下载,文件包含完整的对话内容(用户消息和 AI 回复) -### Requirement: Sidebar dark theme -侧边栏 SHALL 使用深色背景,与浅色内容区形成视觉对比。侧边栏背景 SHALL 覆盖整个侧边栏高度,包括搜索区、列表区和底部按钮区。 - -#### Scenario: Sidebar appearance in light mode -- **WHEN** 系统为浅色主题 -- **THEN** 侧边栏使用深灰色背景,文字使用浅色 +### Requirement: Sidebar theme +侧边栏 SHALL 使用主题自适应的背景色(`bg-sidebar`),与内容区形成视觉区分。 -#### Scenario: Sidebar appearance in dark mode -- **WHEN** 系统为深色主题 -- **THEN** 侧边栏使用黑色背景 +#### Scenario: Sidebar appearance +- **WHEN** 系统切换主题 +- **THEN** 侧边栏背景色跟随主题自适应变化 ### Requirement: Icon-driven action buttons 侧边栏中的会话操作按钮(新建、重命名、删除、导出)SHALL 使用 Lucide 图标组件而非文字。操作按钮仅在 hover 会话项时显示,默认隐藏。 diff --git a/openspec/specs/incremental-mindmap-generation/spec.md b/openspec/specs/incremental-mindmap-generation/spec.md deleted file mode 100644 index ca70f3b..0000000 --- a/openspec/specs/incremental-mindmap-generation/spec.md +++ /dev/null @@ -1,87 +0,0 @@ -## ADDED Requirements - -### Requirement: Deterministic node ID generation -系统 SHALL 使用基于节点 label 和父路径的确定性算法生成节点 ID,替代随机 UUID。算法 SHALL 保证:相同 label + 相同路径 → 相同 ID。这确保同一概念在多次生成中保持 identity 稳定。 - -#### Scenario: Same concept generates same ID -- **WHEN** 两次生成都在路径「React →」下产生 label 为「useState」的节点 -- **THEN** 两次生成中该节点的 ID 相同 - -#### Scenario: Same label in different paths generates different IDs -- **WHEN** 「useState」分别出现在「React → 基础」和「Vue → 基础」两条路径下 -- **THEN** 两个节点的 ID 不同 - -### Requirement: Incremental generation prompt -系统 SHALL 在已有树结构时使用增量 prompt 替代全量 prompt。增量 prompt SHALL 将现有树的完整 Markdown 表示(含 node_id)和语料内容一起发送给 LLM,SHALL 要求模型输出 JSON 操作列表而非完整树。 - -增量 prompt 的输出格式 SHALL 为: -```json -{ - "analysis": "新内容主要补充了useEffect的清理机制细节...", - "operations": [ - { "op": "add_child", "parent_id": "n3", "node": { "label": "useEffect", "summary": "处理副作用的核心Hook" } }, - { "op": "update", "node_id": "n7", "changes": { "summary": "新认知: 闭包陷阱的根因是..." } }, - { "op": "merge", "from_id": "n12", "to_id": "n5" }, - { "op": "noop" } - ] -} -``` - -#### Scenario: Incremental generation adds new node -- **WHEN** 现有树包含「React → useState」,新对话讨论了 useEffect,用户触发更新 -- **THEN** 模型输出 `add_child` 操作将 useEffect 添加到 React 下 - -#### Scenario: Incremental generation updates existing node -- **WHEN** 新对话补充了 useState 闭包陷阱的深入解释 -- **THEN** 模型输出 `update` 操作修改 useState 节点的 summary - -#### Scenario: Incremental generation detects redundant concepts -- **WHEN** 现有树中两个节点实际是同一概念的不同表述 -- **THEN** 模型输出 `merge` 操作合并两个节点 - -#### Scenario: No meaningful new content -- **WHEN** 新对话内容与现有树完全无关或没有实质补充 -- **THEN** 模型输出 `{"operations": [{"op": "noop"}]}`,树保持不变 - -### Requirement: Operation executor -系统 SHALL 提供操作执行器,将模型输出的操作指令应用到现有树。执行器 SHALL: - -- `add_child`:查找 parent_id 节点,追加新节点到其 children 末尾 -- `update`:查找 node_id 节点,合并非破坏性修改(仅更新指定的 label 或 summary) -- `merge`:将 from_id 的 children 合并到 to_id,从 from_id 的父节点 children 中移除 -- `delete_leaf`:删除叶子节点,有 children 的节点拒绝删除 -- 无效 node_id:静默跳过,记录日志 -- `editedByUser: true` 的节点 SHALL 被操作执行器保护,拒绝 update/merge/delete - -#### Scenario: Operation applied successfully -- **WHEN** 模型输出 3 个有效操作 -- **THEN** 树结构精确反映所有操作,变更记录为 3 - -#### Scenario: Invalid node_id skipped -- **WHEN** 模型输出的操作引用不存在的 node_id -- **THEN** 该操作被静默跳过,不影响其他操作,日志记录警告 - -#### Scenario: Protected node not overwritten -- **WHEN** 模型输出 `update` 操作修改 `editedByUser: true` 的节点 -- **THEN** 操作被拒绝,节点保持编辑后的内容 - -### Requirement: Incremental generation fallback -增量生成解析失败时,系统 SHALL 自动降级为全量 Markdown 再生。降级时 SHALL 在界面显示提示:「增量生成失败,已使用全量模式」。 - -#### Scenario: Incremental parse failure falls back to full regeneration -- **WHEN** 模型返回的 JSON 不包含有效 operations 数组,或 JSON 解析失败 -- **THEN** 系统降级使用全量 Markdown 解析,旧树被替换 - -### Requirement: First-time vs incremental mode selection -系统 SHALL 根据图谱状态选择生成模式: -- 树为空(首次生成)→ 全量模式 -- 树非空 → 增量模式 -- 用户可在设置中强制选择「全量重建」 - -#### Scenario: First generation uses full mode -- **WHEN** 图谱 tree 为空,用户触发生成 -- **THEN** 系统使用全量 prompt - -#### Scenario: Subsequent generation uses incremental mode -- **WHEN** 图谱 tree 已包含节点,用户触发生成 -- **THEN** 系统默认使用增量 prompt diff --git a/openspec/specs/mindmap-canvas-rendering/spec.md b/openspec/specs/mindmap-canvas-rendering/spec.md index e97f675..1d7d122 100644 --- a/openspec/specs/mindmap-canvas-rendering/spec.md +++ b/openspec/specs/mindmap-canvas-rendering/spec.md @@ -18,7 +18,7 @@ MindMapNode 到 React Flow 数据格式的转换 SHALL 通过 `treeToFlow()` 完 - **THEN** 所有节点和边正确渲染,dagre 自动计算层级位置 ### Requirement: nodeTypes and edgeTypes -系统 SHALL 注册自定义节点类型 `'mindmap'` 和自定义边类型 `'mindmap'`。节点 SHALL 显示 label 文本、summary 副文本、✎ 编辑标记和 💬N 来源徽章。边 SHALL 使用 smoothstep 贝塞尔曲线。 +系统 SHALL 注册自定义节点类型 `'mindmap'` 和自定义边类型 `'mindmap'`。节点 SHALL 显示 label 文本、summary 副文本、✎ 编辑标记。边 SHALL 使用 smoothstep 贝塞尔曲线。 ### Requirement: Responsive sizing 系统 SHALL 在容器尺寸变化时自动调整画布。面板通过 flex 布局填充可用空间。 diff --git a/openspec/specs/mindmap-content-selection/spec.md b/openspec/specs/mindmap-content-selection/spec.md deleted file mode 100644 index 8b13789..0000000 --- a/openspec/specs/mindmap-content-selection/spec.md +++ /dev/null @@ -1 +0,0 @@ - diff --git a/openspec/specs/mindmap-corpus/spec.md b/openspec/specs/mindmap-corpus/spec.md deleted file mode 100644 index 1a6501e..0000000 --- a/openspec/specs/mindmap-corpus/spec.md +++ /dev/null @@ -1,106 +0,0 @@ -## ADDED Requirements - -### Requirement: CorpusEntry data model -系统 SHALL 定义语料条目 `CorpusEntry` 类型,表达一条消息到图谱的关联: - -``` -CorpusEntry { - id: string // UUID - messageId: string // 指向 conversationStore 中的 Message - selectedText?: string // 选中文本片段(原始回答的子文本) - range?: { start: number; end: number } // 选中文本在原始回答中的字符偏移 - note?: string // 用户备注 - enabled: boolean // 是否参与生成,默认 true - addedAt: number // 添加时间戳 -} -``` - -`selectedText` SHALL 是 `messageId` 指向的 Message 的子文本。生成时:有 `selectedText` 时 SHALL 使用片段内容;无 `selectedText` 时 SHALL 使用整条消息内容。 - -#### Scenario: CorpusEntry from full message -- **WHEN** 用户将整条 AI 回答加入语料库 -- **THEN** 创建 CorpusEntry,`messageId` 指向该消息,`selectedText` 为 undefined - -#### Scenario: CorpusEntry from text fragment -- **WHEN** 用户选中 AI 回答中的一段文本加入语料库 -- **THEN** 创建 CorpusEntry,`messageId` 指向该消息,`selectedText` 为选中文本,`range` 记录字符偏移 - -### Requirement: MindMap corpus field -`MindMap` 数据模型 SHALL 新增 `corpus` 字段,类型为 `CorpusEntry[]`。新建图谱 SHALL 默认 `corpus` 为空数组。图谱删除时 SHALL 同时删除其语料数据。 - -#### Scenario: New mindmap has empty corpus -- **WHEN** 用户创建新图谱 -- **THEN** 图谱的 `corpus` 初始化为空数组 `[]` - -#### Scenario: Corpus persists with mindmap -- **WHEN** 用户刷新页面或重新打开应用 -- **THEN** 图谱的 `corpus` 数据随图谱一起从 IndexedDB 恢复 - -### Requirement: MindMap monitored conversations -`MindMap` 数据模型 SHALL 新增 `monitoredConversationIds` 字段,类型为 `string[]`。该字段记录图谱监听的对话列表。被监听对话产生新 AI 回答时,系统 SHALL 自动将该回答加入图谱语料库并在 5 秒 debounce 后触发生成。 - -#### Scenario: Monitor a conversation -- **WHEN** 用户在图谱设置中将对话 X 加入监听列表 -- **THEN** 对话 X 的 ID 加入 `monitoredConversationIds` - -#### Scenario: New answer in monitored conversation auto-added to corpus -- **WHEN** 对话 X 被图谱监听,AI 在对话 X 中完成回复 -- **THEN** 系统自动创建 CorpusEntry(messageId 指向新回复),加入图谱 corpus,并在 5 秒 debounce 后触发图谱生成 - -#### Scenario: Unmonitor a conversation -- **WHEN** 用户从监听列表移除对话 X -- **THEN** 对话 X 的新回复不再自动加入图谱语料库 - -### Requirement: Add corpus entry to mindmap -系统 SHALL 支持用户将一条消息直接加入图谱语料库。操作方式 SHALL 包括:消息旁的「加入语料库」按钮、选中文本后右键「加入语料库」。操作 SHALL 直接创建 CorpusEntry 并加入当前活跃图谱的 corpus。 - -#### Scenario: Add full message via button -- **WHEN** 用户在 AI 回答旁点击「加入语料库」按钮 -- **THEN** 创建 CorpusEntry 加入当前活跃图谱的 corpus,`selectedText` 为 undefined - -#### Scenario: Add text fragment via button -- **WHEN** 用户选中 AI 回答中的一段文本后点击「加入语料库」按钮 -- **THEN** 通过 `onMouseDown` 捕获选中文本,创建 CorpusEntry,`selectedText` 为选中文本,`range` 记录偏移 - -#### Scenario: No active mindmap -- **WHEN** 用户点击「加入语料库」但没有打开的图谱 -- **THEN** 操作静默跳过,不执行任何动作 - -### Requirement: Batch add conversation messages to corpus -系统 SHALL 支持将整个对话的所有 AI 回复批量加入图谱语料库。操作 SHALL 为该对话的每条 AI 回复创建独立的 CorpusEntry。 - -#### Scenario: Add entire conversation -- **WHEN** 用户在图谱面板选择「将对话 X 加入语料库」 -- **THEN** 系统为对话 X 的每条 AI 回复创建一条 CorpusEntry,加入当前图谱 corpus - -### Requirement: Remove corpus entry from mindmap -系统 SHALL 支持从图谱语料库中移除语料条目。移除操作 SHALL 不删除原始对话或消息数据。 - -#### Scenario: Remove single corpus entry -- **WHEN** 用户在语料库界面删除某条 CorpusEntry -- **THEN** 该条目从 `MindMap.corpus` 数组移除 - -### Requirement: Toggle corpus entry enabled state -系统 SHALL 支持切换语料条目的 `enabled` 状态。`enabled: false` 的条目 SHALL 不参与图谱生成,但保留在语料库中。 - -#### Scenario: Disable and re-enable corpus entry -- **WHEN** 用户关闭某条语料的启用开关 -- **THEN** 该条目 `enabled` 变为 false;再次打开恢复为 true - -### Requirement: Corpus entry notes -系统 SHALL 支持用户为语料条目添加备注。备注 SHALL 存储在 `CorpusEntry.note` 字段中。 - -### Requirement: Corpus UI in mindmap panel -系统 SHALL 在脑图面板中展示当前图谱的语料库列表。列表 SHALL 按来源对话分组折叠显示。每条语料 SHALL 显示:内容摘要(selectedText 或消息前 60 字符)、启用开关、删除按钮。有备注的条目 SHALL 显示备注指示器。 - -#### Scenario: Display corpus grouped by conversation -- **WHEN** 图谱有来自多个对话的语料条目 -- **THEN** 列表按对话分组显示,每组可折叠 - -#### Scenario: Empty corpus prompt -- **WHEN** 图谱无任何语料条目 -- **THEN** 显示「暂无语料,从对话中选择内容加入语料库」提示 - -#### Scenario: Source deleted indicator -- **WHEN** 语料条目的 messageId 指向的消息已被删除 -- **THEN** 条目显示「来源已删除」标记,自动不参与生成 diff --git a/openspec/specs/mindmap-data/spec.md b/openspec/specs/mindmap-data/spec.md index 11902f9..3b1f813 100644 --- a/openspec/specs/mindmap-data/spec.md +++ b/openspec/specs/mindmap-data/spec.md @@ -1,52 +1,39 @@ ## Purpose Define the core data model for mindmap nodes and mindmap collections, supporting tree structures, source provenance tracking, and IndexedDB persistence. - ## Requirements - ### Requirement: MindMap data model 系统 SHALL 使用以下数据模型表示思维导图: ``` MindMap { - id: string // UUID - title: string // 图谱标题 - tree: MindMapNode[] // 根层级节点列表 - maxDepth?: number // 最大生成深度:3-5 或 0(自动),默认 3 - corpus: CorpusEntry[] // 语料库条目列表 - monitoredConversationIds: string[] // 监听的对话 ID 列表 - collapsedNodeIds?: string[] // 折叠节点 ID 列表 - forceFullRebuild?: boolean // 强制全量重建模式 - lastGeneratedAt?: number // 上次生成时间戳 - createdAt: number // 创建时间戳 - updatedAt: number // 更新时间戳 - generatorProviderId?: string - generatorModelId?: string + id: string + title: string + tree: MindMapNode[] + monitoredConversationIds: string[] + collapsedNodeIds?: string[] + createdAt: number + updatedAt: number } MindMapNode { - id: string // UUID - label: string // 节点显示文本 - summary: string // 节点描述 - content?: string // 可选 Markdown 内容 - contentType?: 'text' | 'markdown' // 内容类型,默认 'text' - children: MindMapNode[] // 子节点列表 - sourceConversationIds: string[] // 贡献该节点的 Conversation ID 列表 - sourceExcerpts: Record // Conversation ID → 消息文本摘录 - editedByUser: boolean // 是否被用户手动编辑过(默认 false) + id: string + label: string + summary: string + content?: string + contentType?: 'text' | 'markdown' + children: MindMapNode[] + editedByUser: boolean } +``` #### Scenario: MindMap data structure - **WHEN** 系统创建新的思维导图 -- **THEN** 生成唯一 ID,记录创建时间,tree 初始为空数组,maxDepth 设为 3 - -#### Scenario: MindMapNode with source tracking -- **WHEN** 图谱生成完成 -- **THEN** 每个节点的 `sourceConversationIds` 填充对话来源,`sourceExcerpts` 存储对应的消息摘录文本 +- **THEN** 生成唯一 ID,记录创建时间,tree 初始为空数组 #### Scenario: Edited node marks - **WHEN** 用户手动编辑节点 -- **THEN** 节点 `editedByUser` 设为 true,`sourceConversationIds` 清空 +- **THEN** 节点 `editedByUser` 设为 true #### Scenario: New node with markdown content - **WHEN** 创建 MindMapNode 且指定 `contentType: 'markdown'` 和 `content: '## Title\n\nContent'` diff --git a/openspec/specs/mindmap-generation/spec.md b/openspec/specs/mindmap-generation/spec.md index f76e47f..9a189e6 100644 --- a/openspec/specs/mindmap-generation/spec.md +++ b/openspec/specs/mindmap-generation/spec.md @@ -1,122 +1,70 @@ ## Purpose -Enable AI-powered mindmap generation from conversation history using LLM prompts, supporting both full rebuild and incremental update modes with structured JSON output. +Enable AI-powered mindmap generation from conversation history using LLM prompts with structured JSON output, supporting full tree rebuild with edited-node preservation. ## Requirements ### Requirement: Generate mindmap from conversation history -系统 SHALL 支持通过 LLM 从对话内容生成思维导图树结构。输入内容 SHALL 优先使用图谱语料库内容。生成模式 SHALL 根据图谱状态自动选择:首次生成使用全量模式(输出完整 Markdown/JSON 树),后续生成使用增量模式(输出操作指令)。增量模式 SHALL 提供旧树摘要而非完整树,减少 prompt token 消耗。生成 prompt SHALL 指示 LLM 在节点中使用 `content` 字段承载 Markdown 格式内容(加粗、斜体、行内代码、删除线、代码块、表格),并在输出 JSON 中标注 `contentType: 'markdown'`。系统 SHALL 在解析 JSON 响应时识别 `contentType` 和 `content` 字段并存储到 MindMapNode。 +系统 SHALL 支持通过 LLM 从对话内容生成思维导图树结构。生成 SHALL 使用全量 JSON 模式输出完整树结构。生成 prompt SHALL 指示 LLM 在节点中使用 `content` 字段承载 Markdown 格式内容(加粗、斜体、行内代码、删除线、代码块、表格),并在输出 JSON 中标注 `contentType: 'markdown'`。系统 SHALL 在解析 JSON 响应时识别 `contentType` 和 `content` 字段并存储到 MindMapNode。`editedByUser: true` 的节点 SHALL 在 `mergeEditedNodes` 中被保护不被覆盖。 -#### Scenario: First-time generation -- **WHEN** 用户对未包含树的图谱触发生成 -- **THEN** 系统使用全量模式,构建完整 prompt,LLM 输出完整树结构 - -#### Scenario: Incremental generation -- **WHEN** 图谱已有树结构,用户传入新内容触发生成 -- **THEN** 系统使用增量模式,提供旧树摘要,LLM 输出操作指令 +#### Scenario: Full regeneration from conversation +- **WHEN** 用户发送消息触发脑图生成 +- **THEN** 系统构建包含现有树上下文的 prompt,LLM 输出完整树结构,`mergeEditedNodes` 保护用户编辑 #### Scenario: AI generates node with markdown content -- **WHEN** 语料包含代码示例,且生成模式为全量或增量 +- **WHEN** 语料包含代码示例 - **THEN** LLM 输出的节点可能包含带 `contentType: 'markdown'` 的 `content` 字段(含代码块或表格) - **AND** 系统正确解析并存储 `contentType` 和 `content` 字段 -### Requirement: Markdown to tree parsing -系统 SHALL 将 LLM 返回的 Markdown 文本解析为 MindMapNode 树结构。解析规则: -- `# 标题` → 根节点(Tree 数组中添加一项) -- `## 标题` → 根节点的直接子节点 -- `### 标题` → 二级子节点 -- 标题中包含 `—` 时,`—` 之前为 label,之后为 summary -- 非标题行作为其上方最近节点的 summary 累积 - -#### Scenario: Parse well-formed markdown -- **WHEN** LLM 返回标准 Markdown 标题结构(# / ## / ###) -- **THEN** 系统正确解析为三层嵌套的 MindMapNode 树,每个节点的 label 和 summary 正确提取 - -#### Scenario: Parse markdown with separator -- **WHEN** LLM 返回 `## useState — React 中最基础的状态 Hook` -- **THEN** 节点 label 为 "useState",summary 为 "React 中最基础的状态 Hook" - -#### Scenario: Parse malformed markdown -- **WHEN** LLM 返回不符合约定的文本(如没有 # 标题、嵌套超过 3 层、纯文本无结构) -- **THEN** 系统做防御性处理:忽略非标题行,超过 3 层的 ### 视为 ### 级别,不抛出异常 - -### Requirement: Incremental update via full regeneration -系统 SHALL 保留全量再生作为降级路径。当增量操作解析失败时,SHALL 自动降级到全量 Markdown 再生。`editedByUser: true` 的节点 SHALL 在操作执行器中被保护不被覆盖。用户 SHALL 可通过设置强制选择「全量重建」。 - -#### Scenario: Tree grows across multiple sessions -- **WHEN** 同一个图谱关联了 3 个 Conversation,每个对话涉及同一主题的不同方面 -- **THEN** 触发同步后,增量操作整合新知识点,保持已有结构稳定 - -#### Scenario: Full rebuild on demand -- **WHEN** 用户在图谱设置中选择「全量重建」模式并触发生成 -- **THEN** 系统使用全量 prompt 重新生成完整树 - ### Requirement: Generation state management -系统 SHALL 在生成过程中管理以下状态:idle、generating、complete、error。生成中 SHALL 实时渲染部分树并显示进度信息。 +系统 SHALL 在生成过程中管理以下状态:idle、generating、complete、error。生成中 SHALL 显示加载指示器。 #### Scenario: Generating state - **WHEN** LLM 正在生成图谱内容 -- **THEN** 图谱面板显示实时更新的树结构和进度(已生成 N 个主题 · 深度 X/3) +- **THEN** 图谱面板显示加载动画(骨架屏) #### Scenario: Generation error - **WHEN** LLM 调用失败 -- **THEN** 系统显示错误提示并提供"重试"按钮,保留最后一次成功渲染的树结构不变 - -### Requirement: Auto-sync mode -系统 SHALL 支持自动同步模式。当 Conversation 的 `autoSync` 为 true 时,每次 AI 回复完成后,系统 SHALL 在 5 秒 debounce 后自动触发图谱生成。 - -#### Scenario: Disable auto-sync -- **WHEN** 用户关闭 Conversation 的自动同步开关 -- **THEN** 后续 AI 回复不再自动触发图谱生成,用户需手动点击"更新图谱" - -### Requirement: Manual sync trigger -系统 SHALL 在思维导图面板提供「更新图谱」按钮。生成 SHALL 从图谱语料库读取启用的内容;如果语料库为空,SHALL 提示用户添加语料。 - -### Requirement: Monitored conversation auto-generation -被 `monitoredConversationIds` 监听的对话产生新 AI 回答时,系统 SHALL 自动将该回答加入图谱语料库,并在 5 秒 debounce 后自动触发图谱生成。 - -#### Scenario: Auto-generation from monitored conversation -- **WHEN** 图谱监听对话 X,对话 X 中 AI 完成回复 -- **THEN** 系统自动创建 CorpusEntry 加入图谱 corpus,5 秒后触发图谱生成 - -### Requirement: Generation model selection -系统 SHALL 允许用户为图谱生成指定使用的模型。默认 SHALL 使用当前 Conversation 的模型。用户 SHALL 可在图谱设置中覆盖为任意可用模型。 +- **THEN** 系统显示错误提示,保留最后一次成功渲染的树结构不变 ### Requirement: Depth and breadth constraints -系统 SHALL 限制图谱生成的树深度(最多 N 层,N 由图谱的 `maxDepth` 配置决定,默认为 3,可配置为 1-5 或自动模式)和每层节点数(最多 10 个直接子节点)。LLM prompt 中 SHALL 明确这些限制。 +系统 SHALL NOT 在解析阶段对 LLM 生成的脑图施加硬编码深度上限或每节点子节点数量上限。`parseJsonToTree` 和 `jsonNodeToMindMapNode` SHALL 接受 LLM 返回的任意深度树结构和任意子节点数量,不做截断。 -#### Scenario: Max depth enforcement at configured depth -- **WHEN** 图谱 `maxDepth` 为 4,LLM 返回超过 4 层的标题(如 #####) -- **THEN** 解析器忽略第五层及更深层的标题 +`mindmapTreeToContext` 中的 `maxNodes=200` 序列化截断 SHALL 保持不变(这是 prompt 上下文限制,而非树结构限制)。 -#### Scenario: Breadth constraint enforcement -- **WHEN** LLM 返回某个节点超过 10 个直接子节点 -- **THEN** 系统仅保留前 10 个,超出部分忽略 +#### Scenario: LLM returns deep tree beyond old limit +- **WHEN** LLM 返回 8 层深的树结构 +- **THEN** 解析器完整保留所有 8 层节点,不做截断 -#### Scenario: Auto mode depth -- **WHEN** 图谱为自动模式(maxDepth = 0),LLM 返回任意深度内容 -- **THEN** prompt 不指定层数限制,解析器安全上限为 6 层 +#### Scenario: LLM returns many children per node +- **WHEN** LLM 返回某个节点有 15 个直接子节点 +- **THEN** 解析器完整保留所有 15 个子节点,不做截断 + +#### Scenario: Existing tree data unaffected +- **WHEN** 旧持久化数据被加载 +- **THEN** 系统正常渲染,现有树结构不受影响 ### Requirement: Few-shot prompt examples 系统 SHALL 在 LLM system prompt 中包含高质量示例,展示按概念维度分类的树结构和内容风格。 +> 注:当前 `buildFullMindmapPrompt()` 未包含 few-shot 示例,仅有结构指令。此为待实现的优化。 + ### Requirement: Structured JSON output (preferred format) -系统 SHALL 在 provider 支持时使用 JSON mode 约束 LLM 输出结构化 JSON。JSON 解析失败时 SHALL 自动降级为 Markdown 解析。 +系统 SHALL 始终使用 JSON mode 约束 LLM 输出结构化 JSON(当 provider 支持 `response_format: json_object` 时)。系统 SHALL NOT 使用 `` HTML 注释标记模式。JSON 解析 SHALL 直接使用 `JSON.parse`,不做多阶段容错修复或 Markdown 回退。解析失败时 SHALL 保持当前树不变。 #### Scenario: JSON mode supported -- **WHEN** 当前 provider 的 `apiEndpoint` 匹配 OpenAI/DeepSeek/SiliconFlow +- **WHEN** 当前 provider 的 `apiEndpoint` 匹配已知支持列表(OpenAI/DeepSeek/SiliconFlow/OpenRouter/Google) - **THEN** 系统使用 `response_format: { type: "json_object" }` 请求 -#### Scenario: JSON parse failure fallback +#### Scenario: JSON parse failure - **WHEN** LLM 返回非 JSON 内容或 JSON 解析失败 -- **THEN** 系统自动降级使用 Markdown 解析 +- **THEN** 系统保持当前树结构不变,不触发树更新 ### Requirement: Quality validation -系统 SHALL 在生成完成后执行质量校验:重复节点、空节点、深度超限、广度超限。警告 SHALL 以非阻塞形式显示。 +系统 SHALL 在生成完成后执行质量校验:重复节点、空节点。警告 SHALL 以非阻塞形式显示。 -### Requirement: Source conversation tracking -系统 SHALL 在生成节点时填充 `sourceConversationIds` 字段,通过 prompt 中的 `[src:convId/msgId]` 标识实现。节点解析后 SHALL 存储实际对话 ID 和消息摘录。 +> 注:当前未实现生成后质量校验逻辑。 -#### Scenario: Node without explicit attribution -- **WHEN** LLM 未标注来源 -- **THEN** 节点使用输入消息列表中的最新对话 ID 作为降级来源 +#### Scenario: Duplicate node detected +- **WHEN** 生成结果包含 label 完全相同的同级节点 +- **THEN** 系统输出警告但不自动修改树结构 diff --git a/openspec/specs/mindmap-panel-layout/spec.md b/openspec/specs/mindmap-panel-layout/spec.md index 5a73884..978037f 100644 --- a/openspec/specs/mindmap-panel-layout/spec.md +++ b/openspec/specs/mindmap-panel-layout/spec.md @@ -1,57 +1,50 @@ ## Purpose -脑图面板的三栏布局、可调整宽度、工具栏及附属区域的交互规范。 +脑图面板的布局、工具栏、会话关联及交互规范。 ## Requirements ### Requirement: Right panel layout -系统 SHALL 在主内容区右侧提供思维导图面板。面板 SHALL 位于侧边栏和聊天区之后,形成三栏布局。面板 SHALL 仅在全局开关开启时显示。 +系统 SHALL 在主内容区右侧提供思维导图面板。面板 SHALL 位于侧边栏和聊天区之后,形成三栏布局。面板 SHALL 通过顶栏 Network 图标按钮切换显示/隐藏。 #### Scenario: Panel visible -- **WHEN** 用户开启全局图谱面板开关 -- **THEN** 右侧显示图谱面板(默认宽度 350px),聊天区收缩以适应面板 +- **WHEN** 用户点击顶栏 Network 图标开启面板 +- **THEN** 右侧显示图谱面板,聊天区收缩以适应面板 #### Scenario: Panel hidden -- **WHEN** 用户关闭全局图谱面板开关 +- **WHEN** 用户点击顶栏 Network 图标关闭面板 - **THEN** 右侧图谱面板隐藏,聊天区恢复完整宽度 -### Requirement: Resizable panel -系统 SHALL 允许用户通过拖动面板左侧分隔线调整面板宽度。宽度范围 SHALL 为 200px-600px。 - ### Requirement: Global toggle button -系统 SHALL 在聊天区顶栏提供全局图谱面板的开关按钮。 +系统 SHALL 在聊天区顶栏提供图谱面板的开关按钮(Network 图标)。面板 SHALL 默认显示。 ### Requirement: Panel toolbar 系统 SHALL 在思维导图面板顶部提供工具栏: -- **图谱选择器**: 下拉菜单,列出所有已创建的图谱 -- **更新图谱按钮**: 触发手动同步生成(物料优先) -- **自动同步开关**: 切换关联 Conversation 的 autoSync 状态 -- **导出下拉菜单**: 包含 PNG 1x / PNG 2x / PNG 3x / SVG / Markdown 导出选项 -- **图谱设置按钮**: 打开图谱生成设置对话框 -- **关闭按钮**: 关闭面板 +- **图谱选择器**: 下拉菜单(shadcn Select),列出所有已创建的图谱,切换时更新活跃图谱 +- **全屏按钮**: Maximize2/Minimize2 图标,fixed inset-0 z-50 覆盖全屏 +- **导出下拉菜单**: Download 图标 + DropdownMenu,包含 PNG 1x / PNG 2x / PNG 3x / SVG / Markdown +- **关闭按钮**: X 图标,隐藏面板 #### Scenario: Export dropdown menu -- **WHEN** 用户点击导出按钮旁的下拉箭头 +- **WHEN** 用户点击导出下拉按钮 - **THEN** 展开菜单显示 PNG 1x、PNG 2x、PNG 3x、SVG、Markdown 五个选项 -### Requirement: New conversation dialog with mindmap association -系统 SHALL 在用户创建新对话时弹出对话框,询问思维导图关联方式:不关联、关联到已有图谱(单选下拉)、创建新图谱。同时提供"开启自动同步"复选框。 - -### Requirement: Responsive behavior -系统 SHALL 在小屏幕(<768px)下隐藏思维导图面板。 +### Requirement: Conversation linking area +系统 SHALL 在面板工具栏下方提供「关联会话」可折叠区域。显示已关联的会话列表(当前 Conversation 标注"(当前)")。提供「关联当前」按钮(Link2 图标)将当前活跃 Conversation 关联到图谱,以及每个已关联项的取消关联按钮(X 图标)。关联的 Conversation 的 AI 回复会自动触发脑图生成。 -### Requirement: Material pool area -系统 SHALL 在脑图面板工具栏下方提供「生成物料」可折叠区域。显示物料列表(来源对话 → 消息摘要),支持单个删除和全部清空操作。 +#### Scenario: Link current conversation +- **WHEN** 用户点击「关联当前」按钮 +- **THEN** 当前活跃 Conversation 的 ID 被添加到图谱的 `monitoredConversationIds` 数组 -#### Scenario: Material pool visible -- **WHEN** 脑图面板打开且物料池非空 -- **THEN** 显示物料列表,标题显示物料数量 +#### Scenario: Unlink conversation +- **WHEN** 用户悬停在已关联会话上并点击 X 按钮 +- **THEN** 该 Conversation 从 `monitoredConversationIds` 中移除 -### Requirement: Reasoning content display -系统 SHALL 在脑图生成过程中,如果 LLM 返回 reasoning 内容,展示为可折叠的「AI 思考过程」区域。 +### Requirement: Node count display +系统 SHALL 在面板工具栏中显示当前图谱的节点总数(递归统计所有层级的节点)。 -### Requirement: Generation progress indicator -系统 SHALL 在脑图生成过程中显示进度信息:已生成节点数和当前最大深度。生成完成后自动消失。 +### Requirement: Responsive behavior +系统 SHALL 在小屏幕(<768px)下隐藏思维导图面板(`hidden md:block`)。 -### Requirement: Quality validation warnings -系统 SHALL 在生成完成后,如有校验警告,在面板底部以黄色警告形式显示。 +### Requirement: New conversation dialog with mindmap association +系统 SHALL 在用户创建新对话时弹出 NewConversationDialog,允许用户选择关联到已有图谱(下拉选择)或输入名称创建新图谱。 diff --git a/openspec/specs/mindmap-streaming-preview/spec.md b/openspec/specs/mindmap-streaming-preview/spec.md deleted file mode 100644 index cb19937..0000000 --- a/openspec/specs/mindmap-streaming-preview/spec.md +++ /dev/null @@ -1,41 +0,0 @@ -## ADDED Requirements - -### Requirement: Real-time tree update during generation -系统 SHALL 在 LLM 流式返回内容时,每收到一个 chunk 后实时解析当前累积的完整文本为 MindMapNode 树并更新渲染。更新 SHALL 使用 150ms 防抖以避免过度渲染。 - -#### Scenario: First chunk renders root node -- **WHEN** LLM 开始流式返回,第一个 chunk 包含 `# React Hooks` -- **THEN** 树视图中立即显示根节点 "React Hooks",状态为「生成中…」 - -#### Scenario: Subsequent chunks add child nodes -- **WHEN** LLM 继续返回 `## useState`、`### 基本用法` -- **THEN** 树视图实时扩展:根节点下出现子节点 "useState",其下展开 "基本用法" - -#### Scenario: Partial markdown produces incomplete tree -- **WHEN** LLM 返回不完整的 Markdown(如 `## useState —— 状态管理 Hook\n###` 后中断) -- **THEN** 解析器产出可渲染的部分树("useState" 节点存在,其下子节点待后续文本补全) - -### Requirement: Generation progress indicator -系统 SHALL 在脑图生成过程中显示进度信息。进度信息 SHALL 包含:已生成的节点总数、当前最大深度层级。 - -#### Scenario: Show progress during generation -- **WHEN** 生成过程已产出 12 个节点,最大深度为 2 层 -- **THEN** 面板顶部显示「已生成 12 个主题 · 深度 2/3」 - -### Requirement: Reasoning content display -对于支持 reasoning 的模型(如 deepseek-reasoner),系统 SHALL 提取 `reasoning_content` 并在脑图面板中展示为可折叠的「AI 思考过程」区域。折叠默认状态为展开。 - -#### Scenario: Show reasoning content -- **WHEN** LLM 流式返回中包含 `reasoning_content` delta -- **THEN** 系统在脑图面板顶部渲染可折叠区域,标题为「AI 思考过程」,内容为 reasoning 文本 - -#### Scenario: No reasoning content -- **WHEN** LLM 流式返回中不含 `reasoning_content` -- **THEN** 不渲染思考过程区域,不显示空白占位 - -### Requirement: Stream interruption handling -系统 SHALL 在流式解析失败(如 parseMarkdownToTree 抛出异常)时继续尝试后续 chunk 的解析,不中断整个生成流程。最终 SHALL 使用最后一次成功解析的树作为结果。 - -#### Scenario: Intermediate parse fails -- **WHEN** 某个 chunk 的累积文本导致 parseMarkdownToTree 抛出异常 -- **THEN** 系统忽略该次解析失败,保留上一次成功解析的树继续渲染,等待后续 chunk 重试 diff --git a/openspec/specs/mindmap-tree-view/spec.md b/openspec/specs/mindmap-tree-view/spec.md index 57ce1a1..9d92b29 100644 --- a/openspec/specs/mindmap-tree-view/spec.md +++ b/openspec/specs/mindmap-tree-view/spec.md @@ -1,7 +1,7 @@ ## MODIFIED Requirements ### Requirement: Tree rendering -系统 SHALL 使用 React Flow(@xyflow/react)+ dagre 布局渲染 MindMapNode 树结构,替代原有 DOM 缩进列表方式。渲染内容 SHALL 包括:自定义节点(label + summary + ✎/💬N)、节点间 smoothstep 贝塞尔连接线、Background 网格、Controls 缩放控件、MiniMap 导航。树 SHALL 使用层级布局(dagre rankdir: LR,左→右)。 +系统 SHALL 使用 React Flow(@xyflow/react)+ dagre 布局渲染 MindMapNode 树结构,替代原有 DOM 缩进列表方式。渲染内容 SHALL 包括:自定义节点(label + summary + ✎ 编辑标记)、节点间 smoothstep 贝塞尔连接线、Background 网格、Controls 缩放控件、MiniMap 导航。树 SHALL 使用层级布局(dagre rankdir: LR,左→右)。 #### Scenario: Render multi-level tree - **WHEN** 图谱包含 4 层节点结构 @@ -29,9 +29,9 @@ ### Requirement: Node visual states 系统 SHALL 实现以下节点视觉状态: - **编辑标记**: `editedByUser === true` → 显示 Pencil 图标 -- **来源标记**: `sourceConversationIds.length > 0` → 显示 💬N 徽章 - **有子节点**: 显示 ChevronDown/ChevronRight 展开按钮 - **叶子节点**: 无展开按钮 +- **Markdown 内容节点**: `contentType === 'markdown'` → 在节点内渲染 Markdown 正文(react-markdown + remarkGfm) ### Requirement: Empty state tree 为空时 SHALL 显示 "此图谱暂无内容" 提示。 diff --git a/openspec/specs/model-provider/spec.md b/openspec/specs/model-provider/spec.md index ff5ce23..5124de4 100644 --- a/openspec/specs/model-provider/spec.md +++ b/openspec/specs/model-provider/spec.md @@ -10,6 +10,7 @@ Provider { apiEndpoint: string // API 基础 URL,如 "https://api.openai.com/v1" apiKey: string // API 密钥 models: Model[] // 该提供商下的可用模型列表 + supportsJsonMode: boolean // 是否支持 JSON mode(detectJsonMode 自动检测) createdAt: number // 创建时间戳 updatedAt: number // 更新时间戳 } @@ -26,15 +27,11 @@ Model { - **THEN** 系统生成唯一 ID,记录创建和更新时间,存储所有必填字段 ### Requirement: Add provider -系统 SHALL 允许用户添加自定义模型提供商。用户 MUST 提供提供商名称、API 端点 URL 和 API 密钥。添加提供商时,系统 SHALL 尝试连接 API 端点验证可用性。用户 SHALL 可选择手动输入模型列表,或通过 API 自动获取可用模型列表(如果端点支持)。 +系统 SHALL 允许用户添加自定义模型提供商。用户 MUST 提供提供商名称、API 端点 URL 和 API 密钥。用户 SHALL 可选择手动输入模型列表,或通过 API 自动获取可用模型列表(如果端点支持)。 #### Scenario: Add provider with valid configuration - **WHEN** 用户填写提供商名称、API 端点和 API 密钥,并提交 -- **THEN** 系统验证 API 连接,成功后保存提供商配置,并显示在提供商列表中 - -#### Scenario: Add provider with invalid endpoint -- **WHEN** 用户填写的 API 端点无法连接 -- **THEN** 系统显示连接失败错误信息,不保存该提供商配置,允许用户修改后重试 +- **THEN** 系统保存提供商配置,并显示在提供商列表中 #### Scenario: Add provider and auto-fetch models - **WHEN** 用户添加提供商后选择"获取模型列表" @@ -60,7 +57,7 @@ Model { - **THEN** 系统更新模型列表,模型选择下拉框同步更新 ### Requirement: Delete provider -系统 SHALL 允许用户删除已有的提供商配置。删除前 SHALL 显示确认对话框。删除提供商后,使用该提供商的会话 SHALL 标记为"提供商不可用"状态,但不删除会话数据。 +系统 SHALL 允许用户删除已有的提供商配置。删除前 SHALL 显示确认对话框。删除提供商后,使用该提供商的会话 SHALL 显示"当前模型不可用"状态,但不删除会话数据。 #### Scenario: Delete provider with confirmation - **WHEN** 用户点击删除提供商并确认 @@ -68,7 +65,7 @@ Model { #### Scenario: Delete provider affects conversations - **WHEN** 用户删除一个提供商,且存在使用该提供商的会话 -- **THEN** 这些会话标记为"提供商不可用",会话历史保留但不可发送新消息 +- **THEN** 这些会话在 ChatPage 中显示"当前模型不可用",会话历史保留但不可发送新消息 ### Requirement: Provider preset templates 系统 SHALL 提供常见提供商的预设模板(OpenAI、Anthropic/via compatible endpoint、DeepSeek、Ollama),一键填充 API 端点和默认模型列表。用户 SHALL 仍可修改预设的字段值。 From ea30b386ed0df07ad1cacc77435c8c8c0c6a7073 Mon Sep 17 00:00:00 2001 From: web4zn Date: Fri, 8 May 2026 21:53:45 +0800 Subject: [PATCH 2/5] feat: extract reusable FlowShell component with rich card nodes FlowShell: zero-dependency React Flow canvas shell with dark/light theme, dagre LR/TB layout, hierarchical gradient accent bars, smoothstep edges, auto-arrange button. Replaces MindMapNodeComponent and MindMapEdgeComponent. Ultraworked with Sisyphus Co-authored-by: Sisyphus --- src/components/flow-shell/FlowEdge.tsx | 19 ++ src/components/flow-shell/FlowNode.tsx | 64 ++++ src/components/flow-shell/FlowShell.tsx | 219 ++++++++++++++ src/components/flow-shell/flow-shell.css | 280 ++++++++++++++++++ src/components/flow-shell/index.ts | 16 + src/features/mindmap/MindMapEdgeComponent.tsx | 26 -- src/features/mindmap/MindMapNodeComponent.tsx | 89 ------ src/features/mindmap/MindMapTree.tsx | 157 +++++----- .../mindmap/__tests__/MindMapTree.test.tsx | 26 +- src/features/mindmap/types.ts | 1 - src/lib/__tests__/mindmap-layout.test.ts | 2 - src/lib/mindmap-layout.ts | 1 - 12 files changed, 694 insertions(+), 206 deletions(-) create mode 100644 src/components/flow-shell/FlowEdge.tsx create mode 100644 src/components/flow-shell/FlowNode.tsx create mode 100644 src/components/flow-shell/FlowShell.tsx create mode 100644 src/components/flow-shell/flow-shell.css create mode 100644 src/components/flow-shell/index.ts delete mode 100644 src/features/mindmap/MindMapEdgeComponent.tsx delete mode 100644 src/features/mindmap/MindMapNodeComponent.tsx diff --git a/src/components/flow-shell/FlowEdge.tsx b/src/components/flow-shell/FlowEdge.tsx new file mode 100644 index 0000000..cc16268 --- /dev/null +++ b/src/components/flow-shell/FlowEdge.tsx @@ -0,0 +1,19 @@ +import { BaseEdge, getSmoothStepPath, type EdgeProps } from '@xyflow/react' +import { memo } from 'react' + +function FlowEdgeComponent(props: EdgeProps & { data?: { color?: string } }) { + const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data } = props + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }) + const color = data?.color ?? 'var(--flow-pattern)' + + return +} + +export default memo(FlowEdgeComponent) diff --git a/src/components/flow-shell/FlowNode.tsx b/src/components/flow-shell/FlowNode.tsx new file mode 100644 index 0000000..c8be7aa --- /dev/null +++ b/src/components/flow-shell/FlowNode.tsx @@ -0,0 +1,64 @@ +import { memo } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import type { FlowNodeData } from './index' + +function FlowNodeComponent({ id, data, selected }: NodeProps & { data: FlowNodeData }) { + const hasMarkdown = data.contentType === 'markdown' && data.content + const depth = data.depth ?? 0 + const depthClass = depth >= 4 ? 'depth-4' : `depth-${depth}` + + return ( +
+ + + +
+ +
+ {data.hasChildren && ( + + )} + {!data.hasChildren && } + + {data.label} + + + {data.editedByUser && ( + + + + )} + +
+ + {hasMarkdown ? ( +
+ {data.content!} +
+ ) : data.summary ? ( +
{data.summary}
+ ) : null} +
+ ) +} + +export default memo(FlowNodeComponent) diff --git a/src/components/flow-shell/FlowShell.tsx b/src/components/flow-shell/FlowShell.tsx new file mode 100644 index 0000000..b8891fa --- /dev/null +++ b/src/components/flow-shell/FlowShell.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ReactFlow, + Background, + Controls, + MiniMap, + Panel, + applyNodeChanges, + applyEdgeChanges, + type Node, + type Edge, + type NodeChange, + type EdgeChange, + type ReactFlowInstance, + type NodeMouseHandler, + type OnNodeDrag, + type BackgroundVariant, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import dagre from '@dagrejs/dagre' +import './flow-shell.css' +import FlowNodeComponent from './FlowNode' +import type { FlowNodeData } from './index' + +const NODE_WIDTH = 200 +const NODE_HEIGHT = 100 +const RICH_NODE_HEIGHT = 220 + +const _nodeTypes = { flow: FlowNodeComponent } + +export interface FlowShellProps { + nodes: Node[] + edges: Edge[] + theme?: 'dark' | 'light' + layout?: 'dagre-lr' | 'dagre-tb' + fitView?: boolean + fitViewPadding?: number + minZoom?: number + maxZoom?: number + nodesDraggable?: boolean + nodesConnectable?: boolean + elementsSelectable?: boolean + deleteKeyCode?: string + onInit?: (instance: ReactFlowInstance) => void + onNodeDoubleClick?: NodeMouseHandler + onNodeContextMenu?: NodeMouseHandler + onNodeDragStop?: OnNodeDrag +} + +function applyLayout( + flowNodes: Node[], + flowEdges: Edge[], + direction: string, +): { nodes: Node[]; edges: Edge[] } { + const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})) + g.setGraph({ + rankdir: direction === 'dagre-tb' ? 'TB' : 'LR', + nodesep: 50, + ranksep: 160, + edgesep: 40, + marginx: 60, + marginy: 60, + }) + + for (const n of flowNodes) { + const hasRichContent = n.data?.contentType === 'markdown' && n.data?.content + g.setNode(n.id, { + width: NODE_WIDTH, + height: hasRichContent ? RICH_NODE_HEIGHT : NODE_HEIGHT, + }) + } + for (const e of flowEdges) { + g.setEdge(e.source, e.target) + } + + dagre.layout(g) + + const layoutedNodes = flowNodes.map((n) => { + const pos = g.node(n.id) + if (!pos) return n + return { + ...n, + position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 }, + } + }) + + return { nodes: layoutedNodes, edges: flowEdges } +} + +export default function FlowShell(props: FlowShellProps) { + const { + nodes: rawNodes, + edges: rawEdges, + theme = 'light', + layout = 'dagre-lr', + fitView = true, + fitViewPadding = 0.3, + minZoom = 0.1, + maxZoom = 2, + nodesDraggable = true, + nodesConnectable = false, + elementsSelectable = true, + deleteKeyCode = 'Delete', + onInit, + onNodeDoubleClick, + onNodeContextMenu, + onNodeDragStop, + } = props + + // Stable key: only changes when node IDs or edge connections actually differ + const structureKey = useMemo( + () => + JSON.stringify({ + n: rawNodes.map((n) => n.id), + e: rawEdges.map((e) => `${e.source}→${e.target}`), + }), + [rawNodes, rawEdges], + ) + + // Layout: dagre runs only when structure or direction changes + const layoutResult = useMemo( + () => applyLayout(rawNodes, rawEdges, layout), + // eslint-disable-next-line react-hooks/exhaustive-deps + [structureKey, layout], + ) + + const initialPositionsRef = useRef(layoutResult.nodes as Node[]) + + const [nodes, setNodes] = useState(layoutResult.nodes as Node[]) + const [edges, setEdges] = useState(layoutResult.edges as Edge[]) + + // Tree structure changed → save new layout + reset + useEffect(() => { + initialPositionsRef.current = layoutResult.nodes as Node[] + /* eslint-disable react-hooks/set-state-in-effect */ + setNodes(layoutResult.nodes as Node[]) + setEdges(layoutResult.edges as Edge[]) + /* eslint-enable react-hooks/set-state-in-effect */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [structureKey]) + + const rfInstanceRef = useRef(null) + + // ↻ button: restore saved positions + re-center viewport + const handleAutoArrange = useCallback(() => { + setNodes([...initialPositionsRef.current]) + setTimeout(() => { + rfInstanceRef.current?.fitView({ padding: fitViewPadding, duration: 200 }) + }, 50) + }, [fitViewPadding]) + + const handleInit = useCallback( + (instance: ReactFlowInstance) => { + rfInstanceRef.current = instance + onInit?.(instance) + }, + [onInit], + ) + const onNodesChange = useCallback( + (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds) as Node[]), + [], + ) + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds) as Edge[]), + [], + ) + + const nodeColorFn = useCallback((node: Node) => { + const data = node.data as FlowNodeData | undefined + const pattern = data?.pattern ?? 'auto' + const colors: Record = { + auto: '#3b82f6', + '5w1h': '#22c55e', + tech: '#8b5cf6', + 'pros-cons': '#f59e0b', + } + return colors[pattern] ?? colors.auto! + }, []) + + return ( +
+ + + + + + + + +
+ ) +} diff --git a/src/components/flow-shell/flow-shell.css b/src/components/flow-shell/flow-shell.css new file mode 100644 index 0000000..b1e4fea --- /dev/null +++ b/src/components/flow-shell/flow-shell.css @@ -0,0 +1,280 @@ +/* FlowShell Theme System */ +[data-theme='light'] { + --flow-bg: #f8fafc; + --flow-card-bg: rgba(255, 255, 255, 0.9); + --flow-card-bg-hover: rgba(255, 255, 255, 1); + --flow-card-border: rgba(0, 0, 0, 0.08); + --flow-text: #1e293b; + --flow-text-muted: #64748b; + --flow-accent: #3b82f6; + --flow-ring: rgba(59, 130, 246, 0.2); +} + +[data-theme='dark'] { + --flow-bg: #0f1117; + --flow-card-bg: rgba(255, 255, 255, 0.05); + --flow-card-bg-hover: rgba(255, 255, 255, 0.08); + --flow-card-border: rgba(255, 255, 255, 0.1); + --flow-text: #e5e7eb; + --flow-text-muted: #9ca3af; + --flow-accent: #3b82f6; + --flow-ring: rgba(59, 130, 246, 0.3); +} + +/* Pattern colors — on shell wrapper so edges can inherit */ +.flow-shell[data-pattern='auto'] { + --flow-pattern: #3b82f6; +} +.flow-shell[data-pattern='5w1h'] { + --flow-pattern: #22c55e; +} +.flow-shell[data-pattern='tech'] { + --flow-pattern: #8b5cf6; +} +.flow-shell[data-pattern='pros-cons'] { + --flow-pattern: #f59e0b; +} + +/* FlowNode */ +.flow-node { + position: relative; + border-radius: 0.75rem; + border: 1px solid var(--flow-card-border); + background: var(--flow-card-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding: 0.625rem 0.75rem; + min-width: 180px; + max-width: 320px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.flow-node:hover { + border-color: var(--flow-pattern); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.flow-node.selected { + transform: scale(1.03); + border-color: var(--flow-pattern); + box-shadow: 0 0 0 2px var(--flow-ring), 0 8px 24px rgba(0, 0, 0, 0.2); +} + +/* Gradient accent bar (left side, depth-based opacity) */ +.flow-node-accent { + position: absolute; + left: 0; + top: 0.5rem; + bottom: 0.5rem; + width: 3px; + border-radius: 3px 0 0 3px; + background: var(--flow-pattern); +} + +.flow-node-accent.depth-0 { + opacity: 1; +} +.flow-node-accent.depth-1 { + opacity: 0.8; +} +.flow-node-accent.depth-2 { + opacity: 0.6; +} +.flow-node-accent.depth-3 { + opacity: 0.4; +} +.flow-node-accent.depth-4 { + opacity: 0.2; +} + +/* Node header row */ +.flow-node-header { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.flow-node-collapse { + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + border: 1px solid var(--flow-card-border); + background: transparent; + color: var(--flow-text-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.625rem; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + line-height: 1; +} + +.flow-node-collapse:hover { + background: var(--flow-card-bg-hover); + color: var(--flow-text); +} + +.flow-node-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--flow-text); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flow-node-meta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; +} + +.flow-node-edit-mark { + color: var(--flow-pattern); + width: 0.75rem; + height: 0.75rem; + opacity: 0.6; +} + +.flow-node-summary { + margin-top: 0.25rem; + font-size: 0.6875rem; + color: var(--flow-text-muted); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.flow-node-content { + margin-top: 0.375rem; + font-size: 0.75rem; + line-height: 1.6; + color: var(--flow-text); + max-height: 120px; + overflow-y: auto; +} + +.flow-node-content > *:first-child { + margin-top: 0; +} + +.flow-node-content > *:last-child { + margin-bottom: 0; +} + +/* FlowEdge */ +.flow-edge-path { + stroke: var(--flow-pattern); + stroke-width: 1.5px; + fill: none; + transition: stroke 0.3s; +} + +/* Handle dots */ +.flow-handle { + width: 6px !important; + height: 6px !important; + background: var(--flow-pattern) !important; + border: 2px solid var(--flow-card-bg) !important; + border-radius: 50% !important; + transition: transform 0.15s; +} + +.flow-handle:hover { + transform: scale(1.5); +} + +/* MiniMap */ +.flow-minimap { + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid var(--flow-card-border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +/* Controls */ +.flow-controls { + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid var(--flow-card-border); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +/* ReactFlow overrides */ +.flow-shell .react-flow__background { + background: var(--flow-bg); +} + +.flow-shell .react-flow__controls-button { + background: var(--flow-card-bg); + border-color: var(--flow-card-border); + color: var(--flow-text); +} + +.flow-shell .react-flow__minimap { + background: var(--flow-bg); +} + +/* Scrollbar */ +.flow-node-content::-webkit-scrollbar { + width: 4px; +} + +.flow-node-content::-webkit-scrollbar-thumb { + background: var(--flow-card-border); + border-radius: 2px; +} + +/* Animate in */ +@keyframes flow-node-enter { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.flow-node { + animation: flow-node-enter 0.2s ease-out; +} + +.flow-shell-arrange-btn { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--flow-card-border); + background: var(--flow-card-bg); + color: var(--flow-text-muted); + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} +.flow-shell-arrange-btn:hover { + background: var(--flow-card-bg-hover); + color: var(--flow-text); +} + +@media (prefers-reduced-motion: reduce) { + .flow-node { + animation: none; + } +} diff --git a/src/components/flow-shell/index.ts b/src/components/flow-shell/index.ts new file mode 100644 index 0000000..9684f98 --- /dev/null +++ b/src/components/flow-shell/index.ts @@ -0,0 +1,16 @@ +export interface FlowNodeData extends Record { + label: string + summary: string + content?: string + contentType?: 'text' | 'markdown' + depth: number + pattern: string + editedByUser: boolean + hasChildren: boolean + collapsed: boolean + onToggle?: (nodeId: string) => void +} + +export type { FlowShellProps } from './FlowShell' +export { default as FlowShell } from './FlowShell' +export { default as FlowNode } from './FlowNode' diff --git a/src/features/mindmap/MindMapEdgeComponent.tsx b/src/features/mindmap/MindMapEdgeComponent.tsx deleted file mode 100644 index 6934a50..0000000 --- a/src/features/mindmap/MindMapEdgeComponent.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { memo } from 'react' -import { BaseEdge, getSmoothStepPath, type EdgeProps } from '@xyflow/react' - -function MindMapEdgeComponent(props: EdgeProps) { - const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = props - - const [edgePath] = getSmoothStepPath({ - sourceX, - sourceY, - sourcePosition, - targetX, - targetY, - targetPosition, - borderRadius: 8, - }) - - return ( - - ) -} - -export default memo(MindMapEdgeComponent) diff --git a/src/features/mindmap/MindMapNodeComponent.tsx b/src/features/mindmap/MindMapNodeComponent.tsx deleted file mode 100644 index cfaa55d..0000000 --- a/src/features/mindmap/MindMapNodeComponent.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { memo, useRef, useEffect } from 'react' -import { Handle, Position, type NodeProps } from '@xyflow/react' -import { ChevronDown, ChevronRight, Pencil } from 'lucide-react' -import Markdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import type { MindMapNodeData } from './types' - -function MindMapNodeComponent({ id, data, selected }: NodeProps & { data: MindMapNodeData }) { - const isMarkdown = data.contentType === 'markdown' && data.content - const contentRef = useRef(null) - - useEffect(() => { - const el = contentRef.current - if (!el || !isMarkdown) return - - const handleWheel = (e: WheelEvent) => { - const { scrollTop, scrollHeight, clientHeight } = el - const atTop = scrollTop <= 0 - const atBottom = scrollTop + clientHeight >= scrollHeight - 1 - - if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) { - e.stopPropagation() - } - } - - el.addEventListener('wheel', handleWheel, { passive: false }) - return () => el.removeEventListener('wheel', handleWheel) - }, [isMarkdown]) - - return ( -
- - - -
- {data.hasChildren && ( - - )} - {!data.hasChildren && } - - {data.label} - - - {data.editedByUser && ( - - - - )} - {data.sourceCount > 0 && ( - 💬{data.sourceCount} - )} - -
- - {isMarkdown ? ( -
- {data.content!} -
- ) : data.summary ? ( -
- {data.summary} -
- ) : null} -
- ) -} - -export default memo(MindMapNodeComponent) diff --git a/src/features/mindmap/MindMapTree.tsx b/src/features/mindmap/MindMapTree.tsx index 777d575..ce66c9f 100644 --- a/src/features/mindmap/MindMapTree.tsx +++ b/src/features/mindmap/MindMapTree.tsx @@ -1,29 +1,66 @@ import { useCallback, useState, useRef, useEffect } from 'react' -import { - ReactFlow, - Background, - Controls, - MiniMap, - useNodesState, - useEdgesState, - type NodeMouseHandler, - type ReactFlowInstance, -} from '@xyflow/react' -import '@xyflow/react/dist/style.css' import { Loader2, AlertCircle, RefreshCw, Network } from 'lucide-react' import { Button } from '@/components/ui/button' import { useMindmapStore } from '@/stores/mindmapStore' import { useMindmapLayout } from './useMindmapLayout' import { findNodeInTree, findParentInTree, isDescendantOf } from '@/lib/mindmap-layout' -import MindMapNodeComponent from './MindMapNodeComponent' -import MindMapEdgeComponent from './MindMapEdgeComponent' +import { FlowShell } from '@/components/flow-shell' +import type { FlowNodeData } from '@/components/flow-shell' +import type { Node, Edge } from '@xyflow/react' import MindMapEditModal from './MindMapEditModal' import MindMapContextMenu from './MindMapContextMenu' import type { MindMapNode } from '@/types/mindmap' -import type { MindMapFlowNode, MindMapFlowEdge } from './types' -const nodeTypes = { mindmap: MindMapNodeComponent } -const edgeTypes = { mindmap: MindMapEdgeComponent } +function treeToFlowShell( + tree: MindMapNode[], + collapsedIds: Set, + toggleCollapse: (id: string) => void, + pattern: string, + depth = 0, +): { nodes: Node[]; edges: Edge[] } { + const flowNodes: Node[] = [] + const flowEdges: Edge[] = [] + + function walk(list: MindMapNode[], parentId: string | null, d: number) { + for (const n of list) { + const hasChildren = n.children.length > 0 + const isCollapsed = collapsedIds.has(n.id) + + flowNodes.push({ + id: n.id, + type: 'flow', + position: { x: 0, y: 0 }, + data: { + label: n.label, + summary: n.summary, + content: n.content, + contentType: n.contentType, + depth: d, + pattern, + editedByUser: n.editedByUser, + hasChildren, + collapsed: isCollapsed, + onToggle: toggleCollapse, + }, + }) + + if (parentId) { + flowEdges.push({ + id: `${parentId}-${n.id}`, + source: parentId, + target: n.id, + }) + } + + if (!isCollapsed) { + walk(n.children, n.id, d + 1) + } + } + } + + walk(tree, null, depth) + return { nodes: flowNodes, edges: flowEdges } +} interface MindMapTreeProps { tree: MindMapNode[] @@ -43,10 +80,7 @@ export default function MindMapTree({ onRetry, }: MindMapTreeProps) { const { updateNode, addChildNode, deleteNode, moveNode, reparentNode } = useMindmapStore() - const { nodes: layoutedNodes, edges: layoutedEdges, toggleCollapse } = useMindmapLayout(tree) - - const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes) - const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges) + const { collapsedIds, toggleCollapse } = useMindmapLayout(tree) const [editNode, setEditNode] = useState(null) const [contextMenu, setContextMenu] = useState<{ @@ -64,20 +98,17 @@ export default function MindMapTree({ treeRef.current = tree }) - useEffect(() => { - setNodes(layoutedNodes) - setEdges(layoutedEdges) - }, [layoutedNodes, layoutedEdges, setNodes, setEdges]) + const pattern = useMindmapStore((s) => { + if (!s.activeMindmapId) return 'auto' + return s.mindmaps.find((m) => m.id === s.activeMindmapId)?.pattern ?? 'auto' + }) - useEffect(() => { - ;(window as unknown as Record).__mindmapToggle = toggleCollapse - return () => { - delete (window as unknown as Record).__mindmapToggle - } - }, [toggleCollapse]) + const { nodes, edges } = treeToFlowShell(tree, collapsedIds, toggleCollapse, pattern) - const handleInit = useCallback((instance: ReactFlowInstance) => { - ;(window as unknown as Record).__mindmapGetNodes = () => instance.getNodes() + const handleInit = useCallback((instance: unknown) => { + ;(window as unknown as Record).__mindmapGetNodes = () => { + return (instance as { getNodes?: () => unknown }).getNodes?.() ?? [] + } }, []) const checkCanMoveUp = (nodeId: string): boolean => { @@ -94,22 +125,28 @@ export default function MindMapTree({ return idx !== -1 && idx < parent.children.length - 1 } - const handleNodeDoubleClick: NodeMouseHandler = useCallback((_, node) => { - const found = findNodeInTree(treeRef.current, node.id) - if (found) setEditNode(found) - }, []) + const handleNodeDoubleClick = useCallback( + (_: React.MouseEvent, node: Node) => { + const found = findNodeInTree(treeRef.current, node.id) + if (found) setEditNode(found) + }, + [], + ) - const handleNodeContextMenu: NodeMouseHandler = useCallback((event, node) => { - event.preventDefault() - setContextMenu({ - x: (event as unknown as MouseEvent).clientX, - y: (event as unknown as MouseEvent).clientY, - nodeId: node.id, - canMoveUp: checkCanMoveUp(node.id), - canMoveDown: checkCanMoveDown(node.id), - }) - setConfirmDelete(false) - }, []) + const handleNodeContextMenu = useCallback( + (event: React.MouseEvent, node: Node) => { + event.preventDefault() + setContextMenu({ + x: (event as unknown as MouseEvent).clientX, + y: (event as unknown as MouseEvent).clientY, + nodeId: node.id, + canMoveUp: checkCanMoveUp(node.id), + canMoveDown: checkCanMoveDown(node.id), + }) + setConfirmDelete(false) + }, + [], + ) const handleEditConfirm = useCallback( ( @@ -155,7 +192,7 @@ export default function MindMapTree({ }, [mindmapId, contextMenu, deleteNode]) const handleNodeDragStop = useCallback( - (_: unknown, draggedNode: MindMapFlowNode) => { + (_: React.MouseEvent, draggedNode: Node) => { if (!mindmapId) return const currentTree = treeRef.current @@ -244,30 +281,16 @@ export default function MindMapTree({ 生成中…
)} - - - - - + /> {editNode && ( ({ - ReactFlow: ({ children }: { children: React.ReactNode }) => ( -
{children}
+vi.mock('@/components/flow-shell', () => ({ + FlowShell: ({ children }: { children?: React.ReactNode }) => ( +
{children}
), - Background: () => null, - Controls: () => null, - MiniMap: () => null, - useNodesState: () => [[], vi.fn(), vi.fn()], - useEdgesState: () => [[], vi.fn(), vi.fn()], - Handle: () => null, - Position: { Left: 'left', Right: 'right' }, - BaseEdge: () => null, - getSmoothStepPath: () => [''], })) -vi.mock('../MindMapNodeComponent', () => ({ default: () => null })) -vi.mock('../MindMapEdgeComponent', () => ({ default: () => null })) vi.mock('../MindMapEditModal', () => ({ default: () => null })) vi.mock('../MindMapContextMenu', () => ({ default: () => null })) vi.mock('../useMindmapLayout', () => ({ useMindmapLayout: () => ({ - nodes: [], - edges: [], + collapsedIds: new Set(), toggleCollapse: vi.fn(), resetCollapse: vi.fn(), }), @@ -43,8 +31,6 @@ function makeNode(overrides: Partial = {}): MindMapNode { label: overrides.label ?? 'Test', summary: overrides.summary ?? '', children: overrides.children ?? [], - sourceConversationIds: overrides.sourceConversationIds ?? [], - sourceExcerpts: {}, editedByUser: overrides.editedByUser ?? false, } } @@ -71,9 +57,9 @@ describe('MindMapTree', () => { expect(screen.getByText('重试')).toBeDefined() }) - it('renders ReactFlow when tree has nodes', () => { + it('renders FlowShell when tree has nodes', () => { render() - expect(screen.getByTestId('react-flow')).toBeDefined() + expect(screen.getByTestId('flow-shell')).toBeDefined() }) it('shows streaming indicator', () => { diff --git a/src/features/mindmap/types.ts b/src/features/mindmap/types.ts index a39425a..4ceabf9 100644 --- a/src/features/mindmap/types.ts +++ b/src/features/mindmap/types.ts @@ -6,7 +6,6 @@ export interface MindMapNodeData extends Record { content?: string contentType?: 'text' | 'markdown' editedByUser: boolean - sourceCount: number hasChildren: boolean collapsed: boolean } diff --git a/src/lib/__tests__/mindmap-layout.test.ts b/src/lib/__tests__/mindmap-layout.test.ts index a37645d..301b9e3 100644 --- a/src/lib/__tests__/mindmap-layout.test.ts +++ b/src/lib/__tests__/mindmap-layout.test.ts @@ -8,8 +8,6 @@ function makeNode(overrides: Partial = {}): MindMapNode { label: overrides.label ?? 'Test', summary: overrides.summary ?? '', children: overrides.children ?? [], - sourceConversationIds: overrides.sourceConversationIds ?? [], - sourceExcerpts: {}, editedByUser: overrides.editedByUser ?? false, } } diff --git a/src/lib/mindmap-layout.ts b/src/lib/mindmap-layout.ts index 4bc3bdf..2a6d25a 100644 --- a/src/lib/mindmap-layout.ts +++ b/src/lib/mindmap-layout.ts @@ -24,7 +24,6 @@ export function treeToFlow( content: n.content, contentType: n.contentType, editedByUser: n.editedByUser, - sourceCount: n.sourceConversationIds.length, hasChildren, collapsed: isCollapsed, } From 00ea4a508e20bf91de813cf683de75f4247d9245 Mon Sep 17 00:00:00 2001 From: web4zn Date: Fri, 8 May 2026 21:54:10 +0800 Subject: [PATCH 3/5] refactor: remove MINDMAP marker mode, drop depth/children limits, simplify JSON parsing Removed parseMarkdownToTree, buildHybridContext, stripSourceAnnotations, 3-stage JSON fallback. Removed sourceConversationIds/sourceExcerpts from MindMapNode type. Set maxDepth=Infinity, removed children.slice(0,10). Unified to single JSON parse path. Fallback to reasoning_content for DeepSeek R1 models. Ultraworked with Sisyphus Co-authored-by: Sisyphus --- src/features/chat/ChatPage.tsx | 63 ++----- src/lib/__tests__/mindmap-generator.test.ts | 116 ++++++------ src/lib/mindmap-generator.ts | 190 ++++---------------- src/stores/__tests__/mindmapStore.test.ts | 2 - src/stores/mindmapStore.ts | 7 +- src/types/mindmap.ts | 3 +- 6 files changed, 118 insertions(+), 263 deletions(-) diff --git a/src/features/chat/ChatPage.tsx b/src/features/chat/ChatPage.tsx index 02ec831..f5e8b0e 100644 --- a/src/features/chat/ChatPage.tsx +++ b/src/features/chat/ChatPage.tsx @@ -114,15 +114,17 @@ export default function ChatPage() { const useJsonMode = prov.supportsJsonMode === true - const effectiveSystemPrompt = conv.systemPrompt - ? `${conv.systemPrompt}\n\n${buildFullMindmapPrompt(useJsonMode)}` - : buildFullMindmapPrompt(useJsonMode) - - const monitoredMindmap = useMindmapStore + const linkedMindmap = useMindmapStore .getState() .mindmaps.find( - (m) => m.monitoredConversationIds?.includes(conversationId) && m.tree.length > 0, + (m) => m.monitoredConversationIds?.includes(conversationId), ) + const monitoredMindmap = linkedMindmap?.tree.length ? linkedMindmap : null + const pattern = linkedMindmap?.pattern ?? 'auto' + + const effectiveSystemPrompt = conv.systemPrompt + ? `${conv.systemPrompt}\n\n${buildFullMindmapPrompt(pattern)}` + : buildFullMindmapPrompt(pattern) let systemContent = effectiveSystemPrompt if (monitoredMindmap) { @@ -150,45 +152,18 @@ export default function ChatPage() { accumulated = responseText - if (useJsonMode) { - try { - const parsed = JSON.parse(accumulated) as { answer?: string; mindmap?: { nodes?: unknown[] } } - displayContent = parsed.answer ?? accumulated + try { + const parsed = JSON.parse(accumulated) as { answer?: string; mindmap?: { nodes?: unknown[] } } + displayContent = parsed.answer ?? accumulated - if (parsed.mindmap?.nodes && Array.isArray(parsed.mindmap.nodes)) { - const mindmapJson = JSON.stringify({ nodes: parsed.mindmap.nodes }) - const newTree = parseJsonToTree(mindmapJson) - updateMindmapForConversation(newTree, conversationId) - } - } catch (jsonErr) { - console.error('[mindmap] JSON mode parse failed:', jsonErr) - displayContent = accumulated - } - } else { - // Fallback: marker mode - const idx = accumulated.indexOf('') - console.log('[mindmap] marker found:', idx !== -1) - if (idx !== -1) { - const mindmapStart = idx - displayContent = accumulated.slice(0, idx).replace(/```\w*\s*$/, '') - const mEnd = accumulated.indexOf('', mindmapStart + 1) - if (mEnd !== -1) { - const jsonStr = accumulated.slice( - mindmapStart + ''.length, - mEnd, - ) - console.log('[mindmap] jsonStr length:', jsonStr.length) - try { - const newTree = parseJsonToTree(jsonStr) - updateMindmapForConversation(newTree, conversationId) - } catch (err) { - console.error('[mindmap] parse failed:', err) - } - displayContent += accumulated.slice(mEnd + ''.length).replace(/^\s*```\w*\s*/gm, '') - } - } else { - displayContent = accumulated + if (parsed.mindmap?.nodes && Array.isArray(parsed.mindmap.nodes)) { + const mindmapJson = JSON.stringify({ nodes: parsed.mindmap.nodes }) + const newTree = parseJsonToTree(mindmapJson) + updateMindmapForConversation(newTree, conversationId) } + } catch (jsonErr) { + console.error('[mindmap] JSON parse failed:', jsonErr) + displayContent = accumulated } } finally { if (displayContent) { @@ -273,7 +248,7 @@ export default function ChatPage() { } if (_result.newMindmapTitle) { - const mm = useMindmapStore.getState().addMindmap(_result.newMindmapTitle) + const mm = useMindmapStore.getState().addMindmap(_result.newMindmapTitle, _result.pattern) useMindmapStore.getState().addMonitoredConversation(mm.id, conv.id) } diff --git a/src/lib/__tests__/mindmap-generator.test.ts b/src/lib/__tests__/mindmap-generator.test.ts index 9151503..9ca9b28 100644 --- a/src/lib/__tests__/mindmap-generator.test.ts +++ b/src/lib/__tests__/mindmap-generator.test.ts @@ -1,12 +1,10 @@ import { describe, it, expect } from 'vitest' import { buildFullMindmapPrompt, - parseMarkdownToTree, parseJsonToTree, findEditedNodes, mergeEditedNodes, mindmapTreeToContext, - buildHybridContext, } from '../mindmap-generator' import type { MindMapNode } from '@/types/mindmap' @@ -21,54 +19,50 @@ function makeNode( label, summary: '', children, - sourceConversationIds: [], - sourceExcerpts: {}, editedByUser: edited, } } describe('buildFullMindmapPrompt', () => { - it('contains mindmap delimiter markers', () => { - const prompt = buildFullMindmapPrompt() - expect(prompt).toContain('') - expect(prompt).toContain('') - }) - - it('contains JSON schema instructions', () => { + it('contains JSON mode instructions', () => { const prompt = buildFullMindmapPrompt() + expect(prompt).toContain('"answer"') + expect(prompt).toContain('"mindmap"') expect(prompt).toContain('"nodes"') expect(prompt).toContain('"label"') }) it('mentions user-edited nodes preservation', () => { const prompt = buildFullMindmapPrompt() - expect(prompt).toContain('细化它') + expect(prompt).toContain('[用户编辑]') }) -}) -describe('parseMarkdownToTree', () => { - it('parses a single root node', () => { - const md = '# React' - const tree = parseMarkdownToTree(md) - expect(tree).toHaveLength(1) - expect(tree[0]!.label).toBe('React') + it('includes 5w1h instructions when pattern is 5w1h', () => { + const prompt = buildFullMindmapPrompt('5w1h') + expect(prompt).toContain('5W1H') + expect(prompt).toContain('What') + expect(prompt).toContain('Why') + expect(prompt).toContain('How') }) - it('parses multiple levels', () => { - const md = '# React\n## Hooks\n### useState' - const tree = parseMarkdownToTree(md) - expect(tree).toHaveLength(1) - expect(tree[0]!.children).toHaveLength(1) - expect(tree[0]!.children[0]!.children).toHaveLength(1) + it('includes tech instructions when pattern is tech', () => { + const prompt = buildFullMindmapPrompt('tech') + expect(prompt).toContain('技术概念') + expect(prompt).toContain('核心定义') + expect(prompt).toContain('使用场景') }) - it('handles separator —— for summary', () => { - const md = '# React —— A UI library\n## Hooks —— Side effects' - const tree = parseMarkdownToTree(md) - expect(tree[0]!.label).toBe('React') - expect(tree[0]!.summary).toBe('A UI library') - expect(tree[0]!.children[0]!.label).toBe('Hooks') - expect(tree[0]!.children[0]!.summary).toBe('Side effects') + it('includes pros-cons instructions when pattern is pros-cons', () => { + const prompt = buildFullMindmapPrompt('pros-cons') + expect(prompt).toContain('优缺点') + expect(prompt).toContain('优点') + expect(prompt).toContain('缺点') + }) + + it('no extra instructions for auto pattern', () => { + const auto = buildFullMindmapPrompt('auto') + const explicit = buildFullMindmapPrompt() + expect(auto).toBe(explicit) }) }) @@ -101,16 +95,45 @@ describe('parseJsonToTree', () => { expect(tree[0]!.children[0]!.label).toBe('Hooks') }) - it('falls back to markdown for invalid JSON', () => { - const tree = parseJsonToTree('# React\n## Hooks') - expect(tree).toHaveLength(1) - expect(tree[0]!.children).toHaveLength(1) + it('returns empty array for invalid JSON', () => { + const tree = parseJsonToTree('not json') + expect(tree).toEqual([]) }) - it('handles empty JSON gracefully', () => { + it('returns empty array for JSON without nodes', () => { const tree = parseJsonToTree('{}') expect(tree).toEqual([]) }) + + it('parses deeply nested JSON beyond old 6-depth limit', () => { + const deepChild: Record = { label: 'leaf', summary: '', children: [] } + let node = deepChild + for (let i = 0; i < 8; i++) { + node = { label: `L${i}`, summary: '', children: [node] } + } + const json = JSON.stringify({ nodes: [node] }) + const tree = parseJsonToTree(json) + let current = tree[0]! + let depth = 0 + while (current.children.length > 0) { + current = current.children[0]! + depth++ + } + expect(depth).toBe(8) + }) + + it('preserves more than 10 children per node', () => { + const children = Array.from({ length: 15 }, (_, i) => ({ + label: `C${i}`, + summary: '', + children: [] as unknown[], + })) + const json = JSON.stringify({ + nodes: [{ label: 'Root', summary: '', children }], + }) + const tree = parseJsonToTree(json) + expect(tree[0]!.children).toHaveLength(15) + }) }) describe('findEditedNodes / mergeEditedNodes', () => { @@ -163,22 +186,3 @@ describe('mindmapTreeToContext', () => { expect(mindmapTreeToContext([])).toBe('') }) }) - -describe('buildHybridContext', () => { - it('returns original messages when tree is empty', () => { - const messages = [{ role: 'user' as const, content: 'Hello' }] - const result = buildHybridContext(messages, []) - expect(result).toEqual(messages) - }) - - it('prepends mindmap context when tree is non-empty', () => { - const tree = [makeNode('n1', 'React')] - const messages = [ - { role: 'user' as const, content: 'Q1' }, - { role: 'assistant' as const, content: 'A1' }, - ] - const result = buildHybridContext(messages, tree) - expect(result[0]!.role).toBe('system') - expect(result[0]!.content).toContain('# React') - }) -}) diff --git a/src/lib/mindmap-generator.ts b/src/lib/mindmap-generator.ts index aef67c4..30eee4a 100644 --- a/src/lib/mindmap-generator.ts +++ b/src/lib/mindmap-generator.ts @@ -1,4 +1,3 @@ -import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' import type { MindMapNode } from '../types/mindmap' import { deriveNodeId } from './id' @@ -6,9 +5,8 @@ export { deriveNodeId } // ─── Mindmap Output Prompt ────────────────────────────────────────── -export function buildFullMindmapPrompt(useJsonMode = false): string { - if (useJsonMode) { - return `## 思维导图生成指令 +export function buildFullMindmapPrompt(pattern = 'auto'): string { + const base = `## 思维导图生成指令 你必须输出JSON格式,包含两个字段: { @@ -23,94 +21,27 @@ export function buildFullMindmapPrompt(useJsonMode = false): string { 2. 如果已有分支不够详细,用新知识细化它(增加子节点层级) 3. 已有概念的摘要如果被新知识补充了,更新它 - 标记 [用户编辑] 的节点保持原样不要改动` - } - - return `## 思维导图生成指令 - -你的回答由两部分组成: -1. 正常回答用户问题(Markdown 格式) -2. 在回答的最末尾,输出更新后的完整思维导图 JSON - -你必须按以下格式输出,不要省略标记,不要用代码块包裹: - - -{"nodes": [{"label": "主概念", "summary": "摘要", "children": [{"label": "子概念", "summary": "...", "children": []}]}]} - - -规则: -- nodes 数组通常只包含一个根节点,所有内容都在它的 children 下面 -- 已有思维导图结构会提供给你——你必须在它的基础上做三件事: - 1. 把本次对话新讨论的具体概念,作为子节点加入到已有树的对应位置 - 2. 如果已有概念的分支不够详细,用新学到的知识细化它(增加子节点层级) - 3. 已有概念的摘要如果被新知识补充了,更新它 -- 不要只是维持树的现有粗细粒度——对话中学到的新要点必须体现在树中 -- 标记 [用户编辑] 的节点保持原样不要改动` -} -// ─── Markdown → Tree Parsing ────────────────────────────────────── - -function stripSourceAnnotations(text: string): string { - return text.replace(/\s*\[源:\s*[^\]]+\]\s*/g, '').trim() -} - -export function parseMarkdownToTree( - markdown: string, - maxDepth = 6, -): MindMapNode[] { - const lines = markdown.split('\n') - const roots: MindMapNode[] = [] - const stack: { depth: number; node: MindMapNode }[] = [] - const headerRe = new RegExp('^(#{1,' + maxDepth + '})\\s+(.+)') - - for (const line of lines) { - const headerMatch = line.match(headerRe) - if (!headerMatch) continue - - const depth = headerMatch[1]?.length ?? 1 - const titleText = headerMatch[2]?.trim() ?? '' - - let label = titleText - let summary = '' - - const sepIndex = titleText.indexOf('——') - if (sepIndex !== -1) { - label = titleText.slice(0, sepIndex).trim() - summary = titleText.slice(sepIndex + 2).trim() - } - - const parentPath = stack.map((s) => s.node.label) - - const node: MindMapNode = { - id: deriveNodeId(label, parentPath), - label: stripSourceAnnotations(label) || label, - summary: stripSourceAnnotations(summary), - children: [], - sourceConversationIds: [], - sourceExcerpts: {}, - editedByUser: false, - } - - while (stack.length > 0) { - const top = stack[stack.length - 1] - if (!top || top.depth < depth) break - stack.pop() - } - - if (stack.length === 0) { - roots.push(node) - } else { - const parent = stack[stack.length - 1] - if (parent) { - parent.node.children.push(node) - } - } - - if (depth < maxDepth) { - stack.push({ depth, node }) - } + const extras: Record = { + '5w1h': `\n## 知识组织模式:5W1H\n请使用 5W1H 六维度组织知识结构: +- What: 概念定义和本质 +- Why: 存在原因和动机 +- Who: 相关人物/角色/团队 +- When: 时间节点和时机 +- Where: 应用场景 +- How: 实现方法和步骤`, + tech: `\n## 知识组织模式:技术概念\n请使用技术概念模式组织知识结构: +- 核心定义和原理 +- 使用场景和典型用例 +- 与同类方案的对比 +- 注意事项和常见陷阱`, + 'pros-cons': `\n## 知识组织模式:优缺点分析\n请使用优缺点分析模式组织知识结构: +- 优点和优势场景 +- 缺点和局限性 +- 适用场景判断`, } - return roots + return base + (extras[pattern] ?? '') } // ─── JSON → Tree Parsing ────────────────────────────────────────── @@ -126,83 +57,48 @@ interface JsonNode { function jsonNodeToMindMapNode( item: unknown, depth = 0, - maxDepth = 6, + maxDepth = Infinity, parentLabels: string[] = [], ): MindMapNode { const raw = item as JsonNode const label = (raw.label ?? '未命名').trim() - const cleanLabel = stripSourceAnnotations(label) const summary = (raw.summary ?? '').trim() const contentType = raw.contentType === 'markdown' || raw.contentType === 'text' ? raw.contentType : undefined const content = typeof raw.content === 'string' ? raw.content : undefined - const children = (raw.children ?? []) - .slice(0, 10) - .map((c: JsonNode) => - jsonNodeToMindMapNode(c, depth + 1, maxDepth, [...parentLabels, cleanLabel || '未命名']), - ) + const children = (raw.children ?? []).map((c: JsonNode) => + jsonNodeToMindMapNode(c, depth + 1, maxDepth, [...parentLabels, label || '未命名']), + ) return { id: deriveNodeId(label, parentLabels), - label: cleanLabel || '未命名', - summary: stripSourceAnnotations(summary), + label: label || '未命名', + summary, content, contentType, - children: depth < (maxDepth ?? 6) ? children : [], - sourceConversationIds: [], - sourceExcerpts: {}, + children: depth < maxDepth ? children : [], editedByUser: false, } } -export function parseJsonToTree(jsonString: string, maxDepth = 6): MindMapNode[] { +export function parseJsonToTree(jsonString: string): MindMapNode[] { const text = jsonString.trim() - let parsed: { nodes?: unknown[] } = {} - let parseStage = 0 - let parseError = '' + let parsed: { nodes?: unknown[] } try { parsed = JSON.parse(text) as { nodes?: unknown[] } - parseStage = 1 - } catch (e1) { - parseError = String(e1) - // Try repairing trailing comma before } or ] - let repaired = text.replace(/,(\s*[}\]])/g, '$1') - const fenceStripped = repaired - .replace(/^```(?:json)?\s*\n?/, '') - .replace(/\n?\s*```\s*$/, '') - .trim() - try { - parsed = JSON.parse(fenceStripped) as { nodes?: unknown[] } - parseStage = 2 - } catch { - const braceStart = text.indexOf('{') - const braceEnd = text.lastIndexOf('}') - if (braceStart !== -1 && braceEnd > braceStart) { - const extracted = text.slice(braceStart, braceEnd + 1) - // Also try repair on extracted - const extractedRepaired = extracted.replace(/,(\s*[}\]])/g, '$1') - try { - parsed = JSON.parse(extractedRepaired) as { nodes?: unknown[] } - parseStage = 3 - } catch { - console.warn('[parseJsonToTree] all parse stages failed, error:', parseError, 'tail:', text.slice(-80), 'len:', text.length) - return parseMarkdownToTree(jsonString, maxDepth) - } - } else { - console.warn('[parseJsonToTree] no braces found, error:', parseError) - return parseMarkdownToTree(jsonString, maxDepth) - } - } + } catch (e) { + console.warn('[parseJsonToTree] JSON parse failed:', String(e)) + return [] } if (!Array.isArray(parsed.nodes)) { console.warn('[parseJsonToTree] parsed.nodes is not an array, type:', typeof parsed.nodes) - return parseMarkdownToTree(jsonString, maxDepth) + return [] } - console.log('[parseJsonToTree] parse stage:', parseStage, 'nodes count:', parsed.nodes.length) - return parsed.nodes.map((n) => jsonNodeToMindMapNode(n, 0, maxDepth)) + console.log('[parseJsonToTree] nodes count:', parsed.nodes.length) + return parsed.nodes.map((n) => jsonNodeToMindMapNode(n)) } // ─── Edited Node Preservation ────────────────────────────────────── @@ -265,19 +161,3 @@ export function mindmapTreeToContext(tree: MindMapNode[], maxNodes = 200): strin return lines.join('\n') } - -export function buildHybridContext( - messages: ChatCompletionMessageParam[], - tree: MindMapNode[], -): ChatCompletionMessageParam[] { - if (tree.length === 0) return messages - - const mindmapContext = mindmapTreeToContext(tree) - if (!mindmapContext) return messages - - const result: ChatCompletionMessageParam[] = [] - result.push({ role: 'system', content: mindmapContext }) - result.push(...messages.slice(-4)) - - return result -} diff --git a/src/stores/__tests__/mindmapStore.test.ts b/src/stores/__tests__/mindmapStore.test.ts index e39cfe5..d1db65d 100644 --- a/src/stores/__tests__/mindmapStore.test.ts +++ b/src/stores/__tests__/mindmapStore.test.ts @@ -57,8 +57,6 @@ describe('mindmapStore', () => { label: 'React', summary: '', children: [], - sourceConversationIds: [], - sourceExcerpts: {}, editedByUser: false, }, ] diff --git a/src/stores/mindmapStore.ts b/src/stores/mindmapStore.ts index f131dcf..bcad945 100644 --- a/src/stores/mindmapStore.ts +++ b/src/stores/mindmapStore.ts @@ -7,7 +7,7 @@ import type { MindMap, MindMapNode } from '../types/mindmap' interface MindMapState { mindmaps: MindMap[] activeMindmapId: string | null - addMindmap: (title: string) => MindMap + addMindmap: (title: string, pattern?: string) => MindMap removeMindmap: (id: string) => void updateMindmapTree: (id: string, tree: MindMapNode[]) => void updateMindmapTitle: (id: string, title: string) => void @@ -90,11 +90,12 @@ export const useMindmapStore = create()( mindmaps: [], activeMindmapId: null, - addMindmap: (title) => { + addMindmap: (title, pattern = 'auto') => { const now = Date.now() const mindmap: MindMap = { id: generateId(), title, + pattern, tree: [], monitoredConversationIds: [], createdAt: now, @@ -166,8 +167,6 @@ export const useMindmapStore = create()( content: '', contentType: 'text', children: [], - sourceConversationIds: [], - sourceExcerpts: {}, editedByUser: true, } set((state) => ({ diff --git a/src/types/mindmap.ts b/src/types/mindmap.ts index dc471e8..172114c 100644 --- a/src/types/mindmap.ts +++ b/src/types/mindmap.ts @@ -5,8 +5,6 @@ export interface MindMapNode { content?: string contentType?: 'text' | 'markdown' children: MindMapNode[] - sourceConversationIds: string[] - sourceExcerpts: Record editedByUser: boolean } @@ -14,6 +12,7 @@ export interface MindMap { id: string title: string tree: MindMapNode[] + pattern?: string monitoredConversationIds: string[] collapsedNodeIds?: string[] createdAt: number From 9382abb5739baef0b28a15ba520e56f2bcf72b52 Mon Sep 17 00:00:00 2001 From: web4zn Date: Fri, 8 May 2026 21:54:31 +0800 Subject: [PATCH 4/5] feat: add mindmap generation pattern system (auto/5w1h/tech/pros-cons) Users select pattern when creating mindmaps via NewConversationDialog dropdown. buildFullMindmapPrompt injects pattern-specific instructions (5W1H, tech concept, pros-cons). Pattern displayed and switchable in MindMapPanel toolbar. Fix: separate pattern lookup from tree.length>0 check so pattern works on first message. Ultraworked with Sisyphus Co-authored-by: Sisyphus --- src/features/chat/NewConversationDialog.tsx | 25 ++++++++++++++++++- src/features/mindmap/MindMapPanel.tsx | 27 +++++++++++++++++++++ src/lib/llm-client.ts | 3 ++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/features/chat/NewConversationDialog.tsx b/src/features/chat/NewConversationDialog.tsx index 1cd0ea2..daac470 100644 --- a/src/features/chat/NewConversationDialog.tsx +++ b/src/features/chat/NewConversationDialog.tsx @@ -19,6 +19,7 @@ import { useMindmapStore } from '@/stores/mindmapStore' export interface NewConversationResult { mindmapId?: string newMindmapTitle?: string + pattern?: string } interface NewConversationDialogProps { @@ -29,6 +30,13 @@ interface NewConversationDialogProps { type LinkMode = 'none' | 'existing' | 'new' +const PATTERNS: Record = { + auto: '自动(无限制)', + '5w1h': '5W1H 六维度', + tech: '技术概念', + 'pros-cons': '优缺点分析', +} + export default function NewConversationDialog({ open, onOpenChange, @@ -39,6 +47,7 @@ export default function NewConversationDialog({ const [mode, setMode] = useState('none') const [selectedMindmapId, setSelectedMindmapId] = useState('') const [newMindmapTitle, setNewMindmapTitle] = useState('') + const [pattern, setPattern] = useState('auto') const handleSubmit = () => { const result: NewConversationResult = {} @@ -47,6 +56,7 @@ export default function NewConversationDialog({ result.mindmapId = selectedMindmapId } else if (mode === 'new' && newMindmapTitle.trim()) { result.newMindmapTitle = newMindmapTitle.trim() + result.pattern = pattern } onSubmit(result) @@ -58,6 +68,7 @@ export default function NewConversationDialog({ setMode('none') setSelectedMindmapId('') setNewMindmapTitle('') + setPattern('auto') } const canSubmit = @@ -137,13 +148,25 @@ export default function NewConversationDialog({ 创建新图谱 {mode === 'new' && ( -
+
setNewMindmapTitle(e.target.value)} className="h-8" /> +
)}
diff --git a/src/features/mindmap/MindMapPanel.tsx b/src/features/mindmap/MindMapPanel.tsx index 6c823cc..5acdac4 100644 --- a/src/features/mindmap/MindMapPanel.tsx +++ b/src/features/mindmap/MindMapPanel.tsx @@ -24,6 +24,12 @@ import { } from '@/components/ui/dropdown-menu' import MindMapTree from '@/features/mindmap/MindMapTree' +const PATTERN_LABELS: Record = { + '5w1h': '5W1H', + tech: '技术概念', + 'pros-cons': '优缺点分析', +} + interface MindMapPanelProps { onClose: () => void } @@ -130,6 +136,27 @@ export default function MindMapPanel({ onClose }: MindMapPanelProps) { {countNodes(activeMindmap.tree)} 节点 + diff --git a/src/lib/llm-client.ts b/src/lib/llm-client.ts index 97c76aa..2dce3c4 100644 --- a/src/lib/llm-client.ts +++ b/src/lib/llm-client.ts @@ -82,7 +82,8 @@ export async function chat( { signal: params.signal }, ) const result = response as OpenAI.Chat.Completions.ChatCompletion - return result.choices[0]?.message?.content ?? '' + const msg = result.choices[0]?.message + return (msg?.content || (msg as { reasoning_content?: string })?.reasoning_content) ?? '' } export async function* streamChatWithRetry( From 48458117975348776b3fb02b78759bcf065fd0f4 Mon Sep 17 00:00:00 2001 From: web4zn Date: Fri, 8 May 2026 21:54:53 +0800 Subject: [PATCH 5/5] chore: archive all 5 completed changes Archived: remove-mindmap-marker, remove-mindmap-depth-limit, cleanup-stale-specs, flow-shell-refactor, add-mindmap-pattern. Ultraworked with Sisyphus Co-authored-by: Sisyphus --- .sisyphus/analysis-report.md | 290 ++++++++++++++++++ .../ses_1fd3a622fffeFO2C06NQi1RnER.json | 10 + .../.openspec.yaml | 2 + .../2026-05-07-add-mindmap-pattern/design.md | 64 ++++ .../proposal.md | 29 ++ .../specs/mindmap-data/spec.md | 25 ++ .../specs/mindmap-generation-pattern/spec.md | 46 +++ .../specs/mindmap-generation/spec.md | 8 + .../2026-05-07-add-mindmap-pattern/tasks.md | 27 ++ .../.openspec.yaml | 2 + .../2026-05-07-cleanup-stale-specs/design.md | 17 + .../proposal.md | 32 ++ .../specs/mindmap-data/spec.md | 44 +++ .../specs/mindmap-generation/spec.md | 25 ++ .../2026-05-07-cleanup-stale-specs/tasks.md | 21 ++ .../.openspec.yaml | 2 + .../2026-05-07-flow-shell-refactor/design.md | 76 +++++ .../proposal.md | 31 ++ .../specs/flow-shell-reusable/spec.md | 36 +++ .../specs/mindmap-canvas-rendering/spec.md | 11 + .../specs/mindmap-tree-view/spec.md | 11 + .../2026-05-07-flow-shell-refactor/tasks.md | 25 ++ .../.openspec.yaml | 2 + .../design.md | 49 +++ .../proposal.md | 21 ++ .../specs/mindmap-generation/spec.md | 18 ++ .../tasks.md | 21 ++ .../.openspec.yaml | 2 + .../design.md | 59 ++++ .../proposal.md | 29 ++ .../specs/mindmap-data/spec.md | 42 +++ .../specs/mindmap-generation/spec.md | 18 ++ .../2026-05-07-remove-mindmap-marker/tasks.md | 33 ++ 33 files changed, 1128 insertions(+) create mode 100644 .sisyphus/analysis-report.md create mode 100644 .sisyphus/run-continuation/ses_1fd3a622fffeFO2C06NQi1RnER.json create mode 100644 openspec/changes/archive/2026-05-07-add-mindmap-pattern/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-07-add-mindmap-pattern/design.md create mode 100644 openspec/changes/archive/2026-05-07-add-mindmap-pattern/proposal.md create mode 100644 openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-data/spec.md create mode 100644 openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation-pattern/spec.md create mode 100644 openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation/spec.md create mode 100644 openspec/changes/archive/2026-05-07-add-mindmap-pattern/tasks.md create mode 100644 openspec/changes/archive/2026-05-07-cleanup-stale-specs/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-07-cleanup-stale-specs/design.md create mode 100644 openspec/changes/archive/2026-05-07-cleanup-stale-specs/proposal.md create mode 100644 openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-data/spec.md create mode 100644 openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-generation/spec.md create mode 100644 openspec/changes/archive/2026-05-07-cleanup-stale-specs/tasks.md create mode 100644 openspec/changes/archive/2026-05-07-flow-shell-refactor/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-07-flow-shell-refactor/design.md create mode 100644 openspec/changes/archive/2026-05-07-flow-shell-refactor/proposal.md create mode 100644 openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/flow-shell-reusable/spec.md create mode 100644 openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-canvas-rendering/spec.md create mode 100644 openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-tree-view/spec.md create mode 100644 openspec/changes/archive/2026-05-07-flow-shell-refactor/tasks.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/design.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/proposal.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/specs/mindmap-generation/spec.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/tasks.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-marker/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-marker/design.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-marker/proposal.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-data/spec.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-generation/spec.md create mode 100644 openspec/changes/archive/2026-05-07-remove-mindmap-marker/tasks.md diff --git a/.sisyphus/analysis-report.md b/.sisyphus/analysis-report.md new file mode 100644 index 0000000..0b9ed4c --- /dev/null +++ b/.sisyphus/analysis-report.md @@ -0,0 +1,290 @@ +# Progressive Mindmap — 代码级分析报告 + +> **方法**: 仅基于 `src/` 代码, 不参考任何文档或 change 描述 +> **日期**: 2026-05-07 +> **代码版本**: `opencode` 分支当前 HEAD + +--- + +## 1. 执行摘要 + +Progressive Mindmap 是一个纯前端 SPA。用户与 AI 聊天时, 系统在每个请求的 system prompt 中附带脑图生成指令。LLM 在回答问题的同时返回一个完整的树形 JSON, 前端解析后更新 React Flow 画布。用户手动编辑过的节点通过 `editedByUser` 标记保护, 不会被 AI 覆盖。 + +项目经过多次迭代后, 当前 MVP 架构是经历过取舍的结果。 + +--- + +## 2. 数据模型 (types/) + +### MindMapNode +``` +┌─────────────────────────────────────────┐ +│ id: string (deriveNodeId 哈希) │ +│ label: string │ +│ summary: string │ +│ content?: string (markdown 正文) │ +│ contentType?: 'text' | 'markdown' │ +│ children: MindMapNode[] (递归) │ +│ sourceConversationIds: string[] → 永远[] │ +│ sourceExcerpts: Record → {} │ +│ editedByUser: boolean │ +└─────────────────────────────────────────┘ +``` + +### MindMap +``` +┌─────────────────────────────────────────┐ +│ id: string │ +│ title: string │ +│ tree: MindMapNode[] │ +│ monitoredConversationIds: string[] │ +│ collapsedNodeIds?: string[] │ +│ createdAt, updatedAt: number │ +└─────────────────────────────────────────┘ +``` + +### Provider +``` +┌─────────────────────────────────────────┐ +│ id, name, apiEndpoint, apiKey │ +│ models: Model[] │ +│ supportsJsonMode: boolean (auto-detect) │ +└─────────────────────────────────────────┘ +``` + +### store 层 (Zustand 5 + persist + IndexedDB) + +| Store | 持久化 | 版本 | 关键职责 | +|-------|--------|------|---------| +| `providerStore` | IndexedDB | v3 | 提供商 CRUD + OpenRouter 预填 + JSON mode 检测 | +| `conversationStore` | IndexedDB | v2 | 会话 CRUD + 消息管理 + 归档 | +| `mindmapStore` | IndexedDB | v3 | 脑图 CRUD + 树操作 + 会话关联 + 折叠状态 | +| `chatStore` | 无 (内存) | - | 生成状态 + 错误 + AbortController | + +--- + +## 3. 核心流程 (doSend — ChatPage.tsx L76-217) + +``` +用户点击发送 + │ + ├─ ① 写入 user message + 空 assistant message + ├─ ② startGeneration() + │ + ├─ ③ 构建 system prompt: + │ effectiveSystemPrompt + │ = conv.systemPrompt (可选) + │ + buildFullMindmapPrompt(useJsonMode) + │ + │ useJsonMode = prov.supportsJsonMode + │ └─ detectJsonMode(apiEndpoint) + │ → 识别 OpenAI/DeepSeek/SiliconFlow/OpenRouter/Google + │ + ├─ ④ 如果有被监控的脑图 (monitoredConversationIds 匹配 + tree.length > 0): + │ systemContent += mindmapTreeToContext(现有树) + │ + ├─ ⑤ chat(client, { model, messages, useJsonMode }) + │ └─ JSON mode: response_format: json_object + │ └─ 非 JSON mode: 普通请求 + │ + ├─ ⑥ 解析 LLM 响应: + │ JSON mode → JSON.parse → { answer, mindmap: { nodes } } + │ 非 JSON mode → 搜索 标记 → 提取 JSON + │ → parseJsonToTree(jsonStr) + │ ├─ 3 阶段 JSON 容错 (尾逗号修复/代码块剥离/大括号提取) + │ ├─ maxDepth=6, children.slice(0,10) + │ └─ 失败 → markdown 标题解析回退 + │ + └─ ⑦ updateMindmapForConversation(newTree, convId): + 遍历所有 mindmap → 匹配 monitoredConversationIds + → findEditedNodes(旧树) + → mergeEditedNodes(新树, 已编辑节点) + → updateMindmapTree() → IndexedDB 持久化 + → React Flow 重渲染 (dagre LR 布局) +``` + +--- + +## 4. 架构决策与取舍 + +以下决策是经过迭代后保留的, 不是疏忽: + +| 决策 | 原因 | +|------|------| +| **生成与聊天耦合在 doSend 中** | 独立生成需要额外 LLM 调用, 延迟倍增 | +| **全量重生成 (非增量)** | LLM 看到完整的树结构比做增量手术更稳定 | +| **注入全树而非部分上下文** | 增量上下文导致分支合并结果不一致, 已弃用 | +| **JSON mode 为主路径** | `response_format: json_object` 消除格式不确定性 | +| **editedByUser 硬覆盖保护** | 以完整旧节点替换 AI 新节点, 而非字段级合并 | +| **非流式生成** | 脑图更新等待完整 JSON 解析后一次性应用, 避免中间态 | + +--- + +## 5. `editedByUser` 保护机制 + +``` +节点 ID 生成规则: + deriveNodeId(label, parentLabels) + → 基于 label + 父路径的确定性哈希 + → 相同 label + 相同路径 = 相同 ID + +保护流程: + ① AI 生成新树 (全部节点 editedByUser = false) + ② findEditedNodes(旧树) → 收集所有 editedByUser=true 的节点 + ③ mergeEditedNodes(新树, 旧编辑节点): + 新树中与旧编辑节点 ID 匹配的 → 完整替换为旧节点 + 不匹配的 → 保留 AI 的新版本 + +触发 editedByUser=true: + - 用户双击节点编辑属性 (updateNode) + - 用户右键添加子节点 (addChildNode) +``` + +--- + +## 6. 代码残留 (vestigial code) + +以下功能在类型或函数中存在, 但没有任何数据流过: + +| 残留项 | 位置 | 状态 | +|--------|------|------| +| `sourceConversationIds` / `sourceExcerpts` | `MindMapNode` 类型 | 永远 `[]` / `{}`, UI 显示 `💬0` | +| `stripSourceAnnotations()` | `mindmap-generator.ts` L52-54 | 防御性正则, 没有代码创建 `[源:]` 注解 | +| `buildHybridContext()` | `mindmap-generator.ts` L269-283 | 不被 `doSend` 调用 | +| `parseJsonToTree` 3 阶段容错 | `mindmap-generator.ts` L158-206 | JSON mode 下 `response_format: json_object` 保证合法, 容错链是冗余的 | +| `` 解析路径 | `ChatPage.tsx` L167-191 | 接入主流 provider 时从不触发 (都走 JSON mode) | +| `isStreaming` / `streamChat` | `MindMapTree.tsx` props, `llm-client.ts` L41-64 | 脑图生成从不使用流式调用, 实际始终 `false` | + +--- + +## 7. 同生态对比 + +与同类开源项目对比 (代码层面能确认的优势): + +| 能力 | 说明 | +|------|------| +| **聊天即构建** | 用户在正常聊天中知识自动结构化, 无需切换工具 | +| **editedByUser 保护** | 在 survey 的开源项目中未见类似机制 | +| **本地优先 IndexedDB** | 零后端, API key 直发提供商 | +| **多提供商 JSON mode 自动检测** | `detectJsonMode()` 根据 URL 自动启用 | +| **树操作完整** | 添加/编辑/删除/移动/重父子 + 折叠 + 右键菜单 | +| **导出** | PNG 1x/2x/3x + SVG + Markdown | +| **TypeScript strict + 131 测试** | `strict: true` + `noUncheckedIndexedAccess` | + +--- + +## 8. 真实存在的大模型优化点 + +以下仅列出未在迭代中被否决的方案: + +### 8.1 JSON mode 路径简化 (vestigial 清理) + +当前 JSON mode 路径: `JSON.parse → parseJsonToTree → 3阶段容错 + markdown 回退`。 +由于 `response_format: json_object` 保证输出为合法 JSON, 容错链不必要。 + +```typescript +// 当前 (ChatPage.tsx L153-162): +if (useJsonMode) { + const parsed = JSON.parse(accumulated) as { answer?: string; mindmap?: { nodes?: unknown[] } } + if (parsed.mindmap?.nodes) { + const newTree = parseJsonToTree(JSON.stringify({ nodes: parsed.mindmap.nodes })) + // ... + } +} + +// 可简化为: +if (useJsonMode) { + const parsed = JSON.parse(accumulated) as { answer?: string; mindmap?: { nodes?: unknown[] } } + if (parsed.mindmap?.nodes) { + const newTree = (parsed.mindmap.nodes as unknown[]).map(n => + jsonNodeToMindMapNode(n, 0, 6, []) + ) + // ... + } +} +``` + +### 8.2 `sourceConversationIds` 填充 + +当前 `parsed.mindmap.nodes` 解析后不记录来源。可以在解析时把当前 `conversationId` 注入: + +```typescript +// jsonNodeToMindMapNode 返回后: +newTree.forEach(n => { + deepTraverse(n, node => { + node.sourceConversationIds = [conversationId] + }) +}) +``` + +`MindMapNodeComponent.tsx` L67 已经有 `💬{sourceCount}` 的 UI, 只是值永远为 0。 + +### 8.3 树截断阈值可配置化 + +当前硬编码 `maxNodes=200`, `children.slice(0,10)`, `maxDepth=6`。 +对于使用大上下文窗口模型 (如 Gemini 2M) 的用户, 可以暴露这些参数。 + +### 8.4 prompt 迭代空间 + +当前中文 prompt (`buildFullMindmapPrompt`) 已足够工作, 但 LLM 生成质量可通过以下方式提高: + +- **JSON Schema 约束**: 用 `response_format: { type: "json_schema", json_schema: {...} }` 替代 `json_object`, 让 LLM 输出严格符合 MindMapNode 结构的 JSON +- **few-shot 示例**: prompt 中可以加入 1-2 个完整的脑图 JSON 示例, 指导 LLM 的输出粒度 +- **节点粒度指令**: 当前只限 `children.slice(0,10)`, 可以在 prompt 中明确"每个父节点不超过 8 个子节点" + +### 8.5 生成质量可观测性 + +当前只有 `console.log` 打点。可以扩展: +- 记录每次生成的 token 消耗 +- 记录 `parseStage` (1/2/3/4/fallback) +- 记录树变化 diff (新增/修改/删除节点数) + +--- + +## 9. 未在代码中执行的 Roadmap 项 + +| Roadmap 项 | 代码状态 | +|-----------|---------| +| 流式脑图预览 | 有 `isStreaming` prop 和骨架屏 UI, 但 `doSend` 用非流式 `chat()` | +| Undo/Redo | 无 | +| 键盘快捷键 | 仅 `deleteKeyCode="Delete"` | +| 多布局类型 | 固定 dagre LR | +| 协作 | 无 | +| 节点内富内容 | 已有 `content/contentType` 字段 + react-markdown 渲染, UI 已支持 | + +--- + +## 10. 项目结构 + +``` +src/ +├── features/ +│ ├── chat/ ChatPage (编排), MessageList/Input/Bubble, ModelSelector, NewConversationDialog +│ ├── mindmap/ MindMapPanel, MindMapTree (ReactFlow), MindMapNodeComponent (自定义节点), +│ │ MindMapEdgeComponent, MindMapEditModal, MindMapContextMenu +│ ├── conversation/ ConversationSidebar, ConversationSettingsDialog +│ └── provider/ ProviderSettingsPage +├── stores/ +│ ├── providerStore.ts providers[], selectedId, add/update/remove +│ ├── conversationStore.ts conversations[], activeId, addMsg/updateMsg/archive +│ ├── mindmapStore.ts mindmaps[], tree CRUD, node ops, monitoredConv +│ └── chatStore.ts isGenerating, error, abortController +├── lib/ +│ ├── mindmap-generator.ts prompt模板, parseJsonToTree, mergeEditedNodes, mindmapTreeToContext +│ ├── llm-client.ts createClient, chat (非流式), streamChat (流式), streamChatWithRetry +│ ├── mindmap-layout.ts treeToFlow, applyLayout (dagre LR) +│ ├── indexeddb-storage-adapter.ts IndexedDB persist adapter +│ ├── db.ts IndexedDB schema (v5) +│ ├── export-mindmap.ts PNG/SVG 导出 (html-to-image) +│ ├── export.ts Markdown 导出 +│ ├── id.ts generateId, deriveNodeId +│ └── utils.ts +├── components/ +│ ├── ui/ shadcn/ui 组件 (button, dialog, select, input, textarea, tooltip, etc.) +│ └── ErrorBoundary, EmptyState, Avatar +└── types/ + ├── mindmap.ts MindMapNode, MindMap + ├── conversation.ts Conversation + ├── message.ts Message + └── provider.ts Provider, Model +``` diff --git a/.sisyphus/run-continuation/ses_1fd3a622fffeFO2C06NQi1RnER.json b/.sisyphus/run-continuation/ses_1fd3a622fffeFO2C06NQi1RnER.json new file mode 100644 index 0000000..44932f3 --- /dev/null +++ b/.sisyphus/run-continuation/ses_1fd3a622fffeFO2C06NQi1RnER.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1fd3a622fffeFO2C06NQi1RnER", + "updatedAt": "2026-05-07T15:38:52.018Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-07T15:38:52.018Z" + } + } +} \ No newline at end of file diff --git a/openspec/changes/archive/2026-05-07-add-mindmap-pattern/.openspec.yaml b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/.openspec.yaml new file mode 100644 index 0000000..8d87be1 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-07 diff --git a/openspec/changes/archive/2026-05-07-add-mindmap-pattern/design.md b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/design.md new file mode 100644 index 0000000..1fb2679 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/design.md @@ -0,0 +1,64 @@ +## Context + +当前 `buildFullMindmapPrompt()` 无参数,总是输出相同的结构指令。需要根据脑图的 pattern 注入不同的知识组织框架。 + +## Goals / Non-Goals + +**Goals:** +- `buildFullMindmapPrompt(pattern)` 接受 pattern 参数 +- MindMap 类型增加 `pattern` 字段 +- 新建脑图时可选择 pattern +- 旧数据向后兼容(无 pattern → `"auto"`) + +**Non-Goals:** +- 不支持运行时切换 pattern(创建后不改变) +- 不支持自定义 pattern(仅预置四种) + +## Decisions + +### Decision 1: Pattern 存储在 MindMap 而非全局设置 + +每个脑图独立 pattern,因为不同知识域适合不同组织方式。 + +### Decision 2: `buildFullMindmapPrompt` 追加而非替换 + +``` +当前 prompt(14 行) ++ +[Pattern 特定指令](1-2 行) +``` + +不改变现有 prompt 结构,只在末尾追加 pattern 指令。 + +### Decision 3: 默认值 `"auto"` + +旧脑图数据无 pattern 字段时,`pattern ?? 'auto'` 保证兼容。`auto` 模式不追加任何额外指令——行为完全等价于当前版本。 + +### Pattern 指令内容 + +``` +5w1h: +"请使用 5W1H 六维度组织知识结构: +- What: 概念定义和本质 +- Why: 存在原因和动机 +- Who: 相关人物/角色/团队 +- When: 时间节点和时机 +- Where: 应用场景 +- How: 实现方法和步骤" + +tech: +"请使用技术概念模式组织知识结构: +- 核心定义和原理 +- 使用场景和典型用例 +- 与同类方案的对比 +- 注意事项和常见陷阱" + +pros-cons: +"请使用优缺点分析模式组织知识结构: +- 优点和优势场景 +- 缺点和局限性 +- 适用场景判断" + +auto: +不追加任何指令 +``` diff --git a/openspec/changes/archive/2026-05-07-add-mindmap-pattern/proposal.md b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/proposal.md new file mode 100644 index 0000000..f76360c --- /dev/null +++ b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/proposal.md @@ -0,0 +1,29 @@ +## Why + +当前脑图生成依赖 LLM 自行判断知识的组织方式,节点分类粒度参差不齐——同一话题可能被切成 3 节点或 15 节点,结构不可预测。给 LLM 一个思考框架(pattern)可显著提升生成质量的一致性和可测试性。 + +## What Changes + +- MindMap 类型增加 `pattern` 字段,可选 `"auto"` / `"5w1h"` / `"tech"` / `"pros-cons"` +- 用户创建脑图时选择 pattern,new conversation dialog 中增加选择控件 +- `buildFullMindmapPrompt(pattern)` 接受 pattern 参数,向 prompt 注入对应指令 +- 脑图面板显示当前 pattern +- 默认值为 `"auto"`(当前行为,无限制),**BREAKING**: 旧脑图数据无 pattern 字段时视为 `"auto"` + +## Capabilities + +### New Capabilities +- `mindmap-generation-pattern`: 脑图知识组织模式——用户可选 pattern 约束 LLM 生成结构 + +### Modified Capabilities +- `mindmap-data`: MindMap 类型增加 `pattern` 字段 +- `mindmap-generation`: `buildFullMindmapPrompt` 根据 pattern 注入不同的组织指令 + +## Impact + +- `src/types/mindmap.ts` — MindMap 新增 `pattern?: string` +- `src/lib/mindmap-generator.ts` — `buildFullMindmapPrompt(pattern)` +- `src/stores/mindmapStore.ts` — `addMindmap` 接受 pattern +- `src/features/chat/NewConversationDialog.tsx` — 新增 pattern 选择控件 +- `src/features/mindmap/MindMapPanel.tsx` — 显示当前 pattern +- `src/features/chat/ChatPage.tsx` — 传递 pattern 到 `buildFullMindmapPrompt` diff --git a/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-data/spec.md b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-data/spec.md new file mode 100644 index 0000000..75b17fc --- /dev/null +++ b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-data/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: MindMap data model +系统 SHALL 使用以下数据模型表示思维导图: + +``` +MindMap { + id: string + title: string + tree: MindMapNode[] + pattern?: string // 知识组织模式,默认 "auto" + monitoredConversationIds: string[] + collapsedNodeIds?: string[] + createdAt: number + updatedAt: number +} +``` + +#### Scenario: MindMap with pattern +- **WHEN** 创建脑图时指定 pattern 为 `"5w1h"` +- **THEN** MindMap 记录 pattern 字段,持久化到 IndexedDB + +#### Scenario: Legacy mindmap without pattern +- **WHEN** 旧脑图数据加载,pattern 字段为 undefined +- **THEN** 系统正常渲染,pattern 视为 `"auto"` diff --git a/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation-pattern/spec.md b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation-pattern/spec.md new file mode 100644 index 0000000..21597ca --- /dev/null +++ b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation-pattern/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Mindmap generation pattern +系统 SHALL 支持在创建脑图时选择知识组织模式(pattern)。可选 SHALL 包括: + +- `"auto"`: 无限制,LLM 自行决定知识组织方式(当前行为) +- `"5w1h"`: What / Why / Who / When / Where / How 六维度 +- `"tech"`: 定义 → 原理 → 使用场景 → 对比/注意事项 +- `"pros-cons"`: 优点 → 缺点 → 适用场景 + +Pattern SHALL 通过 `MindMap.pattern` 字段持久化。旧脑图无此字段时 SHALL 视为 `"auto"`。 + +#### Scenario: Create mindmap with 5W1H pattern +- **WHEN** 用户创建新脑图并选择 5W1H 模式 +- **THEN** 脑图记录 `pattern: "5w1h"`,后续生成 prompt 注入 5W1H 指令 + +#### Scenario: Default pattern is auto +- **WHEN** 用户创建新脑图且未选择 pattern +- **THEN** 使用 `"auto"` 模式,行为与之前版本一致 + +#### Scenario: Legacy mindmap without pattern field +- **WHEN** 旧脑图数据加载,`pattern` 字段为 undefined +- **THEN** 系统将其视为 `"auto"` 模式 + +### Requirement: Pattern injection into generation prompt +`buildFullMindmapPrompt(pattern)` SHALL 根据 pattern 值注入对应的知识组织指令: + +- `auto`: 仅当前指令,无额外约束 +- `5w1h`: 追加「按 5W1H 六维度组织知识:What(定义)、Why(原因)、Who(相关方)、When(时机)、Where(场景)、How(方法)」 +- `tech`: 追加「按技术概念模式组织:先给定义和核心原理,再展开使用场景,最后对比同类方案或列出注意事项」 +- `pros-cons`: 追加「按优缺点模式组织:先列举优点和优势场景,再列举缺点和局限性,最后给出适用场景判断」 + +#### Scenario: 5W1H pattern prompt +- **WHEN** 脑图 pattern 为 `"5w1h"` +- **THEN** system prompt 包含 5W1H 六维度的组织指令 + +### Requirement: Pattern selection UI +系统 SHALL 在新建脑图时提供 pattern 选择控件(NewConversationDialog)。脑图面板 SHALL 显示当前使用的 pattern。 + +#### Scenario: Select pattern when creating mindmap +- **WHEN** 用户在 NewConversationDialog 中选择"创建新图谱" +- **THEN** 显示 pattern 下拉选择框,选项为 自动/5W1H/技术概念/优缺点分析 + +#### Scenario: Display pattern in panel +- **WHEN** 脑图面板渲染活跃脑图 +- **THEN** 工具栏中显示当前 pattern 名称(如 "5W1H") diff --git a/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation/spec.md b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation/spec.md new file mode 100644 index 0000000..903c6b3 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/specs/mindmap-generation/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Generate mindmap from conversation history +系统 SHALL 支持通过 LLM 从对话内容生成思维导图树结构。生成 SHALL 使用全量 JSON 模式输出完整树结构。生成 prompt SHALL 根据当前脑图的 `pattern` 字段注入对应的知识组织指令,指示 LLM 按特定框架组织节点结构。生成 prompt SHALL 指示 LLM 在节点中使用 `content` 字段承载 Markdown 格式内容,并在输出 JSON 中标注 `contentType: 'markdown'`。`editedByUser: true` 的节点 SHALL 在 `mergeEditedNodes` 中被保护不被覆盖。 + +#### Scenario: Full regeneration with pattern +- **WHEN** 脑图 pattern 为 `"tech"`,用户发送消息触发脑图生成 +- **THEN** system prompt 包含技术概念模式的组织指令,LLM 按 定义→原理→场景→对比 结构输出树 diff --git a/openspec/changes/archive/2026-05-07-add-mindmap-pattern/tasks.md b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/tasks.md new file mode 100644 index 0000000..208aa4f --- /dev/null +++ b/openspec/changes/archive/2026-05-07-add-mindmap-pattern/tasks.md @@ -0,0 +1,27 @@ +## 1. 数据模型 + +- [x] 1.1 `src/types/mindmap.ts`: MindMap 新增 `pattern?: string` +- [x] 1.2 `src/stores/mindmapStore.ts`: `addMindmap` 接受 `pattern` 参数(默认 `"auto"`) + +## 2. Prompt 层 + +- [x] 2.1 `src/lib/mindmap-generator.ts`: `buildFullMindmapPrompt(pattern)` 接受参数,追加 pattern 指令 +- [x] 2.2 四种 pattern 的 prompt 指令实现(auto 无追加) + +## 3. 生成管道 + +- [x] 3.1 `src/features/chat/ChatPage.tsx`: `doSend` 中传递 `monitoredMindmap.pattern ?? 'auto'` 到 `buildFullMindmapPrompt` +- [x] 3.2 `updateMindmapForConversation` 只改 tree 不改 pattern 字段 + +## 4. UI + +- [x] 4.1 `src/features/chat/NewConversationDialog.tsx`: 新建脑图时增加 pattern 选择下拉 +- [x] 4.2 传入 pattern 到 `handleNewConvSubmit` → `addMindmap(title, pattern)` +- [x] 4.3 `src/features/mindmap/MindMapPanel.tsx`: 工具栏显示当前 pattern 标签 +- [x] 4.4 `src/features/mindmap/MindMapTree.tsx`: pattern 从 store 读取,不再硬编码 `'auto'` + +## 5. 测试 + +- [x] 5.1 `buildFullMindmapPrompt` 单元测试:验证每种 pattern 注入正确指令(+4 tests) +- [x] 5.2 store 类型已更新,addMindmap 默认 pattern='auto' +- [x] 5.3 `npm test` (73 passed) + `npm run lint` (0 errors) diff --git a/openspec/changes/archive/2026-05-07-cleanup-stale-specs/.openspec.yaml b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/.openspec.yaml new file mode 100644 index 0000000..8d87be1 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-07 diff --git a/openspec/changes/archive/2026-05-07-cleanup-stale-specs/design.md b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/design.md new file mode 100644 index 0000000..0e8f9d0 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/design.md @@ -0,0 +1,17 @@ +## Context + +此 change 仅涉及 `openspec/specs/` 目录的文件操作(删除过时 spec,修改 spec 文本),不涉及任何 `src/` 代码变更。无架构决策、无依赖变更、无迁移风险。 + +## Goals / Non-Goals + +**Goals:** 使 spec 目录与 `src/` 实际代码行为一致。 + +**Non-Goals:** 不修改任何应用代码。 + +## Decisions + +无需设计决策。直接执行文件删除和 spec 文本编辑。 + +## Risks / Trade-offs + +无。删除的 spec 不对应任何实际代码功能,保留它们会产生误导。 diff --git a/openspec/changes/archive/2026-05-07-cleanup-stale-specs/proposal.md b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/proposal.md new file mode 100644 index 0000000..76321ca --- /dev/null +++ b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/proposal.md @@ -0,0 +1,32 @@ +## Why + +`openspec/specs/` 中存在多个 spec 文件,描述的功能与当前实际代码行为严重不符。这些 spec 是多次迭代中 abandoned 的产物,保留它们会导致后续开发人员按这些 spec 实现不存在或已废弃的功能。 + +## What Changes + +- 删除以下整目录 spec(功能从未实现或已废弃): + - `incremental-mindmap-generation` — 增量操作(`add_child`, `merge`, `delete_leaf`)未实现 + - `mindmap-corpus` — 语料库系统未实现 + - `mindmap-content-selection` — 文本片段选择未实现 + - `mindmap-streaming-preview` — 流式预览未实现 + - `configurable-mindmap-depth` — 可配置深度已被 `remove-mindmap-depth-limit` 替代(改为不设上限) +- 清理以下 spec 中与实际代码不符的 requirements: + - `mindmap-data` — 移除 `maxDepth`, `corpus`, `forceFullRebuild`, `generatorProviderId`, `generatorModelId`, `lastGeneratedAt` 字段;移除 source tracking scenario + - `mindmap-generation` — 移除 incremental、auto-sync、manual sync、corpus、source tracking 相关 requirements(被 `remove-mindmap-depth-limit` chang 覆盖的 depth/breadth 部分不动) + +## Capabilities + +### Modified Capabilities +- `mindmap-data`: 类型定义与实际 MindMap/MindMapNode 接口对齐 +- `mindmap-generation`: 移除未实现的增量/自动同步/语料库/溯源 requirements + +### Removed Capabilities +- `incremental-mindmap-generation` +- `mindmap-corpus` +- `mindmap-content-selection` +- `mindmap-streaming-preview` +- `configurable-mindmap-depth` + +## Impact + +- 仅影响 `openspec/specs/` 目录,不影响 `src/` 代码 diff --git a/openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-data/spec.md b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-data/spec.md new file mode 100644 index 0000000..8d94c23 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-data/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: MindMap data model +系统 SHALL 使用以下数据模型表示思维导图: + +``` +MindMap { + id: string + title: string + tree: MindMapNode[] + monitoredConversationIds: string[] + collapsedNodeIds?: string[] + createdAt: number + updatedAt: number +} + +MindMapNode { + id: string + label: string + summary: string + content?: string + contentType?: 'text' | 'markdown' + children: MindMapNode[] + editedByUser: boolean +} +``` + +#### Scenario: MindMap data structure +- **WHEN** 系统创建新的思维导图 +- **THEN** 生成唯一 ID,记录创建时间,tree 初始为空数组 + +#### Scenario: Edited node marks +- **WHEN** 用户手动编辑节点 +- **THEN** 节点 `editedByUser` 设为 true + +## REMOVED Requirements + +### Requirement: MindMapNode with source tracking +**Reason**: `sourceConversationIds` / `sourceExcerpts` 从未被填充,无数据流。 +**Migration**: 字段将在后续 `remove-mindmap-marker` change 中从类型定义中移除。现有初始化代码中 `[]` / `{}` 的值可安全忽略。 + +### Requirement: MindMap fields (maxDepth, corpus, etc.) +**Reason**: `maxDepth`, `corpus`, `forceFullRebuild`, `generatorProviderId`, `generatorModelId`, `lastGeneratedAt` 未在 `src/types/mindmap.ts` 中定义,不对应实际代码。 +**Migration**: 无需迁移。这些字段从未在代码中存在过。 diff --git a/openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-generation/spec.md b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-generation/spec.md new file mode 100644 index 0000000..4fb3477 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/specs/mindmap-generation/spec.md @@ -0,0 +1,25 @@ +## REMOVED Requirements + +### Requirement: Incremental update via full regeneration +**Reason**: 增量操作(`add_child`, `merge`, `delete_leaf`, `noop`)未在代码中实现。当前行为是全量重生成 + `mergeEditedNodes` 保护。该 requirement 描述的增量模式(旧树摘要、操作指令输出)与实际代码不符。 +**Migration**: 无需迁移。`mergeEditedNodes` 和 `editedByUser` 保护机制是当前实际使用的方案。 + +### Requirement: Auto-sync mode +**Reason**: `Conversation.autoSync` 字段不存在于实际代码中。无 debounce 自动生成逻辑。 +**Migration**: 无需迁移。当前行为是每条消息发送时触发脑图生成(绑定在 `doSend` 中)。 + +### Requirement: Manual sync trigger +**Reason**: 无独立的"更新图谱"按钮。脑图生成绑定在聊天发送流程中。 +**Migration**: 无需迁移。 + +### Requirement: Monitored conversation auto-generation +**Reason**: 语料库(`CorpusEntry`)系统未实现。被监听对话的 AI 回复不会自动加入语料库。 +**Migration**: 无需迁移。当前行为是每条消息的 system prompt 中包含现有脑图上下文 + 脑图生成指令。 + +### Requirement: Generation model selection +**Reason**: 无独立的图谱生成模型选择。当前使用 Conversation 的模型。 +**Migration**: 无需迁移。 + +### Requirement: Source conversation tracking +**Reason**: `sourceConversationIds` / `sourceExcerpts` 从未被填充。`[src:convId/msgId]` 标识方案未实现。 +**Migration**: 无需迁移。 diff --git a/openspec/changes/archive/2026-05-07-cleanup-stale-specs/tasks.md b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/tasks.md new file mode 100644 index 0000000..ab5b351 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-cleanup-stale-specs/tasks.md @@ -0,0 +1,21 @@ +## 1. Remove stale spec directories + +- [x] 1.1 删除 `openspec/specs/incremental-mindmap-generation/` +- [x] 1.2 删除 `openspec/specs/mindmap-corpus/` +- [x] 1.3 删除 `openspec/specs/mindmap-content-selection/` +- [x] 1.4 删除 `openspec/specs/mindmap-streaming-preview/` +- [x] 1.5 删除 `openspec/specs/configurable-mindmap-depth/` + +## 2. Clean up mindmap-data spec + +- [x] 2.1 `mindmap-data` spec 已在 `remove-mindmap-marker` archive 时同步 +- [x] 2.2 确认 MindMap/MindMapNode 类型与 `src/types/mindmap.ts` 一致 + +## 3. Clean up mindmap-generation spec + +- [x] 3.1 移除 6 个过时 requirements: Incremental update, Auto-sync, Manual sync, Monitored conversation, Generation model selection, Source tracking +- [x] 3.2 更新 "Generate mindmap" requirement 移除增量/语料库描述,对齐当前全量 JSON mode 行为 + +## 4. Verify + +- [x] 4.1 剩余 12 个 spec 目录,6 个 requirement 对齐代码 diff --git a/openspec/changes/archive/2026-05-07-flow-shell-refactor/.openspec.yaml b/openspec/changes/archive/2026-05-07-flow-shell-refactor/.openspec.yaml new file mode 100644 index 0000000..8d87be1 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-flow-shell-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-07 diff --git a/openspec/changes/archive/2026-05-07-flow-shell-refactor/design.md b/openspec/changes/archive/2026-05-07-flow-shell-refactor/design.md new file mode 100644 index 0000000..5bafed5 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-flow-shell-refactor/design.md @@ -0,0 +1,76 @@ +## Context + +当前 `MindMapTree`(307 行)将 React Flow 初始化、dagre 布局、节点/边渲染、事件处理全部耦合在一起。`MindMapNodeComponent`(89 行)和 `MindMapEdgeComponent`(26 行)与项目类型强绑定。需要抽离可复用的纯 UI 层。 + +## Goals / Non-Goals + +**Goals:** +- `FlowShell` 组件零业务依赖,可直接复制到其他项目 +- 节点升级为 Rich 卡片(层级渐变色条 + Markdown 正文 + 折叠按钮) +- 暗色主题默认,CSS 变量控制 +- 现有交互逻辑(双击/右键/拖拽)保持不变 +- `MindMapTree` 变薄为胶水层 + +**Non-Goals:** +- 不改变 store 或数据流 +- 不改变编辑模态框/右键菜单 +- 不实现自定义布局引擎(仍用 dagre) + +## Decisions + +### Decision 1: FlowShell 使用泛型 props + +```typescript +interface FlowShellProps { + tree: T[] + getLabel: (node: T) => string + getSummary?: (node: T) => string + getContent?: (node: T) => string | undefined + getContentType?: (node: T) => 'text' | 'markdown' | undefined + isEdited?: (node: T) => boolean + theme?: 'dark' | 'light' + pattern?: string + layout?: 'dagre-lr' | 'dagre-tb' + onNodeDoubleClick?: (node: T) => void + onNodeContextMenu?: (event: MouseEvent, node: T) => void + onNodeDragStop?: (dragged: T, target: T | null) => void +} +``` + +通过 accessor 函数解耦 MindMapNode 类型依赖。 + +### Decision 2: 层级色条用 CSS opacity 渐变 + +``` +depth 0: opacity-100 (最浓) +depth 1: opacity-80 +depth 2: opacity-60 +depth 3: opacity-40 +depth 4+: opacity-20 +``` + +不需要 JS 计算,纯 CSS。 + +### Decision 3: Pattern 配色映射 + +``` +auto: 蓝色系 hsl(217, 91%, 60%) +5w1h: 绿色系 hsl(142, 71%, 45%) +tech: 紫色系 hsl(271, 91%, 65%) +pros-cons: 琥珀系 hsl(37, 92%, 50%) +``` + +### Decision 4: 文件结构 + +``` +src/components/flow-shell/ +├── FlowShell.tsx ← 主组件,dagre 布局 + ReactFlow 初始化 +├── FlowNode.tsx ← Rich 节点卡片 +├── FlowEdge.tsx ← Smoothstep 边线 +├── flow-shell.css ← CSS 变量 + 动画 +└── index.ts ← 导出 FlowShell + 类型 +``` + +### Decision 5: 不引入新依赖 + +只用已有的 `@xyflow/react` + `@dagrejs/dagre`。不引入 `elkjs` 或其它布局库。 diff --git a/openspec/changes/archive/2026-05-07-flow-shell-refactor/proposal.md b/openspec/changes/archive/2026-05-07-flow-shell-refactor/proposal.md new file mode 100644 index 0000000..3b23b3f --- /dev/null +++ b/openspec/changes/archive/2026-05-07-flow-shell-refactor/proposal.md @@ -0,0 +1,31 @@ +## Why + +当前 `MindMapNodeComponent` / `MindMapEdgeComponent` / `MindMapTree` 三者与业务逻辑高度耦合,无法单独复用。将纯 UI 视图层抽离为 `FlowShell` 组件,既是本项目代码质量提升,也可直接复制到其他 React Flow 项目使用。 + +## What Changes + +- 新建 `src/components/flow-shell/` 目录,包含: + - `FlowShell.tsx` — 画布壳(Background + Controls + MiniMap + theme + dagre 布局) + - `FlowNode.tsx` — Rich 节点卡片(层级渐变色条 + label + summary + markdown 正文 + 折叠按钮 + 编辑标记) + - `FlowEdge.tsx` — Smoothstep 边线 + - `index.ts` — 统一导出 +- `MindMapTree.tsx` 重构为胶水层:导入 FlowShell,注入 `design=rich`、`pattern=auto`、`theme=dark`,连接 store 和事件 +- `MindMapNodeComponent.tsx` / `MindMapEdgeComponent.tsx` 删除,替换为 FlowShell 内置实现 +- FlowShell 零业务依赖:不 import `mindmapStore`、`MindMapNode` 类型、项目 Tailwind 配置 + +## Capabilities + +### New Capabilities +- `flow-shell-reusable`: 可复用的 React Flow 画布组件——节点卡片(Rich variant + 层级渐变 + dark/light 主题)、边线(Smoothstep)、画布壳(Background/Controls/MiniMap)、主题 CSS 变量 + +### Modified Capabilities +- `mindmap-canvas-rendering`: MindMapTree 从直接使用 React Flow 改为导入 FlowShell +- `mindmap-tree-view`: 节点/边组件替换为 FlowShell 内置实现 + +## Impact + +- `src/components/flow-shell/` — 新增 4 文件,零业务依赖 +- `src/features/mindmap/MindMapTree.tsx` — 重构为胶水层(307→~100 行) +- `src/features/mindmap/MindMapNodeComponent.tsx` — 删除 +- `src/features/mindmap/MindMapEdgeComponent.tsx` — 删除 +- `src/features/mindmap/__tests__/MindMapTree.test.tsx` — 更新 mock diff --git a/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/flow-shell-reusable/spec.md b/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/flow-shell-reusable/spec.md new file mode 100644 index 0000000..af5cb39 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/flow-shell-reusable/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: FlowShell component +系统 SHALL 提供 `FlowShell` 组件,封装 React Flow (@xyflow/react) 画布,内置 Background(dots)、Controls(interactive)、MiniMap。FlowShell SHALL 不依赖项目业务类型或 store,通过 props 接受所有数据。FlowShell SHALL 支持 `layout` 属性切换 dagre 布局方向(LR/TB)。 + +#### Scenario: FlowShell renders with nodes +- **WHEN** 传入 `nodes` 和 `edges` 数组 +- **THEN** 画布渲染 Background/Controls/MiniMap,节点按 dagre 布局排列 + +#### Scenario: FlowShell supports dark/light theme +- **WHEN** `theme="dark"` 传入 +- **THEN** 画布使用暗色背景,节点卡片半透明毛玻璃效果 + +#### Scenario: FlowShell includes Controls +- **WHEN** 渲染 FlowShell +- **THEN** Controls 面板显示并可用,默认 `showInteractive: true` + +### Requirement: FlowNode component +系统 SHALL 提供 `FlowNode` 组件,渲染 Rich variant 节点卡片。卡片 SHALL 包含:label(粗标题)、summary(灰色副文本)、Markdown 正文(content 存在时,react-markdown 渲染)、折叠按钮(圆形 +/-)、编辑标记(Pencil 图标,editedByUser 时显示)。卡片 SHALL 有左侧层级渐变色条(L0 最深 → L4 最淡)。 + +#### Scenario: FlowNode with deep hierarchy +- **WHEN** 节点 depth=3,pattern="auto" +- **THEN** 左侧色条颜色为层级渐变蓝(L3 淡蓝),折叠按钮可见 + +#### Scenario: FlowNode with markdown content +- **WHEN** 节点 `contentType="markdown"` 且 `content` 非空 +- **THEN** 卡片底部渲染 react-markdown 正文,带滚动 + +### Requirement: FlowEdge component +系统 SHALL 提供 `FlowEdge` 组件,使用 smoothstep 贝塞尔曲线。边线 SHALL 支持可选渐变色(从父节点色渐变到子节点色)。 + +### Requirement: Zero business dependency +FlowShell / FlowNode / FlowEdge SHALL NOT 导入以下模块:`mindmapStore`、`conversationStore`、`providerStore`、`chatStore`、`@/types/mindmap`。所有数据 SHALL 通过 props 传入。 + +### Requirement: CSS variable theme system +FlowShell SHALL 通过自有 CSS 变量定义主题色,不依赖项目 Tailwind 主题。变量 SHALL 包括:`--flow-bg`、`--flow-card-bg`、`--flow-text`、`--flow-accent`、`--flow-border`。支持 `data-theme="dark"` / `data-theme="light"` 切换。 diff --git a/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-canvas-rendering/spec.md b/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-canvas-rendering/spec.md new file mode 100644 index 0000000..d22f14c --- /dev/null +++ b/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-canvas-rendering/spec.md @@ -0,0 +1,11 @@ +## MODIFIED Requirements + +### Requirement: React Flow rendering +系统 SHALL 使用 `FlowShell` 组件渲染 MindMapNode 树结构,替代直接使用 `@xyflow/react`。`FlowShell` SHALL 通过 `design="rich"`、`pattern`(来自 MindMap.pattern)、`theme="dark"` 配置视觉样式。dagre 布局计算 SHALL 移至 FlowShell 内部。 + +#### Scenario: Render tree with FlowShell +- **WHEN** 图谱包含根节点和子节点 +- **THEN** FlowShell 渲染所有节点,使用 Smoothstep 边线连接,dark 主题 + 层级渐变色条 + +### Requirement: nodeTypes and edgeTypes +系统 SHALL 使用 FlowShell 内置的节点类型 `'flow'` 和边类型 `'flow-smoothstep'`,替代原有的 `'mindmap'` 自定义类型。节点 SHALL 显示 Rich 卡片(label + summary + content + 层级色条 + 折叠按钮 + 编辑标记)。 diff --git a/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-tree-view/spec.md b/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-tree-view/spec.md new file mode 100644 index 0000000..637ad78 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-flow-shell-refactor/specs/mindmap-tree-view/spec.md @@ -0,0 +1,11 @@ +## MODIFIED Requirements + +### Requirement: Tree rendering +系统 SHALL 使用 `FlowShell` 替代直接 React Flow 渲染。节点 SHALL 使用 FlowShell 内置的 Rich 卡片样式(层级渐变色条 + label + summary + content)。边 SHALL 使用 FlowShell 内置的 Smoothstep 边线。 + +### Requirement: Node visual states +系统 SHALL 使用 FlowShell 内建的节点视觉状态: +- **编辑标记**: `editedByUser === true` → Pencil 图标 +- **层级渐变**: 色条颜色从 L0 最深到 L4 最淡 +- **折叠按钮**: 圆形 +/- 按钮 +- **选中态**: ring + scale-105 + glow shadow diff --git a/openspec/changes/archive/2026-05-07-flow-shell-refactor/tasks.md b/openspec/changes/archive/2026-05-07-flow-shell-refactor/tasks.md new file mode 100644 index 0000000..d5e1df9 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-flow-shell-refactor/tasks.md @@ -0,0 +1,25 @@ +## 1. FlowShell 组件 + +- [x] 1.1 创建 `src/components/flow-shell/FlowShell.tsx` — 画布壳(ReactFlow + dagre 布局 + Background + Controls + MiniMap) +- [x] 1.2 创建 `src/components/flow-shell/FlowNode.tsx` — Rich 节点(层级色条 + label + summary + content + 折叠 + 编辑标记) +- [x] 1.3 创建 `src/components/flow-shell/FlowEdge.tsx` — Smoothstep 边线 +- [x] 1.4 创建 `src/components/flow-shell/flow-shell.css` — CSS 变量 + dark/light 主题 + 动画 +- [x] 1.5 创建 `src/components/flow-shell/index.ts` — 统一导出 + 类型 + +## 2. MindMapTree 重构 + +- [x] 2.1 `MindMapTree.tsx` 导入 FlowShell,删除裸 ReactFlow 代码 +- [x] 2.2 注入 theme="dark",pattern 暂时硬编码 "auto"(`add-mindmap-pattern` 完成后改为动态) +- [x] 2.3 保留所有事件处理(双击/右键/拖拽/折叠/编辑) +- [x] 2.4 保留 useMindmapLayout 用于折叠状态管理 + `treeRef.current` 用于树操作 + +## 3. 清理旧组件 + +- [x] 3.1 删除 `src/features/mindmap/MindMapNodeComponent.tsx` +- [x] 3.2 删除 `src/features/mindmap/MindMapEdgeComponent.tsx` + +## 4. 测试 + +- [x] 4.1 更新 `MindMapTree.test.tsx` mock(FlowShell 替代 @xyflow/react 直接 mock) +- [x] 4.2 `npm test` 全部通过 (69 tests) +- [x] 4.3 `npm run lint` 通过 (0 errors) diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/.openspec.yaml b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/.openspec.yaml new file mode 100644 index 0000000..8d87be1 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-07 diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/design.md b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/design.md new file mode 100644 index 0000000..3afb307 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/design.md @@ -0,0 +1,49 @@ +## Context + +当前 `src/lib/mindmap-generator.ts` 中有三处硬编码的截断: + +``` +parseJsonToTree(jsonString, maxDepth = 6) → 调用方不传参, 固定 6 +parseMarkdownToTree(markdown, maxDepth = 6) → 同上, 深度超限时 child 不入 stack +jsonNodeToMindMapNode(item, depth, maxDepth) → depth >= maxDepth 时 children = [] +jsonNodeToMindMapNode 内: children.slice(0, 10) → 每节点最多 10 个子节点 +``` + +LLM 在 prompt 中没有深度/子节点上限的约束,生成完以后解析阶段却丢弃了数据。调用方 `ChatPage.tsx` 两处 `parseJsonToTree(...)` 都不传 `maxDepth`。 + +## Goals / Non-Goals + +**Goals:** +- LLM 生成的任意深度的树都能完整解析 +- LLM 生成的任意子节点数的节点都完整保留 +- 调用方无需改动 + +**Non-Goals:** +- 不引入 `maxDepth` 可配置 UI(那是另一个 change) +- 不改变 prompt 内容 +- 不改 `mindmapTreeToContext` 的 `maxNodes=200`(那是 prompt 序列化截断,非树结构截断) + +## Decisions + +### Decision 1: `maxDepth` → `Infinity`,而非移除参数 + +保留参数签名(向下兼容),默认值从 `6` 改为 `Infinity`。 + +**备选**: 直接移除参数 → 改为无参数函数 → 调用方需改动测试文件 → 选保留签名更安全。 + +### Decision 2: 移除 `children.slice(0, 10)` + +直接删除 `.slice(0, 10)`,不对子节点数组做任何截断。 + +### Decision 3: Markdown 解析正则 `^(#{1,6})\\s` → `^(#+)\\s` + +当前 `new RegExp('^(#{1,' + maxDepth + '})\\s+(.+)')` 在 `maxDepth=Infinity` 下产生 `^(#{1,Infinity})\\s+(.+)` —— 这在 JS 中是合法语法,等同 `^(#+)\\s+(.+)`。无需特殊处理。 + +### Decision 4: `parseMarkdownToTree` 的 depth boundary + +当前 L108: `if (depth < maxDepth) { stack.push(...) }`。`depth < Infinity` → 始终为 true。所有层级都会被 push 到 stack,无截断。 + +## Risks / Trade-offs + +- [内存/性能] LLM 返回深度极大的树(如 100 层)或单节点有 200 个子节点时,React Flow 渲染可能变慢 → Mitigation: 已有 `mindmapTreeToContext` 的 `maxNodes=200` 作为 prompt 上下文截断保护;LLM 实际生成超大树的概率极低 (当前 prompt 无深度指令时 LLM 通常产出 3-5 层) +- [已废弃的 spec] `configurable-mindmap-depth` 和 `mindmap-generation` 中的 `maxDepth` 相关 requirement 与当前修改不一致 → Mitigation: 后续独立 change 更新或清理这些 spec diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/proposal.md b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/proposal.md new file mode 100644 index 0000000..d7e9059 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/proposal.md @@ -0,0 +1,21 @@ +## Why + +`parseJsonToTree` / `parseMarkdownToTree` / `jsonNodeToMindMapNode` 对 LLM 生成的脑图施加了硬编码深度上限(`maxDepth=6`,调用方不传参)和每节点子节点数上限(`children.slice(0,10)`)。LLM 生成时不设限制,但解析阶段一刀切截断,导致第 7 层及更深节点的子节点数据被丢弃,超过 10 个子节点的分支被静默截断。 + +## What Changes + +- 移除 `parseJsonToTree` / `parseMarkdownToTree` / `jsonNodeToMindMapNode` 中的 `maxDepth` 默认上限(改为 `Infinity` 或不截断) +- 移除 `jsonNodeToMindMapNode` 中的 `children.slice(0, 10)` 子节点数量硬限制 +- 保留 `mindmapTreeToContext` 中的 `maxNodes=200`(这是 prompt 上下文序列化截断,不是树结构截断) +- **BREAKING**: 无。只是移除了数据丢弃逻辑,已持久化的旧树不受影响 + +## Capabilities + +### Modified Capabilities +- `mindmap-generation`: 解析器不再对 LLM 输出施加深度和子节点数量上限 + +## Impact + +- `src/lib/mindmap-generator.ts`: `parseJsonToTree`, `parseMarkdownToTree`, `jsonNodeToMindMapNode` +- 调用方无需改动(已经不传 `maxDepth` 参数,将自动使用移除上限后的行为) +- 测试文件需更新:`src/lib/__tests__/mindmap-generator.test.ts` diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/specs/mindmap-generation/spec.md b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/specs/mindmap-generation/spec.md new file mode 100644 index 0000000..69fd5e1 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/specs/mindmap-generation/spec.md @@ -0,0 +1,18 @@ +## MODIFIED Requirements + +### Requirement: Depth and breadth constraints +系统 SHALL NOT 在解析阶段对 LLM 生成的脑图施加硬编码深度上限或每节点子节点数量上限。`parseJsonToTree` 和 `jsonNodeToMindMapNode` SHALL 接受 LLM 返回的任意深度树结构和任意子节点数量,不做截断。 + +`mindmapTreeToContext` 中的 `maxNodes=200` 序列化截断 SHALL 保持不变(这是 prompt 上下文限制,而非树结构限制)。 + +#### Scenario: LLM returns deep tree beyond old limit +- **WHEN** LLM 返回 8 层深的树结构 +- **THEN** 解析器完整保留所有 8 层节点,不做截断 + +#### Scenario: LLM returns many children per node +- **WHEN** LLM 返回某个节点有 15 个直接子节点 +- **THEN** 解析器完整保留所有 15 个子节点,不做截断 + +#### Scenario: Existing tree data unaffected +- **WHEN** 旧持久化数据被加载 +- **THEN** 系统正常渲染,现有树结构不受影响 diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/tasks.md b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/tasks.md new file mode 100644 index 0000000..2c84665 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-depth-limit/tasks.md @@ -0,0 +1,21 @@ +## 1. Core changes + +- [x] 1.1 `parseJsonToTree`: 默认值 `maxDepth = 6` → `maxDepth = Infinity` +- [x] 1.2 `jsonNodeToMindMapNode`: 默认值 `maxDepth = 6` → `maxDepth = Infinity` +- [x] 1.3 `jsonNodeToMindMapNode` L140: 移除 `children.slice(0, 10)` 改为 `children` +- [x] 1.4 `jsonNodeToMindMapNode` L151: `depth < (maxDepth ?? 6)` → `depth < maxDepth` (去掉多余 `??`) +- [x] 1.5 `parseMarkdownToTree` 已由 `remove-mindmap-marker` 整体删除 + +> 注:任务 1.1-1.4 的代码改动已在 `remove-mindmap-marker` 实施时一并完成。 + +## 2. Tests + +- [x] 2.1 新增测试:深度超过 6 层(8 层)的 JSON 完整解析 +- [x] 2.2 新增测试:单节点 15 个子节点全部保留 +- [x] 2.3 确认现有测试全部通过 (`npm test` — 69 passed) +- [x] 2.4 检查 `npm run lint` 通过 (0 errors) + +## 3. Deferred to follow-up changes + +- [x] 3.1 `parseMarkdownToTree` 已随 `remove-mindmap-marker` 删除 +- [x] 3.2 旧 spec 清理 → `cleanup-stale-specs` diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-marker/.openspec.yaml b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/.openspec.yaml new file mode 100644 index 0000000..8d87be1 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-07 diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-marker/design.md b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/design.md new file mode 100644 index 0000000..ff200d9 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/design.md @@ -0,0 +1,59 @@ +## CONTEXT + +当前 `buildFullMindmapPrompt` 有两条路径:JSON mode(`response_format: json_object`)和 marker mode(`` HTML 注释)。`supportsJsonMode` 通过 `detectJsonMode()` 自动检测,匹配 OpenAI/DeepSeek/SiliconFlow/OpenRouter/Google 等主流端点。非 JSON mode 的 endpoint(如自定义 Ollama 实例)走 marker mode。 + +## GOALS / NON-GOALS + +**Goals:** +- 统一为 JSON mode 单一路径 +- 删除 marker mode 相关的死代码:`parseMarkdownToTree`、`buildHybridContext`、`stripSourceAnnotations` +- 简化 `parseJsonToTree`:移除 3 阶段容错,保留直接 `JSON.parse` + 错误处理 +- 移除 `sourceConversationIds` / `sourceExcerpts` 字段(从未填充) + +**Non-Goals:** +- 不改变 JSON mode 的 prompt 内容(仅删除 `useJsonMode=false` 分支) +- 不移除 `supportsJsonMode` / `detectJsonMode`(保留用于判断是否传 `response_format`) +- 不改变已持久化的数据格式 + +## DECISIONS + +### Decision 1: `buildFullMindmapPrompt` 去掉参数 + +```typescript +// 改前: +export function buildFullMindmapPrompt(useJsonMode = false): string + +// 改后: +export function buildFullMindmapPrompt(): string // 始终返回 JSON mode prompt +``` + +调用方 `ChatPage.tsx` L117-119 相应简化。 + +### Decision 2: ChatPage 移除 marker 解析 + +`ChatPage.tsx` L167-191 整个 else 分支删除。`doSend` L115 `useJsonMode` 变量可去掉(始终为 true)。 + +### Decision 3: `parseJsonToTree` 简化 + +```typescript +// 改前: 3阶段容错 + markdown回退 (L158-206) +// 改后: +export function parseJsonToTree(jsonString: string): MindMapNode[] { + const parsed = JSON.parse(jsonString) as { nodes?: unknown[] } + if (!Array.isArray(parsed.nodes)) return [] + return parsed.nodes.map(n => jsonNodeToMindMapNode(n)) +} +``` + +### Decision 4: 删除的文件/函数 + +| 删除项 | 原因 | +|--------|------| +| `parseMarkdownToTree` | 仅被已移除的回退路径调用 | +| `buildHybridContext` | `doSend` 不使用(用内联构建) | +| `stripSourceAnnotations` | 无上游调用 | +| `sourceConversationIds` / `sourceExcerpts` 字段 | 从未填充 | + +## RISKS / TRADE-OFFS + +- [非 JSON mode provider 不再能生成脑图] → Mitigation: 主流 provider 全部支持 `response_format: json_object`(OpenAI API 兼容标准)。自定义 Ollama endpoint 若不支持可配置 proxy diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-marker/proposal.md b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/proposal.md new file mode 100644 index 0000000..3b2d334 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/proposal.md @@ -0,0 +1,29 @@ +## Why + +JSON mode (`response_format: json_object`) 已成为主流路径,`` 标记模式、markdown 解析回退、`buildHybridContext`、`stripSourceAnnotations` 成为死代码。`sourceConversationIds`/`sourceExcerpts` 类型字段无数据流。一并清理。 + +## What Changes + +- `buildFullMindmapPrompt(useJsonMode)` — 移除 `useJsonMode=false` 分支,统一为 JSON 格式 +- `ChatPage.tsx` L167-191 — 移除 `` 标记搜索解析路径 +- `parseJsonToTree` — 移除 3 阶段 JSON 容错 + markdown 回退;`response_format: json_object` 保证合法 JSON,只需直接 `JSON.parse` + 错误处理 +- `parseMarkdownToTree` — 整体删除(仅被已移除的回退路径调用) +- `buildHybridContext` — 删除(`ChatPage` 不用它) +- `stripSourceAnnotations` — 删除 +- `sourceConversationIds` / `sourceExcerpts` 字段 — 从 `MindMapNode` 类型和所有初始化代码中移除 +- `MindMapNodeComponent` L67-69 — 移除 `💬{sourceCount}` 显示 +- 更新相关测试 + +## Capabilities + +### Modified Capabilities +- `mindmap-generation`: 移除标记模式,统一为 JSON mode;移除 markdown 解析回退 +- `mindmap-data`: 移除 `sourceConversationIds` 和 `sourceExcerpts` 字段 + +## Impact + +- `src/lib/mindmap-generator.ts` — 大幅简化 +- `src/features/chat/ChatPage.tsx` — 移除 else 分支 +- `src/types/mindmap.ts` — 移除 2 个字段 +- `src/features/mindmap/MindMapNodeComponent.tsx` — 移除 sourceCount UI +- 测试文件更新 diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-data/spec.md b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-data/spec.md new file mode 100644 index 0000000..78438a7 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-data/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: MindMap data model +系统 SHALL 使用以下数据模型表示思维导图: + +``` +MindMap { + id: string + title: string + tree: MindMapNode[] + monitoredConversationIds: string[] + collapsedNodeIds?: string[] + createdAt: number + updatedAt: number +} + +MindMapNode { + id: string + label: string + summary: string + content?: string + contentType?: 'text' | 'markdown' + children: MindMapNode[] + editedByUser: boolean +} +``` + +#### Scenario: MindMap data structure +- **WHEN** 系统创建新的思维导图 +- **THEN** 生成唯一 ID,记录创建时间,tree 初始为空数组 + +#### Scenario: Edited node marks +- **WHEN** 用户手动编辑节点 +- **THEN** 节点 `editedByUser` 设为 true + +#### Scenario: New node with markdown content +- **WHEN** 创建 MindMapNode 且指定 `contentType: 'markdown'` 和 `content: '## Title\n\nContent'` +- **THEN** 节点存储完整 Markdown 内容且类型标记为 markdown + +#### Scenario: Existing node remains compatible +- **WHEN** 现有节点(无 `contentType` 和 `content` 字段)被反序列化 +- **THEN** 节点正常加载,`contentType` 默认为 `'text'`,行为与旧版本一致 diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-generation/spec.md b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-generation/spec.md new file mode 100644 index 0000000..a17d486 --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/specs/mindmap-generation/spec.md @@ -0,0 +1,18 @@ +## MODIFIED Requirements + +### Requirement: Structured JSON output (preferred format) +系统 SHALL 始终使用 JSON mode 约束 LLM 输出结构化 JSON(当 provider 支持 `response_format: json_object` 时)。系统 SHALL NOT 使用 `` HTML 注释标记模式。JSON 解析 SHALL 直接使用 `JSON.parse`,不做多阶段容错修复或 Markdown 回退。解析失败时 SHALL 保持当前树不变。 + +#### Scenario: JSON mode supported +- **WHEN** 当前 provider 的 `apiEndpoint` 匹配已知支持列表(OpenAI/DeepSeek/SiliconFlow/OpenRouter/Google) +- **THEN** 系统使用 `response_format: { type: "json_object" }` 请求 + +#### Scenario: JSON parse failure +- **WHEN** LLM 返回非 JSON 内容或 JSON 解析失败 +- **THEN** 系统保持当前树结构不变,不触发树更新 + +## REMOVED Requirements + +### Requirement: Markdown to tree parsing +**Reason**: Markdown 解析仅用于 `` marker mode 的 JSON 回退路径。该路径已被移除。 +**Migration**: 无需迁移。JSON mode 始终产生可直接 `JSON.parse` 的结构化输出。 diff --git a/openspec/changes/archive/2026-05-07-remove-mindmap-marker/tasks.md b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/tasks.md new file mode 100644 index 0000000..9696f7d --- /dev/null +++ b/openspec/changes/archive/2026-05-07-remove-mindmap-marker/tasks.md @@ -0,0 +1,33 @@ +## 1. mindmap-generator.ts 简化 + +- [x] 1.1 `buildFullMindmapPrompt`: 移除 `useJsonMode` 参数,只保留 JSON mode prompt +- [x] 1.2 删除 `parseMarkdownToTree` 函数 +- [x] 1.3 删除 `buildHybridContext` 函数 +- [x] 1.4 删除 `stripSourceAnnotations` 函数 +- [x] 1.5 `parseJsonToTree`: 移除 3 阶段容错,改为直接 `JSON.parse` + 错误处理 +- [x] 1.6 `jsonNodeToMindMapNode`: 移除 `stripSourceAnnotations` 调用,直接用 `raw.label` + +## 2. ChatPage.tsx 简化 + +- [x] 2.1 `buildFullMindmapPrompt()` 不再传参 +- [x] 2.2 `doSend` L153-192: 移除 if/else 分支 + `` 标记解析,统一为 JSON 解析路径 +- [x] 2.3 `useJsonMode` 保留用于 `chat()` 的 `response_format` 控制 + +## 3. 类型清理 + +- [x] 3.1 `src/types/mindmap.ts`: 移除 `sourceConversationIds` 和 `sourceExcerpts` 字段 +- [x] 3.2 `src/lib/mindmap-generator.ts`: 所有初始化中去掉这两个字段 +- [x] 3.3 `src/stores/mindmapStore.ts`: `addChildNode` 中去掉这两个字段 +- [x] 3.4 `src/features/mindmap/MindMapNodeComponent.tsx` L67-69: 移除 `sourceCount` 显示 + +## 4. mindmap-layout.ts / types.ts 清理 + +- [x] 4.1 `src/features/mindmap/types.ts`: `MindMapNodeData` 移除 `sourceCount` +- [x] 4.2 `src/lib/mindmap-layout.ts`: `treeToFlow` 移除 `sourceCount` 赋值 +- [x] 4.3 搜全项目确认无残留引用 + +## 5. 测试 + +- [x] 5.1 更新 4 个测试文件移除 marker mode / markdown 解析 / source tracking 相关用例 +- [x] 5.2 `npm test` 全部通过 (67 tests) +- [x] 5.3 `npm run lint` 通过 (0 errors)