From 9a1d821162e2bb527b8a5bea969fe13a98eaecad Mon Sep 17 00:00:00 2001 From: web4zn Date: Tue, 5 May 2026 15:19:43 +0800 Subject: [PATCH 01/15] feat: add OpenRouter preset provider for zero-config onboarding - Add preset?: boolean field to Provider type - Auto-inject OpenRouter preset with 5 free models on first launch - Protect preset providers from deletion (toast + disabled button) - Show preset badge and API key setup guide in ProviderSettingsPage - Add error guidance in ChatPage and MindMapPanel for free model failures - Add 3 tests for preset provider protection and deletion control --- .../changes/default-free-model/.openspec.yaml | 2 + openspec/changes/default-free-model/design.md | 48 +++++++++++++++++ .../changes/default-free-model/proposal.md | 27 ++++++++++ .../specs/model-provider/spec.md | 12 +++++ .../specs/preset-model-provider/spec.md | 20 +++++++ openspec/changes/default-free-model/tasks.md | 21 ++++++++ .../changes/mindmap-export/.openspec.yaml | 2 + openspec/changes/mindmap-export/design.md | 40 ++++++++++++++ openspec/changes/mindmap-export/proposal.md | 26 +++++++++ .../specs/mindmap-panel-layout/spec.md | 8 +++ .../specs/mindmap-png-export/spec.md | 20 +++++++ .../specs/mindmap-svg-export/spec.md | 13 +++++ openspec/changes/mindmap-export/tasks.md | 24 +++++++++ .../changes/rich-node-content/.openspec.yaml | 2 + openspec/changes/rich-node-content/design.md | 39 ++++++++++++++ .../changes/rich-node-content/proposal.md | 30 +++++++++++ .../specs/mindmap-data/spec.md | 12 +++++ .../specs/mindmap-generation/spec.md | 9 ++++ .../specs/mindmap-node-editing/spec.md | 9 ++++ .../specs/rich-node-content/spec.md | 27 ++++++++++ openspec/changes/rich-node-content/tasks.md | 26 +++++++++ src/features/chat/ChatPage.tsx | 13 ++++- src/features/mindmap/MindMapPanel.tsx | 8 ++- .../provider/ProviderSettingsPage.tsx | 37 +++++++++++-- src/stores/__tests__/providerStore.test.ts | 53 +++++++++++++++++++ src/stores/providerStore.ts | 47 +++++++++++++++- src/types/provider.ts | 1 + 27 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/default-free-model/.openspec.yaml create mode 100644 openspec/changes/default-free-model/design.md create mode 100644 openspec/changes/default-free-model/proposal.md create mode 100644 openspec/changes/default-free-model/specs/model-provider/spec.md create mode 100644 openspec/changes/default-free-model/specs/preset-model-provider/spec.md create mode 100644 openspec/changes/default-free-model/tasks.md create mode 100644 openspec/changes/mindmap-export/.openspec.yaml create mode 100644 openspec/changes/mindmap-export/design.md create mode 100644 openspec/changes/mindmap-export/proposal.md create mode 100644 openspec/changes/mindmap-export/specs/mindmap-panel-layout/spec.md create mode 100644 openspec/changes/mindmap-export/specs/mindmap-png-export/spec.md create mode 100644 openspec/changes/mindmap-export/specs/mindmap-svg-export/spec.md create mode 100644 openspec/changes/mindmap-export/tasks.md create mode 100644 openspec/changes/rich-node-content/.openspec.yaml create mode 100644 openspec/changes/rich-node-content/design.md create mode 100644 openspec/changes/rich-node-content/proposal.md create mode 100644 openspec/changes/rich-node-content/specs/mindmap-data/spec.md create mode 100644 openspec/changes/rich-node-content/specs/mindmap-generation/spec.md create mode 100644 openspec/changes/rich-node-content/specs/mindmap-node-editing/spec.md create mode 100644 openspec/changes/rich-node-content/specs/rich-node-content/spec.md create mode 100644 openspec/changes/rich-node-content/tasks.md diff --git a/openspec/changes/default-free-model/.openspec.yaml b/openspec/changes/default-free-model/.openspec.yaml new file mode 100644 index 0000000..eebe4d8 --- /dev/null +++ b/openspec/changes/default-free-model/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-05 diff --git a/openspec/changes/default-free-model/design.md b/openspec/changes/default-free-model/design.md new file mode 100644 index 0000000..a8620c3 --- /dev/null +++ b/openspec/changes/default-free-model/design.md @@ -0,0 +1,48 @@ +## Decisions + +### D1: 免费模型提供商选择 + +**选择**:预置 **OpenRouter**,包含多个免费模型。endpoint:`https://openrouter.ai/api/v1`。 + +**预置模型列表**: + +| 模型 ID | 说明 | +|---------|------| +| `google/gemma-3-12b-it:free` | Google Gemma 3 12B,综合能力强 | +| `meta-llama/llama-3.3-70b-instruct:free` | Meta Llama 3.3 70B,推理强 | +| `mistralai/mistral-nemo:free` | Mistral Nemo,轻量高效 | +| `deepseek/deepseek-r1:free` | DeepSeek R1,中文推理强 | +| `qwen/qwen2.5-7b-instruct:free` | Qwen 2.5 7B,中文对话好 | + +**替代方案**: +- SiliconFlow:中文好但模型单一(全是 Qwen 系) +- DeepSeek 直连:需单独注册 +- Ollama 本地:需安装,非开箱即用 + +**决策依据**:OpenRouter 一个 key 通吃多厂商免费模型,用户无需针对每个厂商单独注册。模型列表覆盖 Google/Meta/Mistral/DeepSeek/Qwen 五大来源,中文和英文场景均覆盖。API 完全兼容 OpenAI 格式,零代码改动。 + +### D2: 预置提供商保护 + +**选择**:通过 `preset: true` 标记。删除操作检查此标记,弹 toast 提示「系统预置模型不可删除」。 + +**替代方案**: +- 硬编码 ID 判断:脆弱,ID 可被篡改 +- 单独存储预设列表:增加复杂度 + +**决策依据**:数据标记方式简单可靠,与现有 Provider 数据模型最小侵入。用户仍可禁用模型(`enabled: false`),只是不能删除提供商。 + +### D3: 初始化时机 + +**选择**:ProviderStore 的 persist 恢复完成后,检查 `providers.length === 0`,若为空则注入预置提供商。 + +**替代方案**: +- 固定写在初始 state 中:如果用户删了预置,每次刷新都会重新注入 +- 在组件层判断:逻辑分散,多个入口需要相同判断 + +**决策依据**:在 store 初始化阶段统一处理,只触发一次。已有用户(store 不空)完全不受影响。 + +### D4: 免费模型降级体验 + +**选择**:免费模型调用失败时,错误提示中引导用户切换其他免费模型或添加自有 API key。 + +**决策依据**:OpenRouter 免费模型各有速率限制(约 20 req/min 共享额度),比单提供商更灵活——A 模型限流了可切 B 模型继续用。同时提示用户可添加自有提供商获更稳定体验。 diff --git a/openspec/changes/default-free-model/proposal.md b/openspec/changes/default-free-model/proposal.md new file mode 100644 index 0000000..2e594b3 --- /dev/null +++ b/openspec/changes/default-free-model/proposal.md @@ -0,0 +1,27 @@ +## Why + +当前新用户打开应用后需要在设置中手动配置 API 提供商和密钥才能使用。没有「开箱即用」的体验,许多潜在用户在配置步骤就流失了。提供预配置的 OpenRouter 免费模型列表,用户只需粘贴 API key 即可使用多种免费模型(Google/Meta/Mistral/DeepSeek),无需单独对接每个厂商。 + +## What Changes + +- **预置 OpenRouter 提供商**:系统初始化时自动创建 OpenRouter 配置,包含多个免费模型(Gemma 3、Llama 3.3、Mistral Nemo、DeepSeek R1 等)。endpoint 统一为 `https://openrouter.ai/api/v1`。 +- **免费模型标识**:Provider 数据模型新增 `preset: boolean` 字段,标记系统预置的提供商。预置提供商不可删除,但用户可禁用其模型。 +- **初始化逻辑**:ProviderStore 在首次启动时(store 为空)自动注册预置 OpenRouter 提供商。已有用户的 store 不受影响。 +- **配置页提示**:在 ProviderSettingsPage 中预置提供商下方显示说明文字和「获取免费 API Key」链接(指向 openrouter.ai)。 +- **降级策略**:免费模型不可用时(如限流),错误提示中建议切换其他免费模型或配置自有 API key。 + +## Capabilities + +### New Capabilities +- `preset-model-provider`: 系统预置免费模型提供商,首次启动自动注册,开箱即用 + +### Modified Capabilities +- `model-provider`: Provider 数据模型新增 `preset` 字段;新增预置提供商保护(不可删除) + +## Impact + +- **数据模型**:`src/types/provider.ts` Provider 新增 `preset?: boolean` +- **状态管理**:`src/stores/providerStore.ts` 新增初始化逻辑,注入 OpenRouter 配置及免费模型列表 +- **UI 组件**:`src/features/provider/ProviderSettingsPage.tsx` 预置提供商 UI 区分 + 注册引导链接 +- **配置持久化**:预置提供商存储方式与用户自建一致(IndexedDB) +- **迁移**:首次启动检测(store 为空 → 注入预置),已有用户不受影响 diff --git a/openspec/changes/default-free-model/specs/model-provider/spec.md b/openspec/changes/default-free-model/specs/model-provider/spec.md new file mode 100644 index 0000000..45927dd --- /dev/null +++ b/openspec/changes/default-free-model/specs/model-provider/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Provider data model +系统 SHALL 使用以下数据模型表示模型提供商。Provider SHALL 包含字段:`id: string`(唯一标识)、`name: string`(显示名称)、`endpoint: string`(API 端点)、`apiKey: string`(API 密钥)、`models: Model[]`(模型列表)、`preset?: boolean`(可选,标记为系统预置,预置提供商不可删除)、`createdAt: number`(创建时间戳)、`updatedAt: number`(更新时间戳)。 + +#### Scenario: Preset provider has preset flag +- **WHEN** 预置提供商被创建 +- **THEN** `preset` 字段为 `true` + +#### Scenario: User-created provider has no preset flag +- **WHEN** 用户手动添加提供商 +- **THEN** `preset` 字段为 `undefined` 或 `false` diff --git a/openspec/changes/default-free-model/specs/preset-model-provider/spec.md b/openspec/changes/default-free-model/specs/preset-model-provider/spec.md new file mode 100644 index 0000000..c402bfc --- /dev/null +++ b/openspec/changes/default-free-model/specs/preset-model-provider/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Preset provider initialization +系统 SHALL 在首次启动(ProviderStore 中无提供商)时自动注册一个预置提供商:OpenRouter(endpoint `https://openrouter.ai/api/v1`),包含至少 5 个免费模型。预置提供商 SHALL 使用 `preset: true` 标记,不可被用户删除,但其模型可被禁用。已存在提供商的用户 SHALL 不受此初始化影响。 + +#### Scenario: First launch auto-creates OpenRouter preset +- **WHEN** 用户首次打开应用,ProviderStore 中无任何提供商 +- **THEN** 系统自动创建 `preset: true` 的 OpenRouter 提供商,包含 Gemma 3 12B、Llama 3.3 70B、Mistral Nemo、DeepSeek R1、Qwen 2.5 7B 等免费模型 +- **AND** 提供商设置页面显示该预置提供商 + +#### Scenario: Existing user is unaffected +- **WHEN** 用户已有至少一个提供商配置 +- **THEN** 系统不注入任何预置提供商,用户数据完全不变 + +### Requirement: Free model switching guidance +当某个免费模型调用失败时,错误提示中 SHALL 包含引导文字「免费模型有限制,可切换其他免费模型或添加自己的 API key 获得更稳定体验」。 + +#### Scenario: Free model rate limit exceeded +- **WHEN** 选中免费模型返回 429 或调用失败 +- **THEN** 错误提示中包含切换模型和添加自有 key 的引导文字 diff --git a/openspec/changes/default-free-model/tasks.md b/openspec/changes/default-free-model/tasks.md new file mode 100644 index 0000000..a76a94b --- /dev/null +++ b/openspec/changes/default-free-model/tasks.md @@ -0,0 +1,21 @@ +## Tasks + +### 1. 数据模型扩展 +- [x] `src/types/provider.ts`:Provider 新增 `preset?: boolean` 字段 + +### 2. ProviderStore 初始化逻辑 +- [x] `src/stores/providerStore.ts`:persist 恢复后检测 `providers.length === 0`,注入预置 OpenRouter 提供商 +- [x] 预置配置:endpoint `https://openrouter.ai/api/v1`,models 列表 5 个免费模型(Gemma 3 12B、Llama 3.3 70B、Mistral Nemo、DeepSeek R1、Qwen 2.5 7B) + +### 3. 预置提供商保护 +- [x] `src/stores/providerStore.ts`:`removeProvider` 拒绝删除 `preset: true` 的提供商 +- [x] `src/features/provider/ProviderSettingsPage.tsx`:预置提供商显示标识标记 + +### 4. 降级引导 +- [x] `src/features/chat/ChatPage.tsx`:免费模型错误提示中引导切换其他模型或添加自有 key +- [x] `src/features/mindmap/MindMapPanel.tsx`:生成脑图失败时提示同上 + +### 5. 测试 +- [x] `providerStore.test.ts`:验证首次启动注入预置提供商、已有用户不受影响 +- [x] `providerStore.test.ts`:验证预置提供商不可删除 +- [x] 手动测试:清除 IndexedDB 后刷新,验证预置提供商自动出现 diff --git a/openspec/changes/mindmap-export/.openspec.yaml b/openspec/changes/mindmap-export/.openspec.yaml new file mode 100644 index 0000000..eebe4d8 --- /dev/null +++ b/openspec/changes/mindmap-export/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-05 diff --git a/openspec/changes/mindmap-export/design.md b/openspec/changes/mindmap-export/design.md new file mode 100644 index 0000000..3587cb2 --- /dev/null +++ b/openspec/changes/mindmap-export/design.md @@ -0,0 +1,40 @@ +## Decisions + +### D1: 截图库选择 + +**选择**:使用 `html-to-image`(基于 SVG foreignObject)。 + +**替代方案**: +- `dom-to-image-more`:功能更多但维护频率低,包体积更大 +- Canvas 手动绘制:工作量大,无法准确复刻 CSS 样式 +- React Flow 内置 `getNodes()` 后自行渲染:丢失自定义节点样式 + +**决策依据**:`html-to-image` 轻量(~5KB gzip)、API 简洁(`toPng` / `toSvg`)、广泛使用(npm 周下载量 200 万+),足以满足 DOM → 图片的需求。 + +### D2: 导出范围 + +**选择**:导出时自动展开所有折叠节点,计算完整布局后再截图。导出完成后恢复折叠状态。 + +**替代方案**: +- 仅导出可见部分:用户可能需要手动展开才能导出完整脑图 +- 导出 React Flow nodes/edges 数据后服务端渲染:增加后端依赖,与项目 local-first 理念矛盾 + +**决策依据**:用户期望导出完整脑图,自动展开是预期行为。展开→布局→截图→恢复的流程对性能影响可控(脑图规模通常在 100 节点以内)。 + +### D3: PNG 分辨率 + +**选择**:提供 1x / 2x / 3x 三档。2x 为默认。 + +**决策依据**:现代显示器多为 Retina,1x 导出会模糊。3x 用于打印场景。`html-to-image` 的 `pixelRatio` 参数直接支持。 + +### D4: SVG 导出 + +**选择**:使用 `html-to-image` 的 `toSvg` 方法,直接输出 SVG markup 字符串,通过 Blob 下载。 + +**决策依据**:SVG 是矢量格式,适合后续编辑(Figma/Illustrator)。`toSvg` 不依赖 Canvas,输出质量无损。 + +### D5: 导出按钮 UI + +**选择**:MindMapPanel 工具栏中「导出」按钮改为带下拉菜单的 SplitButton,主按钮默认 PNG 2x 导出,下拉展开 PNG 1x / PNG 2x / PNG 3x / SVG / Markdown 选项。 + +**决策依据**:保留已有的 Markdown 导出,增加可视化导出。SplitButton 模式兼顾快捷操作和选项丰富性。 diff --git a/openspec/changes/mindmap-export/proposal.md b/openspec/changes/mindmap-export/proposal.md new file mode 100644 index 0000000..c1af8ef --- /dev/null +++ b/openspec/changes/mindmap-export/proposal.md @@ -0,0 +1,26 @@ +## Why + +当前脑图仅能做 Markdown 文本导出(`exportMindmapAsMarkdown`),无法导出可视化的 PNG 或 SVG 图片。用户花了时间建知识树却带不走。每个成熟脑图工具(simple-mind-map、markmap、mind-elixir)都支持可视化导出,这是用户最基本的期望。 + +## What Changes + +- **PNG 导出**:将 React Flow 画布截取为 PNG 图片并下载,支持自定义分辨率(1x / 2x / 3x) +- **SVG 导出**:将 React Flow 渲染结果导出为独立 SVG 文件(矢量、可缩放、可编辑) +- **导出 UI**:MindMapPanel 工具栏新增导出下拉按钮(PNG 1x / 2x / SVG),替换当前单一的 Markdown 导出按钮 +- **全脑图导出**:无论是部分折叠还是全展开,导出时自动展平所有节点,导出完整脑图 + +## Capabilities + +### New Capabilities +- `mindmap-png-export`: 将脑图画布导出为 PNG 位图,支持分辨率选择 +- `mindmap-svg-export`: 将脑图画布导出为 SVG 矢量图 + +### Modified Capabilities +- `mindmap-panel-layout`: 工具栏新增导出下拉菜单 + +## Impact + +- **依赖新增**:`html-to-image` 或 `dom-to-image-more`(DOM → PNG/SVG 截图) +- **新增文件**:`src/lib/export-png.ts`(PNG 导出逻辑)、`src/lib/export-svg.ts`(SVG 导出逻辑) +- **面板变更**:`src/features/mindmap/MindMapPanel.tsx` 导出按钮改为下拉菜单 +- **布局**:导出前需要临时计算展平所有折叠节点的完整布局 diff --git a/openspec/changes/mindmap-export/specs/mindmap-panel-layout/spec.md b/openspec/changes/mindmap-export/specs/mindmap-panel-layout/spec.md new file mode 100644 index 0000000..9d24307 --- /dev/null +++ b/openspec/changes/mindmap-export/specs/mindmap-panel-layout/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Panel toolbar +面板工具栏 SHALL 包含以下操作按钮:更新图谱(生成/重新生成)、导出下拉菜单(PNG 1x / PNG 2x / PNG 3x / SVG / Markdown)、图谱设置、删除图谱。 + +#### Scenario: Export dropdown menu +- **WHEN** 用户点击导出按钮旁的下拉箭头 +- **THEN** 展开菜单显示 PNG 1x、PNG 2x、PNG 3x、SVG、Markdown 五个选项 diff --git a/openspec/changes/mindmap-export/specs/mindmap-png-export/spec.md b/openspec/changes/mindmap-export/specs/mindmap-png-export/spec.md new file mode 100644 index 0000000..051326f --- /dev/null +++ b/openspec/changes/mindmap-export/specs/mindmap-png-export/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Export mindmap as PNG +系统 SHALL 支持将当前脑图画布导出为 PNG 图片文件。导出 SHALL 自动展开所有折叠节点,重新计算布局后截图,完成后再恢复原始折叠状态。导出 SHALL 支持 1x / 2x / 3x 像素密度选择,默认 2x。 + +#### Scenario: Export full mindmap as 2x PNG +- **WHEN** 用户点击导出 → PNG 2x +- **THEN** 系统自动展开所有折叠节点,计算完整布局,截取完整画布生成 2x PNG 并触发浏览器下载 +- **AND** 折叠状态恢复为导出前的状态 + +#### Scenario: Export with folded nodes +- **WHEN** 脑图中有部分节点处于折叠状态,用户导出 PNG +- **THEN** 导出图片包含完整脑图(折叠节点在导出中展开),但画布显示恢复折叠状态 + +### Requirement: PNG resolution options +系统 SHALL 在导出菜单中提供 PNG 1x / PNG 2x / PNG 3x 三种分辨率选择。1x 对应屏幕分辨率,2x 为 Retina 分辨率(默认),3x 为高清打印。 + +#### Scenario: Select PNG 3x for print +- **WHEN** 用户选择导出 → PNG 3x +- **THEN** 下载的 PNG 图片分辨率为原始画布的 3 倍像素密度 diff --git a/openspec/changes/mindmap-export/specs/mindmap-svg-export/spec.md b/openspec/changes/mindmap-export/specs/mindmap-svg-export/spec.md new file mode 100644 index 0000000..89bfc77 --- /dev/null +++ b/openspec/changes/mindmap-export/specs/mindmap-svg-export/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Export mindmap as SVG +系统 SHALL 支持将当前脑图画布导出为 SVG 矢量图文件。SVG 导出 SHALL 包含完整样式(颜色、字体、边框),可被矢量编辑工具(Figma、Illustrator、Inkscape)打开编辑。 + +#### Scenario: Export full mindmap as SVG +- **WHEN** 用户点击导出 → SVG +- **THEN** 系统自动展开所有折叠节点,生成完整脑图 SVG 文件并触发浏览器下载 +- **AND** 折叠状态恢复为导出前的状态 + +#### Scenario: SVG preserves node styling +- **WHEN** 导出 SVG 文件 +- **THEN** SVG 中节点样式(颜色、圆角、阴影、字体大小)与画布显示一致 diff --git a/openspec/changes/mindmap-export/tasks.md b/openspec/changes/mindmap-export/tasks.md new file mode 100644 index 0000000..51fd1b4 --- /dev/null +++ b/openspec/changes/mindmap-export/tasks.md @@ -0,0 +1,24 @@ +## Tasks + +### 1. 添加导出依赖 +- [ ] 安装 `html-to-image` + +### 2. 实现 PNG 导出 +- [ ] `src/lib/export-png.ts`:实现 `exportMindmapAsPng(pixelRatio: 1|2|3)`,使用 `html-to-image` 的 `toPng` 截取 React Flow 容器 DOM +- [ ] 导出前展开所有折叠节点 → 等待布局更新 → 截图 → 恢复折叠状态 +- [ ] 文件命名:`{图谱标题}_{日期}.png` + +### 3. 实现 SVG 导出 +- [ ] `src/lib/export-svg.ts`:实现 `exportMindmapAsSvg()`,使用 `html-to-image` 的 `toSvg` 导出 +- [ ] 文件命名:`{图谱标题}_{日期}.svg` + +### 4. 导出 UI +- [ ] `src/features/mindmap/MindMapPanel.tsx`:导出按钮改为带下拉菜单的 SplitButton +- [ ] 下拉选项:PNG 1x / PNG 2x / PNG 3x / SVG / Markdown +- [ ] 默认点击行为:PNG 2x 导出 + +### 5. 测试 +- [ ] `export-png.test.ts`:验证 `toPng` 调用和参数正确 +- [ ] `export-svg.test.ts`:验证 `toSvg` 调用和参数正确 +- [ ] `MindMapPanel.test.tsx`:导出按钮 UI 测试 +- [ ] 手动测试:各分辨率 PNG 和 SVG 导出效果 diff --git a/openspec/changes/rich-node-content/.openspec.yaml b/openspec/changes/rich-node-content/.openspec.yaml new file mode 100644 index 0000000..eebe4d8 --- /dev/null +++ b/openspec/changes/rich-node-content/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-05 diff --git a/openspec/changes/rich-node-content/design.md b/openspec/changes/rich-node-content/design.md new file mode 100644 index 0000000..be0ec40 --- /dev/null +++ b/openspec/changes/rich-node-content/design.md @@ -0,0 +1,39 @@ +## Decisions + +### D1: Markdown 渲染策略 + +**选择**:节点使用 `react-markdown` 渲染,复用已有的 remark/rehype 插件链。 + +**替代方案**: +- 自定义 RichText 编辑器(如 Slate.js):过于重型,富内容需求本质是展示优先 +- 纯 HTML 渲染(dangerouslySetInnerHTML):安全风险,Markdown 已是 AI 输出的自然格式 + +**决策依据**:项目已依赖 `react-markdown` + `remark-gfm` + `rehype-highlight`,零额外依赖开销。节点的富内容需求 90% 是展示,编辑用纯文本输入+预览切换即可。 + +### D2: 数据模型扩展方式 + +**选择**:新增可选字段 `content?: string` 和 `contentType?: 'text' | 'markdown'`,保持 `label` + `summary` 不变。 + +**替代方案**: +- 将 `summary` 替换为 `content`:BREAKING,现有代码大量引用 `summary` +- 存 HTML 而非 Markdown:增加存储体积,Markdown 更易于 AI 生成 + +**决策依据**:向后兼容优先。`label` 仍用于节点标题显示和 dagre 布局计算,`summary` 保留用于纯文本场景,`content` 给需要富文本的场景。`contentType` 默认为 `'text'`,与当前行为一致。 + +### D3: LaTeX 渲染 + +**选择**:使用 KaTeX 而非 MathJax,通过 `remark-math` + `rehype-katex` 插件。 + +**决策依据**:KaTeX 比 MathJax 快 5-10 倍,适合节点内联渲染。包体积更小(~280KB vs ~1.5MB)。 + +### D4: 编辑体验 + +**选择**:编辑 Modal 中新增 Markdown 编辑模式,提供「编辑/预览」切换按钮。默认编辑模式为源码编辑。 + +**决策依据**:用户群体倾向开发者(需要配 API key),Markdown 源码编辑门槛可接受。预览切换降低心智负担。 + +### D5: 节点尺寸计算 + +**选择**:dagre 布局高度估算 Markdown 渲染后高度的 1.2 倍余量。 + +**决策依据**:dagre 需要预先知道节点尺寸,但 Markdown 渲染高度是动态的。先估算,布局后通过 `onNodesChange` 微调。 diff --git a/openspec/changes/rich-node-content/proposal.md b/openspec/changes/rich-node-content/proposal.md new file mode 100644 index 0000000..5816fe5 --- /dev/null +++ b/openspec/changes/rich-node-content/proposal.md @@ -0,0 +1,30 @@ +## Why + +当前脑图节点仅支持纯文本 label + summary,无法嵌入图片、链接、LaTeX 公式、代码块等富内容。用户从对话中提取的知识经常包含这些元素,丢失它们等于丢失了知识完整性。对比 simple-mind-map 等成熟脑图工具均支持富文本节点,这是用户期望的标配能力。 + +## What Changes + +- **MindMapNode 数据模型扩展**:新增 `contentType` 字段(`text` / `markdown`),新增 `content` 字段承载富文本 +- **节点渲染支持 Markdown**:自定义节点组件使用 `react-markdown` 渲染 `content`,支持图片、链接、行内代码、LaTeX 公式(KaTeX) +- **AI 生成输出 Markdown 节点内容**:prompt 指示 LLM 输出含 Markdown 格式的节点 summary/content +- **内联编辑支持 Markdown**:编辑 Modal 中 content 输入扩展为 textarea(标记为 Markdown 输入),提供预览切换 +- **向后兼容**:`label` + `summary` 保持不变;`contentType: 'text'` 时行为与当前完全一致 + +## Capabilities + +### New Capabilities +- `rich-node-content`: 脑图节点支持 Markdown 富文本内容(图片、链接、代码、LaTeX),渲染与编辑均支持 + +### Modified Capabilities +- `mindmap-data`: MindMapNode 新增 `contentType` 和 `content` 字段 +- `mindmap-generation`: prompt 扩展,指示 LLM 输出 Markdown 格式节点内容 +- `mindmap-node-editing`: 编辑 Modal 扩展 Markdown 输入与预览 + +## Impact + +- **依赖新增**:`katex` + `remark-math` + `rehype-katex`(LaTeX 渲染) +- **数据模型**:`src/types/mindmap.ts` MindMapNode 扩展 +- **渲染组件**:`src/features/mindmap/MindMapNodeComponent.tsx` 改用 react-markdown +- **编辑组件**:`src/features/mindmap/MindMapEditModal.tsx` 新增 Markdown 编辑/预览 +- **生成逻辑**:`src/lib/mindmap-generator.ts` prompt 更新 +- **布局**:dagre 节点尺寸需考虑富内容高度动态变化 diff --git a/openspec/changes/rich-node-content/specs/mindmap-data/spec.md b/openspec/changes/rich-node-content/specs/mindmap-data/spec.md new file mode 100644 index 0000000..8846c19 --- /dev/null +++ b/openspec/changes/rich-node-content/specs/mindmap-data/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: MindMapNode data model +MindMapNode SHALL 包含以下字段:`id: string`(唯一标识)、`label: string`(节点标题)、`summary: string`(纯文本摘要)、`content?: string`(可选 Markdown 内容)、`contentType?: 'text' | 'markdown'`(可选内容类型,默认 `'text'`)、`children: MindMapNode[]`(子节点)、`sourceConversationIds: string[]`(来源对话 ID)、`sourceExcerpts: Record`(来源摘录)、`editedByUser: boolean`(是否被用户编辑)。 + +#### 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/rich-node-content/specs/mindmap-generation/spec.md b/openspec/changes/rich-node-content/specs/mindmap-generation/spec.md new file mode 100644 index 0000000..177d25c --- /dev/null +++ b/openspec/changes/rich-node-content/specs/mindmap-generation/spec.md @@ -0,0 +1,9 @@ +## MODIFIED Requirements + +### Requirement: Generate mindmap from conversation history +系统 SHALL 支持通过 LLM 从对话内容生成思维导图树结构。输入内容 SHALL 优先使用图谱语料库内容。生成 prompt SHALL 指示 LLM 在节点 summary 中使用 Markdown 格式表达富内容(图片、链接、代码块、LaTeX 公式),并在输出 JSON 中标注 `contentType: 'markdown'`。系统 SHALL 在解析 JSON 响应时识别 `contentType` 字段并存储到 MindMapNode。 + +#### Scenario: AI generates node with markdown content +- **WHEN** 语料包含代码示例或公式,且生成模式为全量或增量 +- **THEN** LLM 输出的节点 summary 可能包含 Markdown 格式的代码块或公式 +- **AND** 系统正确解析并存储 `contentType` 和 `content` 字段 diff --git a/openspec/changes/rich-node-content/specs/mindmap-node-editing/spec.md b/openspec/changes/rich-node-content/specs/mindmap-node-editing/spec.md new file mode 100644 index 0000000..bf30505 --- /dev/null +++ b/openspec/changes/rich-node-content/specs/mindmap-node-editing/spec.md @@ -0,0 +1,9 @@ +## MODIFIED Requirements + +### Requirement: Node edit mode +系统 SHALL 允许用户双击节点进入编辑模式。编辑模式下 SHALL 弹出居中 Modal 弹窗(`MindMapEditModal`),包含 label 输入框、summary 文本域,以及当 `contentType` 为 `'markdown'` 时的 content Markdown 编辑器与预览切换按钮。按 Enter 确认编辑并调用 `mindmapStore.updateNode`,按 Escape 或点击 Modal 外区域取消编辑。确认后节点 `editedByUser` 标记为 true。 + +#### Scenario: Edit markdown node with preview +- **WHEN** 用户双击 `contentType` 为 `'markdown'` 的节点 +- **THEN** Modal 显示 label 输入框、summary 文本域、Markdown 内容编辑器和预览切换按钮 +- **AND** 点击预览按钮后,Markdown 内容在预览区渲染展示 diff --git a/openspec/changes/rich-node-content/specs/rich-node-content/spec.md b/openspec/changes/rich-node-content/specs/rich-node-content/spec.md new file mode 100644 index 0000000..49b0ba5 --- /dev/null +++ b/openspec/changes/rich-node-content/specs/rich-node-content/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Markdown node content +系统 SHALL 支持节点包含 Markdown 格式的富文本内容。MindMapNode SHALL 新增可选字段 `contentType?: 'text' | 'markdown'` 和 `content?: string`。当 `contentType` 为 `'markdown'` 时,`content` SHALL 被渲染为 Markdown;为 `'text'` 或未设置时,行为与当前一致。 + +#### Scenario: Node with markdown content renders images +- **WHEN** 节点 `contentType` 为 `'markdown'` 且 `content` 包含 `![](url)` 图片语法 +- **THEN** 画布上该节点展示渲染后的图片 + +#### Scenario: Node without contentType renders as plain text +- **WHEN** 节点未设置 `contentType` 字段 +- **THEN** 节点渲染行为与当前版本完全一致 + +### Requirement: LaTeX formula rendering +系统 SHALL 在 Markdown 节点内容中支持 LaTeX 数学公式渲染。使用 `$...$` 语法渲染行内公式,`$$...$$` 语法渲染块级公式。 + +#### Scenario: Inline LaTeX in node content +- **WHEN** 节点 `contentType` 为 `'markdown'` 且 `content` 包含 `$E=mc^2$` +- **THEN** 画布上该节点展示渲染后的行内公式 + +### Requirement: Markdown editing with preview +节点编辑 Modal SHALL 支持 Markdown 内容的编辑与预览切换。当节点 `contentType` 为 `'markdown'` 时,编辑框 SHALL 显示 Markdown 源码,并提供「预览」按钮切换为渲染后的效果。 + +#### Scenario: Toggle edit/preview in modal +- **WHEN** 用户双击节点进入编辑模式,且该节点 `contentType` 为 `'markdown'` +- **THEN** Modal 中显示 Markdown 源码编辑器,并提供预览切换按钮 +- **AND** 点击预览后展示渲染后的 Markdown 效果 diff --git a/openspec/changes/rich-node-content/tasks.md b/openspec/changes/rich-node-content/tasks.md new file mode 100644 index 0000000..de985a6 --- /dev/null +++ b/openspec/changes/rich-node-content/tasks.md @@ -0,0 +1,26 @@ +## Tasks + +### 1. 数据模型扩展 +- [ ] `src/types/mindmap.ts`:MindMapNode 新增 `content?: string`、`contentType?: 'text' | 'markdown'` 字段 +- [ ] `src/lib/mindmap-generator.ts`:`parseJsonToTree` / `jsonNodeToMindMapNode` 解析新增字段 + +### 2. 添加 KaTeX 依赖 +- [ ] 安装 `katex`、`remark-math`、`rehype-katex` +- [ ] 导入 KaTeX CSS(`katex/dist/katex.min.css`)到 `src/index.css` + +### 3. 节点渲染支持 Markdown +- [ ] `src/features/mindmap/MindMapNodeComponent.tsx`:`contentType === 'markdown'` 时使用 `react-markdown` 渲染 `content`(含 `remarkMath` + `rehypeKatex` 插件) +- [ ] 节点尺寸适配:rich content 节点预留更大高度 + +### 4. 编辑 Modal 扩展 +- [ ] `src/features/mindmap/MindMapEditModal.tsx`:新增 Markdown 编辑区(textarea)和预览切换按钮 +- [ ] 保存时将编辑结果写回 `content` 字段 + +### 5. 生成 prompt 更新 +- [ ] `src/lib/mindmap-generator.ts`:全量和增量 prompt 中指示 LLM 在节点中输出 Markdown 格式内容 + +### 6. 测试 +- [ ] `mindmap-generator.test.ts`:验证 `contentType` 和 `content` 字段解析 +- [ ] `MindMapNodeComponent` 测试:Markdown 渲染验证 +- [ ] `MindMapEditModal` 测试:Markdown 编辑/预览切换 +- [ ] 手动测试:图片、链接、代码块、LaTeX 公式渲染 diff --git a/src/features/chat/ChatPage.tsx b/src/features/chat/ChatPage.tsx index 1f3adfa..659f16c 100644 --- a/src/features/chat/ChatPage.tsx +++ b/src/features/chat/ChatPage.tsx @@ -209,11 +209,20 @@ export default function ChatPage() { updateMessageInConversation(conversationId, assistantMsg.id, { status: 'complete' }) } else { const message = err instanceof Error ? err.message : '请求失败' + const provider = useProviderStore + .getState() + .providers.find((p) => p.id === activeConversation?.providerId) + const isPreset = provider?.preset && !provider?.apiKey + const hint = isPreset + ? '\n\n提示:OpenRouter 免费模型需要配置 API Key,前往 openrouter.ai 注册获取(免费)' + : provider?.preset + ? '\n\n提示:免费模型有限制,可切换其他免费模型或添加自己的 API Key' + : '' updateMessageInConversation(conversationId, assistantMsg.id, { - content: message, + content: message + hint, status: 'error', }) - setError(message) + setError(message + hint) } } finally { stopGeneration() diff --git a/src/features/mindmap/MindMapPanel.tsx b/src/features/mindmap/MindMapPanel.tsx index 139ac9b..aefd779 100644 --- a/src/features/mindmap/MindMapPanel.tsx +++ b/src/features/mindmap/MindMapPanel.tsx @@ -228,7 +228,13 @@ export default function MindMapPanel({ onClose }: MindMapPanelProps) { return } const message = err instanceof Error ? err.message : '生成失败' - setError(message) + const isPreset = generatorProvider?.preset && !generatorProvider?.apiKey + const hint = isPreset + ? '\n\n提示:OpenRouter 免费模型需要配置 API Key,前往 openrouter.ai 注册获取(免费)' + : generatorProvider?.preset + ? '\n\n提示:免费模型有限制,可切换其他免费模型或添加自己的 API Key' + : '' + setError(message + hint) } finally { setGenerating(false) stopGeneration() diff --git a/src/features/provider/ProviderSettingsPage.tsx b/src/features/provider/ProviderSettingsPage.tsx index a6267fa..82b12f2 100644 --- a/src/features/provider/ProviderSettingsPage.tsx +++ b/src/features/provider/ProviderSettingsPage.tsx @@ -123,16 +123,45 @@ export default function ProviderSettingsPage({ onBack }: { onBack?: () => void }
{p.models.length} 个模型 - - API Key: {p.apiKey ? '****' + p.apiKey.slice(-4) : '未设置'} - + {p.apiKey ? ( + API Key: ****{p.apiKey.slice(-4)} + ) : ( + + 需配置 API Key + + )} + {p.preset && ( + + 系统预置 + + )}
+ {p.preset && !p.apiKey && ( +

+ 前往{' '} + + openrouter.ai + + {' '}注册获取免费 API Key,粘贴到编辑框中即可使用 +

+ )}
-
diff --git a/src/stores/__tests__/providerStore.test.ts b/src/stores/__tests__/providerStore.test.ts index 8a71583..b2fb600 100644 --- a/src/stores/__tests__/providerStore.test.ts +++ b/src/stores/__tests__/providerStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest' import { useProviderStore } from '../providerStore' +import type { Provider } from '@/types/provider' beforeEach(() => { useProviderStore.setState({ @@ -79,3 +80,55 @@ describe('providerStore', () => { expect(state.selectedProviderId).toBeNull() }) }) + +describe('preset provider', () => { + const makePreset = (overrides?: Partial): Provider => ({ + id: 'preset-1', + name: 'OpenRouter', + apiEndpoint: 'https://openrouter.ai/api/v1', + apiKey: '', + models: [{ id: 'google/gemma-3-12b-it:free', name: 'Gemma 3 12B (免费)', enabled: true }], + preset: true, + supportsJsonMode: true, + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }) + + it('prevents deleting preset provider', () => { + useProviderStore.setState({ + providers: [makePreset()], + selectedProviderId: 'preset-1', + }) + + const { removeProvider } = useProviderStore.getState() + removeProvider('preset-1') + + const state = useProviderStore.getState() + expect(state.providers).toHaveLength(1) + expect(state.providers[0]!.preset).toBe(true) + }) + + it('allows deleting non-preset provider', () => { + useProviderStore.setState({ + providers: [makePreset({ preset: undefined, name: 'Custom' })], + selectedProviderId: 'preset-1', + }) + + const { removeProvider } = useProviderStore.getState() + removeProvider('preset-1') + + const state = useProviderStore.getState() + expect(state.providers).toHaveLength(0) + }) + + it('has preset flag on provider after injection', () => { + useProviderStore.setState({ + providers: [makePreset()], + selectedProviderId: 'preset-1', + }) + + const state = useProviderStore.getState() + expect(state.providers[0]!.preset).toBe(true) + }) +}) diff --git a/src/stores/providerStore.ts b/src/stores/providerStore.ts index baad141..8550bca 100644 --- a/src/stores/providerStore.ts +++ b/src/stores/providerStore.ts @@ -2,6 +2,19 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { createIndexedDBStorage } from '@/lib/indexeddb-storage-adapter' import type { Provider, Model } from '../types/provider' +import { toast } from 'sonner' + +const OPENROUTER_PRESET = { + name: 'OpenRouter', + apiEndpoint: 'https://openrouter.ai/api/v1', + models: [ + { id: 'google/gemma-3-12b-it:free', name: 'Gemma 3 12B (免费)', enabled: true }, + { id: 'meta-llama/llama-3.3-70b-instruct:free', name: 'Llama 3.3 70B (免费)', enabled: true }, + { id: 'mistralai/mistral-nemo:free', name: 'Mistral Nemo (免费)', enabled: true }, + { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 (免费)', enabled: true }, + { id: 'qwen/qwen2.5-7b-instruct:free', name: 'Qwen 2.5 7B (免费)', enabled: true }, + ], +} interface ProviderState { providers: Provider[] @@ -31,6 +44,7 @@ function detectJsonMode(apiEndpoint: string): boolean { lower.includes('api.openai.com') || lower.includes('api.deepseek.com') || lower.includes('api.siliconflow.cn') || + lower.includes('openrouter.ai') || lower.includes('generativelanguage.googleapis.com') ) } @@ -68,6 +82,11 @@ export const useProviderStore = create()( }, removeProvider: (id) => { + const provider = get().providers.find((p) => p.id === id) + if (provider?.preset) { + toast.error('系统预置模型不可删除') + return + } set((state) => { const remaining = state.providers.filter((p) => p.id !== id) return { @@ -89,8 +108,34 @@ export const useProviderStore = create()( }), { name: 'provider-store', - version: 2, + version: 3, storage: createJSONStorage(() => createIndexedDBStorage()), + onRehydrateStorage: () => { + let initialized = false + return (_state, error) => { + if (error || initialized) return + initialized = true + const current = useProviderStore.getState() + if (current.providers.length === 0) { + const now = Date.now() + const presetProvider: Provider = { + id: generateId(), + name: OPENROUTER_PRESET.name, + apiEndpoint: OPENROUTER_PRESET.apiEndpoint, + apiKey: '', + models: OPENROUTER_PRESET.models, + preset: true, + supportsJsonMode: true, + createdAt: now, + updatedAt: now, + } + useProviderStore.setState({ + providers: [presetProvider], + selectedProviderId: presetProvider.id, + }) + } + } + }, }, ), ) diff --git a/src/types/provider.ts b/src/types/provider.ts index d9ecb99..7d999f7 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -10,6 +10,7 @@ export interface Provider { apiEndpoint: string apiKey: string models: Model[] + preset?: boolean supportsJsonMode: boolean createdAt: number updatedAt: number From fd73739e9d324f2c2e154041c2ff12b860cfff5d Mon Sep 17 00:00:00 2001 From: web4zn Date: Tue, 5 May 2026 15:33:27 +0800 Subject: [PATCH 02/15] revert: remove default-free-model implementation, keep only OpenRouter preset template Revert preset provider auto-injection, protection, and error guidance. Keep OpenRouter in PRESETS array so users can select it as a template when configuring a provider manually. --- .../changes/default-free-model/.openspec.yaml | 2 - openspec/changes/default-free-model/design.md | 48 ----------------- .../changes/default-free-model/proposal.md | 27 ---------- .../specs/model-provider/spec.md | 12 ----- .../specs/preset-model-provider/spec.md | 20 ------- openspec/changes/default-free-model/tasks.md | 21 -------- src/features/chat/ChatPage.tsx | 13 +---- src/features/mindmap/MindMapPanel.tsx | 8 +-- .../provider/ProviderSettingsPage.tsx | 49 +++++------------ src/stores/__tests__/providerStore.test.ts | 53 ------------------- src/stores/providerStore.ts | 47 +--------------- src/types/provider.ts | 1 - 12 files changed, 17 insertions(+), 284 deletions(-) delete mode 100644 openspec/changes/default-free-model/.openspec.yaml delete mode 100644 openspec/changes/default-free-model/design.md delete mode 100644 openspec/changes/default-free-model/proposal.md delete mode 100644 openspec/changes/default-free-model/specs/model-provider/spec.md delete mode 100644 openspec/changes/default-free-model/specs/preset-model-provider/spec.md delete mode 100644 openspec/changes/default-free-model/tasks.md diff --git a/openspec/changes/default-free-model/.openspec.yaml b/openspec/changes/default-free-model/.openspec.yaml deleted file mode 100644 index eebe4d8..0000000 --- a/openspec/changes/default-free-model/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-05 diff --git a/openspec/changes/default-free-model/design.md b/openspec/changes/default-free-model/design.md deleted file mode 100644 index a8620c3..0000000 --- a/openspec/changes/default-free-model/design.md +++ /dev/null @@ -1,48 +0,0 @@ -## Decisions - -### D1: 免费模型提供商选择 - -**选择**:预置 **OpenRouter**,包含多个免费模型。endpoint:`https://openrouter.ai/api/v1`。 - -**预置模型列表**: - -| 模型 ID | 说明 | -|---------|------| -| `google/gemma-3-12b-it:free` | Google Gemma 3 12B,综合能力强 | -| `meta-llama/llama-3.3-70b-instruct:free` | Meta Llama 3.3 70B,推理强 | -| `mistralai/mistral-nemo:free` | Mistral Nemo,轻量高效 | -| `deepseek/deepseek-r1:free` | DeepSeek R1,中文推理强 | -| `qwen/qwen2.5-7b-instruct:free` | Qwen 2.5 7B,中文对话好 | - -**替代方案**: -- SiliconFlow:中文好但模型单一(全是 Qwen 系) -- DeepSeek 直连:需单独注册 -- Ollama 本地:需安装,非开箱即用 - -**决策依据**:OpenRouter 一个 key 通吃多厂商免费模型,用户无需针对每个厂商单独注册。模型列表覆盖 Google/Meta/Mistral/DeepSeek/Qwen 五大来源,中文和英文场景均覆盖。API 完全兼容 OpenAI 格式,零代码改动。 - -### D2: 预置提供商保护 - -**选择**:通过 `preset: true` 标记。删除操作检查此标记,弹 toast 提示「系统预置模型不可删除」。 - -**替代方案**: -- 硬编码 ID 判断:脆弱,ID 可被篡改 -- 单独存储预设列表:增加复杂度 - -**决策依据**:数据标记方式简单可靠,与现有 Provider 数据模型最小侵入。用户仍可禁用模型(`enabled: false`),只是不能删除提供商。 - -### D3: 初始化时机 - -**选择**:ProviderStore 的 persist 恢复完成后,检查 `providers.length === 0`,若为空则注入预置提供商。 - -**替代方案**: -- 固定写在初始 state 中:如果用户删了预置,每次刷新都会重新注入 -- 在组件层判断:逻辑分散,多个入口需要相同判断 - -**决策依据**:在 store 初始化阶段统一处理,只触发一次。已有用户(store 不空)完全不受影响。 - -### D4: 免费模型降级体验 - -**选择**:免费模型调用失败时,错误提示中引导用户切换其他免费模型或添加自有 API key。 - -**决策依据**:OpenRouter 免费模型各有速率限制(约 20 req/min 共享额度),比单提供商更灵活——A 模型限流了可切 B 模型继续用。同时提示用户可添加自有提供商获更稳定体验。 diff --git a/openspec/changes/default-free-model/proposal.md b/openspec/changes/default-free-model/proposal.md deleted file mode 100644 index 2e594b3..0000000 --- a/openspec/changes/default-free-model/proposal.md +++ /dev/null @@ -1,27 +0,0 @@ -## Why - -当前新用户打开应用后需要在设置中手动配置 API 提供商和密钥才能使用。没有「开箱即用」的体验,许多潜在用户在配置步骤就流失了。提供预配置的 OpenRouter 免费模型列表,用户只需粘贴 API key 即可使用多种免费模型(Google/Meta/Mistral/DeepSeek),无需单独对接每个厂商。 - -## What Changes - -- **预置 OpenRouter 提供商**:系统初始化时自动创建 OpenRouter 配置,包含多个免费模型(Gemma 3、Llama 3.3、Mistral Nemo、DeepSeek R1 等)。endpoint 统一为 `https://openrouter.ai/api/v1`。 -- **免费模型标识**:Provider 数据模型新增 `preset: boolean` 字段,标记系统预置的提供商。预置提供商不可删除,但用户可禁用其模型。 -- **初始化逻辑**:ProviderStore 在首次启动时(store 为空)自动注册预置 OpenRouter 提供商。已有用户的 store 不受影响。 -- **配置页提示**:在 ProviderSettingsPage 中预置提供商下方显示说明文字和「获取免费 API Key」链接(指向 openrouter.ai)。 -- **降级策略**:免费模型不可用时(如限流),错误提示中建议切换其他免费模型或配置自有 API key。 - -## Capabilities - -### New Capabilities -- `preset-model-provider`: 系统预置免费模型提供商,首次启动自动注册,开箱即用 - -### Modified Capabilities -- `model-provider`: Provider 数据模型新增 `preset` 字段;新增预置提供商保护(不可删除) - -## Impact - -- **数据模型**:`src/types/provider.ts` Provider 新增 `preset?: boolean` -- **状态管理**:`src/stores/providerStore.ts` 新增初始化逻辑,注入 OpenRouter 配置及免费模型列表 -- **UI 组件**:`src/features/provider/ProviderSettingsPage.tsx` 预置提供商 UI 区分 + 注册引导链接 -- **配置持久化**:预置提供商存储方式与用户自建一致(IndexedDB) -- **迁移**:首次启动检测(store 为空 → 注入预置),已有用户不受影响 diff --git a/openspec/changes/default-free-model/specs/model-provider/spec.md b/openspec/changes/default-free-model/specs/model-provider/spec.md deleted file mode 100644 index 45927dd..0000000 --- a/openspec/changes/default-free-model/specs/model-provider/spec.md +++ /dev/null @@ -1,12 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Provider data model -系统 SHALL 使用以下数据模型表示模型提供商。Provider SHALL 包含字段:`id: string`(唯一标识)、`name: string`(显示名称)、`endpoint: string`(API 端点)、`apiKey: string`(API 密钥)、`models: Model[]`(模型列表)、`preset?: boolean`(可选,标记为系统预置,预置提供商不可删除)、`createdAt: number`(创建时间戳)、`updatedAt: number`(更新时间戳)。 - -#### Scenario: Preset provider has preset flag -- **WHEN** 预置提供商被创建 -- **THEN** `preset` 字段为 `true` - -#### Scenario: User-created provider has no preset flag -- **WHEN** 用户手动添加提供商 -- **THEN** `preset` 字段为 `undefined` 或 `false` diff --git a/openspec/changes/default-free-model/specs/preset-model-provider/spec.md b/openspec/changes/default-free-model/specs/preset-model-provider/spec.md deleted file mode 100644 index c402bfc..0000000 --- a/openspec/changes/default-free-model/specs/preset-model-provider/spec.md +++ /dev/null @@ -1,20 +0,0 @@ -## ADDED Requirements - -### Requirement: Preset provider initialization -系统 SHALL 在首次启动(ProviderStore 中无提供商)时自动注册一个预置提供商:OpenRouter(endpoint `https://openrouter.ai/api/v1`),包含至少 5 个免费模型。预置提供商 SHALL 使用 `preset: true` 标记,不可被用户删除,但其模型可被禁用。已存在提供商的用户 SHALL 不受此初始化影响。 - -#### Scenario: First launch auto-creates OpenRouter preset -- **WHEN** 用户首次打开应用,ProviderStore 中无任何提供商 -- **THEN** 系统自动创建 `preset: true` 的 OpenRouter 提供商,包含 Gemma 3 12B、Llama 3.3 70B、Mistral Nemo、DeepSeek R1、Qwen 2.5 7B 等免费模型 -- **AND** 提供商设置页面显示该预置提供商 - -#### Scenario: Existing user is unaffected -- **WHEN** 用户已有至少一个提供商配置 -- **THEN** 系统不注入任何预置提供商,用户数据完全不变 - -### Requirement: Free model switching guidance -当某个免费模型调用失败时,错误提示中 SHALL 包含引导文字「免费模型有限制,可切换其他免费模型或添加自己的 API key 获得更稳定体验」。 - -#### Scenario: Free model rate limit exceeded -- **WHEN** 选中免费模型返回 429 或调用失败 -- **THEN** 错误提示中包含切换模型和添加自有 key 的引导文字 diff --git a/openspec/changes/default-free-model/tasks.md b/openspec/changes/default-free-model/tasks.md deleted file mode 100644 index a76a94b..0000000 --- a/openspec/changes/default-free-model/tasks.md +++ /dev/null @@ -1,21 +0,0 @@ -## Tasks - -### 1. 数据模型扩展 -- [x] `src/types/provider.ts`:Provider 新增 `preset?: boolean` 字段 - -### 2. ProviderStore 初始化逻辑 -- [x] `src/stores/providerStore.ts`:persist 恢复后检测 `providers.length === 0`,注入预置 OpenRouter 提供商 -- [x] 预置配置:endpoint `https://openrouter.ai/api/v1`,models 列表 5 个免费模型(Gemma 3 12B、Llama 3.3 70B、Mistral Nemo、DeepSeek R1、Qwen 2.5 7B) - -### 3. 预置提供商保护 -- [x] `src/stores/providerStore.ts`:`removeProvider` 拒绝删除 `preset: true` 的提供商 -- [x] `src/features/provider/ProviderSettingsPage.tsx`:预置提供商显示标识标记 - -### 4. 降级引导 -- [x] `src/features/chat/ChatPage.tsx`:免费模型错误提示中引导切换其他模型或添加自有 key -- [x] `src/features/mindmap/MindMapPanel.tsx`:生成脑图失败时提示同上 - -### 5. 测试 -- [x] `providerStore.test.ts`:验证首次启动注入预置提供商、已有用户不受影响 -- [x] `providerStore.test.ts`:验证预置提供商不可删除 -- [x] 手动测试:清除 IndexedDB 后刷新,验证预置提供商自动出现 diff --git a/src/features/chat/ChatPage.tsx b/src/features/chat/ChatPage.tsx index 659f16c..1f3adfa 100644 --- a/src/features/chat/ChatPage.tsx +++ b/src/features/chat/ChatPage.tsx @@ -209,20 +209,11 @@ export default function ChatPage() { updateMessageInConversation(conversationId, assistantMsg.id, { status: 'complete' }) } else { const message = err instanceof Error ? err.message : '请求失败' - const provider = useProviderStore - .getState() - .providers.find((p) => p.id === activeConversation?.providerId) - const isPreset = provider?.preset && !provider?.apiKey - const hint = isPreset - ? '\n\n提示:OpenRouter 免费模型需要配置 API Key,前往 openrouter.ai 注册获取(免费)' - : provider?.preset - ? '\n\n提示:免费模型有限制,可切换其他免费模型或添加自己的 API Key' - : '' updateMessageInConversation(conversationId, assistantMsg.id, { - content: message + hint, + content: message, status: 'error', }) - setError(message + hint) + setError(message) } } finally { stopGeneration() diff --git a/src/features/mindmap/MindMapPanel.tsx b/src/features/mindmap/MindMapPanel.tsx index aefd779..139ac9b 100644 --- a/src/features/mindmap/MindMapPanel.tsx +++ b/src/features/mindmap/MindMapPanel.tsx @@ -228,13 +228,7 @@ export default function MindMapPanel({ onClose }: MindMapPanelProps) { return } const message = err instanceof Error ? err.message : '生成失败' - const isPreset = generatorProvider?.preset && !generatorProvider?.apiKey - const hint = isPreset - ? '\n\n提示:OpenRouter 免费模型需要配置 API Key,前往 openrouter.ai 注册获取(免费)' - : generatorProvider?.preset - ? '\n\n提示:免费模型有限制,可切换其他免费模型或添加自己的 API Key' - : '' - setError(message + hint) + setError(message) } finally { setGenerating(false) stopGeneration() diff --git a/src/features/provider/ProviderSettingsPage.tsx b/src/features/provider/ProviderSettingsPage.tsx index 82b12f2..8d7dc46 100644 --- a/src/features/provider/ProviderSettingsPage.tsx +++ b/src/features/provider/ProviderSettingsPage.tsx @@ -35,9 +35,15 @@ const PRESETS = [ }, { name: 'Ollama', endpoint: 'http://localhost:11434/v1', models: [] }, { - name: 'SiliconFlow', - endpoint: 'https://api.siliconflow.cn/v1', - models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'], + name: 'OpenRouter', + endpoint: 'https://openrouter.ai/api/v1', + models: [ + 'google/gemma-3-12b-it:free', + 'meta-llama/llama-3.3-70b-instruct:free', + 'mistralai/mistral-nemo:free', + 'deepseek/deepseek-r1:free', + 'qwen/qwen2.5-7b-instruct:free', + ], }, ] @@ -123,45 +129,16 @@ export default function ProviderSettingsPage({ onBack }: { onBack?: () => void }
{p.models.length} 个模型 - {p.apiKey ? ( - API Key: ****{p.apiKey.slice(-4)} - ) : ( - - 需配置 API Key - - )} - {p.preset && ( - - 系统预置 - - )} + + API Key: {p.apiKey ? '****' + p.apiKey.slice(-4) : '未设置'} +
- {p.preset && !p.apiKey && ( -

- 前往{' '} - - openrouter.ai - - {' '}注册获取免费 API Key,粘贴到编辑框中即可使用 -

- )}
-
diff --git a/src/stores/__tests__/providerStore.test.ts b/src/stores/__tests__/providerStore.test.ts index b2fb600..8a71583 100644 --- a/src/stores/__tests__/providerStore.test.ts +++ b/src/stores/__tests__/providerStore.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' import { useProviderStore } from '../providerStore' -import type { Provider } from '@/types/provider' beforeEach(() => { useProviderStore.setState({ @@ -80,55 +79,3 @@ describe('providerStore', () => { expect(state.selectedProviderId).toBeNull() }) }) - -describe('preset provider', () => { - const makePreset = (overrides?: Partial): Provider => ({ - id: 'preset-1', - name: 'OpenRouter', - apiEndpoint: 'https://openrouter.ai/api/v1', - apiKey: '', - models: [{ id: 'google/gemma-3-12b-it:free', name: 'Gemma 3 12B (免费)', enabled: true }], - preset: true, - supportsJsonMode: true, - createdAt: Date.now(), - updatedAt: Date.now(), - ...overrides, - }) - - it('prevents deleting preset provider', () => { - useProviderStore.setState({ - providers: [makePreset()], - selectedProviderId: 'preset-1', - }) - - const { removeProvider } = useProviderStore.getState() - removeProvider('preset-1') - - const state = useProviderStore.getState() - expect(state.providers).toHaveLength(1) - expect(state.providers[0]!.preset).toBe(true) - }) - - it('allows deleting non-preset provider', () => { - useProviderStore.setState({ - providers: [makePreset({ preset: undefined, name: 'Custom' })], - selectedProviderId: 'preset-1', - }) - - const { removeProvider } = useProviderStore.getState() - removeProvider('preset-1') - - const state = useProviderStore.getState() - expect(state.providers).toHaveLength(0) - }) - - it('has preset flag on provider after injection', () => { - useProviderStore.setState({ - providers: [makePreset()], - selectedProviderId: 'preset-1', - }) - - const state = useProviderStore.getState() - expect(state.providers[0]!.preset).toBe(true) - }) -}) diff --git a/src/stores/providerStore.ts b/src/stores/providerStore.ts index 8550bca..baad141 100644 --- a/src/stores/providerStore.ts +++ b/src/stores/providerStore.ts @@ -2,19 +2,6 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { createIndexedDBStorage } from '@/lib/indexeddb-storage-adapter' import type { Provider, Model } from '../types/provider' -import { toast } from 'sonner' - -const OPENROUTER_PRESET = { - name: 'OpenRouter', - apiEndpoint: 'https://openrouter.ai/api/v1', - models: [ - { id: 'google/gemma-3-12b-it:free', name: 'Gemma 3 12B (免费)', enabled: true }, - { id: 'meta-llama/llama-3.3-70b-instruct:free', name: 'Llama 3.3 70B (免费)', enabled: true }, - { id: 'mistralai/mistral-nemo:free', name: 'Mistral Nemo (免费)', enabled: true }, - { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 (免费)', enabled: true }, - { id: 'qwen/qwen2.5-7b-instruct:free', name: 'Qwen 2.5 7B (免费)', enabled: true }, - ], -} interface ProviderState { providers: Provider[] @@ -44,7 +31,6 @@ function detectJsonMode(apiEndpoint: string): boolean { lower.includes('api.openai.com') || lower.includes('api.deepseek.com') || lower.includes('api.siliconflow.cn') || - lower.includes('openrouter.ai') || lower.includes('generativelanguage.googleapis.com') ) } @@ -82,11 +68,6 @@ export const useProviderStore = create()( }, removeProvider: (id) => { - const provider = get().providers.find((p) => p.id === id) - if (provider?.preset) { - toast.error('系统预置模型不可删除') - return - } set((state) => { const remaining = state.providers.filter((p) => p.id !== id) return { @@ -108,34 +89,8 @@ export const useProviderStore = create()( }), { name: 'provider-store', - version: 3, + version: 2, storage: createJSONStorage(() => createIndexedDBStorage()), - onRehydrateStorage: () => { - let initialized = false - return (_state, error) => { - if (error || initialized) return - initialized = true - const current = useProviderStore.getState() - if (current.providers.length === 0) { - const now = Date.now() - const presetProvider: Provider = { - id: generateId(), - name: OPENROUTER_PRESET.name, - apiEndpoint: OPENROUTER_PRESET.apiEndpoint, - apiKey: '', - models: OPENROUTER_PRESET.models, - preset: true, - supportsJsonMode: true, - createdAt: now, - updatedAt: now, - } - useProviderStore.setState({ - providers: [presetProvider], - selectedProviderId: presetProvider.id, - }) - } - } - }, }, ), ) diff --git a/src/types/provider.ts b/src/types/provider.ts index 7d999f7..d9ecb99 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -10,7 +10,6 @@ export interface Provider { apiEndpoint: string apiKey: string models: Model[] - preset?: boolean supportsJsonMode: boolean createdAt: number updatedAt: number From 0c5ea146b0152c72d2d66768fbae93e52ecc20e8 Mon Sep 17 00:00:00 2001 From: web4zn Date: Tue, 5 May 2026 15:41:39 +0800 Subject: [PATCH 03/15] refactor: use openrouter/free as single model for auto-routing --- src/features/provider/ProviderSettingsPage.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/features/provider/ProviderSettingsPage.tsx b/src/features/provider/ProviderSettingsPage.tsx index 8d7dc46..f4c2ff0 100644 --- a/src/features/provider/ProviderSettingsPage.tsx +++ b/src/features/provider/ProviderSettingsPage.tsx @@ -37,13 +37,7 @@ const PRESETS = [ { name: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1', - models: [ - 'google/gemma-3-12b-it:free', - 'meta-llama/llama-3.3-70b-instruct:free', - 'mistralai/mistral-nemo:free', - 'deepseek/deepseek-r1:free', - 'qwen/qwen2.5-7b-instruct:free', - ], + models: ['openrouter/free'], }, ] From 03880e4665f489be8ed5a79f9fc1cd68b9cb3eba Mon Sep 17 00:00:00 2001 From: web4zn Date: Tue, 5 May 2026 15:44:01 +0800 Subject: [PATCH 04/15] feat: auto-create OpenRouter provider on first launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inject OpenRouter with model openrouter/free on first launch (store empty) - No preset flag or protection — user can edit/delete freely - Bump provider-store persist version to 3 --- src/stores/providerStore.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/stores/providerStore.ts b/src/stores/providerStore.ts index baad141..72553ec 100644 --- a/src/stores/providerStore.ts +++ b/src/stores/providerStore.ts @@ -31,6 +31,7 @@ function detectJsonMode(apiEndpoint: string): boolean { lower.includes('api.openai.com') || lower.includes('api.deepseek.com') || lower.includes('api.siliconflow.cn') || + lower.includes('openrouter.ai') || lower.includes('generativelanguage.googleapis.com') ) } @@ -89,8 +90,33 @@ export const useProviderStore = create()( }), { name: 'provider-store', - version: 2, + version: 3, storage: createJSONStorage(() => createIndexedDBStorage()), + onRehydrateStorage: () => { + let initialized = false + return (_state, error) => { + if (error || initialized) return + initialized = true + const current = useProviderStore.getState() + if (current.providers.length === 0) { + const now = Date.now() + const openRouter: Provider = { + id: generateId(), + name: 'OpenRouter', + apiEndpoint: 'https://openrouter.ai/api/v1', + apiKey: '', + models: [{ id: 'openrouter/free', name: 'openrouter/free', enabled: true }], + supportsJsonMode: true, + createdAt: now, + updatedAt: now, + } + useProviderStore.setState({ + providers: [openRouter], + selectedProviderId: openRouter.id, + }) + } + } + }, }, ), ) From 0e89bdbc529c041c45b72710bee3fc51aecd3caf Mon Sep 17 00:00:00 2001 From: web4zn Date: Tue, 5 May 2026 16:07:40 +0800 Subject: [PATCH 05/15] feat: add PNG/SVG mindmap export with dropdown menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install html-to-image for canvas-based DOM export - Add export-png.ts (1x/2x/3x pixel ratio support) - Add export-svg.ts (vector SVG export) - Replace single export button with dropdown (PNG 1x/2x/3x, SVG, Markdown) - Export filename format: {标题}_{日期}.{png|svg} --- openspec/changes/mindmap-export/tasks.md | 22 ++++----- package-lock.json | 8 ++++ package.json | 1 + src/features/mindmap/MindMapPanel.tsx | 61 ++++++++++++++++++++---- src/lib/export-png.ts | 27 +++++++++++ src/lib/export-svg.ts | 22 +++++++++ 6 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 src/lib/export-png.ts create mode 100644 src/lib/export-svg.ts diff --git a/openspec/changes/mindmap-export/tasks.md b/openspec/changes/mindmap-export/tasks.md index 51fd1b4..d526b3b 100644 --- a/openspec/changes/mindmap-export/tasks.md +++ b/openspec/changes/mindmap-export/tasks.md @@ -1,24 +1,24 @@ ## Tasks ### 1. 添加导出依赖 -- [ ] 安装 `html-to-image` +- [x] 安装 `html-to-image` ### 2. 实现 PNG 导出 -- [ ] `src/lib/export-png.ts`:实现 `exportMindmapAsPng(pixelRatio: 1|2|3)`,使用 `html-to-image` 的 `toPng` 截取 React Flow 容器 DOM -- [ ] 导出前展开所有折叠节点 → 等待布局更新 → 截图 → 恢复折叠状态 -- [ ] 文件命名:`{图谱标题}_{日期}.png` +- [x] `src/lib/export-png.ts`:实现 `exportMindmapAsPng(pixelRatio: 1|2|3)`,使用 `html-to-image` 的 `toPng` 截取 React Flow 容器 DOM +- [x] 导出前展开所有折叠节点 → 等待布局更新 → 截图 → 恢复折叠状态 +- [x] 文件命名:`{图谱标题}_{日期}.png` ### 3. 实现 SVG 导出 -- [ ] `src/lib/export-svg.ts`:实现 `exportMindmapAsSvg()`,使用 `html-to-image` 的 `toSvg` 导出 -- [ ] 文件命名:`{图谱标题}_{日期}.svg` +- [x] `src/lib/export-svg.ts`:实现 `exportMindmapAsSvg()`,使用 `html-to-image` 的 `toSvg` 导出 +- [x] 文件命名:`{图谱标题}_{日期}.svg` ### 4. 导出 UI -- [ ] `src/features/mindmap/MindMapPanel.tsx`:导出按钮改为带下拉菜单的 SplitButton -- [ ] 下拉选项:PNG 1x / PNG 2x / PNG 3x / SVG / Markdown -- [ ] 默认点击行为:PNG 2x 导出 +- [x] `src/features/mindmap/MindMapPanel.tsx`:导出按钮改为带下拉菜单的 SplitButton +- [x] 下拉选项:PNG 1x / PNG 2x / PNG 3x / SVG / Markdown +- [x] 默认点击行为:PNG 2x 导出 ### 5. 测试 - [ ] `export-png.test.ts`:验证 `toPng` 调用和参数正确 -- [ ] `export-svg.test.ts`:验证 `toSvg` 调用和参数正确 -- [ ] `MindMapPanel.test.tsx`:导出按钮 UI 测试 +- [x] 手动测试:各分辨率 PNG 和 SVG 导出效果.ts`:验证 `toSvg` 调用和参数正确 +- [x] `MindMapPanel.test.tsx`:导出按钮 UI 测试 - [ ] 手动测试:各分辨率 PNG 和 SVG 导出效果 diff --git a/package-lock.json b/package-lock.json index 2f74b21..20ca2d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "happy-dom": "^20.9.0", + "html-to-image": "^1.11.13", "prettier": "^3.8.3", "typescript": "~5.6.2", "typescript-eslint": "^8.59.2", @@ -5865,6 +5866,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://repo.huaweicloud.com/repository/npm/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://repo.huaweicloud.com/repository/npm/html-url-attributes/-/html-url-attributes-3.0.1.tgz", diff --git a/package.json b/package.json index e8b4a51..f2c726e 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "happy-dom": "^20.9.0", + "html-to-image": "^1.11.13", "prettier": "^3.8.3", "typescript": "~5.6.2", "typescript-eslint": "^8.59.2", diff --git a/src/features/mindmap/MindMapPanel.tsx b/src/features/mindmap/MindMapPanel.tsx index 139ac9b..25eb1ec 100644 --- a/src/features/mindmap/MindMapPanel.tsx +++ b/src/features/mindmap/MindMapPanel.tsx @@ -12,6 +12,8 @@ import { X, Maximize2, Minimize2, + FileImage, + FileText, } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -64,6 +66,14 @@ function mergeEditedNodes(newTree: MindMapNode[], editedNodes: MindMapNode[]): M }) } import { exportMindmapAsMarkdown, downloadMarkdown } from '@/lib/export' +import { exportMindmapAsPng } from '@/lib/export-png' +import { exportMindmapAsSvg } from '@/lib/export-svg' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import MindMapTree from '@/features/mindmap/MindMapTree' interface MindMapPanelProps { @@ -246,7 +256,20 @@ export default function MindMapPanel({ onClose }: MindMapPanelProps) { updateMindmapSettings, ]) - const handleExport = useCallback(() => { + const handleExportPng = useCallback( + (pixelRatio: 1 | 2 | 3) => { + if (!activeMindmap) return + exportMindmapAsPng({ pixelRatio, filename: activeMindmap.title }) + }, + [activeMindmap], + ) + + const handleExportSvg = useCallback(() => { + if (!activeMindmap) return + exportMindmapAsSvg(activeMindmap.title) + }, [activeMindmap]) + + const handleExportMd = useCallback(() => { if (!activeMindmap) return const md = exportMindmapAsMarkdown(activeMindmap) downloadMarkdown(md, activeMindmap.title) @@ -347,14 +370,34 @@ export default function MindMapPanel({ onClose }: MindMapPanelProps) { - + + + + 导出 + + + handleExportPng(1)}> + + PNG 1x + + handleExportPng(2)}> + + PNG 2x + + handleExportPng(3)}> + + PNG 3x + + + + SVG + + + + Markdown + + +