Compare commits

...

55 Commits

Author SHA1 Message Date
kangfenmao
170632a199 chore: bump version to 1.6.4 and update release notes
- Updated version in package.json to 1.6.4.
- Revised release notes to reflect new features, bug fixes, and technical updates.
- Added new features including CherryIN provider, right-click context menu for notes, and search functionality in the mini app page.
- Fixed issues related to reasoning block insertion order, knowledge base deletion, and Qwen model URL configuration.
2025-10-11 14:21:06 +08:00
ABucket
cd5841cdd4 fix: Provider icons are not displayed after selecting SiliconFlow in the "images" page (#10620) 2025-10-11 14:21:06 +08:00
ABucket
763afc5ca2 fix: Quick Assistant fails to correctly inject variables in prompts (#10617) 2025-10-11 14:21:06 +08:00
ABucket
45f033ff4e fix: AI_TypeValidationError when calling Ling-1T model (#10622) 2025-10-11 14:21:06 +08:00
kangfenmao
f8fadcc73f fix: adjust overflow properties in MessageGroup component
- Changed overflow properties in the GridContainer styled component to improve layout handling. Overflow is now set to hidden for vertical alignment.
2025-10-11 14:01:32 +08:00
kangfenmao
a94e5dad5f feat: remove some minapp and update related configurations
- Introduced new app icon for Stepfun.
- Updated minapps configuration to include Stepfun with its logo and URL.
- Removed Yuewen app from configurations and translations.
- Updated translations for multiple languages to reflect the addition of Stepfun and removal of Yuewen.
- Incremented version in the store configuration and added migration logic for new provider integration.
2025-10-11 11:54:37 +08:00
kangfenmao
632fd4c567 chore: update @ai-sdk/google to version 2.0.17 and add corresponding patch 2025-10-11 11:43:29 +08:00
ABucket
401e17eb0e feat: allow right click to create note and folder (#10523)
* feat: allow right click to create note and folder

* fix: duplicate menu for notes or folder

* fix: create notes in folder when a folder is selected
2025-10-11 10:29:00 +08:00
beyondkmp
80fc118465 feat: support search in mini app page (#10609)
*  feat: add webview find-in-page overlay

* 🐛 fix: reset webview search on tab change

* fix clear search issue

* 🐛 fix: rebind webview search events

* 🐛 fix: disable spellcheck in search input

* fix spellcheck

* 🐛 fix: webview search can now reopen after closing

Fixed an issue where the search overlay couldn't be reopened after closing.
The openSearch callback was unnecessarily depending on webviewRef.current,
causing event listener rebinding issues. Removed the redundant webviewRef
check as isWebviewReady is sufficient to ensure webview readiness.

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

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

---------

Co-authored-by: Payne Fu <payne@Paynes-Mac-mini.rcoffice.ringcentral.com>
Co-authored-by: Payne Fu <payne@Paynes-MBP.rcoffice.ringcentral.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-11 10:28:51 +08:00
Tristan Zhang
9a8d7640f5 fix: insert reasoning block before the content block (#10545)
fix: always insert reasoning block before the content block
2025-10-11 10:28:43 +08:00
Chen Tao
2b3f6d5640 fix: knowledge base not delete and websearch rag error (#10595)
* fix: knowledge base not  delete

* fix: websearch rag error

* chore: add comment
2025-10-11 10:28:29 +08:00
beyondkmp
a2d81e6204 feat: add updating dialog in render (#10569)
* feat: replace update dialog handling with quit and install functionality

* refactor: remove App_ShowUpdateDialog and implement App_QuitAndInstall in IpcChannel
* update ipc.ts to handle quit and install action
* modify AppUpdater to include quitAndInstall method
* adjust preload index to invoke new quit and install action
* enhance AboutSettings to manage update dialog state and trigger quit and install

* fix(AboutSettings): handle null update info in update dialog state management

* fix(UpdateDialog): improve error handling during update installation and enhance release notes processing

* fix(AppUpdater): remove redundant assignment of releaseInfo after update download

* fix(IpcChannel): remove UpdateDownloadedCancelled enum value

* format code

* fix(UpdateDialog): enhance installation process with loading state and error handling

* update i18n

* fix(i18n): Auto update translations for PR #10569

* feat(UpdateAppButton): integrate UpdateDialog and update button functionality for better user experience

* fix(UpdateDialog): update installation handler to support async operation and ensure modal closes after installation

* refactor(AppUpdater.test): remove deprecated formatReleaseNotes tests to streamline test suite

* refactor(update-dialog): simplify dialog close handling

Replace onOpenChange with onClose prop to directly handle dialog closing
Remove redundant handleClose function and simplify button onPress handler

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-11 10:27:52 +08:00
Tristan Zhang
b6107c5fb1 fix: change the url for qwen (#10584) 2025-10-11 10:27:42 +08:00
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
128 changed files with 5277 additions and 2055 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 +0,0 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -447,7 +447,10 @@ 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}`;
}
@@ -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,

View File

@@ -0,0 +1,13 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -452,7 +452,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -125,59 +125,17 @@ 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.4
🎨 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:
- Providers: add CherryIN provider
- Notes: Add right-click context menu to create notes and folders
- Mini App: Add search functionality in mini app page
- Update Dialog: Add updating dialog in renderer process
- Mini App: Remove some mini apps
🎨 界面改进与问题修复:
- 集成 HeroUI 和 Tailwind CSS 框架
- 优化消息通知样式,统一 toast 组件
- 免费模型移至底部固定位置,便于访问
- 重构快捷面板和输入栏工具,操作更流畅
- 优化导航栏和侧边栏响应式设计
- 改进滚动条组件,支持水平滚动
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
- 各种界面优化和问题修复
<!--LANG:END-->
Bug Fixes:
- Fix reasoning block insertion order - now inserts before content block
- Fix knowledge base deletion and web search RAG errors
- Fix Qwen model URL configuration

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.4",
"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",
@@ -369,7 +369,7 @@
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
"@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

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

@@ -5,8 +5,8 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
@@ -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',
@@ -227,7 +229,6 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -330,6 +331,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()
@@ -130,7 +132,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -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

@@ -1,17 +1,15 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog, net } from 'electron'
import { app, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -66,7 +63,6 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
@@ -247,37 +243,9 @@ export default class AppUpdater {
}
}
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
}
/**
@@ -349,38 +317,9 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

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

@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file'
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
@@ -147,11 +147,16 @@ class KnowledgeService {
}
}
private getDbPath = (id: string): string => {
// 消除网络搜索requestI d中的特殊字符
return path.join(this.storageDir, sanitizeFilename(id, '_'))
}
/**
* Delete knowledge base file
*/
private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = path.join(this.storageDir, id)
const dbPath = this.getDbPath(id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -244,7 +249,8 @@ class KnowledgeService {
dimensions
})
try {
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)

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

@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})

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

@@ -51,7 +51,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -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)
@@ -220,7 +221,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
add: ({
base,
item,
@@ -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

@@ -23,6 +23,7 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
@@ -166,7 +167,7 @@ export async function buildStreamTextParams(
params.tools = tools
}
if (assistant.prompt) {
params.system = assistant.prompt
params.system = await replacePromptVariables(assistant.prompt, model.name)
}
logger.debug('params', params)
return {

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.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

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

@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
{!isReady && (
<EmptyView>
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
<Avatar
src={currentAppInfo?.logo}
size={80}

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

@@ -0,0 +1,101 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store'
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
const logger = loggerService.withContext('UpdateDialog')
interface UpdateDialogProps {
isOpen: boolean
onClose: () => void
releaseInfo: UpdateInfo | null
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (isOpen && releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [isOpen, releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[85vh]',
header: 'border-b border-divider',
footer: 'border-t border-divider'
}}>
<ModalContent>
{(onModalClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
<p className="text-default-500 text-small">
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
</p>
</ModalHeader>
<ModalBody>
<ScrollShadow className="max-h-[450px]" hideScrollBar>
<div className="markdown rounded-lg bg-default-50 p-4">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</div>
</ScrollShadow>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
{t('update.later')}
</Button>
<Button
color="primary"
onPress={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
export default UpdateDialog

View File

@@ -39,6 +39,7 @@ import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
@@ -46,7 +47,6 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
@@ -145,14 +145,14 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'dashscope',
name: i18n.t('minapps.qwen'),
url: 'https://tongyi.aliyun.com/qianwen/',
url: 'https://www.tongyi.com/',
logo: QwenModelLogo
},
{
id: 'stepfun',
name: i18n.t('minapps.yuewen'),
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo,
name: i18n.t('minapps.stepfun'),
url: 'https://stepfun.com',
logo: StepfunAppLogo,
bodered: true
},
{

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
// cherryin: [],
cherryin: [],
vertexai: [],
'302ai': [
{
@@ -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'
@@ -78,16 +80,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
}
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
// cherryin: {
// id: 'cherryin',
// name: 'CherryIN',
// type: 'openai',
// apiKey: '',
// apiHost: 'https://open.cherryin.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.net',
models: [],
isSystem: true,
enabled: true
},
silicon: {
id: 'silicon',
name: 'Silicon',
@@ -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) {
@@ -708,17 +732,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
// cherryin: {
// api: {
// url: 'https://open.cherryin.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
cherryin: {
api: {
url: 'https://open.cherryin.net'
},
websites: {
official: 'https://open.cherryin.ai',
apiKey: 'https://open.cherryin.ai/console/token',
docs: 'https://open.cherryin.ai',
models: 'https://open.cherryin.ai/pricing'
}
},
ph8: {
api: {
url: 'https://ph8.co'
@@ -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

@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
const deleteKnowledgeBase = (baseId: string) => {
const base = bases.find((b) => b.id === baseId)
if (!base) return
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
dispatch(deleteBase({ baseId }))
// remove assistant knowledge_base
const _assistants = assistants.map((assistant) => {

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",
@@ -1582,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -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",
@@ -4351,6 +4412,7 @@
"later": "Later",
"message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"warning": {

View File

@@ -251,6 +251,7 @@
"added": "已添加",
"case_sensitive": "区分大小写",
"collapse": "收起",
"download": "下载",
"includes_user_questions": "包含用户提问",
"manage": "管理",
"select_model": "选择模型",
@@ -1582,13 +1583,13 @@
"nami-ai-search": "纳米AI搜索",
"qwen": "通义千问",
"sensechat": "商量",
"stepfun": "阶跃AI",
"tencent-yuanbao": "腾讯元宝",
"tiangong-ai": "天工AI",
"wanzhi": "万知",
"wenxin": "文心一言",
"wps-copilot": "WPS灵犀",
"xiaoyi": "小艺",
"yuewen": "跃问",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -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",
@@ -4351,6 +4412,7 @@
"later": "稍后",
"message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"warning": {

View File

@@ -251,6 +251,7 @@
"added": "已新增",
"case_sensitive": "區分大小寫",
"collapse": "折疊",
"download": "下載",
"includes_user_questions": "包含使用者提問",
"manage": "管理",
"select_model": "選擇模型",
@@ -1582,13 +1583,13 @@
"nami-ai-search": "納米AI搜索",
"qwen": "通義千問",
"sensechat": "商量",
"stepfun": "階躍AI",
"tencent-yuanbao": "騰訊元寶",
"tiangong-ai": "天工AI",
"wanzhi": "萬知",
"wenxin": "文心一言",
"wps-copilot": "WPS靈犀",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -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",
@@ -4351,6 +4412,7 @@
"later": "稍後",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"warning": {

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": {
@@ -1581,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -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",
@@ -4350,6 +4412,7 @@
"later": "Μετά",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"warning": {

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": {
@@ -1581,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -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",
@@ -4350,6 +4412,7 @@
"later": "Más tarde",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"warning": {

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": {
@@ -1581,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -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",
@@ -4350,6 +4412,7 @@
"later": "Plus tard",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"warning": {

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": {
@@ -1581,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "通義千問",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "騰訊元宝",
"tiangong-ai": "Skywork",
"wanzhi": "万知",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -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",
@@ -4350,6 +4412,7 @@
"later": "後で",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"warning": {

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": {
@@ -1581,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -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",
@@ -4350,6 +4412,7 @@
"later": "Mais tarde",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"warning": {

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": {
@@ -1581,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Tencent Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -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",
@@ -4350,6 +4412,7 @@
"later": "Позже",
"message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"warning": {

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,29 @@ 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: hidden;
}
&.grid {
grid-template-columns: repeat(
@@ -355,11 +367,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 +401,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 +421,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

@@ -1,4 +1,6 @@
import { SyncOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Button } from 'antd'
@@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => {
const { update } = useRuntime()
const { autoCheckUpdate } = useSettings()
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
if (!update) {
return null
@@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => {
<Container>
<UpdateButton
className="nodrag"
onClick={() => window.api.showUpdateDialog()}
onClick={onOpen}
icon={<SyncOutlined />}
color="orange"
variant="outlined"
size="small">
{t('button.update_available')}
</UpdateButton>
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
</Container>
)
}

View File

@@ -14,6 +14,7 @@ import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar'
import WebviewSearch from './components/WebviewSearch'
const logger = loggerService.withContext('MinAppPage')
@@ -184,6 +185,7 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools}
/>
</ToolbarWrapper>
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
{!isReady && (
<LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />

View File

@@ -0,0 +1,298 @@
import { Button, Input } from '@heroui/react'
import { loggerService } from '@logger'
import type { WebviewTag } from 'electron'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type FoundInPageResult = Electron.FoundInPageResult
interface WebviewSearchProps {
webviewRef: React.RefObject<WebviewTag | null>
isWebviewReady: boolean
appId: string
}
const logger = loggerService.withContext('WebviewSearch')
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
const [query, setQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [activeIndex, setActiveIndex] = useState(0)
const [currentWebview, setCurrentWebview] = useState<WebviewTag | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null)
const focusInput = useCallback(() => {
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
focusFrameRef.current = window.requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}, [])
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
if (!options?.keepQuery) {
setQuery('')
}
setMatchCount(0)
setActiveIndex(0)
}, [])
const stopSearch = useCallback(() => {
const target = webviewRef.current ?? attachedWebviewRef.current
if (!target) return
try {
target.stopFindInPage('clearSelection')
} catch (error) {
logger.error('stopFindInPage failed', { error })
}
}, [webviewRef])
const closeSearch = useCallback(() => {
setIsVisible(false)
stopSearch()
resetSearchState({ keepQuery: true })
}, [resetSearchState, stopSearch])
const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => {
const target = webviewRef.current ?? attachedWebviewRef.current
if (!target) {
logger.debug('Skip performSearch: webview not attached')
return
}
if (!text) {
stopSearch()
resetSearchState({ keepQuery: true })
return
}
try {
target.findInPage(text, options)
} catch (error) {
logger.error('findInPage failed', { error })
window.toast?.error(t('common.error'))
}
},
[resetSearchState, stopSearch, t, webviewRef]
)
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
if (!event.result) return
const { activeMatchOrdinal, matches } = event.result
if (matches !== undefined) {
setMatchCount(matches)
}
if (activeMatchOrdinal !== undefined) {
setActiveIndex(activeMatchOrdinal)
}
}, [])
const openSearch = useCallback(() => {
if (!isWebviewReady) {
logger.debug('Skip openSearch: webview not ready')
return
}
setIsVisible(true)
focusInput()
}, [focusInput, isWebviewReady])
const goToNext = useCallback(() => {
if (!query) return
performSearch(query, { forward: true, findNext: true })
}, [performSearch, query])
const goToPrevious = useCallback(() => {
if (!query) return
performSearch(query, { forward: false, findNext: true })
}, [performSearch, query])
useEffect(() => {
const nextWebview = webviewRef.current ?? null
if (currentWebview === nextWebview) return
setCurrentWebview(nextWebview)
}, [currentWebview, webviewRef])
useEffect(() => {
const target = currentWebview
if (!target) {
attachedWebviewRef.current = null
return
}
const handle = handleFoundInPage
attachedWebviewRef.current = target
target.addEventListener('found-in-page', handle)
return () => {
target.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === target) {
try {
target.stopFindInPage('clearSelection')
} catch (error) {
logger.error('stopFindInPage failed', { error })
}
attachedWebviewRef.current = null
}
}
}, [currentWebview, handleFoundInPage])
useEffect(() => {
if (!isVisible) return
focusInput()
}, [focusInput, isVisible])
useEffect(() => {
if (!isVisible) return
if (!query) {
performSearch('')
return
}
performSearch(query)
}, [currentWebview, isVisible, performSearch, query])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
openSearch()
return
}
if (!isVisible) return
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (event.shiftKey) {
goToPrevious()
} else {
goToNext()
}
}
}
window.addEventListener('keydown', handleKeydown, true)
return () => {
window.removeEventListener('keydown', handleKeydown, true)
}
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
useEffect(() => {
if (!isWebviewReady) {
setIsVisible(false)
resetSearchState()
stopSearch()
return
}
}, [isWebviewReady, resetSearchState, stopSearch])
useEffect(() => {
if (!appId) return
if (lastAppIdRef.current === appId) return
lastAppIdRef.current = appId
setIsVisible(false)
resetSearchState()
stopSearch()
}, [appId, resetSearchState, stopSearch])
useEffect(() => {
return () => {
stopSearch()
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}
}, [stopSearch])
if (!isVisible) {
return null
}
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
const disableNavigation = !query || matchCount === 0
return (
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
<Input
ref={inputRef}
autoFocus
value={query}
onValueChange={setQuery}
spellCheck={'false'}
placeholder={t('common.search')}
size="sm"
radius="sm"
variant="flat"
classNames={{
base: 'w-[240px]',
inputWrapper:
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
input: 'text-small focus:outline-none focus-visible:outline-none',
innerWrapper: 'gap-0'
}}
/>
<span
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
title={noResultTitle}
role="status"
aria-live="polite"
aria-atomic="true">
{matchLabel}
</span>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToPrevious}
isDisabled={disableNavigation}
aria-label="Previous match"
className="text-default-500 hover:text-default-900">
<ChevronUp size={16} />
</Button>
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToNext}
isDisabled={disableNavigation}
aria-label="Next match"
className="text-default-500 hover:text-default-900">
<ChevronDown size={16} />
</Button>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={closeSearch}
aria-label={t('common.close')}
className="text-default-500 hover:text-default-900">
<X size={16} />
</Button>
</div>
)
}
export default WebviewSearch

View File

@@ -0,0 +1,237 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { WebviewTag } from 'electron'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import WebviewSearch from '../WebviewSearch'
const translations: Record<string, string> = {
'common.close': 'Close',
'common.error': 'Error',
'common.no_results': 'No results',
'common.search': 'Search'
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => translations[key] ?? key
})
}))
const createWebviewMock = () => {
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
const findInPageMock = vi.fn()
const stopFindInPageMock = vi.fn()
const webview = {
addEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
if (!listeners.has(type)) {
listeners.set(type, new Set())
}
listeners.get(type)!.add(listener)
}
),
removeEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
listeners.get(type)?.delete(listener)
}
),
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
} as unknown as WebviewTag
const emit = (type: string, result?: Electron.FoundInPageResult) => {
listeners.get(type)?.forEach((listener) => {
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
event.result = result
listener(event)
})
}
return {
emit,
findInPageMock,
stopFindInPageMock,
webview
}
}
const openSearchOverlay = async () => {
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
}
const originalRAF = window.requestAnimationFrame
const originalCAF = window.cancelAnimationFrame
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
})
const cancelAnimationFrameMock = vi.fn()
beforeAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: requestAnimationFrameMock,
writable: true
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: cancelAnimationFrameMock,
writable: true
})
})
afterAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: originalRAF
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: originalCAF
})
})
describe('WebviewSearch', () => {
const toastMock = {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
addToast: vi.fn()
}
beforeEach(() => {
Object.assign(window, { toast: toastMock })
})
afterEach(() => {
vi.clearAllMocks()
})
it('opens the search overlay with keyboard shortcut', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
await openSearchOverlay()
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
it('performs searches and navigates between results', async () => {
const { emit, findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
await act(async () => {
emit('found-in-page', {
requestId: 1,
matches: 3,
activeMatchOrdinal: 1,
selectionArea: undefined as unknown as Electron.Rectangle,
finalUpdate: false
} as Electron.FoundInPageResult)
})
const nextButton = screen.getByRole('button', { name: 'Next match' })
await waitFor(() => {
expect(nextButton).not.toBeDisabled()
})
await user.click(nextButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
})
const previousButton = screen.getByRole('button', { name: 'Previous match' })
await user.click(previousButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('clears search state when appId changes', async () => {
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalled()
})
await act(async () => {
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
})
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
})
it('shows toast error when search fails', async () => {
const { findInPageMock, webview } = createWebviewMock()
findInPageMock.mockImplementation(() => {
throw new Error('findInPage failed')
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(toastMock.error).toHaveBeenCalledWith('Error')
})
})
it('stops search when component unmounts', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
stopFindInPageMock.mockClear()
unmount()
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
})
it('ignores keyboard shortcut when webview is not ready', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
await act(async () => {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
})
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
expect(findInPageMock).not.toHaveBeenCalled()
})
})

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
@@ -314,47 +385,55 @@ const NotesPage: FC = () => {
}, [activeFilePath])
// 获取目标文件夹路径(选中文件夹或根目录)
const getTargetFolderPath = useCallback(() => {
if (selectedFolderId) {
const selectedNode = findNodeById(notesTree, selectedFolderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
const getTargetFolderPath = useCallback(
(targetFolderId?: string) => {
const folderId = targetFolderId || selectedFolderId
if (folderId) {
const selectedNode = findNode(notesTree, folderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
}
}
return notesPath // 默认返回根目录
}, [selectedFolderId, notesTree, notesPath, findNodeById])
return notesPath // 默认返回根目录
},
[selectedFolderId, notesTree, notesPath]
)
// 创建文件夹
const handleCreateFolder = useCallback(
async (name: string) => {
async (name: string, targetFolderId?: string) => {
try {
const targetPath = getTargetFolderPath()
const targetPath = getTargetFolderPath(targetFolderId)
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]
)
// 创建笔记
const handleCreateNote = useCallback(
async (name: string) => {
async (name: string, targetFolderId?: string) => {
try {
isCreatingNoteRef.current = true
const targetPath = getTargetFolderPath()
const targetPath = getTargetFolderPath(targetFolderId)
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 +443,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 +494,7 @@ const NotesPage: FC = () => {
}
} else if (node.type === 'folder') {
setSelectedFolderId(node.id)
await handleToggleExpanded(node.id)
handleToggleExpanded(node.id)
}
},
[dispatch, handleToggleExpanded, invalidateFileContent]
@@ -457,28 +504,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 +541,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 +573,7 @@ const NotesPage: FC = () => {
}, 500)
}
},
[activeFilePath, dispatch, findNodeById, sortType, t]
[activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
)
// 处理文件上传
@@ -535,7 +590,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 +599,8 @@ const NotesPage: FC = () => {
}
// 排序并显示成功信息
await sortAllLevels(sortType)
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath)))
await refreshTree()
const successMessage = t('notes.upload_success')
@@ -554,37 +610,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 +791,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,16 +23,19 @@ 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 {
onCreateFolder: (name: string, parentId?: string) => void
onCreateNote: (name: string, parentId?: string) => void
onCreateFolder: (name: string, targetFolderId?: string) => void
onCreateNote: (name: string, targetFolderId?: string) => void
onSelectNode: (node: NotesTreeNode) => void
onDeleteNode: (nodeId: string) => void
onRenameNode: (nodeId: string, newName: string) => void
@@ -43,6 +50,181 @@ 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 // 控制是否渲染子节点
openDropdownKey: string | null
onDropdownOpenChange: (key: string | null) => void
}
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,
openDropdownKey,
onDropdownOpenChange
}) => {
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']}
open={openDropdownKey === node.id}
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
<div onContextMenu={(e) => e.stopPropagation()}>
<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}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={onDropdownOpenChange}
/>
))}
</div>
)}
</div>
)
}
)
const NotesSidebar: FC<NotesSidebarProps> = ({
onCreateFolder,
onCreateNote,
@@ -61,7 +243,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')
@@ -69,6 +254,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const [isShowSearch, setIsShowSearch] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const scrollbarRef = useRef<any>(null)
@@ -184,6 +370,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 +497,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 +530,81 @@ 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)
}
})
}
if (node.type === 'folder') {
baseMenuItems.push(
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: () => {
onCreateNote(t('notes.untitled_note'), node.id)
}
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: () => {
onCreateFolder(t('notes.untitled_folder'), node.id)
}
},
{ type: 'divider' }
)
}
baseMenuItems.push(
{
label: t('notes.rename'),
key: 'rename',
@@ -312,7 +621,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
window.api.openPath(node.externalPath)
}
}
]
)
if (node.type !== 'folder') {
baseMenuItems.push(
{
@@ -330,6 +639,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 +699,17 @@ 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,
onCreateNote,
onCreateFolder
]
)
@@ -537,6 +790,23 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
fileInput.click()
}, [onUploadFiles])
const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => {
return [
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: handleCreateNote
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: handleCreateFolder
}
]
}, [t, handleCreateNote, handleCreateFolder])
return (
<SidebarContainer
onDragOver={(e) => {
@@ -565,23 +835,154 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
/>
<NotesTreeContainer>
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{filteredTree.map((node) => renderTreeNode(node))}
{!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>
{shouldUseVirtualization ? (
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<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}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
</div>
</div>
)
})}
</div>
{!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>
)}
</VirtualizedTreeContainer>
</Dropdown>
) : (
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<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}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
))
: 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}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
))}
{!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>
</Dropdown>
)}
</NotesTreeContainer>
{isDragOverSidebar && <DragOverIndicator />}
@@ -592,7 +993,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 +1007,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 +1141,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 +1199,8 @@ const DragOverIndicator = styled.div`
`
const DropHintNode = styled.div`
margin-top: 8px;
margin: 6px 0;
margin-bottom: 20px;
${TreeNodeContainer} {
background-color: transparent;
@@ -773,4 +1221,4 @@ const DropHintText = styled.div`
font-style: italic;
`
export default NotesSidebar
export default memo(NotesSidebar)

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