Compare commits

...

42 Commits

Author SHA1 Message Date
kangfenmao
6a8544fb0e chore: bump version to 1.6.3 2025-10-08 22:08:08 +08:00
kangfenmao
37f7042f0f refactor: update styling and layout in Message component and NotesSidebar
- Adjusted class names in Message component for better layout management.
- Modified margin in DropHintNode of NotesSidebar for improved spacing.
- Enhanced BackupService to remove 'notes_tree' from indexedDB during data restoration.
2025-10-08 21:42:50 +08:00
亢奋猫
65d066cbef fix: migration for missing providers … (#10438)
chore: bump version to 1.6.3 and add migration for missing providers #10425

fix: #10425

- Updated the version from 158 to 159 in the persisted reducer configuration.
- Implemented a migration function to ensure missing system providers are added to the state during the migration to version 159, enhancing state consistency.
2025-10-08 19:28:08 +08:00
George·Dong
504531d4d5 feat(notes): add spell-check control (#10507)
* feat(notes): add spell-check control

* feat(notes): add spell-check toggle to preview mode toolbar

* feat(settings): move spellcheck to global and use hook
2025-10-08 17:48:26 +08:00
Tristan Zhang
d4b3428160 feat: Support automatic line wrapping for tables in notes (#10503)
* feat: add table auto-wrap feature for notes

* chore: lint

* feat: remove settings for auto wrap
2025-10-08 01:57:00 +08:00
Daniel Hofheinz
cd881ceb34 fix(ui): remove redundant scrollbar in side-by-side view & fix message menubar overflow (#10543)
* fix(ui): remove redundant scrollbar in side-by-side view

Changed GridContainer from styled(Scrollbar) to styled.div to
eliminate redundant horizontal scrollbar in multi-model horizontal
layout mode. The Scrollbar component is designed for vertical
scrolling and conflicts with horizontal layouts.

Fixes #10520

* fix(ui): restore vertical scrollbar for grid mode while preserving horizontal fix

Optimal solution: Use Scrollbar component as base to preserve auto-hide
behavior for vertical modes (grid, vertical, fold) while overriding its
overflow-y behavior for horizontal mode only.

This approach:
- Preserves the June 2025 UX optimization (auto-hide scrollbars)
- Fixes horizontal scrollbar issue from #10520
- Restores vertical scrolling for grid mode
- Maintains auto-hide behavior for all vertical scrolling modes
- Minimal change with no code duplication

The Scrollbar component provides scrollbar thumb auto-hide after 1.5s,
which enhances UX for vertical scrolling. By using CSS overrides only
for horizontal mode, we get the best of both worlds.

* chore: fix import sorting in MessageGroup.tsx

Unrelated to PR scope - fixing to unblock CI.
Auto-fixed via eslint --fix (moved Scrollbar import to correct position).
Also updated yarn.lock to resolve dependency sync.

* fix(ui): add explicit overflow declarations for all grid modes

Previous fix relied on CSS inheritance from Scrollbar base component,
but display: grid interferes with overflow property inheritance.

This iteration adds explicit overflow-y: auto and overflow-x: hidden
to grid, fold, vertical, and multi-select modes to ensure vertical
scrolling works reliably across all layouts.

- horizontal mode: overflow-y visible, overflow-x auto (unchanged)
- grid/fold/vertical modes: explicit overflow-y auto, overflow-x hidden
- multi-select mode: explicit overflow-y auto, overflow-x hidden

Fixes vertical scrollbar missing in grid mode reported by @EurFelux

* fix(Messages): adjust overflow behavior in message groups

Fix scrollbar issues by hiding vertical overflow in horizontal layout and simplifying overflow handling in grid layout

* feat(HorizontalScrollContainer): add classNames prop for container and content styling

allow custom styling of container and content via classNames prop

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-08 01:55:21 +08:00
Phantom
a9843b4128 feat: expand clickable area of topic in-place renaming (#10548)
* chore: update electron dependency from 37.4.0 to 37.6.0

* feat(TopicsTab): add double click to edit topic name

Move double click handler from TopicName component to parent div to improve UX

* fix(TopicsTab): prevent topic edit on double click when already editing
2025-10-07 14:24:29 +08:00
Murphy
d2d5064eed fix: forked topic and rename modal retaining old name after rename (#10528)
fix: sync active topic metadata after rename
2025-10-07 00:02:48 +08:00
rebecca554owen
8bec7640fa fix(metrics): restore first token latency reporting (#10538) 2025-10-06 22:19:09 +08:00
沿途风浪
fcf53f06ef fix(models vision) (#10530) 2025-10-05 20:39:32 +08:00
one
2048f210e7 feat(CodeEditor): add a prop to enable the readOnly extension (#10516)
* feat(CodeEditor): add a prop to enable the readOnly extension

* feat: enable keymap for TextFilePreview
2025-10-04 23:24:50 +08:00
PP Kun
78eacccf6e chore(build): Upgrade electron (#10525)
- 将 electron 从 37.4.0 升级到 37.6.0
- 解决旧版本导致macOS 26卡顿问题
2025-10-04 12:42:07 +08:00
Tristan Zhang
a436ab1d78 fix(TextFilePreview): make editor read-only but can be copied (#10499)
* fix(TextFilePreview): make editor read-only but can be copied

* feat: add table auto-wrap feature for notes

* Revert "feat: add table auto-wrap feature for notes"

This reverts commit 7785f480b1.
2025-10-03 19:23:49 +08:00
Phantom
2aedbf5702 fix(reasoning): support deepseek v3.2, claude 4.5, glm 4.6 (#10475)
* fix(reasoning): update deepseek model id regex pattern to match more variants

The previous regex pattern was too restrictive and didn't account for all possible deepseek model id formats. This change expands the pattern to support more variants while maintaining the same functionality.

* fix(reasoning): update deepseek model id regex pattern to match more variants

* fix(reasoning): improve regex pattern for deepseek model matching

Update the regex pattern to be more precise in matching deepseek model versions.
Add detailed comments explaining the pattern and note future improvements.

* feat(models): add GLM-4.6 model to supported list

Update model configuration to include new GLM-4.6 model and add it to the supported models for thinking token functionality

* feat(models): add claude sonnet 4.5 model to anthropic provider
2025-10-03 14:36:18 +08:00
Tristan Zhang
b7e7174f3d feat: add middle-click tab closing (#10498) 2025-10-02 20:56:53 +08:00
Tristan Zhang
e7e5c0456f feat: allowing notes to be renamed using LLM (#10487)
* feat: implement auto-renaming feature for notes

* feat: motion effects for auto renaming in notes

* feat: add i18n for zh-tw for auto renaming in notes

* chore: lint
2025-10-02 20:45:46 +08:00
purefkh
53e38ed1aa feat(models): update Gemini regex (#10463)
* feat(models): update Gemini regex

* fix: lint

* fix format
2025-10-02 17:45:42 +08:00
Tristan Zhang
f91e7da0a1 feat: add notes export (#10488)
* feat: add notes export

* chore: fix lint error

* feat: unified export interface for notes

* fix: hide export reasoning when exporting notes

* chore: fix lint error

* chore: remove debug log
2025-10-02 08:09:11 +01:00
dependabot[bot]
74db4c4646 ci(deps): bump actions/github-script from 7 to 8 (#10480)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:16:02 +08:00
dependabot[bot]
1e4902b267 ci(deps): bump actions/checkout from 4 to 5 (#10479)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:15:38 +08:00
dependabot[bot]
932b1d529a ci(deps): bump actions/setup-node from 4 to 5 (#10478)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:15:14 +08:00
LeaderOnePro
38ac42af8c feat: add GitHub Copilot CLI integration to coding tools (#10403)
* feat: add GitHub Copilot CLI integration to coding tools

- Add githubCopilotCli to codeTools enum
- Support @github/copilot package installation
- Add 'copilot' executable command mapping
- Update Redux store to include GitHub Copilot CLI state
- Add GitHub Copilot CLI option to UI with proper provider mapping
- Implement environment variable handling for GitHub authentication
- Fix model selection logic to disable model choice for GitHub Copilot CLI
- Update launch validation to not require model selection for GitHub Copilot CLI
- Fix prepareLaunchEnvironment and executeLaunch to handle no-model scenario

This enables users to launch GitHub Copilot CLI directly from Cherry Studio's
code tools interface without needing to select a model, as GitHub Copilot CLI
uses GitHub's built-in models and authentication.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* style: apply code formatting for GitHub Copilot CLI integration

Auto-fix code style inconsistencies using project's Biome formatter.
Resolves semicolon, comma, and quote style issues to match project standards.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* feat: conditionally render model selector for GitHub Copilot CLI

- Hide model selector component when GitHub Copilot CLI is selected
- Maintain validation logic to allow GitHub Copilot CLI without model selection
- Improve UX by removing empty model dropdown for GitHub Copilot CLI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-30 23:43:19 +08:00
MyPrototypeWhat
d11a2cd95c chore: update dependencies and versioning across packages (#10471)
- Bumped versions for several @ai-sdk packages in package.json and yarn.lock to their latest releases, including @ai-sdk/amazon-bedrock, @ai-sdk/google-vertex, @ai-sdk/mistral, and @ai-sdk/perplexity.
- Updated ai package version from 5.0.44 to 5.0.59.
- Updated aiCore package version from 1.0.0-alpha.18 to 1.0.1 and adjusted dependencies accordingly.
- Ensured compatibility with the latest zod version in multiple packages.
2025-09-30 18:39:48 +08:00
Phantom
23f61b0d62 feat: support gpt-5-codex (#10448)
* feat(models): add gpt5_codex model support

Add support for gpt5_codex model type in model configuration and type definitions. Update getThinkModelType to handle codex variant of gpt5 models.

* feat(models): add gpt-5-codex model logo and update logo mapping

Add new GPT-5-Codex model logo image and include it in the logo mapping configuration
2025-09-29 23:22:25 +08:00
Kejiang Ma
961ee22327 feat: add new provider intel OVMS(openvino model server) (#9853)
* add new provider: OVMS(openvino model server)

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* remove useless comments

* add note: support windows only

* fix eslint error; add migrate for ovms provider

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix ci error after rebase

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* modifications base on reviewers' comments

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* show intel-ovms provider only on windows and intel cpu

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* complete i18n for intel ovms

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* update ovms 2025.3; apply patch for model qwen3-8b on local

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix lint issues

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix issues for format, type checking

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* remove test code

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix issues after rebase

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

---------

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>
2025-09-29 18:36:54 +08:00
LeaderOnePro
c7d2588f1a feat: add LongCat provider support (#10365)
* feat: add LongCat provider support

- Add LongCat to SystemProviderIds enum
- Add LongCat provider logo and configuration
- Configure API endpoints and URLs based on official docs
- Add two models: LongCat-Flash-Chat and LongCat-Flash-Thinking
- Update provider mappings for proper integration

The LongCat provider uses OpenAI-compatible API format and supports
up to 8K tokens output with daily free quota of 500K tokens.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* feat: add migration for LongCat provider

- Add migration version 158 for LongCat provider
- Ensure existing users get LongCat provider on app update
- Follow standard migration pattern for simple provider additions

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

---------

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>
2025-09-28 20:54:42 +08:00
MyPrototypeWhat
06ab2822be Refactor/reasoning time (#10393) 2025-09-28 19:38:44 +08:00
kangfenmao
bb0ec0a3ec chore: update @ai-sdk/google patch and refine getModelPath function
- Updated the resolution and checksum for the @ai-sdk/google patch in yarn.lock.
- Enhanced the getModelPath function to check for "models/" in the modelId before returning the path, improving its robustness.
2025-09-28 16:32:51 +08:00
MyPrototypeWhat
483b4e090e feat(toolUsePlugin): separate provider-defined tools from prompt tool (#10428)
* feat(toolUsePlugin): separate provider-defined tools from prompt tools in context

- Enhanced the `createPromptToolUsePlugin` function to distinguish between provider-defined tools and other tools, ensuring only non-provider-defined tools are saved in the context.
- Updated the handling of tools in the transformed parameters to retain provider-defined tools while removing others.
- Improved error handling in `ToolExecutor` by logging tool and tool use details for better debugging.
- Refactored various components to use `NormalToolResponse` instead of `MCPToolResponse`, aligning with the new response structure across multiple message components.

* refactor(toolUsePlugin): streamline tool handling in createPromptToolUsePlugin

- Updated the `createPromptToolUsePlugin` function to improve type handling for tools, ensuring proper type inference and reducing the use of type assertions.
- Enhanced clarity in the separation of provider-defined tools and prompt tools, maintaining functionality while improving code readability.

* refactor(ToolExecutor): remove debug logging for tool and tool use

- Removed console logging for tool and tool use details in the ToolExecutor class to clean up the code and improve performance. This change enhances the clarity of the code without affecting functionality.
2025-09-28 16:27:26 +08:00
kangfenmao
4975c2d9e8 chore: update build configurations to use secrets for sensitive environment variables
- Modified GitHub Actions workflows to replace environment variable references with secrets for MAIN_VITE_MINERU_API_KEY, RENDERER_VITE_AIHUBMIX_SECRET, and RENDERER_VITE_PPIO_APP_SECRET.
- Added onwarn handler in electron.vite.config.ts to suppress specific warnings related to CommonJS variables in ESM.
2025-09-28 16:12:48 +08:00
kangfenmao
5365fddec9 chore: bump version to 1.6.2
- Updated release notes to reflect recent optimizations and bug fixes, including improvements to the note-taking feature and resolution of issues with CherryAI and VertexAI.
- Bumped version number from 1.6.1 to 1.6.2 in package.json.
2025-09-28 15:07:21 +08:00
kangfenmao
e401685449 lint: fix code format 2025-09-28 14:57:01 +08:00
MyPrototypeWhat
e195ad4a8f refactor(tools): enhance descriptions for knowledge and web search tools (#10433)
* refactor(tools): enhance descriptions for knowledge and web search tools

- Updated the descriptions for the knowledgeSearchTool and webSearchTool to provide clearer context on their functionality.
- Improved the formatting of prepared queries and relevant links in the descriptions to enhance user understanding.
- Added information on how to use the tools with additional context for refined searches.

* fix:format lint
2025-09-28 14:56:04 +08:00
one
20f5271682 fix: quick assistant avatar and search (#10281) 2025-09-28 14:15:56 +08:00
Phantom
5524571c80 fix(ErrorBlock): prevent event propagation when removing block (#10368)
This PR correctly addresses an event propagation issue where clicking the close button on an error alert was unintentionally triggering the parent click handler (which opens the detail modal).
2025-09-28 14:09:11 +08:00
Phantom
cd3031479c fix(reasoning): correct regex pattern for deepseek model detection (#10407) 2025-09-28 14:07:30 +08:00
Pleasure1234
1df6e8c732 refactor(notes): improve notes management with local state and file handling (#10395)
* refactor(notes): improve notes management with local state and file handling

- Replace UUID-based IDs with SHA1 hash of file paths for better consistency
- Remove database storage for notes tree, use local state management instead
- Add localStorage persistence for starred and expanded states
- Improve cross-platform path normalization (replace backslashes with forward slashes)
- Refactor tree operations to use optimized in-memory operations
- Enhance file watcher integration for better sync performance
- Simplify notes service with direct file system operations
- Remove database dependencies from notes tree management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "Merge remote-tracking branch 'origin/main' into refactor/note"

This reverts commit 389386ace8, reversing
changes made to 4428f511b0.

* fix: format error

* refactor: noteservice

* refactor(notes): 完成笔记状态从localStorage向Redux的迁移

- 将starred和expanded路径状态从localStorage迁移到Redux store
- 添加版本159迁移逻辑,自动从localStorage迁移现有数据到Redux
- 优化NotesPage组件,使用Redux状态管理替代本地localStorage操作
- 改进SaveToKnowledgePopup的错误处理和验证逻辑
- 删除NotesTreeService中已废弃的localStorage写入函数
- 增强组件性能,使用ref避免不必要的依赖更新

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: ci

* feat(notes): add in-place renaming for notes in HeaderNavbar

- Implemented an input field for renaming the current note directly in the HeaderNavbar.
- Added handlers for title change, blur, and key events to manage renaming logic.
- Updated the breadcrumb display to accommodate the new title input.
- Enhanced styling for the title input to ensure seamless integration with the existing UI.

This feature improves user experience by allowing quick edits without navigating away from the notes list.

* Update NotesEditor.tsx

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-09-28 13:50:52 +08:00
Xin Rui
ed2e01491e fix: clear @ and other input text when exiting model selection menu w… (#10427)
fix: clear @ and other input text when exiting model selection menu with Esc
2025-09-28 13:44:27 +08:00
kangfenmao
228ed474ce chore: update @ai-sdk/google patch and modify getModelPath function
- Updated the resolution and checksum for the @ai-sdk/google patch in yarn.lock.
- Removed the patch reference from package.json for @ai-sdk/google.
- Modified the getModelPath function to simplify its implementation, removing the baseURL parameter.
2025-09-28 13:31:18 +08:00
亢奋猫
6829a03437 fix: AI_APICallError for Gemini via proxy #10366 (#10429)
When sending requests to Gemini via proxy, the system returns:
"模型不存在或者请求路径错误".
2025-09-28 13:01:49 +08:00
Phantom
dabfb8dc0e style(settings): remove unnecessary padding from ContentContainer (#10379) 2025-09-26 17:50:00 +08:00
Zhaokun
4aa9c9f225 feat: improve content protection during file operations (#10378)
* feat: improve content protection during file operations

- Add validation for knowledge base configuration before saving
- Enhance error handling for note content reading
- Implement content backup and restoration during file rename
- Add content verification after rename operations
- Improve user feedback with specific error messages

* fix: format check

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>
2025-09-26 17:49:24 +08:00
104 changed files with 4390 additions and 1849 deletions

View File

@@ -2,8 +2,8 @@ name: Auto I18N
env:
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
on:
pull_request:
@@ -26,7 +26,7 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
- name: 📦 Setting Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1

View File

@@ -37,7 +37,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1

View File

@@ -12,7 +12,7 @@ jobs:
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Delete merged branch
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.git.deleteRef({

View File

@@ -56,7 +56,7 @@ jobs:
ref: main
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
@@ -99,9 +99,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -110,15 +110,15 @@ jobs:
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -128,9 +128,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Rename artifacts with nightly format
shell: bash

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -47,7 +47,7 @@ jobs:
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
@@ -86,9 +86,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -98,15 +98,15 @@ jobs:
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -116,9 +116,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Release
uses: ncipollo/release-action@v1

View File

@@ -1,36 +1,13 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
}
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
-function getModelPath(modelId) {
+function getModelPath(modelId, baseURL) {
+ if (baseURL?.includes('cherryin')) {
+ return `models/${modelId}`;
+ }
return modelId.includes("/") ? modelId : `models/${modelId}`;
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
}
@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class {
rawValue: rawResponse
} = await postJsonToApi2({
url: `${this.config.baseURL}/${getModelPath(
- this.modelId
+ this.modelId,
+ this.config.baseURL
)}:generateContent`,
headers: mergedHeaders,
body: args,
@@ -962,7 +966,8 @@ var GoogleGenerativeAILanguageModel = class {
);
const { responseHeaders, value: response } = await postJsonToApi2({
url: `${this.config.baseURL}/${getModelPath(
- this.modelId
+ this.modelId,
+ this.config.baseURL
)}:streamGenerateContent?alt=sse`,
headers,
body: args,
// src/google-generative-ai-options.ts

View File

@@ -125,59 +125,21 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
🚀 New Features:
- Refactored AI core engine for more efficient and stable content generation
- Added support for multiple AI model providers: CherryIN, AiOnly
- Added API server functionality for external application integration
- Added PaddleOCR document recognition for enhanced document processing
- Added Anthropic OAuth authentication support
- Added data storage space limit notifications
- Added font settings for global and code fonts customization
- Added auto-copy feature after translation completion
- Added keyboard shortcuts: rename topic, edit last message, etc.
- Added text attachment preview for viewing file contents in messages
- Added custom window control buttons (minimize, maximize, close)
- Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads
- Support for Qwen image recognition models (Qwen-Image)
- Added iFlow CLI support
- Converted knowledge base and web search to tool-calling approach for better flexibility
What's New in v1.6.3
🎨 UI Improvements & Bug Fixes:
- Integrated HeroUI and Tailwind CSS framework
- Optimized message notification styles with unified toast component
- Moved free models to bottom with fixed position for easier access
- Refactored quick panel and input bar tools for smoother operation
- Optimized responsive design for navbar and sidebar
- Improved scrollbar component with horizontal scrolling support
- Fixed multiple translation issues: paste handling, file processing, state management
- Various UI optimizations and bug fixes
<!--LANG:zh-CN-->
🚀 新功能:
- 重构 AI 核心引擎,提供更高效稳定的内容生成
- 新增多个 AI 模型提供商支持CherryIN、AiOnly
- 新增 API 服务器功能,支持外部应用集成
- 新增 PaddleOCR 文档识别,增强文档处理能力
- 新增 Anthropic OAuth 认证支持
- 新增数据存储空间限制提醒
- 新增字体设置,支持全局字体和代码字体自定义
- 新增翻译完成后自动复制功能
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
- 新增文本附件预览,可查看消息中的文件内容
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
- 支持通义千问长文本qwen-long和文档分析qwen-doc模型原生文件上传
- 支持通义千问图像识别模型Qwen-Image
- 新增 iFlow CLI 支持
- 知识库和网页搜索转换为工具调用方式,提升灵活性
Features:
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
- Code Tools: Add GitHub Copilot CLI integration
🎨 界面改进与问题修复:
- 集成 HeroUI 和 Tailwind CSS 框架
- 优化消息通知样式,统一 toast 组件
- 免费模型移至底部固定位置,便于访问
- 重构快捷面板和输入栏工具,操作更流畅
- 优化导航栏和侧边栏响应式设计
- 改进滚动条组件,支持水平滚动
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
- 各种界面优化和问题修复
<!--LANG:END-->
Bug Fixes:
- Fix migration for missing providers
- Fix forked topic retaining old name after rename
- Restore first token latency reporting in metrics
- Fix UI scrollbar and overflow issues
Technical Updates:
- Upgrade to Electron 37.6.0
- Update dependencies across packages

View File

@@ -34,6 +34,10 @@ export default defineConfig({
output: {
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
},
onwarn(warning, warn) {
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
warn(warning)
}
},
sourcemap: isDev
@@ -111,6 +115,10 @@ export default defineConfig({
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
},
onwarn(warning, warn) {
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
warn(warning)
}
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.6.1",
"version": "1.6.3",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -97,10 +97,10 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.21",
"@ai-sdk/google-vertex": "^3.0.27",
"@ai-sdk/mistral": "^2.0.14",
"@ai-sdk/perplexity": "^2.0.9",
"@ai-sdk/amazon-bedrock": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.33",
"@ai-sdk/mistral": "^2.0.17",
"@ai-sdk/perplexity": "^2.0.11",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
@@ -215,7 +215,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.44",
"ai": "^5.0.59",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -238,7 +238,7 @@
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"electron": "37.4.0",
"electron": "37.6.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-store": "^8.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.0-alpha.18",
"version": "1.0.1",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -36,15 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.17",
"@ai-sdk/azure": "^2.0.30",
"@ai-sdk/deepseek": "^1.0.17",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/anthropic": "^2.0.22",
"@ai-sdk/azure": "^2.0.42",
"@ai-sdk/deepseek": "^1.0.20",
"@ai-sdk/openai": "^2.0.42",
"@ai-sdk/openai-compatible": "^1.0.19",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.9",
"@ai-sdk/xai": "^2.0.18",
"@ai-sdk/provider-utils": "^3.0.10",
"@ai-sdk/xai": "^2.0.23",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
return params
}
context.mcpTools = params.tools
// 分离 provider-defined 和其他类型的工具
const providerDefinedTools: ToolSet = {}
const promptTools: ToolSet = {}
// 构建系统提示符
for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) {
if (tool.type === 'provider-defined') {
// provider-defined 类型的工具保留在 tools 参数中
providerDefinedTools[toolName] = tool
} else {
// 其他工具转换为 prompt 模式
promptTools[toolName] = tool
}
}
// 只有当有非 provider-defined 工具时才保存到 context
if (Object.keys(promptTools).length > 0) {
context.mcpTools = promptTools
}
// 构建系统提示符(只包含非 provider-defined 工具)
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
let systemMessage: string | null = systemPrompt
if (config.createSystemMessage) {
// 🎯 如果用户提供了自定义处理函数,使用它
systemMessage = config.createSystemMessage(systemPrompt, params, context)
}
// 移除 tools改为 prompt 模式
// 保留 provider-defined tools移除其他 tools
const transformedParams = {
...params,
...(systemMessage ? { system: systemMessage } : {}),
tools: undefined
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
}
context.originalParams = transformedParams
return transformedParams
@@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
let textBuffer = ''
// let stepId = ''
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
if (!context.mcpTools) {
throw new Error('No tools available')
return new TransformStream()
}
// 从 context 中获取或初始化 usage 累加器

View File

@@ -1,6 +1,7 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { InferToolInput, InferToolOutput } from 'ai'
import { ProviderOptionsMap } from '../../../options/types'
import { OpenRouterSearchConfig } from './openrouter'
@@ -58,24 +59,31 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
export type WebSearchToolOutputSchema = {
// Anthropic 工具 - 手动定义
anthropicWebSearch: Array<{
url: string
title: string
pageAge: string | null
encryptedContent: string
type: string
}>
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
// OpenAI 工具 - 基于实际输出
openaiWebSearch: {
// TODO: 上游定义不规范,是unknown
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
openai: {
status: 'completed' | 'failed'
}
'openai-chat': {
status: 'completed' | 'failed'
}
// Google 工具
googleSearch: {
// TODO: 上游定义不规范,是unknown
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
google: {
webSearchQueries?: string[]
groundingChunks?: Array<{
web?: { uri: string; title: string }
}>
}
}
export type WebSearchToolInputSchema = {
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
}

View File

@@ -34,6 +34,7 @@ export enum IpcChannel {
App_GetBinaryPath = 'app:get-binary-path',
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
App_InstallOvmsBinary = 'app:install-ovms-binary',
App_LogToMain = 'app:log-to-main',
App_SaveData = 'app:save-data',
App_GetDiskInfo = 'app:get-disk-info',
@@ -220,6 +221,7 @@ export enum IpcChannel {
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName',
// DevTools
System_ToggleDevTools = 'system:toggleDevTools',
@@ -330,6 +332,15 @@ export enum IpcChannel {
// OCR
OCR_ocr = 'ocr:ocr',
// OVMS
Ovms_AddModel = 'ovms:add-model',
Ovms_StopAddModel = 'ovms:stop-addmodel',
Ovms_GetModels = 'ovms:get-models',
Ovms_IsRunning = 'ovms:is-running',
Ovms_GetStatus = 'ovms:get-status',
Ovms_RunOVMS = 'ovms:run-ovms',
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
Cherryai_GetSignature = 'cherryai:get-signature'
}

View File

@@ -217,7 +217,8 @@ export enum codeTools {
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex',
iFlowCli = 'iflow-cli'
iFlowCli = 'iflow-cli',
githubCopilotCli = 'github-copilot-cli'
}
export enum terminalApps {

View File

@@ -1,5 +1,7 @@
const https = require('https')
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
/**
* Downloads a file from a URL with redirect handling
@@ -32,4 +34,39 @@ async function downloadWithRedirects(url, destinationPath) {
})
}
module.exports = { downloadWithRedirects }
/**
* Downloads a file using PowerShell Invoke-WebRequest command
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<boolean>} Promise that resolves to true if download succeeds
*/
async function downloadWithPowerShell(url, destinationPath) {
return new Promise((resolve, reject) => {
try {
// Only support windows platform for PowerShell download
if (process.platform !== 'win32') {
return reject(new Error('PowerShell download is only supported on Windows'))
}
const outputDir = path.dirname(destinationPath)
fs.mkdirSync(outputDir, { recursive: true })
// PowerShell command to download the file with progress disabled for faster download
const psCommand = `powershell -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest '${url}' -OutFile '${destinationPath}'"`
console.log(`Downloading with PowerShell: ${url}`)
execSync(psCommand, { stdio: 'inherit' })
if (fs.existsSync(destinationPath)) {
console.log(`Download completed: ${destinationPath}`)
resolve(true)
} else {
reject(new Error('Download failed: File not found after download'))
}
} catch (error) {
reject(new Error(`PowerShell download failed: ${error.message}`))
}
})
}
module.exports = { downloadWithRedirects, downloadWithPowerShell }

View File

@@ -0,0 +1,177 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_PKG_NAME = 'ovms250911.zip'
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
/**
* Downloads and extracts the OVMS binary for the specified platform
*/
async function downloadOvmsBinary() {
// Create output directory structure - OVMS goes into its own subdirectory
const csDir = path.join(os.homedir(), '.cherrystudio')
// Ensure directories exist
fs.mkdirSync(csDir, { recursive: true })
const csOvmsDir = path.join(csDir, 'ovms')
// Delete existing OVMS directory if it exists
if (fs.existsSync(csOvmsDir)) {
fs.rmSync(csOvmsDir, { recursive: true })
}
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
// Try each URL until one succeeds
let downloadSuccess = false
let lastError = null
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
try {
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(downloadUrl, tempFilename)
// If we get here, download was successful
downloadSuccess = true
console.log(`Successfully downloaded from: ${downloadUrl}`)
break
} catch (error) {
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
lastError = error
// Clean up failed download file if it exists
if (fs.existsSync(tempFilename)) {
try {
fs.unlinkSync(tempFilename)
} catch (cleanupError) {
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
}
}
// Continue to next URL if this one failed
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
console.log(`Trying next URL...`)
}
}
}
// Check if any download succeeded
if (!downloadSuccess) {
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
return 103
}
try {
console.log(`Extracting to ${csDir}...`)
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS to ${csDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS: ${error.message}`)
if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
// Check if ovmsDir is empty and remove it if so
try {
const ovmsDir = path.join(csDir, 'ovms')
const files = fs.readdirSync(ovmsDir)
if (files.length === 0) {
fs.rmSync(ovmsDir, { recursive: true })
console.log(`Removed empty directory: ${ovmsDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
return 105
}
return 104
}
return 0
}
/**
* Get the CPU Name and ID
*/
function getCpuInfo() {
const cpuInfo = {
name: '',
id: ''
}
// Use PowerShell to get CPU information
try {
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
const psOutput = execSync(psCommand).toString()
const cpuData = JSON.parse(psOutput)
if (Array.isArray(cpuData)) {
cpuInfo.name = cpuData[0].Name || ''
cpuInfo.id = cpuData[0].DeviceID || ''
} else {
cpuInfo.name = cpuData.Name || ''
cpuInfo.id = cpuData.DeviceID || ''
}
} catch (error) {
console.error(`Failed to get CPU info: ${error.message}`)
}
return cpuInfo
}
/**
* Main function to install OVMS
*/
async function installOvms() {
const platform = os.platform()
console.log(`Detected platform: ${platform}`)
const cpuName = getCpuInfo().name
console.log(`CPU Name: ${cpuName}`)
// Check if CPU name contains "Ultra"
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
return 101
}
// only support windows
if (platform !== 'win32') {
console.error('OVMS installation is only supported on Windows.')
return 102
}
return await downloadOvmsBinary()
}
// Run the installation
installOvms()
.then((retcode) => {
if (retcode === 0) {
console.log('OVMS installation successful')
} else {
console.error('OVMS installation failed')
}
process.exit(retcode)
})
.catch((error) => {
console.error('OVMS installation failed:', error)
process.exit(100)
})

View File

@@ -35,6 +35,7 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -81,6 +82,7 @@ const obsidianVaultService = new ObsidianVaultService()
const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance()
const dxtService = new DxtService()
const ovmsManager = new OvmsManager()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
@@ -432,6 +434,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// system
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()
@@ -710,6 +713,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
//copilot
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
@@ -841,6 +845,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ocrService.ocr(file, provider)
)
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
ovmsManager.addModel(modelName, modelId, modelSource, task)
)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
}

View File

@@ -31,7 +31,10 @@ interface VersionInfo {
class CodeToolsService {
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null
private terminalsCache: {
terminals: TerminalConfig[]
timestamp: number
} | null = null
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
@@ -82,6 +85,8 @@ class CodeToolsService {
return '@qwen-code/qwen-code'
case codeTools.iFlowCli:
return '@iflow-ai/iflow-cli'
case codeTools.githubCopilotCli:
return '@github/copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -99,6 +104,8 @@ class CodeToolsService {
return 'qwen'
case codeTools.iFlowCli:
return 'iflow'
case codeTools.githubCopilotCli:
return 'copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -144,7 +151,9 @@ class CodeToolsService {
case terminalApps.powershell:
// Check for PowerShell in PATH
try {
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
await execAsync('powershell -Command "Get-Host"', {
timeout: 3000
})
return terminal
} catch {
try {
@@ -384,7 +393,9 @@ class CodeToolsService {
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
const { stdout } = await execAsync(`"${executablePath}" --version`, {
timeout: 10000
})
// Extract version number from output (format may vary by tool)
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
@@ -425,7 +436,10 @@ class CodeToolsService {
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
this.versionCache.set(cacheKey, {
version: latestVersion!,
timestamp: now
})
logger.debug(`Cached latest version for ${packageName}`)
} catch (error) {
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)

View File

@@ -0,0 +1,586 @@
import { exec } from 'node:child_process'
import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { loggerService } from '@logger'
import * as fs from 'fs-extra'
import * as path from 'path'
const logger = loggerService.withContext('OvmsManager')
const execAsync = promisify(exec)
interface OvmsProcess {
pid: number
path: string
workingDirectory: string
}
interface ModelConfig {
name: string
base_path: string
}
interface OvmsConfig {
mediapipe_config_list: ModelConfig[]
}
class OvmsManager {
private ovms: OvmsProcess | null = null
/**
* Recursively terminate a process and all its child processes
* @param pid Process ID to terminate
* @returns Promise<{ success: boolean; message?: string }>
*/
private async terminalProcess(pid: number): Promise<{ success: boolean; message?: string }> {
try {
// Check if the process is running
const processCheckCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
const { stdout: processStdout } = await execAsync(`powershell -Command "${processCheckCommand}"`)
if (!processStdout.trim()) {
logger.info(`Process with PID ${pid} is not running`)
return { success: true, message: `Process with PID ${pid} is not running` }
}
// Find child processes
const childProcessCommand = `Get-WmiObject -Class Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | Select-Object ProcessId | ConvertTo-Json`
const { stdout: childStdout } = await execAsync(`powershell -Command "${childProcessCommand}"`)
// If there are child processes, terminate them first
if (childStdout.trim()) {
const childProcesses = JSON.parse(childStdout)
const childList = Array.isArray(childProcesses) ? childProcesses : [childProcesses]
logger.info(`Found ${childList.length} child processes for PID ${pid}`)
// Recursively terminate each child process
for (const childProcess of childList) {
const childPid = childProcess.ProcessId
logger.info(`Terminating child process PID: ${childPid}`)
await this.terminalProcess(childPid)
}
} else {
logger.info(`No child processes found for PID ${pid}`)
}
// Finally, terminate the parent process
const killCommand = `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`
await execAsync(`powershell -Command "${killCommand}"`)
logger.info(`Terminated process with PID: ${pid}`)
// Wait for the process to disappear with 5-second timeout
const timeout = 5000 // 5 seconds
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
const checkCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
const { stdout: checkStdout } = await execAsync(`powershell -Command "${checkCommand}"`)
if (!checkStdout.trim()) {
logger.info(`Process with PID ${pid} has disappeared`)
return { success: true, message: `Process ${pid} and all child processes terminated successfully` }
}
// Wait 300ms before checking again
await new Promise((resolve) => setTimeout(resolve, 300))
}
logger.warn(`Process with PID ${pid} did not disappear within timeout`)
return { success: false, message: `Process ${pid} did not disappear within 5 seconds` }
} catch (error) {
logger.error(`Failed to terminate process ${pid}:`, error as Error)
return { success: false, message: `Failed to terminate process ${pid}` }
}
}
/**
* Stop OVMS process if it's running
* @returns Promise<{ success: boolean; message?: string }>
*/
public async stopOvms(): Promise<{ success: boolean; message?: string }> {
try {
// Check if OVMS process is running
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('OVMS process is not running')
return { success: true, message: 'OVMS process is not running' }
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length === 0) {
logger.info('OVMS process is not running')
return { success: true, message: 'OVMS process is not running' }
}
// Terminate all OVMS processes using terminalProcess
for (const process of processList) {
const result = await this.terminalProcess(process.Id)
if (!result.success) {
logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`)
return { success: false, message: `Failed to terminate OVMS process: ${result.message}` }
}
logger.info(`Terminated OVMS process with PID: ${process.Id}`)
}
// Reset the ovms instance
this.ovms = null
logger.info('OVMS process stopped successfully')
return { success: true, message: 'OVMS process stopped successfully' }
} catch (error) {
logger.error(`Failed to stop OVMS process: ${error}`)
return { success: false, message: 'Failed to stop OVMS process' }
}
}
/**
* Run OVMS by ensuring config.json exists and executing run.bat
* @returns Promise<{ success: boolean; message?: string }>
*/
public async runOvms(): Promise<{ success: boolean; message?: string }> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
const runBatPath = path.join(ovmsDir, 'run.bat')
try {
// Check if config.json exists, if not create it with default content
if (!(await fs.pathExists(configPath))) {
logger.info(`Config file does not exist, creating: ${configPath}`)
// Ensure the models directory exists
await fs.ensureDir(path.dirname(configPath))
// Create config.json with default content
const defaultConfig = {
mediapipe_config_list: [],
model_config_list: []
}
await fs.writeJson(configPath, defaultConfig, { spaces: 2 })
logger.info(`Config file created: ${configPath}`)
}
// Check if run.bat exists
if (!(await fs.pathExists(runBatPath))) {
logger.error(`run.bat not found at: ${runBatPath}`)
return { success: false, message: 'run.bat not found' }
}
// Run run.bat without waiting for it to complete
logger.info(`Starting OVMS with run.bat: ${runBatPath}`)
exec(`"${runBatPath}"`, { cwd: ovmsDir }, (error) => {
if (error) {
logger.error(`Error running run.bat: ${error}`)
}
})
logger.info('OVMS started successfully')
return { success: true }
} catch (error) {
logger.error(`Failed to run OVMS: ${error}`)
return { success: false, message: 'Failed to run OVMS' }
}
}
/**
* Get OVMS status - checks installation and running status
* @returns 'not-installed' | 'not-running' | 'running'
*/
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
const homeDir = homedir()
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
try {
// Check if OVMS executable exists
if (!(await fs.pathExists(ovmsPath))) {
logger.info(`OVMS executable not found at: ${ovmsPath}`)
return 'not-installed'
}
// Check if OVMS process is running
//const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq "${ovmsPath.replace(/\\/g, '\\\\')}" } | Select-Object Id | ConvertTo-Json`;
//const { stdout } = await execAsync(`powershell -Command "${psCommand}"`);
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('OVMS process not running')
return 'not-running'
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length > 0) {
logger.info('OVMS process is running')
return 'running'
} else {
logger.info('OVMS process not running')
return 'not-running'
}
} catch (error) {
logger.info(`Failed to check OVMS status: ${error}`)
return 'not-running'
}
}
/**
* Initialize OVMS by finding the executable path and working directory
*/
public async initializeOvms(): Promise<boolean> {
// Use PowerShell to find ovms.exe processes with their paths
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.error('Command to find OVMS process returned no output')
return false
}
logger.debug(`OVMS process output: ${stdout}`)
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
// Find the first process with a valid path
for (const process of processList) {
this.ovms = {
pid: process.Id,
path: process.Path,
workingDirectory: path.dirname(process.Path)
}
return true
}
return this.ovms !== null
}
/**
* Check if the Model Name and ID are valid, they are valid only if they are not used in the config.json
* @param modelName Name of the model to check
* @param modelId ID of the model to check
*/
public async isNameAndIDAvalid(modelName: string, modelId: string): Promise<boolean> {
if (!modelName || !modelId) {
logger.error('Model name and ID cannot be empty')
return false
}
const homeDir = homedir()
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return false
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn(`No mediapipe_config_list found in config: ${configPath}`)
return false
}
// Check if the model name or ID already exists in the config
const exists = config.mediapipe_config_list.some(
(model) => model.name === modelName || model.base_path === modelId
)
if (exists) {
logger.warn(`Model with name "${modelName}" or ID "${modelId}" already exists in the config`)
return false
}
} catch (error) {
logger.error(`Failed to check model existence: ${error}`)
return false
}
return true
}
private async applyModelPath(modelDirPath: string): Promise<boolean> {
const homeDir = homedir()
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
if (!(await fs.pathExists(patchDir))) {
return true
}
const modelId = path.basename(modelDirPath)
// get all sub directories in patchDir
const patchs = await fs.readdir(patchDir)
for (const patch of patchs) {
const fullPatchPath = path.join(patchDir, patch)
if (fs.lstatSync(fullPatchPath).isDirectory()) {
if (modelId.toLowerCase().includes(patch.toLowerCase())) {
// copy all files from fullPath to modelDirPath
try {
const files = await fs.readdir(fullPatchPath)
for (const file of files) {
const srcFile = path.join(fullPatchPath, file)
const destFile = path.join(modelDirPath, file)
await fs.copyFile(srcFile, destFile)
}
} catch (error) {
logger.error(`Failed to copy files from ${fullPatchPath} to ${modelDirPath}: ${error}`)
return false
}
logger.info(`Applied patchs for model ${modelId}`)
return true
}
}
}
return true
}
/**
* Add a model to OVMS by downloading it
* @param modelName Name of the model to add
* @param modelId ID of the model to download
* @param modelSource Model Source: huggingface, hf-mirror and modelscope, default is huggingface
* @param task Task type: text_generation, embedding, rerank, image_generation
*/
public async addModel(
modelName: string,
modelId: string,
modelSource: string,
task: string = 'text_generation'
): Promise<{ success: boolean; message?: string }> {
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
const homeDir = homedir()
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const pathModel = path.join(ovdndDir, 'models', modelId)
try {
// check the ovdnDir+'models'+modelId exist or not
if (await fs.pathExists(pathModel)) {
logger.error(`Model with ID ${modelId} already exists`)
return { success: false, message: 'Model ID already exists!' }
}
// remove the model directory if it exists
if (await fs.pathExists(pathModel)) {
logger.info(`Removing existing model directory: ${pathModel}`)
await fs.remove(pathModel)
}
// Use ovdnd.exe for downloading instead of ovms.exe
const ovdndPath = path.join(ovdndDir, 'ovdnd.exe')
const command =
`"${ovdndPath}" --pull ` +
`--model_repository_path "${ovdndDir}/models" ` +
`--source_model "${modelId}" ` +
`--model_name "${modelName}" ` +
`--target_device GPU ` +
`--task ${task} ` +
`--overwrite_models`
const env: Record<string, string | undefined> = {
...process.env,
OVMS_DIR: ovdndDir,
PYTHONHOME: path.join(ovdndDir, 'python'),
PATH: `${process.env.PATH};${ovdndDir};${path.join(ovdndDir, 'python')}`
}
if (modelSource) {
env.HF_ENDPOINT = modelSource
}
logger.info(`Running command: ${command} from ${modelSource}`)
const { stdout } = await execAsync(command, { env: env, cwd: ovdndDir })
logger.info('Model download completed')
logger.debug(`Command output: ${stdout}`)
} catch (error) {
// remove ovdnDir+'models'+modelId if it exists
if (await fs.pathExists(pathModel)) {
logger.info(`Removing failed model directory: ${pathModel}`)
await fs.remove(pathModel)
}
logger.error(`Failed to add model: ${error}`)
return {
success: false,
message: `Download model ${modelId} failed, please check following items and try it again:<p>- the model id</p><p>- network connection and proxy</p>`
}
}
// Update config file
if (!(await this.updateModelConfig(modelName, modelId))) {
logger.error('Failed to update model config')
return { success: false, message: 'Failed to update model config' }
}
if (!(await this.applyModelPath(pathModel))) {
logger.error('Failed to apply model patchs')
return { success: false, message: 'Failed to apply model patchs' }
}
logger.info(`Model ${modelName} added successfully with ID ${modelId}`)
return { success: true }
}
/**
* Stop the model download process if it's running
* @returns Promise<{ success: boolean; message?: string }>
*/
public async stopAddModel(): Promise<{ success: boolean; message?: string }> {
try {
// Check if ovdnd.exe process is running
const psCommand = `Get-Process -Name "ovdnd" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('ovdnd process is not running')
return { success: true, message: 'Model download process is not running' }
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length === 0) {
logger.info('ovdnd process is not running')
return { success: true, message: 'Model download process is not running' }
}
// Terminate all ovdnd processes
for (const process of processList) {
this.terminalProcess(process.Id)
}
logger.info('Model download process stopped successfully')
return { success: true, message: 'Model download process stopped successfully' }
} catch (error) {
logger.error(`Failed to stop model download process: ${error}`)
return { success: false, message: 'Failed to stop model download process' }
}
}
/**
* check if the model id exists in the OVMS configuration
* @param modelId ID of the model to check
*/
public async checkModelExists(modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return false
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn('No mediapipe_config_list found in config')
return false
}
return config.mediapipe_config_list.some((model) => model.base_path === modelId)
} catch (error) {
logger.error(`Failed to check model existence: ${error}`)
return false
}
}
/**
* Update the model configuration file
*/
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
// Ensure the models directory exists
await fs.ensureDir(path.dirname(configPath))
let config: OvmsConfig
// Read existing config or create new one
if (await fs.pathExists(configPath)) {
config = await fs.readJson(configPath)
} else {
config = { mediapipe_config_list: [] }
}
// Ensure mediapipe_config_list exists
if (!config.mediapipe_config_list) {
config.mediapipe_config_list = []
}
// Add new model config
const newModelConfig: ModelConfig = {
name: modelName,
base_path: modelId
}
// Check if model already exists, if so, update it
const existingIndex = config.mediapipe_config_list.findIndex((model) => model.base_path === modelId)
if (existingIndex >= 0) {
config.mediapipe_config_list[existingIndex] = newModelConfig
logger.info(`Updated existing model config: ${modelName}`)
} else {
config.mediapipe_config_list.push(newModelConfig)
logger.info(`Added new model config: ${modelName}`)
}
// Write config back to file
await fs.writeJson(configPath, config, { spaces: 2 })
logger.info(`Config file updated: ${configPath}`)
} catch (error) {
logger.error(`Failed to update model config: ${error}`)
return false
}
return true
}
/**
* Get all models from OVMS config, filtered for image generation models
* @returns Array of model configurations
*/
public async getModels(): Promise<ModelConfig[]> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return []
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn('No mediapipe_config_list found in config')
return []
}
// Filter models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
const imageGenerationModels = config.mediapipe_config_list.filter((model) => {
const modelName = model.name.toLowerCase()
return (
modelName.startsWith('sd') ||
modelName.startsWith('stable-diffusion') ||
modelName.startsWith('stable diffusion') ||
modelName.startsWith('flux')
)
})
logger.info(`Found ${imageGenerationModels.length} image generation models`)
return imageGenerationModels
} catch (error) {
logger.error(`Failed to get models: ${error}`)
return []
}
}
}
export default OvmsManager

View File

@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import * as fs from 'node:fs'
import { readFile } from 'node:fs/promises'
import os from 'node:os'
@@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
if (entry.isDirectory() && options.includeDirectories) {
const stats = await fs.promises.stat(entryPath)
const externalDirPath = entryPath.replace(/\\/g, '/')
const dirTreeNode: NotesTreeNode = {
id: uuidv4(),
id: createHash('sha1').update(externalDirPath).digest('hex'),
name: entry.name,
treePath: treePath,
externalPath: entryPath,
externalPath: externalDirPath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'folder',
@@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
: `/${nameWithoutExt}`
const externalFilePath = entryPath.replace(/\\/g, '/')
const fileTreeNode: NotesTreeNode = {
id: uuidv4(),
id: createHash('sha1').update(externalFilePath).digest('hex'),
name: name,
treePath: fileTreePath,
externalPath: entryPath,
externalPath: externalFilePath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'file'

View File

@@ -95,7 +95,8 @@ const api = {
},
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
},
devTools: {
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
@@ -285,6 +286,16 @@ const api = {
clearAuthCache: (projectId: string, clientEmail?: string) =>
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
},
ovms: {
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),
getModels: () => ipcRenderer.invoke(IpcChannel.Ovms_GetModels),
isRunning: () => ipcRenderer.invoke(IpcChannel.Ovms_IsRunning),
getStatus: () => ipcRenderer.invoke(IpcChannel.Ovms_GetStatus),
runOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_RunOVMS),
stopOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_StopOVMS)
},
config: {
set: (key: string, value: any, isNotify: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
@@ -350,6 +361,7 @@ const api = {
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
installOvmsBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallOvmsBinary),
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {

View File

@@ -22,6 +22,8 @@ export class AiSdkToChunkAdapter {
private accumulate: boolean | undefined
private isFirstChunk = true
private enableWebSearch: boolean = false
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
constructor(
private onChunk: (chunk: Chunk) => void,
@@ -34,6 +36,17 @@ export class AiSdkToChunkAdapter {
this.enableWebSearch = enableWebSearch || false
}
private markFirstTokenIfNeeded() {
if (this.firstTokenTimestamp === null && this.responseStartTimestamp !== null) {
this.firstTokenTimestamp = Date.now()
}
}
private resetTimingState() {
this.responseStartTimestamp = null
this.firstTokenTimestamp = null
}
/**
* 处理 AI SDK 流结果
* @param aiSdkResult AI SDK 的流结果对象
@@ -61,6 +74,8 @@ export class AiSdkToChunkAdapter {
webSearchResults: [],
reasoningId: ''
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset link converter state at the start of stream
this.isFirstChunk = true
@@ -73,6 +88,7 @@ export class AiSdkToChunkAdapter {
if (this.enableWebSearch) {
const remainingText = flushLinkConverterBuffer()
if (remainingText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: remainingText
@@ -87,6 +103,7 @@ export class AiSdkToChunkAdapter {
}
} finally {
reader.releaseLock()
this.resetTimingState()
}
}
@@ -137,6 +154,7 @@ export class AiSdkToChunkAdapter {
// Only emit chunk if there's text to send
if (finalText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: this.accumulate ? final.text : finalText
@@ -161,16 +179,18 @@ export class AiSdkToChunkAdapter {
break
case 'reasoning-delta':
final.reasoningContent += chunk.text || ''
if (chunk.text) {
this.markFirstTokenIfNeeded()
}
this.onChunk({
type: ChunkType.THINKING_DELTA,
text: final.reasoningContent || '',
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
text: final.reasoningContent || ''
})
break
case 'reasoning-end':
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent
text: final.reasoningContent || ''
})
final.reasoningContent = ''
break
@@ -261,44 +281,37 @@ export class AiSdkToChunkAdapter {
break
}
case 'finish':
case 'finish': {
const usage = {
completion_tokens: chunk.totalUsage?.outputTokens || 0,
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
total_tokens: chunk.totalUsage?.totalTokens || 0
}
const metrics = this.buildMetrics(chunk.totalUsage)
const baseResponse = {
text: final.text || '',
reasoning_content: final.reasoningContent || ''
}
this.onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
}
})
this.onChunk({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
}
})
this.resetTimingState()
break
}
// === 源和文件相关事件 ===
case 'source':
@@ -334,6 +347,34 @@ export class AiSdkToChunkAdapter {
default:
}
}
private buildMetrics(totalUsage?: {
inputTokens?: number | null
outputTokens?: number | null
totalTokens?: number | null
}) {
if (!totalUsage) {
return undefined
}
const completionTokens = totalUsage.outputTokens ?? 0
const now = Date.now()
const start = this.responseStartTimestamp ?? now
const firstToken = this.firstTokenTimestamp
const timeFirstToken = Math.max(firstToken != null ? firstToken - start : 0, 0)
const baseForCompletion = firstToken ?? start
let timeCompletion = Math.max(now - baseForCompletion, 0)
if (timeCompletion === 0 && completionTokens > 0) {
timeCompletion = 1
}
return {
completion_tokens: completionTokens,
time_first_token_millsec: timeFirstToken,
time_completion_millsec: timeCompletion
}
}
}
export default AiSdkToChunkAdapter

View File

@@ -12,6 +12,7 @@ import { VertexAPIClient } from './gemini/VertexAPIClient'
import { NewAPIClient } from './newapi/NewAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { OVMSClient } from './ovms/OVMSClient'
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
@@ -63,6 +64,12 @@ export class ApiClientFactory {
return instance
}
if (provider.id === 'ovms') {
logger.debug(`Creating OVMSClient for provider: ${provider.id}`)
instance = new OVMSClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的 Provider Type
switch (provider.type) {
case 'openai':

View File

@@ -0,0 +1,56 @@
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { objectKeys, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
const logger = loggerService.withContext('OVMSClient')
export class OVMSClient extends OpenAIAPIClient {
constructor(provider: Provider) {
super(provider)
}
override async listModels(): Promise<OpenAI.Models.Model[]> {
try {
const sdk = await this.getSdkInstance()
const chatModelsResponse = await sdk.request({
method: 'get',
path: '../v1/config'
})
logger.debug(`Chat models response: ${JSON.stringify(chatModelsResponse)}`)
// Parse the config response to extract model information
const config = chatModelsResponse as Record<string, any>
const models = objectKeys(config)
.map((modelName) => {
const modelInfo = config[modelName]
// Check if model has at least one version with "AVAILABLE" state
const hasAvailableVersion = modelInfo?.model_version_status?.some(
(versionStatus: any) => versionStatus?.state === 'AVAILABLE'
)
if (hasAvailableVersion) {
return {
id: modelName,
object: 'model' as const,
owned_by: 'ovms',
created: Date.now()
}
}
return null // Skip models without available versions
})
.filter(Boolean) // Remove null entries
logger.debug(`Processed models: ${JSON.stringify(models)}`)
// Filter out unsupported models
return models.filter((model): model is OpenAI.Models.Model => model !== null && isSupportedModel(model))
} catch (error) {
logger.error(`Error listing OVMS models: ${error}`)
return []
}
}
}

View File

@@ -5,7 +5,6 @@ import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { Assistant } from '@renderer/types'
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
import reasoningTimePlugin from './reasoningTimePlugin'
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
import { createTelemetryPlugin } from './telemetryPlugin'
@@ -39,9 +38,9 @@ export function buildPlugins(
}
// 3. 推理模型时添加推理插件
if (middlewareConfig.enableReasoning) {
plugins.push(reasoningTimePlugin)
}
// if (middlewareConfig.enableReasoning) {
// plugins.push(reasoningTimePlugin)
// }
// 4. 启用Prompt工具调用时添加工具插件
if (middlewareConfig.isPromptToolUse) {

View File

@@ -18,7 +18,7 @@ import { loggerService } from '@renderer/services/LoggerService'
import store from '@renderer/store'
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, isEmpty } from 'lodash'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { getAiSdkProviderId } from './factory'
@@ -120,7 +120,7 @@ export function providerToAiSdkConfig(
// 构建基础配置
const baseConfig = {
baseURL: actualProvider.apiHost,
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
// 处理OpenAI模式
@@ -195,7 +195,10 @@ export function providerToAiSdkConfig(
} else if (baseConfig.baseURL.endsWith('/v1')) {
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
}
baseConfig.baseURL = isEmpty(baseConfig.baseURL) ? '' : baseConfig.baseURL
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
}
}
// 如果AI SDK支持该provider使用原生配置

View File

@@ -18,12 +18,13 @@ export const knowledgeSearchTool = (
) => {
return tool({
name: 'builtin_knowledge_search',
description: `Search the knowledge base for relevant information using pre-analyzed search intent.
description: `Knowledge base search tool for retrieving information from user's private knowledge base. This searches your local collection of documents, web content, notes, and other materials you have stored.
Pre-extracted search queries: "${extractedKeywords.question.join(', ')}"
Rewritten query: "${extractedKeywords.rewrite}"
This tool has been configured with search parameters based on the conversation context:
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}
- Query rewrite: "${extractedKeywords.rewrite}"
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
You can use this tool as-is, or provide additionalContext to refine the search focus within the knowledge base.`,
inputSchema: z.object({
additionalContext: z

View File

@@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = (
return tool({
name: 'builtin_web_search',
description: `Search the web and return citable sources using pre-analyzed search intent.
description: `Web search tool for finding current information, news, and real-time data from the internet.
Pre-extracted search keywords: "${extractedKeywords.question.join(', ')}"${
extractedKeywords.links
This tool has been configured with search parameters based on the conversation context:
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}${
extractedKeywords.links?.length
? `
Relevant links: ${extractedKeywords.links.join(', ')}`
- Relevant URLs: ${extractedKeywords.links.join(', ')}`
: ''
}
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
You can use this tool as-is to search with the prepared queries, or provide additionalContext to refine or replace the search terms.`,
inputSchema: z.object({
additionalContext: z

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -75,10 +75,15 @@ export interface CodeEditorProps {
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor is editable.
* Whether the editor view is editable.
* @default true
*/
editable?: boolean
/**
* Set the editor state to read only but keep some user interactions, e.g., keymaps.
* @default false
*/
readOnly?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
@@ -114,6 +119,7 @@ const CodeEditor = ({
style,
className,
editable = true,
readOnly = false,
expanded = true,
wrapped = true
}: CodeEditorProps) => {
@@ -189,6 +195,7 @@ const CodeEditor = ({
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
editable={editable}
readOnly={readOnly}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}

View File

@@ -1,3 +1,4 @@
import { cn } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
@@ -17,6 +18,10 @@ export interface HorizontalScrollContainerProps {
dependencies?: readonly unknown[]
scrollDistance?: number
className?: string
classNames?: {
container?: string
content?: string
}
gap?: string
expandable?: boolean
}
@@ -26,6 +31,7 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
dependencies = [],
scrollDistance = 200,
className,
classNames,
gap = '8px',
expandable = false
}) => {
@@ -95,11 +101,16 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
return (
<Container
className={className}
className={cn(className, classNames?.container)}
$expandable={expandable}
$disableHoverButton={isScrolledToEnd}
onClick={expandable ? handleContainerClick : undefined}>
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
<ScrollContent
ref={scrollRef}
$gap={gap}
$isExpanded={isExpanded}
$expandable={expandable}
className={cn(classNames?.content)}>
{children}
</ScrollContent>
{canScroll && !isExpanded && !isScrolledToEnd && (

View File

@@ -38,6 +38,7 @@ interface PopupContainerProps {
message?: Message
messages?: Message[]
topic?: Topic
rawContent?: string
}
// 转换文件信息数组为树形结构
@@ -140,7 +141,8 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
resolve,
message,
messages,
topic
topic,
rawContent
}) => {
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
const [state, setState] = useState({
@@ -229,7 +231,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
return
}
let markdown = ''
if (topic) {
if (rawContent) {
markdown = rawContent
} else if (topic) {
markdown = await topicToMarkdown(topic, exportReasoning)
} else if (messages && messages.length > 0) {
markdown = messagesToMarkdown(messages, exportReasoning)
@@ -299,7 +303,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
}
}
}
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
@@ -410,9 +413,11 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
</Option>
</Select>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
{!rawContent && (
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
)}
</Form>
</Modal>
)

View File

@@ -9,6 +9,7 @@ interface ObsidianExportOptions {
topic?: Topic
message?: Message
messages?: Message[]
rawContent?: string
}
export default class ObsidianExportPopup {
@@ -24,6 +25,7 @@ export default class ObsidianExportPopup {
topic={options.topic}
message={options.message}
messages={options.messages}
rawContent={options.rawContent}
obsidianTags={''}
open={true}
resolve={(v) => {

View File

@@ -253,12 +253,39 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
let savedCount = 0
try {
// Validate knowledge base configuration before proceeding
if (!selectedBaseId) {
throw new Error('No knowledge base selected')
}
const selectedBase = bases.find((base) => base.id === selectedBaseId)
if (!selectedBase) {
throw new Error('Selected knowledge base not found')
}
if (!selectedBase.version) {
throw new Error('Knowledge base is not properly configured. Please check the knowledge base settings.')
}
if (isNoteMode) {
const note = source.data as NotesTreeNode
const content = note.externalPath
? await window.api.file.readExternal(note.externalPath)
: await window.api.file.read(note.id + '.md')
logger.debug('Note content:', content)
if (!note.externalPath) {
throw new Error('Note external path is required for export')
}
let content = ''
try {
content = await window.api.file.readExternal(note.externalPath)
} catch (error) {
logger.error('Failed to read note file:', error as Error)
throw new Error('Failed to read note content. Please ensure the file exists and is accessible.')
}
if (!content || content.trim() === '') {
throw new Error('Note content is empty. Cannot export empty notes to knowledge base.')
}
logger.debug('Note content loaded', { contentLength: content.length })
await addNote(content)
savedCount = 1
} else {
@@ -283,9 +310,23 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
resolve({ success: true, savedCount })
} catch (error) {
logger.error('save failed:', error as Error)
window.toast.error(
t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed')
// Provide more specific error messages
let errorMessage = t(
isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed'
)
if (error instanceof Error) {
if (error.message.includes('not properly configured')) {
errorMessage = error.message
} else if (error.message.includes('empty')) {
errorMessage = error.message
} else if (error.message.includes('read note content')) {
errorMessage = error.message
}
}
window.toast.error(errorMessage)
setLoading(false)
}
}

View File

@@ -55,12 +55,15 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
footer={null}>
{extension !== undefined ? (
<Editor
editable={false}
readOnly={true}
expanded={false}
height="100%"
style={{ height: '100%' }}
value={text}
language={extension}
options={{
keymap: true
}}
/>
) : (
<Text>{text}</Text>

View File

@@ -48,7 +48,8 @@ const RichEditor = ({
enableContentSearch = false,
isFullWidth = false,
fontFamily = 'default',
fontSize = 16
fontSize = 16,
enableSpellCheck = false
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
// Use the rich editor hook for complete editor management
@@ -71,6 +72,7 @@ const RichEditor = ({
onBlur,
placeholder,
editable,
enableSpellCheck,
scrollParent: () => scrollContainerRef.current,
onShowTableActionMenu: ({ position, actions }) => {
const iconMap: Record<string, React.ReactNode> = {

View File

@@ -14,6 +14,31 @@ export const RichEditorWrapper = styled.div<{
border-radius: 6px;
background: var(--color-background);
overflow-y: hidden;
.ProseMirror table,
.tiptap table {
table-layout: auto !important;
}
.ProseMirror table th,
.ProseMirror table td,
.tiptap th,
.tiptap td {
white-space: normal !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
overflow: visible !important;
text-overflow: clip !important;
}
.ProseMirror table th > *,
.ProseMirror table td > *,
.tiptap td > *,
.tiptap th > * {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
}
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
@@ -21,6 +46,7 @@ export const RichEditorWrapper = styled.div<{
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
`
export const ToolbarWrapper = styled.div`

View File

@@ -50,6 +50,8 @@ export interface RichEditorProps {
fontFamily?: 'default' | 'serif'
/** Font size in pixels */
fontSize?: number
/** Whether to enable spell check */
enableSpellCheck?: boolean
}
export interface ToolbarItem {

View File

@@ -57,6 +57,8 @@ export interface UseRichEditorOptions {
editable?: boolean
/** Whether to enable table of contents functionality */
enableTableOfContents?: boolean
/** Whether to enable spell check */
enableSpellCheck?: boolean
/** Show table action menu (row/column) with concrete actions and position */
onShowTableActionMenu?: (payload: {
type: 'row' | 'column'
@@ -126,6 +128,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
previewLength = 50,
placeholder = '',
editable = true,
enableSpellCheck = false,
onShowTableActionMenu,
scrollParent
} = options
@@ -410,7 +413,9 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// Allow text selection even when not editable
style: editable
? ''
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;'
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;',
// Set spellcheck attribute on the contenteditable element
spellcheck: enableSpellCheck ? 'true' : 'false'
}
},
onUpdate: ({ editor }) => {

View File

@@ -237,7 +237,17 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
onSortEnd={onSortEnd}
className="tabs-sortable"
renderItem={(tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<Tab
key={tab.id}
active={tab.id === activeTabId}
onClick={() => handleTabClick(tab)}
onAuxClick={(e) => {
if (e.button === 1 && tab.id !== 'home') {
e.preventDefault()
e.stopPropagation()
closeTab(tab.id)
}
}}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>

View File

@@ -260,6 +260,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
],
ovms: [],
ollama: [],
lmstudio: [],
silicon: [
@@ -429,6 +430,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
}
],
anthropic: [
{
id: 'claude-sonnet-4-5-20250929',
provider: 'anthropic',
name: 'Claude Sonnet 4.5',
group: 'Claude 4.5'
},
{
id: 'claude-sonnet-4-20250514',
provider: 'anthropic',
@@ -697,6 +704,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
name: 'GLM-4.5-Flash',
group: 'GLM-4.5'
},
{
id: 'glm-4.6',
provider: 'zhipu',
name: 'GLM-4.6',
group: 'GLM-4.6'
},
{
id: 'glm-4.5',
provider: 'zhipu',
@@ -1804,5 +1817,19 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
provider: 'aionly',
group: 'gemini'
}
],
longcat: [
{
id: 'LongCat-Flash-Chat',
name: 'LongCat Flash Chat',
provider: 'longcat',
group: 'LongCat'
},
{
id: 'LongCat-Flash-Thinking',
name: 'LongCat Flash Thinking',
provider: 'longcat',
group: 'LongCat'
}
]
}

View File

@@ -61,6 +61,7 @@ import ChatGPTImageModelLogo from '@renderer/assets/images/models/gpt_image_1.pn
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
import GPT5ModelLogo from '@renderer/assets/images/models/gpt-5.png'
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
import GPT5CodexModelLogo from '@renderer/assets/images/models/gpt-5-codex.png'
import GPT5MiniModelLogo from '@renderer/assets/images/models/gpt-5-mini.png'
import GPT5NanoModelLogo from '@renderer/assets/images/models/gpt-5-nano.png'
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
@@ -162,6 +163,7 @@ export function getModelLogo(modelId: string) {
return undefined
}
// key is regex
const logoMap = {
pixtral: isLight ? PixtralModelLogo : PixtralModelLogoDark,
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
@@ -177,6 +179,7 @@ export function getModelLogo(modelId: string) {
'gpt-5-mini': GPT5MiniModelLogo,
'gpt-5-nano': GPT5NanoModelLogo,
'gpt-5-chat': GPT5ChatModelLogo,
'gpt-5-codex': GPT5CodexModelLogo,
'gpt-5': GPT5ModelLogo,
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
@@ -286,7 +289,7 @@ export function getModelLogo(modelId: string) {
longcat: LongCatAppLogo,
bytedance: BytedanceModelLogo,
'(V_1|V_1_TURBO|V_2|V_2A|V_2_TURBO|DESCRIBE|UPSCALE)': IdeogramModelLogo
}
} as const
for (const key in logoMap) {
const regex = new RegExp(key, 'i')

View File

@@ -22,6 +22,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
default: ['low', 'medium', 'high'] as const,
o: ['low', 'medium', 'high'] as const,
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
gpt5_codex: ['low', 'medium', 'high'] as const,
grok: ['low', 'high'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
@@ -40,6 +41,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
@@ -55,8 +57,13 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
export const getThinkModelType = (model: Model): ThinkingModelType => {
let thinkingModelType: ThinkingModelType = 'default'
const modelId = getLowerBaseModelName(model.id)
if (isGPT5SeriesModel(model)) {
thinkingModelType = 'gpt5'
if (modelId.includes('codex')) {
thinkingModelType = 'gpt5_codex'
} else {
thinkingModelType = 'gpt5'
}
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
@@ -171,9 +178,13 @@ export function isGeminiReasoningModel(model?: Model): boolean {
return false
}
// Gemini 支持思考模式的模型正则
export const GEMINI_THINKING_MODEL_REGEX =
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
if (modelId.includes('gemini-2.5')) {
if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
if (modelId.includes('image') || modelId.includes('tts')) {
return false
}
@@ -328,14 +339,20 @@ export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean
export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
return modelId.includes('glm-4.5')
return ['glm-4.5', 'glm-4.6'].some((id) => modelId.includes(id))
}
export const isDeepSeekHybridInferenceModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
// deepseek官方使用chat和reasoner做推理控制其他provider需要单独判断id可能会有所差别
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型这里有风险
return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
// until the end of the string.
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
// TODO: move to utils and add test cases
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
}
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel

View File

@@ -12,6 +12,7 @@ const visionAllowedModels = [
'gemini-1\\.5',
'gemini-2\\.0',
'gemini-2\\.5',
'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp',
'claude-3',
'claude-sonnet-4',
@@ -21,7 +22,9 @@ const visionAllowedModels = [
'qwen-vl',
'qwen2-vl',
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni',
'qvq',
'internvl2',
'grok-vision-beta',

View File

@@ -11,9 +11,12 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
'i'
)
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
export const GEMINI_SEARCH_REGEX = new RegExp(
'gemini-(?:2.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
'i'
)
export const PERPLEXITY_SEARCH_MODELS = [
'sonar-pro',

View File

@@ -24,9 +24,11 @@ import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
import IntelOvmsLogo from '@renderer/assets/images/providers/intel.png'
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png'
import LongCatProviderLogo from '@renderer/assets/images/providers/longcat.png'
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
@@ -108,6 +110,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
isSystem: true,
enabled: false
},
ovms: {
id: 'ovms',
name: 'OpenVINO Model Server',
type: 'openai',
apiKey: '',
apiHost: 'http://localhost:8000/v3/',
models: SYSTEM_MODELS.ovms,
isSystem: true,
enabled: false
},
ocoolai: {
id: 'ocoolai',
name: 'ocoolAI',
@@ -622,6 +634,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
models: SYSTEM_MODELS['poe'],
isSystem: true,
enabled: false
},
longcat: {
id: 'longcat',
name: 'LongCat',
type: 'openai',
apiKey: '',
apiHost: 'https://api.longcat.chat/openai',
models: SYSTEM_MODELS.longcat,
isSystem: true,
enabled: false
}
} as const
@@ -638,6 +660,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
yi: ZeroOneProviderLogo,
groq: GroqProviderLogo,
zhipu: ZhipuProviderLogo,
ovms: IntelOvmsLogo,
ollama: OllamaProviderLogo,
lmstudio: LMStudioProviderLogo,
moonshot: MoonshotProviderLogo,
@@ -684,7 +707,8 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
'new-api': NewAPIProviderLogo,
'aws-bedrock': AwsProviderLogo,
poe: 'poe', // use svg icon component
aionly: AiOnlyProviderLogo
aionly: AiOnlyProviderLogo,
longcat: LongCatProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@@ -1022,6 +1046,16 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
models: 'https://console.groq.com/docs/models'
}
},
ovms: {
api: {
url: 'http://localhost:8000/v3/'
},
websites: {
official: 'https://www.intel.com/content/www/us/en/developer/tools/openvino-toolkit/overview.html',
docs: 'https://docs.openvino.ai/2025/model-server/ovms_what_is_openvino_model_server.html',
models: 'https://www.modelscope.cn/organization/OpenVINO'
}
},
ollama: {
api: {
url: 'http://localhost:11434'
@@ -1290,6 +1324,17 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
docs: 'https://www.aiionly.com/document',
models: 'https://www.aiionly.com'
}
},
longcat: {
api: {
url: 'https://api.longcat.chat/openai'
},
websites: {
official: 'https://longcat.chat',
apiKey: 'https://longcat.chat/platform/api_keys',
docs: 'https://longcat.chat/platform/docs/zh/',
models: 'https://longcat.chat/platform/docs/zh/APIDocs.html'
}
}
}

View File

@@ -7,7 +7,6 @@ import {
} from '@renderer/types'
// Import necessary types for blocks and new message structure
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
import { NotesTreeNode } from '@renderer/types/note'
import { Dexie, type EntityTable } from 'dexie'
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
@@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', {
quick_phrases: EntityTable<QuickPhrase, 'id'>
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
}
db.version(1).stores({
@@ -118,8 +116,7 @@ db.version(10).stores({
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
translate_languages: '&id, langCode',
quick_phrases: 'id',
message_blocks: 'id, messageId, file.id',
notes_tree: '&id'
message_blocks: 'id, messageId, file.id'
})
export default db

View File

@@ -108,7 +108,11 @@ export const useCodeTools = () => {
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
// 检查是否可以启动(所有必需字段都已填写)
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
const canLaunch = Boolean(
codeToolsState.selectedCliTool &&
codeToolsState.currentDirectory &&
(codeToolsState.selectedCliTool === codeTools.githubCopilotCli || selectedModel)
)
return {
// 状态

View File

@@ -48,6 +48,17 @@ export function useActiveTopic(assistantId: string, topic?: Topic) {
}
}, [activeTopic?.id, assistant])
useEffect(() => {
if (!assistant?.topics?.length || !activeTopic) {
return
}
const latestTopic = assistant.topics.find((item) => item.id === activeTopic.id)
if (latestTopic && latestTopic !== activeTopic) {
setActiveTopic(latestTopic)
}
}, [assistant?.topics, activeTopic])
return { activeTopic, setActiveTopic }
}

View File

@@ -61,6 +61,7 @@ const providerKeyMap = {
nvidia: 'provider.nvidia',
o3: 'provider.o3',
ocoolai: 'provider.ocoolai',
ovms: 'provider.ovms',
ollama: 'provider.ollama',
openai: 'provider.openai',
openrouter: 'provider.openrouter',

View File

@@ -251,6 +251,7 @@
"added": "Added",
"case_sensitive": "Case Sensitive",
"collapse": "Collapse",
"download": "Download",
"includes_user_questions": "Include Your Questions",
"manage": "Manage",
"select_model": "Select Model",
@@ -1696,6 +1697,12 @@
"provider_settings": "Go to provider settings"
},
"notes": {
"auto_rename": {
"empty_note": "Note is empty, cannot generate name",
"failed": "Failed to generate note name",
"label": "Generate Note Name",
"success": "Note name generated successfully"
},
"characters": "Characters",
"collapse": "Collapse",
"content_placeholder": "Please enter the note content...",
@@ -1777,6 +1784,8 @@
"sort_updated_asc": "Update time (oldest first)",
"sort_updated_desc": "Update time (newest first)",
"sort_z2a": "File name (Z-A)",
"spell_check": "Spell Check",
"spell_check_tooltip": "Enable/Disable spell check",
"star": "Favorite note",
"starred_notes": "Collected notes",
"title": "Notes",
@@ -1826,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "Install",
"installing": "Installing",
"reinstall": "Re-Install",
"run": "Run OVMS",
"starting": "Starting",
"stop": "Stop OVMS",
"stopping": "Stopping"
},
"description": "<div><p>1. Download OV Models.</p><p>2. Add Models in 'Manager'.</p><p>Support Windows Only!</p><p>OVMS Install Path: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Please refer to <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS Guide</a></p></dev>",
"download": {
"button": "Download",
"error": "Download Error",
"model_id": {
"label": "Model ID:",
"model_id_pattern": "Model ID must start with OpenVINO/",
"placeholder": "Required e.g. OpenVINO/Qwen3-8B-int4-ov",
"required": "Please enter the model ID"
},
"model_name": {
"label": "Model Name:",
"placeholder": "Required e.g. Qwen3-8B-int4-ov",
"required": "Please enter the model name"
},
"model_source": "Model Source:",
"model_task": "Model Task:",
"success": "Download successful",
"success_desc": "Model \"{{modelName}}\"-\"{{modelId}}\" downloaded successfully, please go to the OVMS management interface to add the model",
"tip": "The model is downloading, sometimes it takes hours. Please be patient...",
"title": "Download Intel OpenVINO Model"
},
"failed": {
"install": "Install OVMS failed:",
"install_code_100": "Unknown Error",
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
"install_code_102": "Only supports Windows",
"install_code_103": "Download OVMS runtime failed",
"install_code_104": "Uncompress OVMS runtime failed",
"install_code_105": "Clean OVMS runtime failed",
"run": "Run OVMS failed:",
"stop": "Stop OVMS failed:"
},
"status": {
"not_installed": "OVMS is not installed",
"not_running": "OVMS is not running",
"running": "OVMS is running",
"unknown": "OVMS status unknown"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "Aspect Ratio",
"aspect_ratios": {
@@ -2057,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "已添加",
"case_sensitive": "区分大小写",
"collapse": "收起",
"download": "下载",
"includes_user_questions": "包含用户提问",
"manage": "管理",
"select_model": "选择模型",
@@ -1696,6 +1697,12 @@
"provider_settings": "跳转到服务商设置界面"
},
"notes": {
"auto_rename": {
"empty_note": "笔记为空,无法生成名称",
"failed": "生成笔记名称失败",
"label": "生成笔记名称",
"success": "笔记名称生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "请输入笔记内容...",
@@ -1777,6 +1784,8 @@
"sort_updated_asc": "更新时间(从旧到新)",
"sort_updated_desc": "更新时间(从新到旧)",
"sort_z2a": "文件名Z-A",
"spell_check": "拼写检查",
"spell_check_tooltip": "启用/禁用拼写检查",
"star": "收藏笔记",
"starred_notes": "收藏的笔记",
"title": "笔记",
@@ -1826,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "安装",
"installing": "正在安装",
"reinstall": "重装",
"run": "运行 OVMS",
"starting": "启动中",
"stop": "停止 OVMS",
"stopping": "停止中"
},
"description": "<div><p>1. 下载 OV 模型.</p><p>2. 在 'Manager' 中添加模型.</p><p>仅支持 Windows!</p><p>OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>请参考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
"download": {
"button": "下载",
"error": "选择失败",
"model_id": {
"label": "模型 ID",
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
"placeholder": "必填,例如 OpenVINO/Qwen3-8B-int4-ov",
"required": "请输入模型 ID"
},
"model_name": {
"label": "模型名称",
"placeholder": "必填,例如 Qwen3-8B-int4-ov",
"required": "请输入模型名称"
},
"model_source": "模型来源:",
"model_task": "模型任务:",
"success": "下载成功",
"success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下载成功,请前往 OVMS 管理界面添加模型",
"tip": "模型正在下载,有时需要几个小时。请耐心等待...",
"title": "下载 Intel OpenVINO 模型"
},
"failed": {
"install": "安装 OVMS 失败:",
"install_code_100": "未知错误",
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "仅支持 Windows",
"install_code_103": "下载 OVMS runtime 失败",
"install_code_104": "解压 OVMS runtime 失败",
"install_code_105": "清理 OVMS runtime 失败",
"run": "运行 OVMS 失败:",
"stop": "停止 OVMS 失败:"
},
"status": {
"not_installed": "OVMS 未安装",
"not_running": "OVMS 未运行",
"running": "OVMS 正在运行",
"unknown": "OVMS 状态未知"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "画幅比例",
"aspect_ratios": {
@@ -2057,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8 大模型开放平台",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "已新增",
"case_sensitive": "區分大小寫",
"collapse": "折疊",
"download": "下載",
"includes_user_questions": "包含使用者提問",
"manage": "管理",
"select_model": "選擇模型",
@@ -1696,6 +1697,12 @@
"provider_settings": "跳轉到服務商設置界面"
},
"notes": {
"auto_rename": {
"empty_note": "筆記為空,無法生成名稱",
"failed": "生成筆記名稱失敗",
"label": "生成筆記名稱",
"success": "筆記名稱生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "請輸入筆記內容...",
@@ -1777,6 +1784,8 @@
"sort_updated_asc": "更新時間(從舊到新)",
"sort_updated_desc": "更新時間(從新到舊)",
"sort_z2a": "文件名Z-A",
"spell_check": "拼寫檢查",
"spell_check_tooltip": "啟用/禁用拼寫檢查",
"star": "收藏筆記",
"starred_notes": "收藏的筆記",
"title": "筆記",
@@ -1826,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "安裝",
"installing": "正在安裝",
"reinstall": "重新安裝",
"run": "執行 OVMS",
"starting": "啟動中",
"stop": "停止 OVMS",
"stopping": "停止中"
},
"description": "<div><p>1. 下載 OV 模型。</p><p>2. 在 'Manager' 中新增模型。</p><p>僅支援 Windows</p><p>OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>請參考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
"download": {
"button": "下載",
"error": "選擇失敗",
"model_id": {
"label": "模型 ID",
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
"placeholder": "必填,例如 OpenVINO/Qwen3-8B-int4-ov",
"required": "請輸入模型 ID"
},
"model_name": {
"label": "模型名稱",
"placeholder": "必填,例如 Qwen3-8B-int4-ov",
"required": "請輸入模型名稱"
},
"model_source": "模型來源:",
"model_task": "模型任務:",
"success": "下載成功",
"success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下載成功,請前往 OVMS 管理界面添加模型",
"tip": "模型正在下載,有時需要幾個小時。請耐心等候...",
"title": "下載 Intel OpenVINO 模型"
},
"failed": {
"install": "安裝 OVMS 失敗:",
"install_code_100": "未知錯誤",
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "僅支援 Windows",
"install_code_103": "下載 OVMS runtime 失敗",
"install_code_104": "解壓 OVMS runtime 失敗",
"install_code_105": "清理 OVMS runtime 失敗",
"run": "執行 OVMS 失敗:",
"stop": "停止 OVMS 失敗:"
},
"status": {
"not_installed": "OVMS 未安裝",
"not_running": "OVMS 未執行",
"running": "OVMS 正在執行",
"unknown": "OVMS 狀態未知"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "畫幅比例",
"aspect_ratios": {
@@ -2057,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8 大模型開放平台",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "προστέθηκε",
"case_sensitive": "Διάκριση πεζών/κεφαλαίων",
"collapse": "συμπεριλάβετε",
"download": "Λήψη",
"includes_user_questions": "Περιλαμβάνει ερωτήσεις χρήστη",
"manage": "χειριστείτε",
"select_model": "επιλογή μοντέλου",
@@ -333,6 +334,7 @@
"new_topic": "Νέο θέμα {{Command}}",
"pause": "Παύση",
"placeholder": "Εισάγετε μήνυμα εδώ...",
"placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή",
"send": "Αποστολή",
"settings": "Ρυθμίσεις",
"thinking": {
@@ -1695,6 +1697,12 @@
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
},
"notes": {
"auto_rename": {
"empty_note": "Το σημείωμα είναι κενό, δεν μπορεί να δημιουργηθεί όνομα",
"failed": "Αποτυχία δημιουργίας ονόματος σημείωσης",
"label": "Δημιουργία ονόματος σημείωσης",
"success": "Η δημιουργία του ονόματος σημειώσεων ολοκληρώθηκε με επιτυχία"
},
"characters": "χαρακτήρας",
"collapse": "σύμπτυξη",
"content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...",
@@ -1776,6 +1784,8 @@
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
"sort_z2a": "όνομα αρχείου (Z-A)",
"spell_check": "Έλεγχος ορθογραφίας",
"spell_check_tooltip": "Ενεργοποίηση/Απενεργοποίηση ελέγχου ορθογραφίας",
"star": "Αγαπημένες σημειώσεις",
"starred_notes": "Σημειώσεις συλλογής",
"title": "σημειώσεις",
@@ -1825,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "Εγκατάσταση",
"installing": "Εγκατάσταση σε εξέλιξη",
"reinstall": "Επανεγκατάσταση",
"run": "Εκτέλεση OVMS",
"starting": "Εκκίνηση σε εξέλιξη",
"stop": "Διακοπή OVMS",
"stopping": "Διακοπή σε εξέλιξη"
},
"description": "<div><p>1. Λήψη μοντέλου OV.</p><p>2. Προσθήκη μοντέλου στο 'Manager'.</p><p>Υποστηρίζεται μόνο στα Windows!</p><p>Διαδρομή εγκατάστασης OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Ανατρέξτε στον <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Οδηγό Intel OVMS</a></p></div>",
"download": {
"button": "Λήψη",
"error": "Η επιλογή απέτυχε",
"model_id": {
"label": "Αναγνωριστικό μοντέλου:",
"model_id_pattern": "Το αναγνωριστικό μοντέλου πρέπει να ξεκινά με OpenVINO/",
"placeholder": "Απαιτείται, π.χ. OpenVINO/Qwen3-8B-int4-ov",
"required": "Παρακαλώ εισάγετε το αναγνωριστικό μοντέλου"
},
"model_name": {
"label": "Όνομα μοντέλου:",
"placeholder": "Απαιτείται, π.χ. Qwen3-8B-int4-ov",
"required": "Παρακαλώ εισάγετε το όνομα του μοντέλου"
},
"model_source": "Πηγή μοντέλου:",
"model_task": "Εργασία μοντέλου:",
"success": "Η λήψη ολοκληρώθηκε με επιτυχία",
"success_desc": "Το μοντέλο \"{{modelName}}\"-\"{{modelId}}\" λήφθηκε επιτυχώς, παρακαλώ μεταβείτε στη διεπαφή διαχείρισης OVMS για να προσθέσετε το μοντέλο",
"tip": "Το μοντέλο κατεβαίνει, μερικές φορές χρειάζονται αρκετές ώρες. Παρακαλώ περιμένετε υπομονετικά...",
"title": "Λήψη μοντέλου Intel OpenVINO"
},
"failed": {
"install": "Η εγκατάσταση του OVMS απέτυχε:",
"install_code_100": "Άγνωστο σφάλμα",
"install_code_101": "Υποστηρίζεται μόνο σε Intel(R) Core(TM) Ultra CPU",
"install_code_102": "Υποστηρίζεται μόνο στα Windows",
"install_code_103": "Η λήψη του OVMS runtime απέτυχε",
"install_code_104": "Η αποσυμπίεση του OVMS runtime απέτυχε",
"install_code_105": "Ο καθαρισμός του OVMS runtime απέτυχε",
"run": "Η εκτέλεση του OVMS απέτυχε:",
"stop": "Η διακοπή του OVMS απέτυχε:"
},
"status": {
"not_installed": "Το OVMS δεν έχει εγκατασταθεί",
"not_running": "Το OVMS δεν εκτελείται",
"running": "Το OVMS εκτελείται",
"unknown": "Άγνωστη κατάσταση OVMS"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "Λόγος διαστάσεων",
"aspect_ratios": {
@@ -2056,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "Πλατφόρμα Ανοιχτής Μεγάλης Μοντέλου PH8",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "Agregado",
"case_sensitive": "Distingue mayúsculas y minúsculas",
"collapse": "Colapsar",
"download": "Descargar",
"includes_user_questions": "Incluye preguntas del usuario",
"manage": "Administrar",
"select_model": "Seleccionar Modelo",
@@ -333,6 +334,7 @@
"new_topic": "Nuevo tema {{Command}}",
"pause": "Pausar",
"placeholder": "Escribe aquí tu mensaje...",
"placeholder_without_triggers": "Escriba un mensaje aquí y presione {{key}} para enviar",
"send": "Enviar",
"settings": "Configuración",
"thinking": {
@@ -1695,6 +1697,12 @@
"provider_settings": "Ir a la configuración del proveedor"
},
"notes": {
"auto_rename": {
"empty_note": "La nota está vacía, no se puede generar un nombre",
"failed": "Error al generar el nombre de la nota",
"label": "Generar nombre de nota",
"success": "Se ha generado correctamente el nombre de la nota"
},
"characters": "carácter",
"collapse": "ocultar",
"content_placeholder": "Introduzca el contenido de la nota...",
@@ -1776,6 +1784,8 @@
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
"sort_z2a": "Nombre de archivo (Z-A)",
"spell_check": "comprobación ortográfica",
"spell_check_tooltip": "Habilitar/deshabilitar revisión ortográfica",
"star": "Notas guardadas",
"starred_notes": "notas guardadas",
"title": "notas",
@@ -1825,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "Instalar",
"installing": "Instalando",
"reinstall": "Reinstalar",
"run": "Ejecutar OVMS",
"starting": "Iniciando",
"stop": "Detener OVMS",
"stopping": "Deteniendo"
},
"description": "<div><p>1. Descargar modelo OV.</p><p>2. Agregar modelo en 'Administrador'.</p><p>¡Solo compatible con Windows!</p><p>Ruta de instalación de OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Consulte la <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guía de Intel OVMS</a></p></dev>",
"download": {
"button": "Descargar",
"error": "Selección fallida",
"model_id": {
"label": "ID del modelo:",
"model_id_pattern": "El ID del modelo debe comenzar con OpenVINO/",
"placeholder": "Requerido, por ejemplo, OpenVINO/Qwen3-8B-int4-ov",
"required": "Por favor, ingrese el ID del modelo"
},
"model_name": {
"label": "Nombre del modelo:",
"placeholder": "Requerido, por ejemplo, Qwen3-8B-int4-ov",
"required": "Por favor, ingrese el nombre del modelo"
},
"model_source": "Fuente del modelo:",
"model_task": "Tarea del modelo:",
"success": "Descarga exitosa",
"success_desc": "El modelo \"{{modelName}}\"-\"{{modelId}}\" se descargó exitosamente, por favor vaya a la interfaz de administración de OVMS para agregar el modelo",
"tip": "El modelo se está descargando, a veces toma varias horas. Por favor espere pacientemente...",
"title": "Descargar modelo Intel OpenVINO"
},
"failed": {
"install": "Error al instalar OVMS:",
"install_code_100": "Error desconocido",
"install_code_101": "Solo compatible con CPU Intel(R) Core(TM) Ultra",
"install_code_102": "Solo compatible con Windows",
"install_code_103": "Error al descargar el tiempo de ejecución de OVMS",
"install_code_104": "Error al descomprimir el tiempo de ejecución de OVMS",
"install_code_105": "Error al limpiar el tiempo de ejecución de OVMS",
"run": "Error al ejecutar OVMS:",
"stop": "Error al detener OVMS:"
},
"status": {
"not_installed": "OVMS no instalado",
"not_running": "OVMS no está en ejecución",
"running": "OVMS en ejecución",
"unknown": "Estado de OVMS desconocido"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "Relación de aspecto",
"aspect_ratios": {
@@ -2056,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplejidad",
"ph8": "Plataforma Abierta de Grandes Modelos PH8",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "Ajouté",
"case_sensitive": "Respecter la casse",
"collapse": "Réduire",
"download": "Télécharger",
"includes_user_questions": "Inclure les questions de l'utilisateur",
"manage": "Gérer",
"select_model": "Sélectionner le Modèle",
@@ -333,6 +334,7 @@
"new_topic": "Nouveau sujet {{Command}}",
"pause": "Pause",
"placeholder": "Entrez votre message ici...",
"placeholder_without_triggers": "Entrez votre message ici, appuyez sur {{key}} pour envoyer",
"send": "Envoyer",
"settings": "Paramètres",
"thinking": {
@@ -1695,6 +1697,12 @@
"provider_settings": "Aller aux paramètres du fournisseur"
},
"notes": {
"auto_rename": {
"empty_note": "La note est vide, impossible de générer un nom",
"failed": "Échec de la génération du nom de note",
"label": "Générer un nom de note",
"success": "La génération du nom de note a réussi"
},
"characters": "caractère",
"collapse": "réduire",
"content_placeholder": "Veuillez saisir le contenu de la note...",
@@ -1776,6 +1784,8 @@
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
"sort_z2a": "Nom de fichier (Z-A)",
"spell_check": "Vérification orthographique",
"spell_check_tooltip": "Activer/Désactiver la vérification orthographique",
"star": "Notes enregistrées",
"starred_notes": "notes de collection",
"title": "notes",
@@ -1825,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "Installer",
"installing": "Installation en cours",
"reinstall": "Réinstaller",
"run": "Exécuter OVMS",
"starting": "Démarrage en cours",
"stop": "Arrêter OVMS",
"stopping": "Arrêt en cours"
},
"description": "<div><p>1. Télécharger le modèle OV.</p><p>2. Ajouter le modèle dans 'Manager'.</p><p>Uniquement compatible avec Windows !</p><p>Chemin d'installation d'OVMS : '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Veuillez vous référer au <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guide Intel OVMS</a></p></dev>",
"download": {
"button": "Télécharger",
"error": "Échec de la sélection",
"model_id": {
"label": "ID du modèle :",
"model_id_pattern": "L'ID du modèle doit commencer par OpenVINO/",
"placeholder": "Requis, par exemple OpenVINO/Qwen3-8B-int4-ov",
"required": "Veuillez saisir l'ID du modèle"
},
"model_name": {
"label": "Nom du modèle :",
"placeholder": "Requis, par exemple Qwen3-8B-int4-ov",
"required": "Veuillez saisir le nom du modèle"
},
"model_source": "Source du modèle :",
"model_task": "Tâche du modèle :",
"success": "Téléchargement réussi",
"success_desc": "Le modèle \"{{modelName}}\"-\"{{modelId}}\" a été téléchargé avec succès, veuillez vous rendre à l'interface de gestion OVMS pour ajouter le modèle",
"tip": "Le modèle est en cours de téléchargement, cela peut parfois prendre plusieurs heures. Veuillez patienter...",
"title": "Télécharger le modèle Intel OpenVINO"
},
"failed": {
"install": "Échec de l'installation d'OVMS :",
"install_code_100": "Erreur inconnue",
"install_code_101": "Uniquement compatible avec les processeurs Intel(R) Core(TM) Ultra",
"install_code_102": "Uniquement compatible avec Windows",
"install_code_103": "Échec du téléchargement du runtime OVMS",
"install_code_104": "Échec de la décompression du runtime OVMS",
"install_code_105": "Échec du nettoyage du runtime OVMS",
"run": "Échec de l'exécution d'OVMS :",
"stop": "Échec de l'arrêt d'OVMS :"
},
"status": {
"not_installed": "OVMS non installé",
"not_running": "OVMS n'est pas en cours d'exécution",
"running": "OVMS en cours d'exécution",
"unknown": "État d'OVMS inconnu"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "Format d'image",
"aspect_ratios": {
@@ -2056,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexité",
"ph8": "Plateforme ouverte de grands modèles PH8",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "追加済み",
"case_sensitive": "大文字と小文字の区別",
"collapse": "折りたたむ",
"download": "ダウンロード",
"includes_user_questions": "ユーザーからの質問を含む",
"manage": "管理",
"select_model": "モデルを選択",
@@ -333,6 +334,7 @@
"new_topic": "新しいトピック {{Command}}",
"pause": "一時停止",
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください",
"send": "送信",
"settings": "設定",
"thinking": {
@@ -1695,6 +1697,12 @@
"provider_settings": "プロバイダー設定に移動"
},
"notes": {
"auto_rename": {
"empty_note": "ノートが空です。名前を生成できません。",
"failed": "ノート名の生成に失敗しました",
"label": "ノート名の生成",
"success": "ノート名の生成に成功しました"
},
"characters": "文字",
"collapse": "閉じる",
"content_placeholder": "メモの内容を入力してください...",
@@ -1776,6 +1784,8 @@
"sort_updated_asc": "更新日時(古い順)",
"sort_updated_desc": "更新日時(新しい順)",
"sort_z2a": "ファイル名Z-A",
"spell_check": "スペルチェック",
"spell_check_tooltip": "スペルチェックの有効/無効",
"star": "お気に入りのノート",
"starred_notes": "収集したノート",
"title": "ノート",
@@ -1825,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "インストール",
"installing": "インストール中",
"reinstall": "再インストール",
"run": "OVMSを実行",
"starting": "起動中",
"stop": "OVMSを停止",
"stopping": "停止中"
},
"description": "<div><p>1. OVモデルをダウンロードします。</p><p>2. 'マネージャー'でモデルを追加します。</p><p>Windowsのみサポート</p><p>OVMSインストールパス: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>詳細は<a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMSガイド</a>をご参照ください。</p></dev>",
"download": {
"button": "ダウンロード",
"error": "ダウンロードエラー",
"model_id": {
"label": "モデルID",
"model_id_pattern": "モデルIDはOpenVINO/で始まる必要があります",
"placeholder": "必須 例: OpenVINO/Qwen3-8B-int4-ov",
"required": "モデルIDを入力してください"
},
"model_name": {
"label": "モデル名",
"placeholder": "必須 例: Qwen3-8B-int4-ov",
"required": "モデル名を入力してください"
},
"model_source": "モデルソース:",
"model_task": "モデルタスク:",
"success": "ダウンロード成功",
"success_desc": "モデル\"{{modelName}}\"-\"{{modelId}}\"ダウンロード成功、OVMS管理インターフェースに移動してモデルを追加してください",
"tip": "モデルはダウンロードされていますが、時には数時間かかります。我慢してください...",
"title": "Intel OpenVINOモデルをダウンロード"
},
"failed": {
"install": "OVMSのインストールに失敗しました:",
"install_code_100": "不明なエラー",
"install_code_101": "Intel(R) Core(TM) Ultra CPUのみサポート",
"install_code_102": "Windowsのみサポート",
"install_code_103": "OVMSランタイムのダウンロードに失敗しました",
"install_code_104": "OVMSランタイムの解凍に失敗しました",
"install_code_105": "OVMSランタイムのクリーンアップに失敗しました",
"run": "OVMSの実行に失敗しました:",
"stop": "OVMSの停止に失敗しました:"
},
"status": {
"not_installed": "OVMSはインストールされていません",
"not_running": "OVMSは実行されていません",
"running": "OVMSは実行中です",
"unknown": "OVMSのステータスが不明です"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "画幅比例",
"aspect_ratios": {
@@ -2056,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "Adicionado",
"case_sensitive": "Diferenciar maiúsculas e minúsculas",
"collapse": "Recolher",
"download": "Baixar",
"includes_user_questions": "Incluir perguntas do usuário",
"manage": "Gerenciar",
"select_model": "Selecionar Modelo",
@@ -333,6 +334,7 @@
"new_topic": "Novo tópico {{Command}}",
"pause": "Pausar",
"placeholder": "Digite sua mensagem aqui...",
"placeholder_without_triggers": "Digite a mensagem aqui, pressione {{key}} para enviar",
"send": "Enviar",
"settings": "Configurações",
"thinking": {
@@ -1695,6 +1697,12 @@
"provider_settings": "Ir para as configurações do provedor"
},
"notes": {
"auto_rename": {
"empty_note": "A nota está vazia, não é possível gerar um nome",
"failed": "Falha ao gerar o nome da nota",
"label": "Gerar nome da nota",
"success": "Nome da nota gerado com sucesso"
},
"characters": "caractere",
"collapse": "[minimizar]",
"content_placeholder": "Introduza o conteúdo da nota...",
@@ -1776,6 +1784,8 @@
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
"sort_z2a": "Nome do arquivo (Z-A)",
"spell_check": "verificação ortográfica",
"spell_check_tooltip": "Ativar/Desativar verificação ortográfica",
"star": "Notas favoritas",
"starred_notes": "notas salvas",
"title": "nota",
@@ -1825,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "Instalar",
"installing": "Instalando",
"reinstall": "Reinstalar",
"run": "Executar OVMS",
"starting": "Iniciando",
"stop": "Parar OVMS",
"stopping": "Parando"
},
"description": "<div><p>1. Baixe o modelo OV.</p><p>2. Adicione o modelo no 'Gerenciador'.</p><p>Compatível apenas com Windows!</p><p>Caminho de instalação do OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Consulte o <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guia do Intel OVMS</a></p></dev>",
"download": {
"button": "Baixar",
"error": "Falha na seleção",
"model_id": {
"label": "ID do modelo:",
"model_id_pattern": "O ID do modelo deve começar com OpenVINO/",
"placeholder": "Obrigatório, por exemplo, OpenVINO/Qwen3-8B-int4-ov",
"required": "Por favor, insira o ID do modelo"
},
"model_name": {
"label": "Nome do modelo:",
"placeholder": "Obrigatório, por exemplo, Qwen3-8B-int4-ov",
"required": "Por favor, insira o nome do modelo"
},
"model_source": "Fonte do modelo:",
"model_task": "Tarefa do modelo:",
"success": "Download concluído com sucesso",
"success_desc": "O modelo \"{{modelName}}\"-\"{{modelId}}\" foi baixado com sucesso, por favor vá para a interface de gerenciamento OVMS para adicionar o modelo",
"tip": "O modelo está sendo baixado, às vezes leva várias horas. Por favor aguarde pacientemente...",
"title": "Baixar modelo Intel OpenVINO"
},
"failed": {
"install": "Falha na instalação do OVMS:",
"install_code_100": "Erro desconhecido",
"install_code_101": "Compatível apenas com CPU Intel(R) Core(TM) Ultra",
"install_code_102": "Compatível apenas com Windows",
"install_code_103": "Falha ao baixar o tempo de execução do OVMS",
"install_code_104": "Falha ao descompactar o tempo de execução do OVMS",
"install_code_105": "Falha ao limpar o tempo de execução do OVMS",
"run": "Falha ao executar o OVMS:",
"stop": "Falha ao parar o OVMS:"
},
"status": {
"not_installed": "OVMS não instalado",
"not_running": "OVMS não está em execução",
"running": "OVMS em execução",
"unknown": "Status do OVMS desconhecido"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "Proporção da Imagem",
"aspect_ratios": {
@@ -2056,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexidade",
"ph8": "Plataforma Aberta de Grandes Modelos PH8",
"poe": "Poe",

View File

@@ -251,6 +251,7 @@
"added": "Добавлено",
"case_sensitive": "Чувствительность к регистру",
"collapse": "Свернуть",
"download": "Скачать",
"includes_user_questions": "Включает вопросы пользователей",
"manage": "Редактировать",
"select_model": "Выбрать модель",
@@ -333,6 +334,7 @@
"new_topic": "Новый топик {{Command}}",
"pause": "Остановить",
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"placeholder_without_triggers": "Введите сообщение здесь, нажмите {{key}}, чтобы отправить",
"send": "Отправить",
"settings": "Настройки",
"thinking": {
@@ -1695,6 +1697,12 @@
"provider_settings": "Перейти к настройкам поставщика"
},
"notes": {
"auto_rename": {
"empty_note": "Заметки пусты, имя невозможно сгенерировать",
"failed": "Создание названия заметки не удалось",
"label": "Создать название заметки",
"success": "Имя заметки успешно создано"
},
"characters": "Символы",
"collapse": "Свернуть",
"content_placeholder": "Введите содержимое заметки...",
@@ -1776,6 +1784,8 @@
"sort_updated_asc": "Время обновления (от старого к новому)",
"sort_updated_desc": "Время обновления (от нового к старому)",
"sort_z2a": "Имя файла (Я-А)",
"spell_check": "Проверка орфографии",
"spell_check_tooltip": "Включить/отключить проверку орфографии",
"star": "Избранные заметки",
"starred_notes": "Сохраненные заметки",
"title": "заметки",
@@ -1825,6 +1835,57 @@
},
"title": "Ollama"
},
"ovms": {
"action": {
"install": "Установить",
"installing": "Установка",
"reinstall": "Переустановить",
"run": "Запустить OVMS",
"starting": "Запуск",
"stop": "Остановить OVMS",
"stopping": "Остановка"
},
"description": "<div><p>1. Загрузите модели OV.</p><p>2. Добавьте модели в 'Менеджер'.</p><p>Поддерживается только Windows!</p><p>Путь установки OVMS: '%USERPROFILE%\\.cherrystudio\\ovms'.</p><p>Пожалуйста, ознакомьтесь с <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>руководством Intel OVMS</a></p></dev>",
"download": {
"button": "Скачать",
"error": "Ошибка загрузки",
"model_id": {
"label": "ID модели",
"model_id_pattern": "ID модели должен начинаться с OpenVINO/",
"placeholder": "Обязательно, например: OpenVINO/Qwen3-8B-int4-ov",
"required": "Пожалуйста, введите ID модели"
},
"model_name": {
"label": "Название модели:",
"placeholder": "Обязательно, например: Qwen3-8B-int4-ov",
"required": "Пожалуйста, введите название модели"
},
"model_source": "Источник модели:",
"model_task": "Задача модели:",
"success": "Скачивание успешно",
"success_desc": "Модель \"{{modelName}}\"-\"{{modelId}}\" успешно скачана, пожалуйста, перейдите в интерфейс управления OVMS, чтобы добавить модель",
"tip": "Модель загружается, иногда это занимает часы. Пожалуйста, будьте терпеливы...",
"title": "Скачать модель Intel OpenVINO"
},
"failed": {
"install": "Ошибка установки OVMS:",
"install_code_100": "Неизвестная ошибка",
"install_code_101": "Поддерживаются только процессоры Intel(R) Core(TM) Ultra CPU",
"install_code_102": "Поддерживается только Windows",
"install_code_103": "Ошибка загрузки среды выполнения OVMS",
"install_code_104": "Ошибка распаковки среды выполнения OVMS",
"install_code_105": "Ошибка очистки среды выполнения OVMS",
"run": "Ошибка запуска OVMS:",
"stop": "Ошибка остановки OVMS:"
},
"status": {
"not_installed": "OVMS не установлен",
"not_running": "OVMS не запущен",
"running": "OVMS запущен",
"unknown": "Статус OVMS неизвестен"
},
"title": "Intel OVMS"
},
"paintings": {
"aspect_ratio": "Пропорции изображения",
"aspect_ratios": {
@@ -2056,6 +2117,7 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8",
"poe": "Poe",

View File

@@ -98,6 +98,10 @@ const CodeToolsPage: FC = () => {
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === codeTools.githubCopilotCli) {
return false
}
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
@@ -196,7 +200,7 @@ const CodeToolsPage: FC = () => {
}
}
if (!selectedModel) {
if (!selectedModel && selectedCliTool !== codeTools.githubCopilotCli) {
return { isValid: false, message: t('code.model_required') }
}
@@ -205,6 +209,11 @@ const CodeToolsPage: FC = () => {
// 准备启动环境
const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => {
if (selectedCliTool === codeTools.githubCopilotCli) {
const userEnv = parseEnvironmentVariables(environmentVariables)
return userEnv
}
if (!selectedModel) return null
const modelProvider = getProviderByModel(selectedModel)
@@ -229,7 +238,9 @@ const CodeToolsPage: FC = () => {
// 执行启动操作
const executeLaunch = async (env: Record<string, string>) => {
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
const modelId = selectedCliTool === codeTools.githubCopilotCli ? '' : selectedModel?.id!
window.api.codeTools.run(selectedCliTool, modelId, currentDirectory, env, {
autoUpdateToLatest,
terminal: selectedTerminal
})
@@ -316,7 +327,12 @@ const CodeToolsPage: FC = () => {
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
message={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span>{t('code.bun_required_message')}</span>
<Button
type="primary"
@@ -345,46 +361,64 @@ const CodeToolsPage: FC = () => {
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('code.model')}
{selectedCliTool === 'claude-code' && (
<Popover
content={
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{getClaudeSupportedProviders(allProviders).map((provider) => {
return (
<Link
key={provider.id}
style={{ color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 4 }}
to={`/settings/provider?id=${provider.id}`}>
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
{getProviderLabel(provider.id)}
<ArrowUpRight size={14} />
</Link>
)
})}
{selectedCliTool !== codeTools.githubCopilotCli && (
<SettingsItem>
<div className="settings-label">
{t('code.model')}
{selectedCliTool === 'claude-code' && (
<Popover
content={
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8
}}>
{getClaudeSupportedProviders(allProviders).map((provider) => {
return (
<Link
key={provider.id}
style={{
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: 4
}}
to={`/settings/provider?id=${provider.id}`}>
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
{getProviderLabel(provider.id)}
<ArrowUpRight size={14} />
</Link>
)
})}
</div>
</div>
</div>
}
trigger="hover"
placement="right">
<HelpCircle size={14} style={{ color: 'var(--color-text-3)', cursor: 'pointer' }} />
</Popover>
)}
</div>
<ModelSelector
providers={availableProviders}
predicate={modelPredicate}
style={{ width: '100%' }}
placeholder={t('code.model_placeholder')}
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
onChange={handleModelChange}
allowClear
/>
</SettingsItem>
}
trigger="hover"
placement="right">
<HelpCircle
size={14}
style={{
color: 'var(--color-text-3)',
cursor: 'pointer'
}}
/>
</Popover>
)}
</div>
<ModelSelector
providers={availableProviders}
predicate={modelPredicate}
style={{ width: '100%' }}
placeholder={t('code.model_placeholder')}
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
onChange={handleModelChange}
allowClear
/>
</SettingsItem>
)}
<SettingsItem>
<div className="settings-label">{t('code.working_directory')}</div>
@@ -403,11 +437,27 @@ const CodeToolsPage: FC = () => {
options={directories.map((dir) => ({
value: dir,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{dir}
</span>
<X
size={14}
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
style={{
marginLeft: 8,
cursor: 'pointer',
color: '#999'
}}
onClick={(e) => handleRemoveDirectory(dir, e)}
/>
</div>
@@ -429,7 +479,14 @@ const CodeToolsPage: FC = () => {
rows={2}
style={{ fontFamily: 'monospace' }}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>{t('code.env_vars_help')}</div>
<div
style={{
fontSize: 12,
color: 'var(--color-text-3)',
marginTop: 4
}}>
{t('code.env_vars_help')}
</div>
</SettingsItem>
{/* 终端选择 (macOS 和 Windows) */}
@@ -464,7 +521,12 @@ const CodeToolsPage: FC = () => {
selectedTerminal !== terminalApps.cmd &&
selectedTerminal !== terminalApps.powershell &&
selectedTerminal !== terminalApps.windowsTerminal && (
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>
<div
style={{
fontSize: 12,
color: 'var(--color-text-3)',
marginTop: 4
}}>
{terminalCustomPaths[selectedTerminal]
? `${t('code.custom_path')}: ${terminalCustomPaths[selectedTerminal]}`
: t('code.custom_path_required')}

View File

@@ -20,7 +20,8 @@ export const CLI_TOOLS = [
{ value: codeTools.qwenCode, label: 'Qwen Code' },
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' },
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
{ value: codeTools.iFlowCli, label: 'iFlow CLI' },
{ value: codeTools.githubCopilotCli, label: 'GitHub Copilot CLI' }
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
@@ -43,7 +44,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.openaiCodex]: (providers) =>
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai'))
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.githubCopilotCli]: () => []
}
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
@@ -158,6 +160,10 @@ export const generateToolEnvironment = ({
env.IFLOW_BASE_URL = baseUrl
env.IFLOW_MODEL_NAME = model.id
break
case codeTools.githubCopilotCli:
env.GITHUB_TOKEN = apiKey || ''
break
}
return env

View File

@@ -250,21 +250,19 @@ const MentionModelsButton: FC<Props> = ({
// ESC关闭时的处理删除 @ 和搜索文本
if (action === 'esc') {
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
if (
hasModelActionRef.current &&
ctx.triggerInfo?.type === 'input' &&
ctx.triggerInfo?.position !== undefined
) {
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) {
// 基于当前光标 + 搜索词精确定位并删除position 仅作兜底
setText((currentText) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
return removeAtSymbolAndText(currentText, caret, searchText || '', ctx.triggerInfo?.position!)
return removeAtSymbolAndText(currentText, caret, searchText || '', triggerInfo.position!)
})
}
}
// Backspace删除@的情况delete-symbol
// @ 已经被Backspace自然删除面板关闭不需要额外操作
triggerInfoRef.current = undefined
}
})
},

View File

@@ -103,7 +103,8 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
const [showDetailModal, setShowDetailModal] = useState(false)
const { t } = useTranslation()
const onRemoveBlock = () => {
const onRemoveBlock = (e: React.MouseEvent) => {
e.stopPropagation()
setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350)
}

View File

@@ -5,7 +5,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
import { Collapse, message as antdMessage, Tooltip } from 'antd'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -105,30 +105,37 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
const ThinkingTimeSeconds = memo(
({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => {
const { t } = useTranslation()
// const [thinkingTime, setThinkingTime] = useState(blockThinkingTime || 0)
const [displayTime, setDisplayTime] = useState(blockThinkingTime)
// FIXME: 这里统计的和请求处统计的有一定误差
// useEffect(() => {
// let timer: NodeJS.Timeout | null = null
// if (isThinking) {
// timer = setInterval(() => {
// setThinkingTime((prev) => prev + 100)
// }, 100)
// } else if (timer) {
// // 立即清除计时器
// clearInterval(timer)
// timer = null
// }
const timer = useRef<NodeJS.Timeout | null>(null)
// return () => {
// if (timer) {
// clearInterval(timer)
// timer = null
// }
// }
// }, [isThinking])
useEffect(() => {
if (isThinking) {
if (!timer.current) {
timer.current = setInterval(() => {
setDisplayTime((prev) => prev + 100)
}, 100)
}
} else {
if (timer.current) {
clearInterval(timer.current)
timer.current = null
}
setDisplayTime(blockThinkingTime)
}
const thinkingTimeSeconds = useMemo(() => (blockThinkingTime / 1000).toFixed(1), [blockThinkingTime])
return () => {
if (timer.current) {
clearInterval(timer.current)
timer.current = null
}
}
}, [isThinking, blockThinkingTime])
const thinkingTimeSeconds = useMemo(
() => ((displayTime < 1000 ? 100 : displayTime) / 1000).toFixed(1),
[displayTime]
)
return isThinking
? t('chat.thinking', {

View File

@@ -235,13 +235,12 @@ describe('ThinkingBlock', () => {
renderThinkingBlock(thinkingBlock)
const activeTimeText = getThinkingTimeText()
expect(activeTimeText).toHaveTextContent('1.0s')
expect(activeTimeText).toHaveTextContent('Thinking...')
})
it('should handle extreme thinking times correctly', () => {
const testCases = [
{ thinking_millsec: 0, expectedTime: '0.0s' },
{ thinking_millsec: 0, expectedTime: '0.1s' }, // New logic: values < 1000ms display as 0.1s
{ thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
]

View File

@@ -1,4 +1,6 @@
import { cn } from '@heroui/react'
import { loggerService } from '@logger'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useAssistant } from '@renderer/hooks/useAssistant'
@@ -225,20 +227,28 @@ const MessageItem: FC<Props> = ({
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
<MessageFooter className="MessageFooter">
<HorizontalScrollContainer
classNames={{
content: cn(
'flex-1 items-center justify-between',
isLastMessage && messageStyle === 'plain' ? 'flex-row-reverse' : 'flex-row'
)
}}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
</HorizontalScrollContainer>
</MessageFooter>
)}
</>
@@ -282,10 +292,8 @@ const MessageContentContainer = styled(Scrollbar)`
overflow-y: auto;
`
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
const MessageFooter = styled.div`
display: flex;
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
align-items: center;
justify-content: space-between;
gap: 10px;

View File

@@ -337,17 +337,30 @@ const GroupContainer = styled.div`
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
width: 100%;
display: grid;
overflow-y: visible;
gap: 16px;
&.horizontal {
padding-bottom: 4px;
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
overflow-y: hidden;
overflow-x: auto;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
border-radius: var(--scrollbar-thumb-radius);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-thumb-hover);
}
}
&.fold,
&.vertical {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 8px;
overflow-y: auto;
overflow-x: hidden;
}
&.grid {
grid-template-columns: repeat(
@@ -355,11 +368,15 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
minmax(0, 1fr)
);
grid-template-rows: auto;
overflow-y: auto;
overflow-x: hidden;
}
&.multi-select-mode {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
overflow-y: auto;
overflow-x: hidden;
.grid {
height: auto;
}
@@ -385,7 +402,7 @@ interface MessageWrapperProps {
const MessageWrapper = styled.div<MessageWrapperProps>`
&.horizontal {
padding: 1px;
overflow-y: auto;
/* overflow-y: auto; */
.message {
height: 100%;
border: 0.5px solid var(--color-border);
@@ -405,8 +422,9 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
}
}
&.grid {
display: block;
height: 300px;
overflow-y: hidden;
overflow: hidden;
border: 0.5px solid var(--color-border);
border-radius: 10px;
cursor: pointer;

View File

@@ -1,13 +1,13 @@
import { KnowledgeSearchToolInput, KnowledgeSearchToolOutput } from '@renderer/aiCore/tools/KnowledgeSearchTool'
import Spinner from '@renderer/components/Spinner'
import i18n from '@renderer/i18n'
import { MCPToolResponse } from '@renderer/types'
import { NormalToolResponse } from '@renderer/types'
import { Typography } from 'antd'
import { FileSearch } from 'lucide-react'
import styled from 'styled-components'
const { Text } = Typography
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: MCPToolResponse }) {
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: NormalToolResponse }) {
const toolInput = toolResponse.arguments as KnowledgeSearchToolInput
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
@@ -28,7 +28,7 @@ export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse
)
}
export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: MCPToolResponse }) {
export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: NormalToolResponse }) {
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
return toolResponse.status === 'done' ? (

View File

@@ -4,6 +4,7 @@ import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import { MCPToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
@@ -57,7 +58,7 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
const [progress, setProgress] = useState<number>(0)
const { setTimeoutTimer } = useTimer()
const toolResponse = block.metadata?.rawMcpToolResponse
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse
const { id, tool, status, response } = toolResponse!
const isPending = status === 'pending'

View File

@@ -1,6 +1,6 @@
import { MemorySearchToolInput, MemorySearchToolOutput } from '@renderer/aiCore/tools/MemorySearchTool'
import Spinner from '@renderer/components/Spinner'
import { MCPToolResponse } from '@renderer/types'
import { NormalToolResponse } from '@renderer/types'
import { Typography } from 'antd'
import { ChevronRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -8,7 +8,7 @@ import styled from 'styled-components'
const { Text } = Typography
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
const { t } = useTranslation()
const toolInput = toolResponse.arguments as MemorySearchToolInput
const toolOutput = toolResponse.response as MemorySearchToolOutput

View File

@@ -1,4 +1,4 @@
import { MCPToolResponse } from '@renderer/types'
import { NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { Collapse } from 'antd'
@@ -11,8 +11,9 @@ interface Props {
}
const prefix = 'builtin_'
const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => {
const ChooseTool = (toolResponse: NormalToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => {
let toolName = toolResponse.tool.name
const toolType = toolResponse.tool.type
if (toolName.startsWith(prefix)) {
toolName = toolName.slice(prefix.length)
}
@@ -20,10 +21,12 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
switch (toolName) {
case 'web_search':
case 'web_search_preview':
return {
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
body: null
}
return toolType === 'provider'
? null
: {
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
body: null
}
case 'knowledge_search':
return {
label: <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />,
@@ -41,7 +44,7 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
export default function MessageTool({ block }: Props) {
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
const toolResponse = block.metadata?.rawMcpToolResponse
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
if (!toolResponse) return null

View File

@@ -1,6 +1,6 @@
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
import Spinner from '@renderer/components/Spinner'
import { MCPToolResponse } from '@renderer/types'
import { NormalToolResponse } from '@renderer/types'
import { Typography } from 'antd'
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -8,7 +8,7 @@ import styled from 'styled-components'
const { Text } = Typography
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
const { t } = useTranslation()
const toolInput = toolResponse.arguments as WebSearchToolInput
const toolOutput = toolResponse.response as WebSearchToolOutput

View File

@@ -525,6 +525,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onContextMenu={() => setTargetTopic(topic)}
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
onDoubleClick={() => {
if (editingTopicId === topic.id && topicEdit.isEditing) return
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}
style={{
borderRadius,
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
@@ -541,13 +546,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onClick={(e) => e.stopPropagation()}
/>
) : (
<TopicName
className={getTopicNameClassName()}
title={topicName}
onDoubleClick={() => {
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}>
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
</TopicName>
)}
@@ -571,7 +570,8 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
} else {
handleDeleteClick(topic.id, e)
}
}}>
}}
onDoubleClick={(e) => e.stopPropagation()}>
{deletingTopicId === topic.id ? (
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (

View File

@@ -386,6 +386,7 @@ const Container = styled.div`
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
&:hover {
background-color: var(--color-list-item-hover);

View File

@@ -5,24 +5,25 @@ import { HStack } from '@renderer/components/Layout'
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
import { NotesTreeNode } from '@types'
import { Dropdown, Tooltip } from 'antd'
import { findNode } from '@renderer/services/NotesTreeService'
import { Dropdown, Input, Tooltip } from 'antd'
import { t } from 'i18next'
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { menuItems } from './MenuConfig'
const logger = loggerService.withContext('HeaderNavbar')
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const { activeNode } = useActiveNode(notesTree)
const [breadcrumbItems, setBreadcrumbItems] = useState<
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
>([])
const [titleValue, setTitleValue] = useState('')
const titleInputRef = useRef<any>(null)
const { settings, updateSettings } = useNotesSettings()
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
@@ -52,37 +53,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
}, [getCurrentNoteContent])
const handleBreadcrumbClick = useCallback(
async (item: { treePath: string; isFolder: boolean }) => {
if (item.isFolder && notesTree) {
try {
// 获取从根目录到点击目录的所有路径片段
const pathParts = item.treePath.split('/').filter(Boolean)
const expandPromises: Promise<NotesTreeNode>[] = []
// 逐级展开从根到目标路径的所有文件夹
for (let i = 0; i < pathParts.length; i++) {
const currentPath = '/' + pathParts.slice(0, i + 1).join('/')
const folderNode = findNodeByPath(notesTree, currentPath)
if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) {
expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true }))
}
}
// 并行执行所有展开操作
if (expandPromises.length > 0) {
await Promise.all(expandPromises)
logger.info('Expanded folder path from breadcrumb:', {
targetPath: item.treePath,
expandedCount: expandPromises.length
})
}
} catch (error) {
logger.error('Failed to expand folder path from breadcrumb:', error as Error)
}
(item: { treePath: string; isFolder: boolean }) => {
if (item.isFolder && onExpandPath) {
onExpandPath(item.treePath)
}
},
[notesTree]
[onExpandPath]
)
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitleValue(e.target.value)
}, [])
const handleTitleBlur = useCallback(() => {
if (activeNode && titleValue.trim() && titleValue.trim() !== activeNode.name.replace('.md', '')) {
onRenameNode?.(activeNode.id, titleValue.trim())
} else if (activeNode) {
// 如果没有更改或为空,恢复原始值
setTitleValue(activeNode.name.replace('.md', ''))
}
}, [activeNode, titleValue, onRenameNode])
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
titleInputRef.current?.blur()
} else if (e.key === 'Escape') {
e.preventDefault()
if (activeNode) {
setTitleValue(activeNode.name.replace('.md', ''))
}
titleInputRef.current?.blur()
}
},
[activeNode]
)
const buildMenuItem = (item: any) => {
@@ -133,13 +138,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
}
}
// 同步标题值
useEffect(() => {
if (activeNode?.type === 'file') {
setTitleValue(activeNode.name.replace('.md', ''))
}
}, [activeNode])
// 构建面包屑路径
useEffect(() => {
if (!activeNode || !notesTree) {
setBreadcrumbItems([])
return
}
const node = findNodeInTree(notesTree, activeNode.id)
const node = findNode(notesTree, activeNode.id)
if (!node) return
const pathParts = node.treePath.split('/').filter(Boolean)
@@ -179,16 +191,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
</HStack>
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
<BreadcrumbsContainer>
<Breadcrumbs>
{breadcrumbItems.map((item, index) => (
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
<BreadcrumbTitle
onClick={() => handleBreadcrumbClick(item)}
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
{item.title}
</BreadcrumbTitle>
</BreadcrumbItem>
))}
<Breadcrumbs style={{ borderRadius: 0 }}>
{breadcrumbItems.map((item, index) => {
const isLastItem = index === breadcrumbItems.length - 1
const isCurrentNote = isLastItem && !item.isFolder
return (
<BreadcrumbItem key={item.key} isCurrent={isLastItem}>
{isCurrentNote ? (
<TitleInputWrapper>
<TitleInput
ref={titleInputRef}
value={titleValue}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
size="small"
variant="borderless"
style={{
fontSize: 'inherit',
padding: 0,
height: 'auto',
lineHeight: 'inherit'
}}
/>
</TitleInputWrapper>
) : (
<BreadcrumbTitle
onClick={() => handleBreadcrumbClick(item)}
$clickable={item.isFolder && !isLastItem}>
{item.title}
</BreadcrumbTitle>
)}
</BreadcrumbItem>
)
})}
</Breadcrumbs>
</BreadcrumbsContainer>
</NavbarCenter>
@@ -303,6 +340,30 @@ export const BreadcrumbsContainer = styled.div`
align-items: center;
}
/* 最后一个面包屑项(当前笔记)可以扩展 */
& li:last-child {
flex: 1 !important;
min-width: 0 !important;
max-width: none !important;
}
/* 覆盖 HeroUI BreadcrumbItem 的样式 */
& li:last-child [data-slot="item"] {
flex: 1 !important;
width: 100% !important;
max-width: none !important;
}
/* 更强的样式覆盖 */
& li:last-child * {
max-width: none !important;
}
& li:last-child > * {
flex: 1 !important;
width: 100% !important;
}
/* 确保分隔符不会与标题重叠 */
& li:not(:last-child)::after {
flex-shrink: 0;
@@ -330,4 +391,64 @@ export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>`
`}
`
export const TitleInputWrapper = styled.div`
width: 100%;
flex: 1;
min-width: 0;
max-width: none;
display: flex;
align-items: center;
`
export const TitleInput = styled(Input)`
&&& {
border: none !important;
box-shadow: none !important;
background: transparent !important;
color: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
font-family: inherit !important;
padding: 0 !important;
height: auto !important;
line-height: inherit !important;
width: 100% !important;
min-width: 0 !important;
max-width: none !important;
flex: 1 !important;
&:focus,
&:hover {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
&::placeholder {
color: var(--color-text-3) !important;
}
input {
border: none !important;
box-shadow: none !important;
background: transparent !important;
color: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
font-family: inherit !important;
padding: 0 !important;
height: auto !important;
line-height: inherit !important;
width: 100% !important;
&:focus,
&:hover {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
}
}
`
export default HeaderNavbar

View File

@@ -1,11 +1,16 @@
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
import CodeEditor from '@renderer/components/CodeEditor'
import { HSpaceBetweenStack } from '@renderer/components/Layout'
import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import Selector from '@renderer/components/Selector'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setEnableSpellCheck } from '@renderer/store/settings'
import { EditorView } from '@renderer/types'
import { Empty, Spin } from 'antd'
import { Empty, Tooltip } from 'antd'
import { SpellCheck } from 'lucide-react'
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -14,15 +19,16 @@ interface NotesEditorProps {
activeNodeId?: string
currentContent: string
tokenCount: number
isLoading: boolean
editorRef: RefObject<RichEditorRef | null>
onMarkdownChange: (content: string) => void
}
const NotesEditor: FC<NotesEditorProps> = memo(
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const { settings } = useNotesSettings()
const { enableSpellCheck } = useSettings()
const currentViewMode = useMemo(() => {
if (settings.defaultViewMode === 'edit') {
return settings.defaultEditMode
@@ -47,14 +53,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
)
}
if (isLoading) {
return (
<LoadingContainer>
<Spin tip={t('common.loading')} />
</LoadingContainer>
)
}
return (
<>
<RichEditorContainer>
@@ -87,6 +85,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
isFullWidth={settings.isFullWidth}
fontFamily={settings.fontFamily}
fontSize={settings.fontSize}
enableSpellCheck={enableSpellCheck}
/>
)}
</RichEditorContainer>
@@ -101,8 +100,21 @@ const NotesEditor: FC<NotesEditorProps> = memo(
color: 'var(--color-text-3)',
display: 'flex',
alignItems: 'center',
gap: 8
gap: 12
}}>
{tmpViewMode === 'preview' && (
<Tooltip placement="top" title={t('notes.spell_check_tooltip')} mouseLeaveDelay={0} arrow>
<ActionIconButton
active={enableSpellCheck}
onClick={() => {
const newValue = !enableSpellCheck
dispatch(setEnableSpellCheck(newValue))
window.api.setEnableSpellCheck(newValue)
}}>
<SpellCheck size={18} />
</ActionIconButton>
</Tooltip>
)}
<Selector
value={tmpViewMode as EditorView}
onChange={(value: EditorView) => setTmpViewMode(value)}
@@ -122,14 +134,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
NotesEditor.displayName = 'NotesEditor'
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
`
const EmptyContainer = styled.div`
display: flex;
justify-content: center;

View File

@@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import {
createFolder,
createNote,
deleteNode,
initWorkSpace,
moveNode,
renameNode,
sortAllLevels,
uploadFiles
addDir,
addNote,
delNode,
loadTree,
renameNode as renameEntry,
sortTree,
uploadNotes
} from '@renderer/services/NotesService'
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
import {
addUniquePath,
findNode,
findNodeByPath,
findParent,
normalizePathValue,
removePathEntries,
reorderTreeNodes,
replacePathEntries,
updateTreeNode
} from '@renderer/services/NotesTreeService'
import { useAppDispatch, useAppSelector, useAppStore } from '@renderer/store'
import {
selectActiveFilePath,
selectExpandedPaths,
selectSortType,
selectStarredPaths,
setActiveFilePath,
setExpandedPaths,
setSortType,
setStarredPaths
} from '@renderer/store/note'
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
import { FileChangeEvent } from '@shared/config/types'
import { useLiveQuery } from 'dexie-react-hooks'
import { debounce } from 'lodash'
import { AnimatePresence, motion } from 'motion/react'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -37,27 +54,98 @@ const NotesPage: FC = () => {
const { t } = useTranslation()
const { showWorkspace } = useShowWorkspace()
const dispatch = useAppDispatch()
const store = useAppStore()
const activeFilePath = useAppSelector(selectActiveFilePath)
const sortType = useAppSelector(selectSortType)
const starredPaths = useAppSelector(selectStarredPaths)
const expandedPaths = useAppSelector(selectExpandedPaths)
const { settings, notesPath, updateNotesPath } = useNotesSettings()
// 混合策略useLiveQuery用于笔记树React Query用于文件内容
const notesTreeQuery = useLiveQuery(() => getNotesTree(), [])
const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery])
const [notesTree, setNotesTree] = useState<NotesTreeNode[]>([])
const starredSet = useMemo(() => new Set(starredPaths), [starredPaths])
const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths])
const { activeNode } = useActiveNode(notesTree)
const { invalidateFileContent } = useFileContentSync()
const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath)
const { data: currentContent = '' } = useFileContent(activeFilePath)
const [tokenCount, setTokenCount] = useState(0)
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
const watcherRef = useRef<(() => void) | null>(null)
const isSyncingTreeRef = useRef(false)
const lastContentRef = useRef<string>('')
const lastFilePathRef = useRef<string | undefined>(undefined)
const isInitialSortApplied = useRef(false)
const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false)
const activeFilePathRef = useRef<string | undefined>(activeFilePath)
const currentContentRef = useRef(currentContent)
const updateStarredPaths = useCallback(
(updater: (paths: string[]) => string[]) => {
const current = store.getState().note.starredPaths
const safeCurrent = Array.isArray(current) ? current : []
const next = updater(safeCurrent) ?? []
if (!Array.isArray(next)) {
return
}
if (next !== safeCurrent) {
dispatch(setStarredPaths(next))
}
},
[dispatch, store]
)
const updateExpandedPaths = useCallback(
(updater: (paths: string[]) => string[]) => {
const current = store.getState().note.expandedPaths
const safeCurrent = Array.isArray(current) ? current : []
const next = updater(safeCurrent) ?? []
if (!Array.isArray(next)) {
return
}
if (next !== safeCurrent) {
dispatch(setExpandedPaths(next))
}
},
[dispatch, store]
)
const mergeTreeState = useCallback(
(nodes: NotesTreeNode[]): NotesTreeNode[] => {
return nodes.map((node) => {
const normalizedPath = normalizePathValue(node.externalPath)
const merged: NotesTreeNode = {
...node,
externalPath: normalizedPath,
isStarred: starredSet.has(normalizedPath)
}
if (node.type === 'folder') {
merged.expanded = expandedSet.has(normalizedPath)
merged.children = node.children ? mergeTreeState(node.children) : []
}
return merged
})
},
[starredSet, expandedSet]
)
const refreshTree = useCallback(async () => {
if (!notesPath) {
setNotesTree([])
return
}
try {
const rawTree = await loadTree(notesPath)
const sortedTree = sortTree(rawTree, sortType)
setNotesTree(mergeTreeState(sortedTree))
} catch (error) {
logger.error('Failed to refresh notes tree:', error as Error)
}
}, [mergeTreeState, notesPath, sortType])
useEffect(() => {
const updateCharCount = () => {
const textContent = editorRef.current?.getContent() || currentContent
@@ -67,19 +155,16 @@ const NotesPage: FC = () => {
updateCharCount()
}, [currentContent])
// 查找树节点 by ID
const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => {
for (const node of tree) {
if (node.id === nodeId) {
return node
}
if (node.children) {
const found = findNodeById(node.children, nodeId)
if (found) return found
}
useEffect(() => {
refreshTree()
}, [refreshTree])
// Re-merge tree state when starred or expanded paths change
useEffect(() => {
if (notesTree.length > 0) {
setNotesTree((prev) => mergeTreeState(prev))
}
return null
}, [])
}, [starredPaths, expandedPaths, mergeTreeState, notesTree.length])
// 保存当前笔记内容
const saveCurrentNote = useCallback(
@@ -107,6 +192,11 @@ const NotesPage: FC = () => {
[saveCurrentNote]
)
const saveCurrentNoteRef = useRef(saveCurrentNote)
const debouncedSaveRef = useRef(debouncedSave)
const invalidateFileContentRef = useRef(invalidateFileContent)
const refreshTreeRef = useRef(refreshTree)
const handleMarkdownChange = useCallback(
(newMarkdown: string) => {
// 记录最新内容和文件路径,用于兜底保存
@@ -118,6 +208,30 @@ const NotesPage: FC = () => {
[debouncedSave, activeFilePath]
)
useEffect(() => {
activeFilePathRef.current = activeFilePath
}, [activeFilePath])
useEffect(() => {
currentContentRef.current = currentContent
}, [currentContent])
useEffect(() => {
saveCurrentNoteRef.current = saveCurrentNote
}, [saveCurrentNote])
useEffect(() => {
debouncedSaveRef.current = debouncedSave
}, [debouncedSave])
useEffect(() => {
invalidateFileContentRef.current = invalidateFileContent
}, [invalidateFileContent])
useEffect(() => {
refreshTreeRef.current = refreshTree
}, [refreshTree])
useEffect(() => {
async function initialize() {
if (!notesPath) {
@@ -133,29 +247,12 @@ const NotesPage: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notesPath])
// 应用初始排序
useEffect(() => {
async function applyInitialSort() {
if (notesTree.length > 0 && !isInitialSortApplied.current) {
try {
await sortAllLevels(sortType)
isInitialSortApplied.current = true
} catch (error) {
logger.error('Failed to apply initial sorting:', error as Error)
}
}
}
applyInitialSort()
}, [notesTree.length, sortType])
// 处理树同步时的状态管理
useEffect(() => {
if (notesTree.length === 0) return
// 如果有activeFilePath但找不到对应节点清空选择
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
const shouldClearPath =
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current
if (shouldClearPath) {
logger.warn('Clearing activeFilePath - node not found in tree', {
@@ -167,7 +264,7 @@ const NotesPage: FC = () => {
}, [notesTree, activeFilePath, activeNode, dispatch])
useEffect(() => {
if (!notesPath || notesTree.length === 0) return
if (!notesPath) return
async function startFileWatcher() {
// 清理之前的监控
@@ -181,31 +278,14 @@ const NotesPage: FC = () => {
try {
if (!notesPath) return
const { eventType, filePath } = data
const normalizedEventPath = normalizePathValue(filePath)
switch (eventType) {
case 'change': {
// 处理文件内容变化 - 只有内容真正改变时才触发更新
if (activeFilePath === filePath) {
try {
// 读取文件最新内容
// const newFileContent = await window.api.file.readExternal(filePath)
// // 获取当前编辑器/缓存中的内容
// const currentEditorContent = editorRef.current?.getMarkdown()
// // 如果编辑器还未初始化完成忽略FileWatcher事件
// if (!isEditorInitialized.current) {
// return
// }
// // 比较内容是否真正发生变化
// if (newFileContent.trim() !== currentEditorContent?.trim()) {
// invalidateFileContent(filePath)
// }
} catch (error) {
logger.error('Failed to read file for content comparison:', error as Error)
// 读取失败时,还是执行原来的逻辑
invalidateFileContent(filePath)
}
} else {
await initWorkSpace(notesPath, sortType)
const activePath = activeFilePathRef.current
if (activePath && normalizePathValue(activePath) === normalizedEventPath) {
invalidateFileContentRef.current?.(normalizedEventPath)
}
break
}
@@ -215,20 +295,18 @@ const NotesPage: FC = () => {
case 'unlink':
case 'unlinkDir': {
// 如果删除的是当前活动文件,清空选择
if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) {
if (
(eventType === 'unlink' || eventType === 'unlinkDir') &&
activeFilePathRef.current &&
normalizePathValue(activeFilePathRef.current) === normalizedEventPath
) {
dispatch(setActiveFilePath(undefined))
editorRef.current?.clear()
}
// 设置同步标志,避免竞态条件
isSyncingTreeRef.current = true
// 重新同步数据库useLiveQuery会自动响应数据库变化
try {
await initWorkSpace(notesPath, sortType)
} catch (error) {
logger.error('Failed to sync database:', error as Error)
} finally {
isSyncingTreeRef.current = false
const refresh = refreshTreeRef.current
if (refresh) {
await refresh()
}
break
}
@@ -261,26 +339,19 @@ const NotesPage: FC = () => {
})
// 如果有未保存的内容,立即保存
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
logger.error('Emergency save failed:', error as Error)
})
if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) {
const saveFn = saveCurrentNoteRef.current
if (saveFn) {
saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => {
logger.error('Emergency save failed:', error as Error)
})
}
}
// 清理防抖函数
debouncedSave.cancel()
debouncedSaveRef.current?.cancel()
}
}, [
notesPath,
notesTree.length,
activeFilePath,
invalidateFileContent,
dispatch,
currentContent,
debouncedSave,
saveCurrentNote,
sortType
])
}, [dispatch, notesPath])
useEffect(() => {
const editor = editorRef.current
@@ -316,13 +387,13 @@ const NotesPage: FC = () => {
// 获取目标文件夹路径(选中文件夹或根目录)
const getTargetFolderPath = useCallback(() => {
if (selectedFolderId) {
const selectedNode = findNodeById(notesTree, selectedFolderId)
const selectedNode = findNode(notesTree, selectedFolderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
}
return notesPath // 默认返回根目录
}, [selectedFolderId, notesTree, notesPath, findNodeById])
}, [selectedFolderId, notesTree, notesPath])
// 创建文件夹
const handleCreateFolder = useCallback(
@@ -332,12 +403,14 @@ const NotesPage: FC = () => {
if (!targetPath) {
throw new Error('No folder path selected')
}
await createFolder(name, targetPath)
await addDir(name, targetPath)
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetPath)))
await refreshTree()
} catch (error) {
logger.error('Failed to create folder:', error as Error)
}
},
[getTargetFolderPath]
[getTargetFolderPath, refreshTree, updateExpandedPaths]
)
// 创建笔记
@@ -350,11 +423,13 @@ const NotesPage: FC = () => {
if (!targetPath) {
throw new Error('No folder path selected')
}
const newNote = await createNote(name, '', targetPath)
dispatch(setActiveFilePath(newNote.externalPath))
const { path: notePath } = await addNote(name, '', targetPath)
const normalizedParent = normalizePathValue(targetPath)
updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent))
dispatch(setActiveFilePath(notePath))
setSelectedFolderId(null)
await sortAllLevels(sortType)
await refreshTree()
} catch (error) {
logger.error('Failed to create note:', error as Error)
} finally {
@@ -364,73 +439,41 @@ const NotesPage: FC = () => {
}, 500)
}
},
[dispatch, getTargetFolderPath, sortType]
)
// 切换展开状态
const toggleNodeExpanded = useCallback(
async (nodeId: string) => {
try {
const tree = await getNotesTree()
const node = findNodeById(tree, nodeId)
if (node && node.type === 'folder') {
await updateNodeInTree(tree, nodeId, {
expanded: !node.expanded
})
}
return tree
} catch (error) {
logger.error('Failed to toggle expanded:', error as Error)
throw error
}
},
[findNodeById]
[dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths]
)
const handleToggleExpanded = useCallback(
async (nodeId: string) => {
try {
await toggleNodeExpanded(nodeId)
} catch (error) {
logger.error('Failed to toggle expanded:', error as Error)
(nodeId: string) => {
const targetNode = findNode(notesTree, nodeId)
if (!targetNode || targetNode.type !== 'folder') {
return
}
const nextExpanded = !targetNode.expanded
// Update Redux state first, then let mergeTreeState handle the UI update
updateExpandedPaths((prev) =>
nextExpanded
? addUniquePath(prev, targetNode.externalPath)
: removePathEntries(prev, targetNode.externalPath, false)
)
},
[toggleNodeExpanded]
)
// 切换收藏状态
const toggleStarred = useCallback(
async (nodeId: string) => {
try {
const tree = await getNotesTree()
const node = findNodeById(tree, nodeId)
if (node && node.type === 'file') {
await updateNodeInTree(tree, nodeId, {
isStarred: !node.isStarred
})
}
return tree
} catch (error) {
logger.error('Failed to toggle star:', error as Error)
throw error
}
},
[findNodeById]
[notesTree, updateExpandedPaths]
)
const handleToggleStar = useCallback(
async (nodeId: string) => {
try {
await toggleStarred(nodeId)
} catch (error) {
logger.error('Failed to toggle star:', error as Error)
(nodeId: string) => {
const node = findNode(notesTree, nodeId)
if (!node) {
return
}
const nextStarred = !node.isStarred
// Update Redux state first, then let mergeTreeState handle the UI update
updateStarredPaths((prev) =>
nextStarred ? addUniquePath(prev, node.externalPath) : removePathEntries(prev, node.externalPath, false)
)
},
[toggleStarred]
[notesTree, updateStarredPaths]
)
// 选择节点
@@ -447,7 +490,7 @@ const NotesPage: FC = () => {
}
} else if (node.type === 'folder') {
setSelectedFolderId(node.id)
await handleToggleExpanded(node.id)
handleToggleExpanded(node.id)
}
},
[dispatch, handleToggleExpanded, invalidateFileContent]
@@ -457,28 +500,35 @@ const NotesPage: FC = () => {
const handleDeleteNode = useCallback(
async (nodeId: string) => {
try {
const nodeToDelete = findNodeById(notesTree, nodeId)
const nodeToDelete = findNode(notesTree, nodeId)
if (!nodeToDelete) return
const isActiveNodeOrParent =
activeFilePath &&
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
await delNode(nodeToDelete)
await deleteNode(nodeId)
await sortAllLevels(sortType)
updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder'))
updateExpandedPaths((prev) =>
removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')
)
// 如果删除的是当前活动节点或其父节点,清空编辑器
if (isActiveNodeOrParent) {
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
const isActiveNode = normalizedActivePath === normalizedDeletePath
const isActiveDescendant =
nodeToDelete.type === 'folder' &&
normalizedActivePath &&
normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
if (isActiveNode || isActiveDescendant) {
dispatch(setActiveFilePath(undefined))
if (editorRef.current) {
editorRef.current.clear()
}
editorRef.current?.clear()
}
await refreshTree()
} catch (error) {
logger.error('Failed to delete node:', error as Error)
}
},
[findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch]
[notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths]
)
// 重命名节点
@@ -487,29 +537,30 @@ const NotesPage: FC = () => {
try {
isRenamingRef.current = true
const tree = await getNotesTree()
const node = findNodeById(tree, nodeId)
if (node && node.name !== newName) {
const oldExternalPath = node.externalPath
const renamedNode = await renameNode(nodeId, newName)
if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) {
dispatch(setActiveFilePath(renamedNode.externalPath))
} else if (
renamedNode.type === 'folder' &&
activeFilePath &&
activeFilePath.startsWith(oldExternalPath + '/')
) {
const relativePath = activeFilePath.substring(oldExternalPath.length)
const newFilePath = renamedNode.externalPath + relativePath
dispatch(setActiveFilePath(newFilePath))
}
await sortAllLevels(sortType)
if (renamedNode.name !== newName) {
window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name }))
}
const node = findNode(notesTree, nodeId)
if (!node || node.name === newName) {
return
}
const oldPath = node.externalPath
const renamed = await renameEntry(node, newName)
if (node.type === 'file' && activeFilePath === oldPath) {
debouncedSaveRef.current?.cancel()
lastFilePathRef.current = renamed.path
dispatch(setActiveFilePath(renamed.path))
} else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) {
const suffix = activeFilePath.slice(oldPath.length)
const nextActivePath = `${renamed.path}${suffix}`
debouncedSaveRef.current?.cancel()
lastFilePathRef.current = nextActivePath
dispatch(setActiveFilePath(nextActivePath))
}
updateStarredPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
updateExpandedPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
await refreshTree()
} catch (error) {
logger.error('Failed to rename node:', error as Error)
} finally {
@@ -518,7 +569,7 @@ const NotesPage: FC = () => {
}, 500)
}
},
[activeFilePath, dispatch, findNodeById, sortType, t]
[activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
)
// 处理文件上传
@@ -535,7 +586,7 @@ const NotesPage: FC = () => {
throw new Error('No folder path selected')
}
const result = await uploadFiles(files, targetFolderPath)
const result = await uploadNotes(files, targetFolderPath)
// 检查上传结果
if (result.fileCount === 0) {
@@ -544,7 +595,8 @@ const NotesPage: FC = () => {
}
// 排序并显示成功信息
await sortAllLevels(sortType)
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath)))
await refreshTree()
const successMessage = t('notes.upload_success')
@@ -554,37 +606,141 @@ const NotesPage: FC = () => {
window.toast.error(t('notes.upload_failed'))
}
},
[getTargetFolderPath, sortType, t]
[getTargetFolderPath, refreshTree, t, updateExpandedPaths]
)
// 处理节点移动
const handleMoveNode = useCallback(
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
if (!notesPath) {
return
}
try {
const result = await moveNode(sourceNodeId, targetNodeId, position)
if (result.success && result.type !== 'manual_reorder') {
await sortAllLevels(sortType)
const sourceNode = findNode(notesTree, sourceNodeId)
const targetNode = findNode(notesTree, targetNodeId)
if (!sourceNode || !targetNode) {
return
}
if (position === 'inside' && targetNode.type !== 'folder') {
return
}
const rootPath = normalizePathValue(notesPath)
const sourceParentNode = findParent(notesTree, sourceNodeId)
const targetParentNode = position === 'inside' ? targetNode : findParent(notesTree, targetNodeId)
const sourceParentPath = sourceParentNode ? sourceParentNode.externalPath : rootPath
const targetParentPath =
position === 'inside' ? targetNode.externalPath : targetParentNode ? targetParentNode.externalPath : rootPath
const normalizedSourceParent = normalizePathValue(sourceParentPath)
const normalizedTargetParent = normalizePathValue(targetParentPath)
const isManualReorder = position !== 'inside' && normalizedSourceParent === normalizedTargetParent
if (isManualReorder) {
// For manual reordering within the same parent, we can optimize by only updating the affected parent
setNotesTree((prev) =>
reorderTreeNodes(prev, sourceNodeId, targetNodeId, position === 'before' ? 'before' : 'after')
)
return
}
const { safeName } = await window.api.file.checkFileName(
normalizedTargetParent,
sourceNode.name,
sourceNode.type === 'file'
)
const destinationPath =
sourceNode.type === 'file'
? `${normalizedTargetParent}/${safeName}.md`
: `${normalizedTargetParent}/${safeName}`
if (destinationPath === sourceNode.externalPath) {
return
}
if (sourceNode.type === 'file') {
await window.api.file.move(sourceNode.externalPath, destinationPath)
} else {
await window.api.file.moveDir(sourceNode.externalPath, destinationPath)
}
updateStarredPaths((prev) =>
replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
)
updateExpandedPaths((prev) => {
let next = replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
next = addUniquePath(next, normalizedTargetParent)
return next
})
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
if (normalizedActivePath) {
if (normalizedActivePath === sourceNode.externalPath) {
dispatch(setActiveFilePath(destinationPath))
} else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) {
const suffix = normalizedActivePath.slice(sourceNode.externalPath.length)
dispatch(setActiveFilePath(`${destinationPath}${suffix}`))
}
}
await refreshTree()
} catch (error) {
logger.error('Failed to move nodes:', error as Error)
}
},
[sortType]
[activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
)
// 处理节点排序
const handleSortNodes = useCallback(
async (newSortType: NotesSortType) => {
try {
// 更新Redux中的排序类型
dispatch(setSortType(newSortType))
await sortAllLevels(newSortType)
} catch (error) {
logger.error('Failed to sort notes:', error as Error)
throw error
dispatch(setSortType(newSortType))
setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType)))
},
[dispatch, mergeTreeState]
)
const handleExpandPath = useCallback(
(treePath: string) => {
if (!treePath) {
return
}
const segments = treePath.split('/').filter(Boolean)
if (segments.length === 0) {
return
}
let nextTree = notesTree
const pathsToAdd: string[] = []
segments.forEach((_, index) => {
const currentPath = '/' + segments.slice(0, index + 1).join('/')
const node = findNodeByPath(nextTree, currentPath)
if (node && node.type === 'folder' && !node.expanded) {
pathsToAdd.push(node.externalPath)
nextTree = updateTreeNode(nextTree, node.id, (current) => ({ ...current, expanded: true }))
}
})
if (pathsToAdd.length > 0) {
setNotesTree(nextTree)
updateExpandedPaths((prev) => {
let updated = prev
pathsToAdd.forEach((path) => {
updated = addUniquePath(updated, path)
})
return updated
})
}
},
[dispatch]
[notesTree, updateExpandedPaths]
)
const getCurrentNoteContent = useCallback(() => {
@@ -631,12 +787,13 @@ const NotesPage: FC = () => {
notesTree={notesTree}
getCurrentNoteContent={getCurrentNoteContent}
onToggleStar={handleToggleStar}
onExpandPath={handleExpandPath}
onRenameNode={handleRenameNode}
/>
<NotesEditor
activeNodeId={activeNode?.id}
currentContent={currentContent}
tokenCount={tokenCount}
isLoading={isContentLoading}
onMarkdownChange={handleMarkdownChange}
editorRef={editorRef}
/>

View File

@@ -6,10 +6,14 @@ import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
import { useAppSelector } from '@renderer/store'
import { fetchNoteSummary } from '@renderer/services/ApiService'
import { RootState, useAppSelector } from '@renderer/store'
import { selectSortType } from '@renderer/store/note'
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
import { exportNote } from '@renderer/utils/export'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
import {
ChevronDown,
ChevronRight,
@@ -19,11 +23,14 @@ import {
FileSearch,
Folder,
FolderOpen,
Sparkles,
Star,
StarOff
StarOff,
UploadIcon
} from 'lucide-react'
import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
interface NotesSidebarProps {
@@ -43,6 +50,171 @@ interface NotesSidebarProps {
const logger = loggerService.withContext('NotesSidebar')
interface TreeNodeProps {
node: NotesTreeNode
depth: number
selectedFolderId?: string | null
activeNodeId?: string
editingNodeId: string | null
renamingNodeIds: Set<string>
newlyRenamedNodeIds: Set<string>
draggedNodeId: string | null
dragOverNodeId: string | null
dragPosition: 'before' | 'inside' | 'after'
inPlaceEdit: any
getMenuItems: (node: NotesTreeNode) => any[]
onSelectNode: (node: NotesTreeNode) => void
onToggleExpanded: (nodeId: string) => void
onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void
onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
onDragEnd: () => void
renderChildren?: boolean // 控制是否渲染子节点
}
const TreeNode = memo<TreeNodeProps>(
({
node,
depth,
selectedFolderId,
activeNodeId,
editingNodeId,
renamingNodeIds,
newlyRenamedNodeIds,
draggedNodeId,
dragOverNodeId,
dragPosition,
inPlaceEdit,
getMenuItems,
onSelectNode,
onToggleExpanded,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
renderChildren = true
}) => {
const { t } = useTranslation()
const isActive = selectedFolderId
? node.type === 'folder' && node.id === selectedFolderId
: node.id === activeNodeId
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
const isRenaming = renamingNodeIds.has(node.id)
const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
const hasChildren = node.children && node.children.length > 0
const isDragging = draggedNodeId === node.id
const isDragOver = dragOverNodeId === node.id
const isDragBefore = isDragOver && dragPosition === 'before'
const isDragInside = isDragOver && dragPosition === 'inside'
const isDragAfter = isDragOver && dragPosition === 'after'
const getNodeNameClassName = () => {
if (isRenaming) return 'shimmer'
if (isNewlyRenamed) return 'typing'
return ''
}
return (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
<div>
<TreeNodeContainer
active={isActive}
depth={depth}
isDragging={isDragging}
isDragOver={isDragOver}
isDragBefore={isDragBefore}
isDragInside={isDragInside}
isDragAfter={isDragAfter}
draggable={!isEditing}
data-node-id={node.id}
onDragStart={(e) => onDragStart(e, node)}
onDragOver={(e) => onDragOver(e, node)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node)}
onDragEnd={onDragEnd}>
<TreeNodeContent onClick={() => onSelectNode(node)}>
<NodeIndent depth={depth} />
{node.type === 'folder' && (
<ExpandIcon
onClick={(e) => {
e.stopPropagation()
onToggleExpanded(node.id)
}}
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ExpandIcon>
)}
<NodeIcon>
{node.type === 'folder' ? (
node.expanded ? (
<FolderOpen size={16} />
) : (
<Folder size={16} />
)
) : (
<File size={16} />
)}
</NodeIcon>
{isEditing ? (
<EditInput
ref={inPlaceEdit.inputRef as Ref<InputRef>}
value={inPlaceEdit.editValue}
onChange={inPlaceEdit.handleInputChange}
onBlur={inPlaceEdit.saveEdit}
onKeyDown={inPlaceEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
autoFocus
size="small"
/>
) : (
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName>
)}
</TreeNodeContent>
</TreeNodeContainer>
</div>
</Dropdown>
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
<div>
{node.children!.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
activeNodeId={activeNodeId}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragEnd={onDragEnd}
renderChildren={renderChildren}
/>
))}
</div>
)}
</div>
)
}
)
const NotesSidebar: FC<NotesSidebarProps> = ({
onCreateFolder,
onCreateNote,
@@ -61,7 +233,10 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const { bases } = useKnowledgeBases()
const { activeNode } = useActiveNode(notesTree)
const sortType = useAppSelector(selectSortType)
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
@@ -184,6 +359,49 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
[bases.length, t]
)
const handleAutoRename = useCallback(
async (note: NotesTreeNode) => {
if (note.type !== 'file') return
setRenamingNodeIds((prev) => new Set(prev).add(note.id))
try {
const content = await window.api.file.readExternal(note.externalPath)
if (!content || content.trim().length === 0) {
window.toast.warning(t('notes.auto_rename.empty_note'))
return
}
const summaryText = await fetchNoteSummary({ content })
if (summaryText) {
onRenameNode(note.id, summaryText)
window.toast.success(t('notes.auto_rename.success'))
} else {
window.toast.error(t('notes.auto_rename.failed'))
}
} catch (error) {
window.toast.error(t('notes.auto_rename.failed'))
logger.error(`Failed to auto-rename note: ${error}`)
} finally {
setRenamingNodeIds((prev) => {
const next = new Set(prev)
next.delete(note.id)
return next
})
setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
setTimeout(() => {
setNewlyRenamedNodeIds((prev) => {
const next = new Set(prev)
next.delete(note.id)
return next
})
}, 700)
}
},
[onRenameNode, t]
)
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
setDraggedNodeId(node.id)
e.dataTransfer.effectAllowed = 'move'
@@ -268,9 +486,26 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
setIsShowSearch(!isShowSearch)
}, [isShowSearch])
const filteredTree = useMemo(() => {
if (!isShowStarred && !isShowSearch) return notesTree
const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
// Flatten tree nodes for virtualization and filtering
const flattenedNodes = useMemo(() => {
const flattenForVirtualization = (
nodes: NotesTreeNode[],
depth: number = 0
): Array<{ node: NotesTreeNode; depth: number }> => {
let result: Array<{ node: NotesTreeNode; depth: number }> = []
for (const node of nodes) {
result.push({ node, depth })
// Include children only if the folder is expanded
if (node.type === 'folder' && node.expanded && node.children && node.children.length > 0) {
result = [...result, ...flattenForVirtualization(node.children, depth + 1)]
}
}
return result
}
const flattenForFiltering = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
let result: NotesTreeNode[] = []
for (const node of nodes) {
@@ -284,18 +519,59 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
}
}
if (node.children && node.children.length > 0) {
result = [...result, ...flattenNodes(node.children)]
result = [...result, ...flattenForFiltering(node.children)]
}
}
return result
}
return flattenNodes(notesTree)
if (isShowStarred || isShowSearch) {
// For filtered views, return flat list without virtualization for simplicity
const filteredNodes = flattenForFiltering(notesTree)
return filteredNodes.map((node) => ({ node, depth: 0 }))
}
// For normal tree view, use hierarchical flattening for virtualization
return flattenForVirtualization(notesTree)
}, [notesTree, isShowStarred, isShowSearch, searchKeyword])
// Use virtualization only for normal tree view with many items
const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: flattenedNodes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28, // Estimated height of each tree item
overscan: 10
})
const filteredTree = useMemo(() => {
if (isShowStarred || isShowSearch) {
return flattenedNodes.map(({ node }) => node)
}
return notesTree
}, [flattenedNodes, isShowStarred, isShowSearch, notesTree])
const getMenuItems = useCallback(
(node: NotesTreeNode) => {
const baseMenuItems: MenuProps['items'] = [
const baseMenuItems: MenuProps['items'] = []
// only show auto rename for file for now
if (node.type !== 'folder') {
baseMenuItems.push({
label: t('notes.auto_rename.label'),
key: 'auto-rename',
icon: <Sparkles size={14} />,
disabled: renamingNodeIds.has(node.id),
onClick: () => {
handleAutoRename(node)
}
})
}
baseMenuItems.push(
{
label: t('notes.rename'),
key: 'rename',
@@ -312,7 +588,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
window.api.openPath(node.externalPath)
}
}
]
)
if (node.type !== 'folder') {
baseMenuItems.push(
{
@@ -330,6 +606,48 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
onClick: () => {
handleExportKnowledge(node)
}
},
{
label: t('chat.topics.export.title'),
key: 'export',
icon: <UploadIcon size={14} />,
children: [
exportMenuOptions.markdown && {
label: t('chat.topics.export.md.label'),
key: 'markdown',
onClick: () => exportNote({ node, platform: 'markdown' })
},
exportMenuOptions.docx && {
label: t('chat.topics.export.word'),
key: 'word',
onClick: () => exportNote({ node, platform: 'docx' })
},
exportMenuOptions.notion && {
label: t('chat.topics.export.notion'),
key: 'notion',
onClick: () => exportNote({ node, platform: 'notion' })
},
exportMenuOptions.yuque && {
label: t('chat.topics.export.yuque'),
key: 'yuque',
onClick: () => exportNote({ node, platform: 'yuque' })
},
exportMenuOptions.obsidian && {
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: () => exportNote({ node, platform: 'obsidian' })
},
exportMenuOptions.joplin && {
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: () => exportNote({ node, platform: 'joplin' })
},
exportMenuOptions.siyuan && {
label: t('chat.topics.export.siyuan'),
key: 'siyuan',
onClick: () => exportNote({ node, platform: 'siyuan' })
}
].filter(Boolean) as ItemType<MenuItemType>[]
}
)
}
@@ -348,115 +666,15 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
return baseMenuItems
},
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
)
const renderTreeNode = useCallback(
(node: NotesTreeNode, depth: number = 0) => {
const isActive = selectedFolderId
? node.type === 'folder' && node.id === selectedFolderId
: node.id === activeNode?.id
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
const hasChildren = node.children && node.children.length > 0
const isDragging = draggedNodeId === node.id
const isDragOver = dragOverNodeId === node.id
const isDragBefore = isDragOver && dragPosition === 'before'
const isDragInside = isDragOver && dragPosition === 'inside'
const isDragAfter = isDragOver && dragPosition === 'after'
return (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
<div>
<TreeNodeContainer
active={isActive}
depth={depth}
isDragging={isDragging}
isDragOver={isDragOver}
isDragBefore={isDragBefore}
isDragInside={isDragInside}
isDragAfter={isDragAfter}
draggable={!isEditing}
data-node-id={node.id}
onDragStart={(e) => handleDragStart(e, node)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}>
<TreeNodeContent onClick={() => onSelectNode(node)}>
<NodeIndent depth={depth} />
{node.type === 'folder' && (
<ExpandIcon
onClick={(e) => {
e.stopPropagation()
onToggleExpanded(node.id)
}}
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ExpandIcon>
)}
<NodeIcon>
{node.type === 'folder' ? (
node.expanded ? (
<FolderOpen size={16} />
) : (
<Folder size={16} />
)
) : (
<File size={16} />
)}
</NodeIcon>
{isEditing ? (
<EditInput
ref={inPlaceEdit.inputRef as Ref<InputRef>}
value={inPlaceEdit.editValue}
onChange={inPlaceEdit.handleInputChange}
onPressEnter={inPlaceEdit.saveEdit}
onBlur={inPlaceEdit.saveEdit}
onKeyDown={inPlaceEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
autoFocus
size="small"
/>
) : (
<NodeName>{node.name}</NodeName>
)}
</TreeNodeContent>
</TreeNodeContainer>
</div>
</Dropdown>
{node.type === 'folder' && node.expanded && hasChildren && (
<div>{node.children!.map((child) => renderTreeNode(child, depth + 1))}</div>
)}
</div>
)
},
[
selectedFolderId,
activeNode?.id,
editingNodeId,
inPlaceEdit.isEditing,
inPlaceEdit.inputRef,
inPlaceEdit.editValue,
inPlaceEdit.handleInputChange,
inPlaceEdit.saveEdit,
inPlaceEdit.handleKeyDown,
draggedNodeId,
dragOverNodeId,
dragPosition,
getMenuItems,
handleDragLeave,
handleDragEnd,
t,
handleDragStart,
handleDragOver,
handleDrop,
onSelectNode,
onToggleExpanded
handleStartEdit,
onToggleStar,
handleExportKnowledge,
handleDeleteNode,
renamingNodeIds,
handleAutoRename,
exportMenuOptions
]
)
@@ -565,9 +783,56 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
/>
<NotesTreeContainer>
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{filteredTree.map((node) => renderTreeNode(node))}
{shouldUseVirtualization ? (
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<TreeNode
node={node}
depth={depth}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
/>
</div>
</div>
)
})}
</div>
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
@@ -580,8 +845,74 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
</TreeNodeContainer>
</DropHintNode>
)}
</TreeContent>
</StyledScrollbar>
</VirtualizedTreeContainer>
) : (
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</TreeContent>
</StyledScrollbar>
)}
</NotesTreeContainer>
{isDragOverSidebar && <DragOverIndicator />}
@@ -592,7 +923,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const SidebarContainer = styled.div`
width: 250px;
min-width: 250px;
height: 100vh;
height: calc(100vh - var(--navbar-height));
background-color: var(--color-background);
border-right: 0.5px solid var(--color-border);
border-top-left-radius: 10px;
@@ -606,7 +937,15 @@ const NotesTreeContainer = styled.div`
overflow: hidden;
display: flex;
flex-direction: column;
height: calc(100vh - 45px);
height: calc(100vh - var(--navbar-height) - 45px);
`
const VirtualizedTreeContainer = styled.div`
flex: 1;
height: 100%;
overflow: auto;
position: relative;
padding-top: 10px;
`
const StyledScrollbar = styled(Scrollbar)`
@@ -732,6 +1071,44 @@ const NodeName = styled.div`
text-overflow: ellipsis;
font-size: 13px;
color: var(--color-text);
position: relative;
will-change: background-position, width;
--color-shimmer-mid: var(--color-text-1);
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
&.shimmer {
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: shimmer 3s linear infinite;
}
&.typing {
display: block;
white-space: nowrap;
overflow: hidden;
animation: typewriter 0.5s steps(40, end);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes typewriter {
from {
width: 0;
}
to {
width: 100%;
}
}
`
const EditInput = styled(Input)`
@@ -752,7 +1129,8 @@ const DragOverIndicator = styled.div`
`
const DropHintNode = styled.div`
margin-top: 8px;
margin: 6px 0;
margin-bottom: 20px;
${TreeNodeContainer} {
background-color: transparent;
@@ -773,4 +1151,4 @@ const DropHintText = styled.div`
font-style: italic;
`
export default NotesSidebar
export default memo(NotesSidebar)

View File

@@ -2,7 +2,6 @@ import { loggerService } from '@logger'
import Selector from '@renderer/components/Selector'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { initWorkSpace } from '@renderer/services/NotesService'
import { EditorView } from '@renderer/types'
import { Button, Input, message, Slider, Switch } from 'antd'
import { FolderOpen } from 'lucide-react'
@@ -70,7 +69,6 @@ const NotesSettings: FC = () => {
}
updateNotesPath(tempPath)
initWorkSpace(tempPath, 'sort_a2z')
window.toast.success(t('notes.settings.data.path_updated'))
} catch (error) {
logger.error('Failed to apply notes path:', error as Error)
@@ -83,7 +81,6 @@ const NotesSettings: FC = () => {
const info = await window.api.getAppInfo()
setTempPath(info.notesPath)
updateNotesPath(info.notesPath)
initWorkSpace(info.notesPath, 'sort_a2z')
window.toast.success(t('notes.settings.data.reset_to_default'))
} catch (error) {
logger.error('Failed to reset to default:', error as Error)

View File

@@ -0,0 +1,353 @@
import { loggerService } from '@logger'
import { TopView } from '@renderer/components/TopView'
import { Provider } from '@renderer/types'
import { AutoComplete, Button, Flex, Form, FormProps, Input, Modal, Progress, Select } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTimer } from '../../../../hooks/useTimer'
const logger = loggerService.withContext('OVMSClient')
interface ShowParams {
title: string
provider: Provider
}
interface Props extends ShowParams {
resolve: (data: any) => unknown
}
type FieldType = {
modelName: string
modelId: string
modelSource: string
task: string
}
interface PresetModel {
modelId: string
modelName: string
modelSource: string
task: string
label: string
}
const PRESET_MODELS: PresetModel[] = [
{
modelId: 'OpenVINO/Qwen3-8B-int4-ov',
modelName: 'Qwen3-8B-int4-ov',
modelSource: 'https://www.modelscope.cn/models',
task: 'text_generation',
label: 'Qwen3-8B-int4-ov (Text Generation)'
},
{
modelId: 'OpenVINO/bge-base-en-v1.5-fp16-ov',
modelName: 'bge-base-en-v1.5-fp16-ov',
modelSource: 'https://www.modelscope.cn/models',
task: 'embeddings',
label: 'bge-base-en-v1.5-fp16-ov (Embeddings)'
},
{
modelId: 'OpenVINO/bge-reranker-base-fp16-ov',
modelName: 'bge-reranker-base-fp16-ov',
modelSource: 'https://www.modelscope.cn/models',
task: 'rerank',
label: 'bge-reranker-base-fp16-ov (Rerank)'
},
{
modelId: 'OpenVINO/DeepSeek-R1-Distill-Qwen-7B-int4-ov',
modelName: 'DeepSeek-R1-Distill-Qwen-7B-int4-ov',
modelSource: 'https://www.modelscope.cn/models',
task: 'text_generation',
label: 'DeepSeek-R1-Distill-Qwen-7B-int4-ov (Text Generation)'
},
{
modelId: 'OpenVINO/stable-diffusion-v1-5-int8-ov',
modelName: 'stable-diffusion-v1-5-int8-ov',
modelSource: 'https://www.modelscope.cn/models',
task: 'image_generation',
label: 'stable-diffusion-v1-5-int8-ov (Image Generation)'
},
{
modelId: 'OpenVINO/FLUX.1-schnell-int4-ov',
modelName: 'FLUX.1-schnell-int4-ov',
modelSource: 'https://www.modelscope.cn/models',
task: 'image_generation',
label: 'FLUX.1-schnell-int4-ov (Image Generation)'
}
]
const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
const [open, setOpen] = useState(true)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState(0)
const [cancelled, setCancelled] = useState(false)
const [form] = Form.useForm()
const { t } = useTranslation()
const { setIntervalTimer, clearIntervalTimer, setTimeoutTimer } = useTimer()
const startFakeProgress = () => {
setProgress(0)
setIntervalTimer(
'progress',
() => {
setProgress((prev) => {
if (prev >= 95) {
return prev // Stop at 95% until actual completion
}
// Simulate realistic download progress with slowing speed
const increment =
prev < 30
? Math.random() * 1 + 0.25
: prev < 60
? Math.random() * 0.5 + 0.125
: Math.random() * 0.25 + 0.03125
return Math.min(prev + increment, 95)
})
},
500
)
}
const stopFakeProgress = (complete = false) => {
clearIntervalTimer('progress')
if (complete) {
setProgress(100)
// Reset progress after a short delay
setTimeoutTimer('progress-reset', () => setProgress(0), 1500)
} else {
setProgress(0)
}
}
const handlePresetSelect = (value: string) => {
const selectedPreset = PRESET_MODELS.find((model) => model.modelId === value)
if (selectedPreset) {
form.setFieldsValue({
modelId: selectedPreset.modelId,
modelName: selectedPreset.modelName,
modelSource: selectedPreset.modelSource,
task: selectedPreset.task
})
}
}
const handleModelIdChange = (value: string) => {
if (value) {
// Extract model name from model ID (part after last '/')
const lastSlashIndex = value.lastIndexOf('/')
if (lastSlashIndex !== -1 && lastSlashIndex < value.length - 1) {
const modelName = value.substring(lastSlashIndex + 1)
form.setFieldValue('modelName', modelName)
}
}
}
const onCancel = async () => {
if (loading) {
// Stop the download
try {
setCancelled(true) // Mark as cancelled by user
logger.info('Stopping download...')
await window.api.ovms.stopAddModel()
stopFakeProgress(false)
setLoading(false)
} catch (error) {
logger.error(`Failed to stop download: ${error}`)
}
return
}
setOpen(false)
}
const onClose = () => {
resolve({})
}
const onFinish: FormProps<FieldType>['onFinish'] = async (values) => {
setLoading(true)
setCancelled(false) // Reset cancelled state
startFakeProgress()
try {
const { modelName, modelId, modelSource, task } = values
logger.info(`🔄 Downloading model: ${modelName} with ID: ${modelId}, source: ${modelSource}, task: ${task}`)
const result = await window.api.ovms.addModel(modelName, modelId, modelSource, task)
if (result.success) {
stopFakeProgress(true) // Complete the progress bar
Modal.success({
title: t('ovms.download.success'),
content: t('ovms.download.success_desc', { modelName: modelName, modelId: modelId }),
onOk: () => {
setOpen(false)
}
})
} else {
stopFakeProgress(false) // Reset progress on error
logger.error(`Download failed, is it cancelled? ${cancelled}`)
// Only show error if not cancelled by user
if (!cancelled) {
Modal.error({
title: t('ovms.download.error'),
content: <div dangerouslySetInnerHTML={{ __html: result.message }}></div>,
onOk: () => {
// Keep the form open for retry
}
})
}
}
} catch (error: any) {
stopFakeProgress(false) // Reset progress on error
logger.error(`Download crashed, is it cancelled? ${cancelled}`)
// Only show error if not cancelled by user
if (!cancelled) {
Modal.error({
title: t('ovms.download.error'),
content: error.message,
onOk: () => {
// Keep the form open for retry
}
})
}
} finally {
setLoading(false)
}
}
return (
<Modal
title={title}
open={open}
onCancel={onCancel}
maskClosable={false}
afterClose={onClose}
footer={null}
transitionName="animation-move-down"
centered
closeIcon={!loading}>
<Form
form={form}
labelCol={{ flex: '110px' }}
labelAlign="left"
colon={false}
style={{ marginTop: 25 }}
onFinish={onFinish}
disabled={false}>
<Form.Item
name="modelId"
label={t('ovms.download.model_id.label')}
rules={[
{ required: true, message: t('ovms.download.model_id.required') },
{
pattern: /^OpenVINO\/.+/,
message: t('ovms.download.model_id.model_id_pattern')
}
]}>
<AutoComplete
placeholder={t('ovms.download.model_id.placeholder')}
options={PRESET_MODELS.map((model) => ({
value: model.modelId,
label: model.label
}))}
onSelect={handlePresetSelect}
onChange={handleModelIdChange}
disabled={loading}
allowClear
/>
</Form.Item>
<Form.Item
name="modelName"
label={t('ovms.download.model_name.label')}
rules={[{ required: true, message: t('ovms.download.model_name.required') }]}>
<Input
placeholder={t('ovms.download.model_name.placeholder')}
spellCheck={false}
maxLength={200}
disabled={loading}
/>
</Form.Item>
<Form.Item
name="modelSource"
label={t('ovms.download.model_source')}
initialValue="https://www.modelscope.cn/models"
rules={[{ required: false }]}>
<Select
options={[
{ value: '', label: 'HuggingFace' },
{ value: 'https://hf-mirror.com', label: 'HF-Mirror' },
{ value: 'https://www.modelscope.cn/models', label: 'ModelScope' }
]}
disabled={loading}
/>
</Form.Item>
<Form.Item
name="task"
label={t('ovms.download.model_task')}
initialValue="text_generation"
rules={[{ required: false }]}>
<Select
options={[
{ value: 'text_generation', label: 'Text Generation' },
{ value: 'embeddings', label: 'Embeddings' },
{ value: 'rerank', label: 'Rerank' },
{ value: 'image_generation', label: 'Image Generation' }
]}
disabled={loading}
/>
</Form.Item>
{loading && (
<Form.Item style={{ marginBottom: 16 }}>
<Progress
percent={Math.round(progress)}
status={progress === 100 ? 'success' : 'active'}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068'
}}
showInfo={true}
format={(percent) => `${percent}%`}
/>
<div style={{ textAlign: 'center', marginTop: 8, color: '#666', fontSize: '14px' }}>
{t('ovms.download.tip')}
</div>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 8, textAlign: 'center' }}>
<Flex justify="end" align="center" style={{ position: 'relative' }}>
<Button
type="primary"
htmlType={loading ? 'button' : 'submit'}
size="middle"
loading={false}
onClick={loading ? onCancel : undefined}>
{loading ? t('common.cancel') : t('ovms.download.button')}
</Button>
</Flex>
</Form.Item>
</Form>
</Modal>
)
}
export default class DownloadOVMSModelPopup {
static topviewId = 0
static hide() {
TopView.hide('DownloadOVMSModelPopup')
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
'DownloadOVMSModelPopup'
)
})
}
}

View File

@@ -8,6 +8,7 @@ import { getProviderLabel } from '@renderer/i18n/label'
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '@renderer/pages/settings'
import EditModelPopup from '@renderer/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup'
import AddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/AddModelPopup'
import DownloadOVMSModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup'
import ManageModelsPopup from '@renderer/pages/settings/ProviderSettings/ModelList/ManageModelsPopup'
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup'
import { Model } from '@renderer/types'
@@ -93,6 +94,11 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
}
}, [provider, t])
const onDownloadModel = useCallback(
() => DownloadOVMSModelPopup.show({ title: t('ovms.download.title'), provider }),
[provider, t]
)
const isLoading = useMemo(() => displayedModelGroups === null, [displayedModelGroups])
return (
@@ -167,9 +173,15 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
<Button type="primary" onClick={onManageModel} icon={<ListCheck size={16} />} disabled={isHealthChecking}>
{t('button.manage')}
</Button>
<Button type="default" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking}>
{t('button.add')}
</Button>
{provider.id !== 'ovms' ? (
<Button type="default" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking}>
{t('button.add')}
</Button>
) : (
<Button type="default" onClick={onDownloadModel} icon={<Plus size={16} />}>
{t('button.download')}
</Button>
)}
</Flex>
</>
)

View File

@@ -0,0 +1,170 @@
import { VStack } from '@renderer/components/Layout'
import { Alert, Button } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingRow, SettingSubtitle } from '..'
const OVMSSettings: FC = () => {
const { t } = useTranslation()
const [ovmsStatus, setOvmsStatus] = useState<'not-installed' | 'not-running' | 'running'>('not-running')
const [isInstallingOvms, setIsInstallingOvms] = useState(false)
const [isRunningOvms, setIsRunningOvms] = useState(false)
const [isStoppingOvms, setIsStoppingOvms] = useState(false)
useEffect(() => {
const checkStatus = async () => {
const status = await window.api.ovms.getStatus()
setOvmsStatus(status)
}
checkStatus()
}, [])
const installOvms = async () => {
try {
setIsInstallingOvms(true)
await window.api.installOvmsBinary()
// 安装成功后重新检查状态
const status = await window.api.ovms.getStatus()
setOvmsStatus(status)
setIsInstallingOvms(false)
} catch (error: any) {
const errCodeMsg = {
'100': t('ovms.failed.install_code_100'),
'101': t('ovms.failed.install_code_101'),
'102': t('ovms.failed.install_code_102'),
'103': t('ovms.failed.install_code_103'),
'104': t('ovms.failed.install_code_104'),
'105': t('ovms.failed.install_code_105')
}
const match = error.message.match(/code (\d+)/)
const code = match ? match[1] : 'unknown'
const errorMsg = errCodeMsg[code as keyof typeof errCodeMsg] || error.message
window.toast.error(t('ovms.failed.install') + errorMsg)
setIsInstallingOvms(false)
}
}
const runOvms = async () => {
try {
setIsRunningOvms(true)
await window.api.ovms.runOvms()
// 运行成功后重新检查状态
const status = await window.api.ovms.getStatus()
setOvmsStatus(status)
setIsRunningOvms(false)
} catch (error: any) {
window.toast.error(t('ovms.failed.run') + error.message)
setIsRunningOvms(false)
}
}
const stopOvms = async () => {
try {
setIsStoppingOvms(true)
await window.api.ovms.stopOvms()
// 停止成功后重新检查状态
const status = await window.api.ovms.getStatus()
setOvmsStatus(status)
setIsStoppingOvms(false)
} catch (error: any) {
window.toast.error(t('ovms.failed.stop') + error.message)
setIsStoppingOvms(false)
}
}
const getAlertType = () => {
switch (ovmsStatus) {
case 'running':
return 'success'
case 'not-running':
return 'warning'
case 'not-installed':
return 'error'
default:
return 'warning'
}
}
const getStatusMessage = () => {
switch (ovmsStatus) {
case 'running':
return t('ovms.status.running')
case 'not-running':
return t('ovms.status.not_running')
case 'not-installed':
return t('ovms.status.not_installed')
default:
return t('ovms.status.unknown')
}
}
return (
<>
<Alert
type={getAlertType()}
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
description={
<VStack>
<SettingRow style={{ width: '100%' }}>
<SettingSubtitle style={{ margin: 0, fontWeight: 'normal' }}>{getStatusMessage()}</SettingSubtitle>
{ovmsStatus === 'not-installed' && (
<Button
type="primary"
onClick={installOvms}
loading={isInstallingOvms}
disabled={isInstallingOvms}
size="small">
{isInstallingOvms ? t('ovms.action.installing') : t('ovms.action.install')}
</Button>
)}
{ovmsStatus === 'not-running' && (
<div style={{ display: 'flex', gap: '8px' }}>
<Button
type="primary"
onClick={installOvms}
loading={isInstallingOvms}
disabled={isInstallingOvms || isRunningOvms}
size="small">
{isInstallingOvms ? t('ovms.action.installing') : t('ovms.action.reinstall')}
</Button>
<Button
type="primary"
onClick={runOvms}
loading={isRunningOvms}
disabled={isRunningOvms}
size="small">
{isRunningOvms ? t('ovms.action.starting') : t('ovms.action.run')}
</Button>
</div>
)}
{ovmsStatus === 'running' && (
<Button
type="primary"
danger
onClick={stopOvms}
loading={isStoppingOvms}
disabled={isStoppingOvms}
size="small">
{isStoppingOvms ? t('ovms.action.stopping') : t('ovms.action.stop')}
</Button>
)}
</SettingRow>
</VStack>
}
/>
<Alert
type="info"
style={{ marginTop: 5 }}
message={'Intel OVMS Guide:'}
description={<div dangerouslySetInnerHTML={{ __html: t('ovms.description') }}></div>}
showIcon
/>
</>
)
}
export default OVMSSettings

View File

@@ -27,6 +27,8 @@ import UrlSchemaInfoPopup from './UrlSchemaInfoPopup'
const logger = loggerService.withContext('ProviderList')
const BUTTON_WRAPPER_HEIGHT = 50
const systemType = await window.api.system.getDeviceType()
const cpuName = await window.api.system.getCpuName()
const ProviderList: FC = () => {
const [searchParams, setSearchParams] = useSearchParams()
@@ -273,6 +275,10 @@ const ProviderList: FC = () => {
}
const filteredProviders = providers.filter((provider) => {
if (provider.id === 'ovms' && (systemType !== 'windows' || !cpuName.toLowerCase().includes('intel'))) {
return false
}
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
const isProviderMatch = matchKeywordsInProvider(keywords, provider)
const isModelMatch = provider.models.some((model) => matchKeywordsInModel(keywords, model))

View File

@@ -47,6 +47,7 @@ import DMXAPISettings from './DMXAPISettings'
import GithubCopilotSettings from './GithubCopilotSettings'
import GPUStackSettings from './GPUStackSettings'
import LMStudioSettings from './LMStudioSettings'
import OVMSSettings from './OVMSSettings'
import ProviderOAuth from './ProviderOAuth'
import SelectProviderModelPopup from './SelectProviderModelPopup'
import VertexAISettings from './VertexAISettings'
@@ -282,6 +283,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
<Divider style={{ width: '100%', margin: '10px 0' }} />
{isProviderSupportAuth(provider) && <ProviderOAuth providerId={provider.id} />}
{provider.id === 'openai' && <OpenAIAlert />}
{provider.id === 'ovms' && <OVMSSettings />}
{isDmxapi && <DMXAPISettings providerId={provider.id} />}
{provider.id === 'anthropic' && (
<>

View File

@@ -11,9 +11,10 @@ import {
setEnableQuickAssistant,
setReadClipboardAtStartup
} from '@renderer/store/settings'
import { matchKeywordsInString } from '@renderer/utils'
import HomeWindow from '@renderer/windows/mini/home/HomeWindow'
import { Button, Select, Switch, Tooltip } from 'antd'
import { FC } from 'react'
import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -26,9 +27,15 @@ const QuickAssistantSettings: FC = () => {
const dispatch = useAppDispatch()
const { assistants } = useAssistants()
const { quickAssistantId } = useAppSelector((state) => state.llm)
const { defaultAssistant } = useDefaultAssistant()
const { defaultAssistant: _defaultAssistant } = useDefaultAssistant()
const { defaultModel } = useDefaultModel()
// Take the "default assistant" from the assistant list first.
const defaultAssistant = useMemo(
() => assistants.find((a) => a.id === _defaultAssistant.id) || _defaultAssistant,
[assistants, _defaultAssistant]
)
const handleEnableQuickAssistant = async (enable: boolean) => {
dispatch(setEnableQuickAssistant(enable))
await window.api.config.set('enableQuickAssistant', enable, true)
@@ -110,27 +117,39 @@ const QuickAssistantSettings: FC = () => {
value={quickAssistantId || defaultAssistant.id}
style={{ width: 300, height: 34 }}
onChange={(value) => dispatch(setQuickAssistantId(value))}
placeholder={t('settings.models.quick_assistant_selection')}>
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
<AssistantItem>
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
<AssistantName>{defaultAssistant.name}</AssistantName>
<Spacer />
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
</AssistantItem>
</Select.Option>
{assistants
.filter((a) => a.id !== defaultAssistant.id)
.map((a) => (
<Select.Option key={a.id} value={a.id}>
placeholder={t('settings.models.quick_assistant_selection')}
showSearch
options={[
{
key: defaultAssistant.id,
value: defaultAssistant.id,
title: defaultAssistant.name,
label: (
<AssistantItem>
<ModelAvatar model={a.model || defaultModel} size={18} />
<AssistantName>{a.name}</AssistantName>
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
<AssistantName>{defaultAssistant.name}</AssistantName>
<Spacer />
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
</AssistantItem>
</Select.Option>
))}
</Select>
)
},
...assistants
.filter((a) => a.id !== defaultAssistant.id)
.map((a) => ({
key: a.id,
value: a.id,
title: a.name,
label: (
<AssistantItem>
<ModelAvatar model={a.model || defaultModel} size={18} />
<AssistantName>{a.name}</AssistantName>
<Spacer />
</AssistantItem>
)
}))
]}
filterOption={(input, option) => matchKeywordsInString(input, option?.title || '')}
/>
</HStack>
)}
<HStack alignItems="center" gap={0}>

View File

@@ -251,6 +251,68 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
}
}
export async function fetchNoteSummary({ content, assistant }: { content: string; assistant?: Assistant }) {
let prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
const resolvedAssistant = assistant || getDefaultAssistant()
const model = getQuickModel() || resolvedAssistant.model || getDefaultModel()
if (prompt && containsSupportedVariables(prompt)) {
prompt = await replacePromptVariables(prompt, model.name)
}
const provider = getProviderByModel(model)
if (!hasApiKey(provider)) {
return null
}
const AI = new AiProviderNew(model)
// only 2000 char and no images
const truncatedContent = content.substring(0, 2000)
const purifiedContent = purifyMarkdownImages(truncatedContent)
const summaryAssistant = {
...resolvedAssistant,
settings: {
...resolvedAssistant.settings,
reasoning_effort: undefined,
qwenThinkMode: false
},
prompt,
model
}
const llmMessages = {
system: prompt,
prompt: purifiedContent
}
const middlewareConfig: AiSdkMiddlewareConfig = {
streamOutput: false,
enableReasoning: false,
isPromptToolUse: false,
isSupportedToolUse: false,
isImageGenerationEndpoint: false,
enableWebSearch: false,
enableGenerateImage: false,
enableUrlContext: false,
mcpTools: []
}
try {
const { getText } = await AI.completions(model.id, llmMessages, {
...middlewareConfig,
assistant: summaryAssistant,
callType: 'summary'
})
const text = getText()
return removeSpecialCharactersForTopicName(text) || null
} catch (error: any) {
return null
}
}
// export async function fetchSearchSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) {
// const model = getQuickModel() || assistant.model || getDefaultModel()
// const provider = getProviderByModel(model)

View File

@@ -89,6 +89,7 @@ export async function restore() {
}
await handleData(data)
notificationService.send({
id: uuid(),
type: 'success',
@@ -850,6 +851,12 @@ export async function handleData(data: Record<string, any>) {
if (data.version >= 2) {
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
// remove notes_tree from indexedDB
if (data.indexedDB['notes_tree']) {
delete data.indexedDB['notes_tree']
}
await restoreDatabase(data.indexedDB)
if (data.version === 3) {

View File

@@ -1,100 +1,10 @@
import { loggerService } from '@logger'
import db from '@renderer/databases'
import {
findNodeInTree,
findParentNode,
getNotesTree,
insertNodeIntoTree,
isParentNode,
moveNodeInTree,
removeNodeFromTree,
renameNodeFromTree
} from '@renderer/services/NotesTreeService'
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
import { getFileDirectory } from '@renderer/utils'
import { v4 as uuidv4 } from 'uuid'
const MARKDOWN_EXT = '.md'
const NOTES_TREE_ID = 'notes-tree-structure'
const logger = loggerService.withContext('NotesService')
export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' }
/**
* 初始化/同步笔记树结构
*/
export async function initWorkSpace(folderPath: string, sortType: NotesSortType): Promise<void> {
const tree = await window.api.file.getDirectoryStructure(folderPath)
await sortAllLevels(sortType, tree)
}
/**
* 创建新文件夹
*/
export async function createFolder(name: string, folderPath: string): Promise<NotesTreeNode> {
const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false)
if (exists) {
logger.warn(`Folder already exists: ${safeName}`)
}
const tree = await getNotesTree()
const folderId = uuidv4()
const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`)
// 查找父节点ID
const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath)
const folder: NotesTreeNode = {
id: folderId,
name: safeName,
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
externalPath: targetPath,
type: 'folder',
children: [],
expanded: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
insertNodeIntoTree(tree, folder, parentNode?.id)
return folder
}
/**
* 创建新笔记文件
*/
export async function createNote(name: string, content: string = '', folderPath: string): Promise<NotesTreeNode> {
const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true)
if (exists) {
logger.warn(`Note already exists: ${safeName}`)
}
const tree = await getNotesTree()
const noteId = uuidv4()
const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}`
await window.api.file.write(notePath, content)
// 查找父节点ID
const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath)
const note: NotesTreeNode = {
id: noteId,
name: safeName,
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
externalPath: notePath,
type: 'file',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
insertNodeIntoTree(tree, note, parentNode?.id)
return note
}
const MARKDOWN_EXT = '.md'
export interface UploadResult {
uploadedNodes: NotesTreeNode[]
@@ -104,641 +14,195 @@ export interface UploadResult {
folderCount: number
}
/**
* 上传文件或文件夹,支持单个或批量上传,保持文件夹结构
*/
export async function uploadFiles(files: File[], targetFolderPath: string): Promise<UploadResult> {
const tree = await getNotesTree()
const uploadedNodes: NotesTreeNode[] = []
let skippedFiles = 0
const markdownFiles = filterMarkdownFiles(files)
skippedFiles = files.length - markdownFiles.length
if (markdownFiles.length === 0) {
return createEmptyUploadResult(files.length, skippedFiles)
}
// 处理重复的根文件夹名称
const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath)
const { filesByPath, foldersToCreate } = groupFilesByPath(processedFiles, targetFolderPath)
const createdFolders = await createFoldersSequentially(foldersToCreate, targetFolderPath, tree, uploadedNodes)
await uploadAllFiles(filesByPath, targetFolderPath, tree, createdFolders, uploadedNodes)
const fileCount = uploadedNodes.filter((node) => node.type === 'file').length
const folderCount = uploadedNodes.filter((node) => node.type === 'folder').length
return {
uploadedNodes,
totalFiles: files.length,
skippedFiles,
fileCount,
folderCount
}
export async function loadTree(rootPath: string): Promise<NotesTreeNode[]> {
return window.api.file.getDirectoryStructure(normalizePath(rootPath))
}
/**
* 删除笔记或文件夹
*/
export async function deleteNode(nodeId: string): Promise<void> {
const tree = await getNotesTree()
const node = findNodeInTree(tree, nodeId)
if (!node) {
throw new Error('Node not found')
}
export function sortTree(nodes: NotesTreeNode[], sortType: NotesSortType): NotesTreeNode[] {
const cloned = nodes.map((node) => ({
...node,
children: node.children ? sortTree(node.children, sortType) : undefined
}))
const sorter = getSorter(sortType)
cloned.sort((a, b) => {
if (a.type === b.type) {
return sorter(a, b)
}
return a.type === 'folder' ? -1 : 1
})
return cloned
}
export async function addDir(name: string, parentPath: string): Promise<{ path: string; name: string }> {
const basePath = normalizePath(parentPath)
const { safeName } = await window.api.file.checkFileName(basePath, name, false)
const fullPath = `${basePath}/${safeName}`
await window.api.file.mkdir(fullPath)
return { path: fullPath, name: safeName }
}
export async function addNote(
name: string,
content: string = '',
parentPath: string
): Promise<{ path: string; name: string }> {
const basePath = normalizePath(parentPath)
const { safeName } = await window.api.file.checkFileName(basePath, name, true)
const notePath = `${basePath}/${safeName}${MARKDOWN_EXT}`
await window.api.file.write(notePath, content)
return { path: notePath, name: safeName }
}
export async function delNode(node: NotesTreeNode): Promise<void> {
if (node.type === 'folder') {
await window.api.file.deleteExternalDir(node.externalPath)
} else if (node.type === 'file') {
} else {
await window.api.file.deleteExternalFile(node.externalPath)
}
await removeNodeFromTree(tree, nodeId)
}
/**
* 重命名笔记或文件夹
*/
export async function renameNode(nodeId: string, newName: string): Promise<NotesTreeNode> {
const tree = await getNotesTree()
const node = findNodeInTree(tree, nodeId)
if (!node) {
throw new Error('Node not found')
}
const dirPath = getFileDirectory(node.externalPath)
const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file')
export async function renameNode(node: NotesTreeNode, newName: string): Promise<{ path: string; name: string }> {
const isFile = node.type === 'file'
const parentDir = normalizePath(getFileDirectory(node.externalPath))
const { safeName, exists } = await window.api.file.checkFileName(parentDir, newName, isFile)
if (exists) {
logger.warn(`Target name already exists: ${safeName}`)
throw new Error(`Target name already exists: ${safeName}`)
}
if (node.type === 'file') {
if (isFile) {
await window.api.file.rename(node.externalPath, safeName)
} else if (node.type === 'folder') {
await window.api.file.renameDir(node.externalPath, safeName)
return { path: `${parentDir}/${safeName}${MARKDOWN_EXT}`, name: safeName }
}
return renameNodeFromTree(tree, nodeId, safeName)
await window.api.file.renameDir(node.externalPath, safeName)
return { path: `${parentDir}/${safeName}`, name: safeName }
}
/**
* 移动节点
*/
export async function moveNode(
sourceNodeId: string,
targetNodeId: string,
position: 'before' | 'after' | 'inside'
): Promise<MoveNodeResult> {
try {
const tree = await getNotesTree()
export async function uploadNotes(files: File[], targetPath: string): Promise<UploadResult> {
const basePath = normalizePath(targetPath)
const markdownFiles = filterMarkdown(files)
const skippedFiles = files.length - markdownFiles.length
// 找到源节点和目标节点
const sourceNode = findNodeInTree(tree, sourceNodeId)
const targetNode = findNodeInTree(tree, targetNodeId)
if (!sourceNode || !targetNode) {
logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
return { success: false }
if (markdownFiles.length === 0) {
return {
uploadedNodes: [],
totalFiles: files.length,
skippedFiles,
fileCount: 0,
folderCount: 0
}
}
// 不允许文件夹被放入文件中
if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
logger.error('Move nodes failed: cannot move a folder inside a file')
return { success: false }
const folders = collectFolders(markdownFiles, basePath)
await createFolders(folders)
let fileCount = 0
for (const file of markdownFiles) {
const { dir, name } = resolveFileTarget(file, basePath)
const { safeName } = await window.api.file.checkFileName(dir, name, true)
const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}`
try {
const content = await file.text()
await window.api.file.write(finalPath, content)
fileCount += 1
} catch (error) {
logger.error('Failed to write uploaded file:', error as Error)
}
}
// 不允许将节点移动到自身内部
if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) {
logger.error('Move nodes failed: cannot move a node inside itself or its descendants')
return { success: false }
}
let targetPath: string = ''
if (position === 'inside') {
// 目标是文件夹内部
if (targetNode.type === 'folder') {
targetPath = targetNode.externalPath
} else {
logger.error('Cannot move node inside a file node')
return { success: false }
}
} else {
const targetParent = findParentNode(tree, targetNodeId)
if (targetParent) {
targetPath = targetParent.externalPath
} else {
targetPath = getFileDirectory(targetNode.externalPath!)
}
}
// 检查是否为同级拖动排序
const sourceParent = findParentNode(tree, sourceNodeId)
const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!)
const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath
if (isSameLevelReorder) {
// 同级拖动排序:跳过文件系统操作,只更新树结构
logger.debug(`Same level reorder detected, skipping file system operations`)
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
// 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序
return success ? { success: true, type: 'manual_reorder' } : { success: false }
}
// 构建新的文件路径
const sourceName = sourceNode.externalPath!.split('/').pop()!
const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '')
const { safeName } = await window.api.file.checkFileName(
targetPath,
sourceNameWithoutExt,
sourceNode.type === 'file'
)
const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '')
const newPath = `${targetPath}/${baseName}`
if (sourceNode.externalPath !== newPath) {
try {
if (sourceNode.type === 'folder') {
await window.api.file.moveDir(sourceNode.externalPath, newPath)
} else {
await window.api.file.move(sourceNode.externalPath, newPath)
}
sourceNode.externalPath = newPath
logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`)
} catch (error) {
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
return { success: false }
}
}
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
return success ? { success: true, type: 'file_system_move' } : { success: false }
} catch (error) {
logger.error('Move nodes failed:', error as Error)
return { success: false }
return {
uploadedNodes: [],
totalFiles: files.length,
skippedFiles,
fileCount,
folderCount: folders.size
}
}
/**
* 对节点数组进行排序
*/
function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void {
// 首先分离文件夹和文件
const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder')
const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file')
// 根据排序类型对文件夹和文件分别进行排序
const sortFunction = getSortFunction(sortType)
folders.sort(sortFunction)
files.sort(sortFunction)
// 清空原数组并重新填入排序后的节点
nodes.length = 0
nodes.push(...folders, ...files)
}
/**
* 根据排序类型获取相应的排序函数
*/
function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
switch (sortType) {
case 'sort_a2z':
return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' })
case 'sort_z2a':
return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' })
case 'sort_updated_desc':
return (a, b) => {
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
return timeB - timeA
}
return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt)
case 'sort_updated_asc':
return (a, b) => {
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
return timeA - timeB
}
return (a, b) => getTime(a.updatedAt) - getTime(b.updatedAt)
case 'sort_created_desc':
return (a, b) => {
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return timeB - timeA
}
return (a, b) => getTime(b.createdAt) - getTime(a.createdAt)
case 'sort_created_asc':
return (a, b) => {
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return timeA - timeB
}
return (a, b) => getTime(a.createdAt) - getTime(b.createdAt)
default:
return (a, b) => a.name.localeCompare(b.name)
}
}
/**
* 递归排序笔记树中的所有层级
*/
export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise<void> {
try {
if (!tree) {
tree = await getNotesTree()
}
sortNodesArray(tree, sortType)
recursiveSortNodes(tree, sortType)
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
logger.info(`Sorted all levels of notes successfully: ${sortType}`)
} catch (error) {
logger.error('Failed to sort all levels of notes:', error as Error)
throw error
}
function getTime(value?: string): number {
return value ? new Date(value).getTime() : 0
}
/**
* 递归对节点中的子节点进行排序
*/
function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void {
for (const node of nodes) {
if (node.type === 'folder' && node.children && node.children.length > 0) {
sortNodesArray(node.children, sortType)
recursiveSortNodes(node.children, sortType)
}
}
function normalizePath(value: string): string {
return value.replace(/\\/g, '/')
}
/**
* 根据外部路径查找节点(递归查找)
*/
function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null {
for (const node of nodes) {
if (node.externalPath === externalPath) {
return node
}
if (node.children && node.children.length > 0) {
const found = findNodeByExternalPath(node.children, externalPath)
if (found) {
return found
}
}
}
return null
function filterMarkdown(files: File[]): File[] {
return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT))
}
/**
* 过滤出 Markdown 文件
*/
function filterMarkdownFiles(files: File[]): File[] {
return Array.from(files).filter((file) => {
if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
return true
function collectFolders(files: File[], basePath: string): Set<string> {
const folders = new Set<string>()
files.forEach((file) => {
const relativePath = file.webkitRelativePath || ''
if (!relativePath.includes('/')) {
return
}
const parts = relativePath.split('/')
parts.pop()
let current = basePath
for (const part of parts) {
current = `${current}/${part}`
folders.add(current)
}
logger.warn(`Skipping non-markdown file: ${file.name}`)
return false
})
return folders
}
/**
* 创建空的上传结果
*/
function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult {
return {
uploadedNodes: [],
totalFiles,
skippedFiles,
fileCount: 0,
folderCount: 0
}
}
/**
* 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath
*/
async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise<File[]> {
// 按根文件夹名称分组文件
const filesByRootFolder = new Map<string, File[]>()
const processedFiles: File[] = []
for (const file of markdownFiles) {
const filePath = file.webkitRelativePath || file.name
if (filePath.includes('/')) {
const rootFolderName = filePath.substring(0, filePath.indexOf('/'))
if (!filesByRootFolder.has(rootFolderName)) {
filesByRootFolder.set(rootFolderName, [])
}
filesByRootFolder.get(rootFolderName)!.push(file)
} else {
// 单个文件,直接添加
processedFiles.push(file)
}
}
// 为每个根文件夹组生成唯一的文件夹名称
for (const [rootFolderName, files] of filesByRootFolder.entries()) {
const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false)
for (const file of files) {
// 创建一个新的 File 对象,并修改 webkitRelativePath
const originalPath = file.webkitRelativePath || file.name
const relativePath = originalPath.substring(originalPath.indexOf('/') + 1)
const newPath = `${safeName}/${relativePath}`
const newFile = new File([file], file.name, {
type: file.type,
lastModified: file.lastModified
})
Object.defineProperty(newFile, 'webkitRelativePath', {
value: newPath,
writable: false
})
processedFiles.push(newFile)
}
}
return processedFiles
}
/**
* 按路径分组文件并收集需要创建的文件夹
*/
function groupFilesByPath(
markdownFiles: File[],
targetFolderPath: string
): { filesByPath: Map<string, File[]>; foldersToCreate: Set<string> } {
const filesByPath = new Map<string, File[]>()
const foldersToCreate = new Set<string>()
for (const file of markdownFiles) {
const filePath = file.webkitRelativePath || file.name
const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : ''
const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath
if (relativeDirPath) {
const pathParts = relativeDirPath.split('/')
let currentPath = targetFolderPath
for (const part of pathParts) {
currentPath = `${currentPath}/${part}`
foldersToCreate.add(currentPath)
}
}
if (!filesByPath.has(fullDirPath)) {
filesByPath.set(fullDirPath, [])
}
filesByPath.get(fullDirPath)!.push(file)
}
return { filesByPath, foldersToCreate }
}
/**
* 顺序创建文件夹(避免竞争条件)
*/
async function createFoldersSequentially(
foldersToCreate: Set<string>,
targetFolderPath: string,
tree: NotesTreeNode[],
uploadedNodes: NotesTreeNode[]
): Promise<Map<string, NotesTreeNode>> {
const createdFolders = new Map<string, NotesTreeNode>()
const sortedFolders = Array.from(foldersToCreate).sort()
const folderCreationLock = new Set<string>()
for (const folderPath of sortedFolders) {
if (folderCreationLock.has(folderPath)) {
continue
}
folderCreationLock.add(folderPath)
async function createFolders(folders: Set<string>): Promise<void> {
const ordered = Array.from(folders).sort((a, b) => a.length - b.length)
for (const folder of ordered) {
try {
const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders)
if (result) {
createdFolders.set(folderPath, result)
if (result.externalPath !== folderPath) {
createdFolders.set(result.externalPath, result)
}
uploadedNodes.push(result)
logger.debug(`Created folder: ${folderPath} -> ${result.externalPath}`)
}
await window.api.file.mkdir(folder)
} catch (error) {
logger.error(`Failed to create folder ${folderPath}:`, error as Error)
} finally {
folderCreationLock.delete(folderPath)
logger.debug('Skip existing folder while uploading notes', {
folder,
error: (error as Error).message
})
}
}
return createdFolders
}
/**
* 创建单个文件夹
*/
async function createSingleFolder(
folderPath: string,
targetFolderPath: string,
tree: NotesTreeNode[],
createdFolders: Map<string, NotesTreeNode>
): Promise<NotesTreeNode | null> {
const existingNode = findNodeByExternalPath(tree, folderPath)
if (existingNode) {
return existingNode
}
const relativePath = folderPath.replace(targetFolderPath + '/', '')
const originalFolderName = relativePath.split('/').pop()!
const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/'))
const { safeName: safeFolderName, exists } = await window.api.file.checkFileName(
parentFolderPath,
originalFolderName,
false
)
const actualFolderPath = `${parentFolderPath}/${safeFolderName}`
if (exists) {
logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`)
}
try {
await window.api.file.mkdir(actualFolderPath)
} catch (error) {
logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error)
}
let parentNode: NotesTreeNode | null
if (parentFolderPath === targetFolderPath) {
parentNode =
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
} else {
parentNode = createdFolders.get(parentFolderPath) || null
if (!parentNode) {
parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null
if (!parentNode) {
parentNode = findNodeByExternalPath(tree, parentFolderPath)
}
}
}
const folderId = uuidv4()
const folder: NotesTreeNode = {
id: folderId,
name: safeFolderName,
treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`,
externalPath: actualFolderPath,
type: 'folder',
children: [],
expanded: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
await insertNodeIntoTree(tree, folder, parentNode?.id)
return folder
}
/**
* 读取文件内容(支持大文件处理)
*/
async function readFileContent(file: File): Promise<string> {
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
if (file.size > MAX_FILE_SIZE) {
logger.warn(
`Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.`
)
}
try {
return await file.text()
} catch (error) {
logger.error(`Failed to read file content for ${file.name}:`, error as Error)
throw new Error(`Failed to read file content: ${file.name}`)
}
}
/**
* 上传所有文件
*/
async function uploadAllFiles(
filesByPath: Map<string, File[]>,
targetFolderPath: string,
tree: NotesTreeNode[],
createdFolders: Map<string, NotesTreeNode>,
uploadedNodes: NotesTreeNode[]
): Promise<void> {
const uploadPromises: Promise<NotesTreeNode | null>[] = []
for (const [dirPath, dirFiles] of filesByPath.entries()) {
for (const file of dirFiles) {
const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders)
.then((result) => {
if (result) {
logger.debug(`Uploaded file: ${result.externalPath}`)
}
return result
})
.catch((error) => {
logger.error(`Failed to upload file ${file.name}:`, error as Error)
return null
})
uploadPromises.push(uploadPromise)
}
function resolveFileTarget(file: File, basePath: string): { dir: string; name: string } {
if (!file.webkitRelativePath || !file.webkitRelativePath.includes('/')) {
const nameWithoutExt = file.name.endsWith(MARKDOWN_EXT) ? file.name.slice(0, -MARKDOWN_EXT.length) : file.name
return { dir: basePath, name: nameWithoutExt }
}
const results = await Promise.all(uploadPromises)
const parts = file.webkitRelativePath.split('/')
const fileName = parts.pop() || file.name
const dirPath = `${basePath}/${parts.join('/')}`
const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT) ? fileName.slice(0, -MARKDOWN_EXT.length) : fileName
results.forEach((result) => {
if (result) {
uploadedNodes.push(result)
}
})
}
/**
* 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点
*/
async function uploadSingleFile(
file: File,
originalDirPath: string,
targetFolderPath: string,
tree: NotesTreeNode[],
createdFolders: Map<string, NotesTreeNode>
): Promise<NotesTreeNode | null> {
const fileName = (file.webkitRelativePath || file.name).split('/').pop()!
const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '')
let actualDirPath = originalDirPath
let parentNode: NotesTreeNode | null = null
if (originalDirPath === targetFolderPath) {
parentNode =
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
if (!parentNode) {
logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`)
}
} else {
parentNode = createdFolders.get(originalDirPath) || null
if (!parentNode) {
parentNode = tree.find((node) => node.externalPath === originalDirPath) || null
if (!parentNode) {
parentNode = findNodeByExternalPath(tree, originalDirPath)
}
}
if (!parentNode) {
for (const [originalPath, createdNode] of createdFolders.entries()) {
if (originalPath === originalDirPath) {
parentNode = createdNode
actualDirPath = createdNode.externalPath
break
}
}
}
if (!parentNode) {
logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`)
return null
}
}
const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true)
if (exists) {
logger.warn(`Note already exists, will be overwritten: ${safeName}`)
}
const notePath = `${actualDirPath}/${safeName}${MARKDOWN_EXT}`
const noteId = uuidv4()
const note: NotesTreeNode = {
id: noteId,
name: safeName,
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
externalPath: notePath,
type: 'file',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
const content = await readFileContent(file)
await window.api.file.write(notePath, content)
await insertNodeIntoTree(tree, note, parentNode?.id)
return note
return { dir: dirPath, name: nameWithoutExt }
}

View File

@@ -1,217 +1,47 @@
import { loggerService } from '@logger'
import db from '@renderer/databases'
import { NotesTreeNode } from '@renderer/types/note'
const MARKDOWN_EXT = '.md'
const NOTES_TREE_ID = 'notes-tree-structure'
const logger = loggerService.withContext('NotesTreeService')
/**
* 获取树结构
*/
export const getNotesTree = async (): Promise<NotesTreeNode[]> => {
const record = await db.notes_tree.get(NOTES_TREE_ID)
return record?.tree || []
export function normalizePathValue(path: string): string {
return path.replace(/\\/g, '/')
}
/**
* 在树中插入节点
*/
export async function insertNodeIntoTree(
tree: NotesTreeNode[],
node: NotesTreeNode,
parentId?: string
): Promise<NotesTreeNode[]> {
try {
if (!parentId) {
tree.push(node)
} else {
const parent = findNodeInTree(tree, parentId)
if (parent && parent.type === 'folder') {
if (!parent.children) {
parent.children = []
}
parent.children.push(node)
}
}
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
return tree
} catch (error) {
logger.error('Failed to insert node into tree:', error as Error)
throw error
}
export function addUniquePath(list: string[], path: string): string[] {
const normalized = normalizePathValue(path)
return list.includes(normalized) ? list : [...list, normalized]
}
/**
* 从树中删除节点
*/
export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise<boolean> {
const removed = removeNodeFromTreeInMemory(tree, nodeId)
if (removed) {
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
}
return removed
}
/**
* 从树中删除节点(仅在内存中操作,不保存数据库)
*/
function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean {
for (let i = 0; i < tree.length; i++) {
if (tree[i].id === nodeId) {
tree.splice(i, 1)
return true
}
if (tree[i].children) {
const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId)
if (removed) {
return true
}
}
}
return false
}
export async function moveNodeInTree(
tree: NotesTreeNode[],
sourceNodeId: string,
targetNodeId: string,
position: 'before' | 'after' | 'inside'
): Promise<boolean> {
try {
const sourceNode = findNodeInTree(tree, sourceNodeId)
const targetNode = findNodeInTree(tree, targetNodeId)
if (!sourceNode || !targetNode) {
logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
export function removePathEntries(list: string[], path: string, deep: boolean): string[] {
const normalized = normalizePathValue(path)
const prefix = `${normalized}/`
return list.filter((item) => {
if (item === normalized) {
return false
}
return !(deep && item.startsWith(prefix))
})
}
// 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序
const sourceParent = findParentNode(tree, sourceNodeId)
const targetParent = findParentNode(tree, targetNodeId)
// 从原位置移除节点(不保存数据库,只在内存中操作)
const removed = removeNodeFromTreeInMemory(tree, sourceNodeId)
if (!removed) {
logger.error('Move nodes in tree failed: could not remove source node')
return false
export function replacePathEntries(list: string[], oldPath: string, newPath: string, deep: boolean): string[] {
const oldNormalized = normalizePathValue(oldPath)
const newNormalized = normalizePathValue(newPath)
const prefix = `${oldNormalized}/`
return list.map((item) => {
if (item === oldNormalized) {
return newNormalized
}
try {
// 根据位置进行放置
if (position === 'inside' && targetNode.type === 'folder') {
if (!targetNode.children) {
targetNode.children = []
}
targetNode.children.push(sourceNode)
targetNode.expanded = true
sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}`
} else {
const targetList = targetParent ? targetParent.children! : tree
const targetIndex = targetList.findIndex((node) => node.id === targetNodeId)
if (targetIndex === -1) {
logger.error('Move nodes in tree failed: target position not found')
return false
}
// 根据position确定插入位置
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
targetList.splice(insertIndex, 0, sourceNode)
// 检查是否为同级排序,如果是则保持原有的 treePath
const isSameLevelReorder = sourceParent === targetParent
// 只有在跨级移动时才更新节点路径
if (!isSameLevelReorder) {
if (targetParent) {
sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}`
} else {
sourceNode.treePath = `/${sourceNode.name}`
}
}
}
// 更新修改时间
sourceNode.updatedAt = new Date().toISOString()
// 只有在所有操作成功后才保存到数据库
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
return true
} catch (error) {
logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error)
// 如果放置失败,尝试恢复原始节点到原位置
// 这里需要重新实现恢复逻辑暂时返回false
return false
if (deep && item.startsWith(prefix)) {
return `${newNormalized}${item.slice(oldNormalized.length)}`
}
} catch (error) {
logger.error('Move nodes in tree failed:', error as Error)
return false
}
return item
})
}
/**
* 重命名节点
*/
export async function renameNodeFromTree(
tree: NotesTreeNode[],
nodeId: string,
newName: string
): Promise<NotesTreeNode> {
const node = findNodeInTree(tree, nodeId)
if (!node) {
throw new Error('Node not found')
}
node.name = newName
const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1)
node.treePath = dirPath + newName
const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1)
node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName
node.updatedAt = new Date().toISOString()
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
return node
}
/**
* 修改节点键值
*/
export async function updateNodeInTree(
tree: NotesTreeNode[],
nodeId: string,
updates: Partial<NotesTreeNode>
): Promise<NotesTreeNode> {
const node = findNodeInTree(tree, nodeId)
if (!node) {
throw new Error('Node not found')
}
Object.assign(node, updates)
node.updatedAt = new Date().toISOString()
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
return node
}
/**
* 在树中查找节点
*/
export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
export function findNode(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
for (const node of tree) {
if (node.id === nodeId) {
return node
}
if (node.children) {
const found = findNodeInTree(node.children, nodeId)
const found = findNode(node.children, nodeId)
if (found) {
return found
}
@@ -220,16 +50,13 @@ export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTree
return null
}
/**
* 根据路径查找节点
*/
export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null {
export function findNodeByPath(tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null {
for (const node of tree) {
if (node.treePath === path) {
if (node.treePath === targetPath || node.externalPath === targetPath) {
return node
}
if (node.children) {
const found = findNodeByPath(node.children, path)
const found = findNodeByPath(node.children, targetPath)
if (found) {
return found
}
@@ -238,53 +65,113 @@ export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNo
return null
}
// ---
// 辅助函数
// ---
export function updateTreeNode(
nodes: NotesTreeNode[],
nodeId: string,
updater: (node: NotesTreeNode) => NotesTreeNode
): NotesTreeNode[] {
let changed = false
/**
* 查找节点的父节点
*/
export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null {
const nextNodes = nodes.map((node) => {
if (node.id === nodeId) {
changed = true
const updated = updater(node)
if (updated.type === 'folder' && !updated.children) {
return { ...updated, children: [] }
}
return updated
}
if (node.children && node.children.length > 0) {
const updatedChildren = updateTreeNode(node.children, nodeId, updater)
if (updatedChildren !== node.children) {
changed = true
return { ...node, children: updatedChildren }
}
}
return node
})
return changed ? nextNodes : nodes
}
export function findParent(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
for (const node of tree) {
if (node.children) {
const isDirectChild = node.children.some((child) => child.id === targetNodeId)
if (isDirectChild) {
return node
}
const parent = findParentNode(node.children, targetNodeId)
if (parent) {
return parent
}
if (!node.children) {
continue
}
if (node.children.some((child) => child.id === nodeId)) {
return node
}
const found = findParent(node.children, nodeId)
if (found) {
return found
}
}
return null
}
/**
* 判断节点是否为另一个节点的父节点
*/
export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean {
const childNode = findNodeInTree(tree, childId)
if (!childNode) {
return false
export function reorderTreeNodes(
nodes: NotesTreeNode[],
sourceId: string,
targetId: string,
position: 'before' | 'after'
): NotesTreeNode[] {
const [updatedNodes, moved] = reorderSiblings(nodes, sourceId, targetId, position)
if (moved) {
return updatedNodes
}
const parentNode = findNodeInTree(tree, parentId)
if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) {
return false
}
if (parentNode.children.some((child) => child.id === childId)) {
return true
}
for (const child of parentNode.children) {
if (isParentNode(tree, child.id, childId)) {
return true
let changed = false
const nextNodes = nodes.map((node) => {
if (!node.children || node.children.length === 0) {
return node
}
const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position)
if (reorderedChildren !== node.children) {
changed = true
return { ...node, children: reorderedChildren }
}
return node
})
return changed ? nextNodes : nodes
}
function reorderSiblings(
nodes: NotesTreeNode[],
sourceId: string,
targetId: string,
position: 'before' | 'after'
): [NotesTreeNode[], boolean] {
const sourceIndex = nodes.findIndex((node) => node.id === sourceId)
const targetIndex = nodes.findIndex((node) => node.id === targetId)
if (sourceIndex === -1 || targetIndex === -1) {
return [nodes, false]
}
return false
const updated = [...nodes]
const [sourceNode] = updated.splice(sourceIndex, 1)
let insertIndex = targetIndex
if (sourceIndex < targetIndex) {
insertIndex -= 1
}
if (position === 'after') {
insertIndex += 1
}
if (insertIndex < 0) {
insertIndex = 0
}
if (insertIndex > updated.length) {
insertIndex = updated.length
}
updated.splice(insertIndex, 0, sourceNode)
return [updated, true]
}

View File

@@ -15,7 +15,7 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
// 内部维护的状态
let thinkingBlockId: string | null = null
let _thinking_millsec = 0
let thinking_millsec_now: number = 0
return {
onThinkingStart: async () => {
@@ -24,27 +24,27 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
type: MessageBlockType.THINKING,
content: '',
status: MessageBlockStatus.STREAMING,
thinking_millsec: _thinking_millsec
thinking_millsec: 0
}
thinkingBlockId = blockManager.initialPlaceholderBlockId!
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
} else if (!thinkingBlockId) {
const newBlock = createThinkingBlock(assistantMsgId, '', {
status: MessageBlockStatus.STREAMING,
thinking_millsec: _thinking_millsec
thinking_millsec: 0
})
thinkingBlockId = newBlock.id
await blockManager.handleBlockTransition(newBlock, MessageBlockType.THINKING)
}
thinking_millsec_now = performance.now()
},
onThinkingChunk: async (text: string, thinking_millsec?: number) => {
_thinking_millsec = thinking_millsec || 0
onThinkingChunk: async (text: string) => {
if (thinkingBlockId) {
const blockChanges: Partial<MessageBlock> = {
content: text,
status: MessageBlockStatus.STREAMING,
thinking_millsec: _thinking_millsec
status: MessageBlockStatus.STREAMING
// thinking_millsec: performance.now() - thinking_millsec_now
}
blockManager.smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING)
}
@@ -52,14 +52,15 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
onThinkingComplete: (finalText: string) => {
if (thinkingBlockId) {
const now = performance.now()
const changes: Partial<MessageBlock> = {
content: finalText,
status: MessageBlockStatus.SUCCESS,
thinking_millsec: _thinking_millsec
thinking_millsec: now - thinking_millsec_now
}
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
thinkingBlockId = null
_thinking_millsec = 0
thinking_millsec_now = 0
} else {
logger.warn(
`[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${blockManager.lastBlockType}) or lastBlockId is null.`

View File

@@ -26,12 +26,17 @@ export const initialState: CodeToolsState = {
[codeTools.qwenCode]: null,
[codeTools.claudeCode]: null,
[codeTools.geminiCli]: null,
[codeTools.openaiCodex]: null
[codeTools.openaiCodex]: null,
[codeTools.iFlowCli]: null,
[codeTools.githubCopilotCli]: null
},
environmentVariables: {
'qwen-code': '',
'claude-code': '',
'gemini-cli': ''
'gemini-cli': '',
'openai-codex': '',
'iflow-cli': '',
'github-copilot-cli': ''
},
directories: [],
currentDirectory: '',
@@ -63,7 +68,10 @@ const codeToolsSlice = createSlice({
state.environmentVariables = {
'qwen-code': '',
'claude-code': '',
'gemini-cli': ''
'gemini-cli': '',
'openai-codex': '',
'iflow-cli': '',
'github-copilot-cli': ''
}
}
state.environmentVariables[state.selectedCliTool] = action.payload

View File

@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 158,
version: 159,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -85,6 +85,15 @@ function addProvider(state: RootState, id: string) {
}
}
// Fix missing provider
function fixMissingProvider(state: RootState) {
SYSTEM_PROVIDERS.forEach((p) => {
if (!state.llm.providers.find((provider) => provider.id === p.id)) {
state.llm.providers.push(p)
}
})
}
// add ocr provider
function addOcrProvider(state: RootState, provider: BuiltinOcrProvider) {
if (!state.ocr.providers.find((p) => p.id === provider.id)) {
@@ -2543,11 +2552,22 @@ const migrateConfig = {
'158': (state: RootState) => {
try {
state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin')
addProvider(state, 'longcat')
return state
} catch (error) {
logger.error('migrate 158 error', error as Error)
return state
}
},
'159': (state: RootState) => {
try {
addProvider(state, 'ovms')
fixMissingProvider(state)
return state
} catch (error) {
logger.error('migrate 159 error', error as Error)
return state
}
}
}

View File

@@ -20,6 +20,8 @@ export interface NoteState {
settings: NotesSettings
notesPath: string
sortType: NotesSortType
starredPaths: string[]
expandedPaths: string[]
}
export const initialState: NoteState = {
@@ -36,7 +38,9 @@ export const initialState: NoteState = {
showWorkspace: true
},
notesPath: '',
sortType: 'sort_a2z'
sortType: 'sort_a2z',
starredPaths: [],
expandedPaths: []
}
const noteSlice = createSlice({
@@ -57,16 +61,32 @@ const noteSlice = createSlice({
},
setSortType: (state, action: PayloadAction<NotesSortType>) => {
state.sortType = action.payload
},
setStarredPaths: (state, action: PayloadAction<string[]>) => {
state.starredPaths = action.payload ?? []
},
setExpandedPaths: (state, action: PayloadAction<string[]>) => {
state.expandedPaths = action.payload ?? []
}
}
})
export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath, setSortType } = noteSlice.actions
export const {
setActiveNodeId,
setActiveFilePath,
updateNotesSettings,
setNotesPath,
setSortType,
setStarredPaths,
setExpandedPaths
} = noteSlice.actions
export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId
export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath
export const selectNotesSettings = (state: RootState) => state.note.settings
export const selectNotesPath = (state: RootState) => state.note.notesPath
export const selectSortType = (state: RootState) => state.note.sortType
export const selectStarredPaths = (state: RootState) => state.note.starredPaths ?? []
export const selectExpandedPaths = (state: RootState) => state.note.expandedPaths ?? []
export default noteSlice.reducer

View File

@@ -425,7 +425,10 @@ describe('streamCallback Integration Tests', () => {
expect(thinkingBlock).toBeDefined()
expect(thinkingBlock?.content).toBe('Final thoughts')
expect(thinkingBlock?.status).toBe(MessageBlockStatus.SUCCESS)
expect((thinkingBlock as any)?.thinking_millsec).toBe(3000)
// thinking_millsec 现在是本地计算的,只验证它存在且是一个合理的数字
expect((thinkingBlock as any)?.thinking_millsec).toBeDefined()
expect(typeof (thinkingBlock as any)?.thinking_millsec).toBe('number')
expect((thinkingBlock as any)?.thinking_millsec).toBeGreaterThanOrEqual(0)
})
it('should handle tool call flow', async () => {

Some files were not shown because too many files have changed in this diff Show More