Compare commits

..

117 Commits

Author SHA1 Message Date
suyao
2cd7f91fff fix: update zod import to use namespace import 2025-10-14 15:33:57 +08:00
suyao
ad7a043fb3 revert type 2025-10-14 15:12:43 +08:00
suyao
7db628702d Merge branch 'main' into feat/mcp-runjs 2025-10-14 15:09:42 +08:00
beyondkmp
866e8e8734 fix: guard webview search against destroyed webviews (#10704)
* 🐛 fix: guard webview search against destroyed webviews

* delete code

* delete code
2025-10-14 14:04:57 +08:00
George·Dong
80e1784777 chore(ci): switch Claude action to custom endpoint (#10701) 2025-10-14 01:23:34 +08:00
亢奋猫
0d760ffa2e feat: add AgentSettingsTab component and integrate into HomeTabs (#10668)
- Introduced the AgentSettingsTab component for managing agent settings.
- Integrated AgentSettingsTab into HomeTabs, allowing access to agent settings based on the active session or topic.
- Updated AgentEssentialSettings to conditionally render the ModelSetting based on props.
- Adjusted styles in various components for consistency and improved layout.
2025-10-13 22:34:27 +08:00
Pleasure1234
88f7e6a854 fix: add esbuild and update tar-fs dependency (#10671)
Added 'esbuild' with version ^0.25.0 and updated 'tar-fs' to ^2.1.4 in package.json. This also updates related entries in yarn.lock to ensure compatibility and resolve dependency issues.
2025-10-13 21:44:31 +08:00
kangfenmao
de37e2355d chore: update @ai-sdk/google to version 2.0.20 and add corresponding patch
- Updated the @ai-sdk/google dependency to version 2.0.20 in package.json.
- Added a new patch file for the updated version to address specific changes in the library.
2025-10-13 21:00:25 +08:00
Chen Tao
f27b04c5b0 fix: support gemini-2.5-image-flash (#10683) 2025-10-13 17:39:08 +08:00
defi-failure
a02b8f4609 chore: update SiliconFlow logo (#10684) 2025-10-13 16:57:20 +08:00
beyondkmp
7b90dfb46c fix: intercept webview keyboard shortcuts for search functionality (#10641)
* feat: intercept webview keyboard shortcuts for search functionality

Implemented keyboard shortcut interception in webview to enable search functionality (Ctrl/Cmd+F) and navigation (Enter/Escape) within mini app pages. Previously, these shortcuts were consumed by the webview content and not propagated to the host application.

Changes:
- Added Webview_SearchHotkey IPC channel for forwarding keyboard events
- Implemented before-input-event handler in WebviewService to intercept Ctrl/Cmd+F, Escape, and Enter
- Extended preload API with onFindShortcut callback for webview shortcut events
- Updated WebviewSearch component to handle shortcuts from both window and webview
- Added comprehensive test coverage for webview shortcut handling

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

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

* fix lint

* refactor: improve webview hotkey initialization and error handling

Refactored webview keyboard shortcut handler for better code organization and reliability.

Changes:
- Extracted keyboard handler logic into reusable attachKeyboardHandler function
- Added initWebviewHotkeys() to initialize handlers for existing webviews on startup
- Integrated initialization in main app entry point
- Added explanatory comment for event.preventDefault() behavior
- Added warning log when webContentsId is unavailable in WebviewSearch

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

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

* feat: add WebviewKeyEvent type and update related components

- Introduced WebviewKeyEvent type to standardize keyboard event handling for webviews.
- Updated preload index to utilize the new WebviewKeyEvent type in the onFindShortcut callback.
- Refactored WebviewSearch component and its tests to accommodate the new type, enhancing type safety and clarity.

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

* fix lint

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 18:45:37 +08:00
Pleasure1234
26a9dba01a fix: claude-translator.yml (#10588)
* Update claude-translator.yml

* Update claude-translator.yml
2025-10-12 18:20:34 +08:00
SuYao
a176814ad1 fix: update ESLint configuration and dependencies, replace zod import… (#10645)
fix: update ESLint configuration and dependencies, replace zod import style
2025-10-12 17:15:52 +08:00
George·Dong
ea51439aac feat(reasoning): add special handling for Grok 4 fast models & qwen3-omni/qwen3-vl (#10367)
* feat(reasoning): add special handling for Grok 4 fast models

* feat(models): add grok4_fast model and refine grok reasoning

* feat(reasoning): unify Grok reasoning handling and XAI params

* feat(models): Grok/qwen handling and XAI

* feat(models): recognize qwen3-vl thinking models and add sizes

* fix(reasoning): reasoning enabled for QwenAlwaysThink models

* feat(reasoning): enable reasoning for Grok 4 Fast models

* fix(reasoning): rename and correct Grok 4 Fast model checks

* fix: adjust Grok-4 Fast reasoning detection for OpenRouter

* fix(reasoning): exclude non-reasoning models from reasoning detection
2025-10-12 11:34:16 +08:00
Chen Tao
162e33f478 fix: remove LRU for websearch rag (#10631) 2025-10-12 00:01:35 +08:00
kangfenmao
ee4c310725 feat: update migration logic and increment version for store
- Incremented version in the store configuration from 161 to 162.
- Updated migration logic to handle new provider integration and state adjustments.
- Removed deprecated migration logic for version 161.
2025-10-11 16:17:00 +08:00
kangfenmao
a000ff2a1a 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 16:07:49 +08:00
kangfenmao
2f9576b2ae 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 16:07:49 +08:00
kangfenmao
92554dd398 chore: update @ai-sdk/google to version 2.0.17 and add corresponding patch 2025-10-11 16:07:49 +08:00
defi-failure
9473ddc762 feature: unified assistant tab (#10590)
* feature: unified assistant tab

* refactor(TagGroup): make TagsContainer component internal by removing export

* refactor(components): migrate styled-components to cn utility classes

Replace styled-components with cn utility classes from @heroui/react for better maintainability and performance

* refactor(AssistantsTab): split AssistantsTab into smaller hooks and components

* fix: click agent item should jump to topic tab

* feat: add AddButton component and refactor usage across tabs

- Introduced a new AddButton component for consistent UI across different tabs.
- Replaced existing button implementations with AddButton in Sessions, Topics, and UnifiedAddButton components.
- Removed unnecessary margin from AssistantsTab's container for improved layout.

---------

Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-10-11 16:07:35 +08:00
SuYao
5f469a71f3 fix: update ai-sdk dependencies to latest versions (#10643) 2025-10-11 15:53:30 +08:00
defi-failure
87bac60afc fix: long dir breaks edit agent layout (#10644) 2025-10-11 14:47:45 +08:00
SuYao
704339e835 fix: increase tool call maxCount (#10642) 2025-10-11 14:21:18 +08:00
ABucket
c8ab7180ba fix: Provider icons are not displayed after selecting SiliconFlow in the "images" page (#10620) 2025-10-11 12:48:26 +08:00
ABucket
11757546c3 fix: Quick Assistant fails to correctly inject variables in prompts (#10617) 2025-10-11 12:45:25 +08:00
ABucket
420b9ec2f2 fix: AI_TypeValidationError when calling Ling-1T model (#10622) 2025-10-11 12:45:00 +08:00
beyondkmp
1c73271e33 fix: support gpt-5-codex for github copilot (#10587)
* fix: support gpt-5-codex for github copilot

- Added patch for @ai-sdk/openai to version 2.0.42 in package.json and yarn.lock.
- Updated editor version for Copilot from v1.97.2 to v1.104.1 in OpenAIBaseClient and providerConfig.
- Enhanced provider configuration to support new model options for Copilot.

* fix: streamline Copilot header management

- Replaced individual header assignments for Copilot with centralized constants in OpenAIBaseClient and providerConfig.
- Enhanced provider configuration to conditionally set response mode for Copilot models, improving routing logic.

* update aisdk

* delete patch

* 🤖 chore: integrate Copilot SDK provider

* use a plugin

* udpate dependency

* fix: remove unused Copilot default headers from OpenAIBaseClient

- Eliminated the import and usage of COPILOT_DEFAULT_HEADERS to streamline header management in the OpenAIBaseClient class.

* update yarn

* fix lint

* format code

* feat: enhance web search tool types in webSearchPlugin

- Added type normalization for web search tools to improve type safety and clarity.
- Updated WebSearchToolInputSchema and WebSearchToolOutputSchema to use normalized types for better consistency across the plugin.
2025-10-11 10:18:09 +08:00
ABucket
acdbe6b9ed 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-10 16:58:14 +01:00
beyondkmp
6c201228d9 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-10 07:00:45 -07:00
Tristan Zhang
73b2a375ad fix: insert reasoning block before the content block (#10545)
fix: always insert reasoning block before the content block
2025-10-09 22:32:13 +08:00
Chen Tao
89bb830b60 fix: knowledge base not delete and websearch rag error (#10595)
* fix: knowledge base not  delete

* fix: websearch rag error

* chore: add comment
2025-10-09 22:29:52 +08:00
Tristan Zhang
2399db4944 fix: adding multiple keys to the zhipu model service is not detected properly (#10583) 2025-10-09 20:40:46 +08:00
beyondkmp
62774b34d3 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-09 15:58:24 +08:00
Tristan Zhang
654f19eaa9 fix: change the url for qwen (#10584) 2025-10-09 13:37:07 +08:00
Tristan Zhang
ce642f17d9 fix: layout for antrophic api tips (#10579)
* fix: layout for antrophic api tips

* lint
2025-10-09 13:20:40 +08:00
fullex
d7bcd5a20e Merge pull request #10096 from CherryHQ/feat/agents-new
feat: agents implemention
2025-10-09 10:07:18 +08:00
suyao
27903e7d9d fix 2025-10-09 09:42:04 +08:00
suyao
a8c0d0a684 fix 2025-10-09 09:10:04 +08:00
suyao
5e33c89fe7 Merge branch 'main' into feat/agents-new 2025-10-09 09:06:06 +08:00
suyao
44d2cb345f return 2025-10-09 08:50:46 +08:00
suyao
49eec68434 done 2025-10-09 08:46:07 +08:00
icarus
d5ae3e6edc Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/mcp-runjs 2025-10-09 00:27:30 +08:00
icarus
6eedcc29ba feat(i18n): add untranslated strings for multiple languages
Add placeholder strings marked with "[to be translated]" in Japanese, Russian, Portuguese, Greek, Spanish and French translation files. These include new message placeholders and auto-rename functionality for notes.
2025-10-09 00:20:39 +08:00
Tristan Zhang
42849e4586 feat: support export image for notes (#10559)
* feat: support export image for notes

* feat: extract functions
2025-10-08 23:32:32 +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
suyao
71d35eddf7 Add JavaScript execution safety limits and improve error handling
- Add 1MB code size limit to prevent memory issues
- Improve timeout error handling with proper cleanup logging
- Remove host environment exposure and fix file descriptor leaks in worker
2025-10-08 07:11:39 +08:00
suyao
9f1c8f2c17 fix: more accurate 2025-10-08 07:08:19 +08:00
suyao
651e9a529e Add JavaScript execution support to code blocks
- Register new IPC channel and handler for JavaScript code execution
- Extend code block execution to support JavaScript, TypeScript, and JS languages
- Add JavaScript service with sandboxed execution using quickJs
- Update UI to show JavaScript execution option alongside Python
2025-10-08 07:07:13 +08:00
suyao
f68f6e9896 chore: i18n 2025-10-08 06:56:21 +08:00
suyao
2dcc68da87 chore: yarn lock 2025-10-08 06:54:14 +08:00
suyao
1db259cd3e Add JavaScript MCP server with QuickJS WASM sandbox execution
- Implement JsServer MCP server with `run_javascript_code` tool for secure code execution
- Add JsService worker management with timeout handling and error formatting
- Include QuickJS WASM binary and integrate with WASI for sandboxed execution
- Update UI with i18n support and JSON result formatting improvements
2025-10-08 06:48:14 +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
Vaayne
68b37e66e9 ⬆️ chore: upgrade electron from 37.4.0 to 37.6.0 2025-10-07 14:36:03 +08:00
Vaayne
d6e7ed81ee Merge remote-tracking branch 'origin/main' into feat/agents-new
# Conflicts:
#	src/renderer/src/pages/home/Tabs/TopicsTab.tsx
#	yarn.lock
2025-10-07 14:33:41 +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
Vaayne
d4c6131fa3 Merge remote-tracking branch 'origin/main' into feat/agents-new
# Conflicts:
#	package.json
#	src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
2025-10-07 12:30:18 +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
Vaayne
53046460ec fix(ClaudeCodeService): update environment variables to use modelInfo provider details 2025-09-30 23:53:10 +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
Vaayne
538291c03f ♻️ refactor: consolidate Claude Code system message handling and streaming logic
- Unify buildClaudeCodeSystemMessage implementation in shared package
- Refactor MessagesService to provide comprehensive message processing API
- Extract streaming logic, error handling, and header preparation into service methods
- Remove duplicate anthropic config from renderer, use shared implementation
- Update ClaudeCodeService to use append mode for custom instructions
- Improve type safety and request validation in message processing
2025-09-30 23:33:41 +08:00
icarus
142ad9e41e refactor(Assistants): move add assistant button inside container div
Improve layout structure by moving the button inside the same container as other elements for better visual grouping
2025-09-30 21:14:48 +08:00
Vaayne
7250ce3514 fix(CodeToolsService): update package name for Claude Code SDK 2025-09-30 18:55:17 +08:00
Vaayne
02cf012671 fix(ProcessTransport): replace spawn with fork for Node.js process handling 2025-09-30 18:50:02 +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
Vaayne
65ac3181a8 feat: add anthropicApiHost to CherryAI and New-API providers 2025-09-30 18:25:51 +08:00
Vaayne
998e54246f 🔖 chore: bump version to 1.7.0-alpha.4 2025-09-30 18:16:15 +08:00
Vaayne
fcd8f7a26e 🐛 fix: update i18n translations, UI components, and provider configuration
- Add error.open_path translation across multiple locales (zh-tw, el-gr, es-es, fr-fr, ja-jp, pt-pt, ru-ru)
- Remove avatarProps from AgentLabel in AgentSettingsPopup
- Add ovms provider to SystemProviderIds
2025-09-30 18:15:08 +08:00
Vaayne
b991afd69a fix(claude): streamline systemPrompt and settingSources initialization 2025-09-30 18:05:32 +08:00
Vaayne
d9d8bae2d6 Merge remote-tracking branch 'origin/main' into feat/agents-new 2025-09-30 18:03:35 +08:00
Vaayne
422ba52093 ⬆️ chore: migrate from Claude Code SDK to Claude Agent SDK v0.1.1
- Replace @anthropic-ai/claude-code with @anthropic-ai/claude-agent-sdk@0.1.1
- Update all import statements across 4 files
- Migrate patch for Electron compatibility (fork vs spawn)
- Handle breaking changes: replace appendSystemPrompt with systemPrompt preset
- Add settingSources configuration for filesystem settings
- Update vendor path in build scripts
- Update package name mapping in CodeToolsService
2025-09-30 17:54:02 +08:00
Vaayne
51630f95fd ♻️ refactor(agents): improve error handling and logging in agent services 2025-09-30 17:17:56 +08:00
Vaayne
ac1cab60a3 fix(cache): reduce cache TTL from 1 minute to 10 seconds for quicker updates 2025-09-29 23:24:36 +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
Vaayne
759f8518b2 fix(logger): enhance cache check for available providers 2025-09-29 23:02:05 +08:00
icarus
7bd6c92f43 refactor(agent): rename getAgentDefaultAvatar to getAgentTypeAvatar for clarity
Simplify avatar handling by removing default avatar logic and always using emoji icons
2025-09-29 21:04:31 +08:00
icarus
ff705d99b3 refactor(AvatarSetting): simplify avatar selection by removing radio options
Remove radio group selection for avatar type and only keep emoji picker
Clean up unused imports and code related to the removed functionality
2025-09-29 21:00:42 +08:00
icarus
7ec17dc771 style(ChatNavbar): adjust AgentLabel styling for better consistency 2025-09-29 20:17:41 +08:00
icarus
35883e8601 fix(i18n): update error message for failed path opening 2025-09-29 19:59:51 +08:00
icarus
48b7bdb9ba feat(file): improve error handling for openPath operation
Add error message translation and proper error propagation when opening a file path fails
2025-09-29 19:53:32 +08:00
icarus
d2d5b4370c feat(ChatNavbar): make path info tag clickable to open file location
Add onClick handler to InfoTag component to open file location when clicked
2025-09-29 19:36:13 +08:00
icarus
27c31d6e0c feat(file): add showInFolder IPC channel to reveal files in explorer
Implement functionality to show files/folders in system explorer through IPC. Includes channel definition, preload API, main handler, and error handling for non-existent paths.
2025-09-29 19:36:05 +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
Vaayne
37b3c08baa ♻️ refactor: improve async processing and error handling in ClaudeCodeService 2025-09-29 17:14:13 +08:00
Vaayne
d8c3f601df ♻️ refactor: correct include filter path for Claude code ripgrep 2025-09-29 16:18:12 +08:00
Vaayne
cff9068359 ♻️ refactor: standardize string quotes and improve logging in Anthropic integration 2025-09-29 14:42:50 +08:00
Vaayne
cc871b7a72 ♻️ refactor: enhance logging and provider handling for Anthropic integration 2025-09-29 14:38:41 +08:00
Vaayne
5b98ef5b3d ♻️ refactor: update import paths for message handling module 2025-09-29 13:12:06 +08:00
Vaayne
3428d15299 ♻️ refactor: centralize agent stream timeouts 2025-09-29 13:06:47 +08:00
Vaayne
9ea3f0842c 📝 docs: refresh AI assistant guidelines 2025-09-29 11:12:56 +08:00
Vaayne
90242e2285 fix: exclude proxy environment variables from login shell environment 2025-09-29 11:05:05 +08:00
Vaayne
1616345261 Bump version to 1.7.0-alpha.3 2025-09-29 09:51:40 +08:00
GitHub Action
0b818477ac fix(i18n): Auto update translations for PR #10096 2025-09-28 15:35:27 +00:00
Vaayne
027d6ea2b2 Merge remote-tracking branch 'origin/main' into feat/agents-new 2025-09-28 23:34:33 +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
196 changed files with 7322 additions and 2777 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

@@ -16,10 +16,13 @@ on:
jobs:
translate:
if: |
(github.event_name == 'issues') ||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
(github.event_name == 'issues')
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|| (
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
&& github.event.sender.type != 'Bot'
&& github.event.pull_request.head.repo.fork == false
)
runs-on: ubuntu-latest
permissions:
contents: read
@@ -29,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -42,7 +45,7 @@ jobs:
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
allowed_non_write_users: "*"
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
prompt: |
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
@@ -105,3 +108,5 @@ jobs:
使用以下命令获取完整信息:
gh issue view ${{ github.event.issue.number }} --json title,body,comments
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}

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,13 +1,13 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..3ea0fadd783f334db71266e45babdcce11076974 100644
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return `models/${modelId}`;
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -0,0 +1,31 @@
diff --git a/sdk.mjs b/sdk.mjs
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// ../src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -6473,14 +6473,11 @@ class ProcessTransport {
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
throw new ReferenceError(errorMessage);
}
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {
+ this.child = fork(pathToClaudeCodeExecutable, args, {
cwd,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal: this.abortController.signal,
env
});

View File

@@ -1,31 +0,0 @@
diff --git a/sdk.mjs b/sdk.mjs
index e2dbafb4e2faa1bf2b6b02f0009a2b9bbf57c757..ea333ae8c69fcd27a9f2d89b3dbfc0a3e4e4dec4 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// src/utils/fsOperations.ts
@@ -6452,13 +6452,12 @@ class ProcessTransport {
throw new ReferenceError(errorMessage);
}
const isNative = isNativeBinary(pathToClaudeCodeExecutable);
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logDebug(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {
+
+ this.logDebug(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
+ this.child = fork(pathToClaudeCodeExecutable, args, {
cwd,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal: this.abortController.signal,
env
});

View File

@@ -2,22 +2,16 @@
This file provides guidance to AI coding assistants when working with code in this repository. Adherence to these guidelines is crucial for maintaining code quality and consistency.
## Guiding Principles
## Guiding Principles (MUST FOLLOW)
- **Clarity and Simplicity**: Write code that is easy to understand and maintain.
- **Consistency**: Follow existing patterns and conventions in the codebase.
- **Correctness**: Ensure code is correct, well-tested, and robust.
- **Efficiency**: Write performant code and use resources judiciously.
## MUST Follow Rules
1. **Code Search**: Use `ast-grep` for semantic code pattern searches when available. Fallback to `rg` (ripgrep) or `grep` for text-based searches.
2. **UI Framework**: Exclusively use **HeroUI** for all new UI components. The use of `antd` or `styled-components` is strictly **PROHIBITED**.
3. **Quality Assurance**: **Always** run `yarn build:check` before finalizing your work or making any commits. This ensures code quality (linting, testing, and type checking).
4. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`.
5. **External Research**: Leverage `subagent` for gathering external information, including latest documentation, API references, news, or web-based research. This keeps the main conversation focused on the task at hand.
6. **Code Reviews**: Always seek a code review from a human developer before merging significant changes. This ensures adherence to project standards and catches potential issues.
7. **Documentation**: Update or create documentation for any new features, modules, or significant changes to existing functionality.
- **Keep it clear**: Write code that is easy to read, maintain, and explain.
- **Match the house style**: Reuse existing patterns, naming, and conventions.
- **Search smart**: Prefer `ast-grep` for semantic queries; fall back to `rg`/`grep` when needed.
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Seek review**: Ask a human developer to review substantial changes before merging.
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
## Development Commands

View File

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

View File

@@ -34,6 +34,10 @@ export default defineConfig({
output: {
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
},
onwarn(warning, warn) {
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
warn(warning)
}
},
sourcemap: isDev
@@ -112,6 +116,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

@@ -2,6 +2,7 @@ import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import importZod from 'eslint-plugin-import-zod'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
@@ -15,7 +16,8 @@ export default defineConfig([
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports
'unused-imports': unusedImports,
'import-zod': importZod
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
@@ -25,6 +27,7 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'import-zod/prefer-zod-namespace': 'error'
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-alpha.2",
"version": "1.7.0-alpha.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -78,7 +78,7 @@
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-code": "patch:@anthropic-ai/claude-code@npm%3A1.0.118#~/.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.patch",
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
@@ -99,10 +99,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.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
"@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",
@@ -152,6 +152,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "0.1.18",
"@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
@@ -219,7 +220,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.68",
"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",
@@ -244,7 +245,7 @@
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"electron": "37.4.0",
"electron": "37.6.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1",
@@ -256,6 +257,7 @@
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
@@ -294,7 +296,7 @@
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.15.0",
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
@@ -370,6 +372,7 @@
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"esbuild": "^0.25.0",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
@@ -377,10 +380,11 @@
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",
"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.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.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,14 +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/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai-compatible": "^1.0.22",
"@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.12",
"@ai-sdk/xai": "^2.0.26",
"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, type Tool } from 'ai'
import { ProviderOptionsMap } from '../../../options/types'
import { OpenRouterSearchConfig } from './openrouter'
@@ -14,6 +15,13 @@ export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tool
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
type NormalizeTool<T> = T extends Tool<infer INPUT, infer OUTPUT> ? Tool<INPUT, OUTPUT> : Tool<any, any>
type AnthropicWebSearchTool = NormalizeTool<ReturnType<typeof anthropic.tools.webSearch_20250305>>
type OpenAIWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearch>>
type OpenAIChatWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearchPreview>>
type GoogleWebSearchTool = NormalizeTool<ReturnType<typeof google.tools.googleSearch>>
/**
* 插件初始化时接收的完整配置对象
*
@@ -58,24 +66,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<AnthropicWebSearchTool>
// 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<AnthropicWebSearchTool>
openai: InferToolInput<OpenAIWebSearchTool>
google: InferToolInput<GoogleWebSearchTool>
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
}

View File

@@ -13,7 +13,7 @@ import { LanguageModelV2 } from '@ai-sdk/provider'
import { createXai } from '@ai-sdk/xai'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { customProvider, Provider } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
/**
* 基础 Provider IDs

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',
@@ -52,6 +53,7 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
// Open
Open_Path = 'open:path',
@@ -94,6 +96,9 @@ export enum IpcChannel {
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
AgentMessage_GetHistory = 'agent-message:get-history',
// JavaScript
Js_Execute = 'js:execute',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -187,6 +192,7 @@ export enum IpcChannel {
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
File_StartWatcher = 'file:startWatcher',
File_StopWatcher = 'file:stopWatcher',
File_ShowInFolder = 'file:showInFolder',
// file service
FileService_Upload = 'file-service:upload',
@@ -224,6 +230,7 @@ export enum IpcChannel {
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName',
// DevTools
System_ToggleDevTools = 'system:toggleDevTools',
@@ -231,7 +238,6 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -334,6 +340,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

@@ -1,4 +1,4 @@
import type { SDKMessage } from '@anthropic-ai/claude-code'
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages'
export type ClaudeCodeRawValue =

View File

@@ -12,8 +12,19 @@ import Anthropic from '@anthropic-ai/sdk'
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import { Provider } from '@types'
import type { ModelMessage } from 'ai'
const logger = loggerService.withContext('anthropic-sdk')
const defaultClaudeCodeSystemPrompt = `You are Claude Code, Anthropic's official CLI for Claude.`
const defaultClaudeCodeSystem: Array<TextBlockParam> = [
{
type: 'text',
text: defaultClaudeCodeSystemPrompt
}
]
/**
* Creates and configures an Anthropic SDK client based on the provider configuration.
*
@@ -44,7 +55,11 @@ const logger = loggerService.withContext('anthropic-sdk')
* const apiKeyClient = getSdkClient(apiKeyProvider);
* ```
*/
export function getSdkClient(provider: Provider, oauthToken?: string | null): Anthropic {
export function getSdkClient(
provider: Provider,
oauthToken?: string | null,
extraHeaders?: Record<string, string | string[]>
): Anthropic {
if (provider.authType === 'oauth') {
if (!oauthToken) {
throw new Error('OAuth token is not available')
@@ -68,7 +83,8 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
'x-stainless-os': 'MacOS',
'x-stainless-arch': 'arm64',
'x-stainless-runtime': 'node',
'x-stainless-runtime-version': 'v22.18.0'
'x-stainless-runtime-version': 'v22.18.0',
...extraHeaders
}
})
}
@@ -77,7 +93,22 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
? provider.apiHost
: (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost
logger.debug('Anthropic API baseURL', { baseURL })
logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id })
if (provider.id === 'aihubmix') {
return new Anthropic({
apiKey: provider.apiKey,
baseURL,
dangerouslyAllowBrowser: true,
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19',
'APP-Code': 'MLTG2087',
...provider.extra_headers,
...extraHeaders
}
})
}
return new Anthropic({
apiKey: provider.apiKey,
authToken: provider.apiKey,
@@ -104,53 +135,36 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
* @param system - Optional user-provided system message (string or TextBlockParam array)
* @returns Combined system message with Claude Code prompt prepended
*
* @example
* ```typescript
* // No system message
* const result1 = buildClaudeCodeSystemMessage();
* // Returns: "You are Claude Code, Anthropic's official CLI for Claude."
*
* // String system message
* const result2 = buildClaudeCodeSystemMessage("You are a helpful assistant.");
* // Returns: [
* // { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
* // { type: 'text', text: "You are a helpful assistant." }
* // ]
*
* // Array system message
* const systemArray = [{ type: 'text', text: 'Custom instructions' }];
* const result3 = buildClaudeCodeSystemMessage(systemArray);
* // Returns: Array with Claude Code message prepended
* ```
*/
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): string | Array<TextBlockParam> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): Array<TextBlockParam> {
if (!system) {
return defaultClaudeCodeSystem
}
if (typeof system === 'string') {
if (system.trim() === defaultClaudeCodeSystem) {
return system
if (system.trim() === defaultClaudeCodeSystemPrompt || system.trim() === '') {
return defaultClaudeCodeSystem
} else {
return [...defaultClaudeCodeSystem, { type: 'text', text: system }]
}
}
if (Array.isArray(system)) {
const firstSystem = system[0]
if (firstSystem.type === 'text' && firstSystem.text.trim() === defaultClaudeCodeSystemPrompt) {
return system
} else {
return [...defaultClaudeCodeSystem, ...system]
}
return [
{
type: 'text',
text: defaultClaudeCodeSystem
},
{
type: 'text',
text: system
}
]
}
if (system[0].text.trim() != defaultClaudeCodeSystem) {
system.unshift({
type: 'text',
text: defaultClaudeCodeSystem
})
}
return system
return defaultClaudeCodeSystem
}
export function buildClaudeCodeSystemModelMessage(system?: string | Array<TextBlockParam>): Array<ModelMessage> {
const textBlocks = buildClaudeCodeSystemMessage(system)
return textBlocks.map((block) => ({
role: 'system',
content: block.text
}))
}

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

@@ -22,3 +22,12 @@ export type MCPProgressEvent = {
callId: string
progress: number // 0-1 range
}
export type WebviewKeyEvent = {
webviewId: number
key: string
control: boolean
meta: boolean
shift: boolean
alt: boolean
}

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)
})

Binary file not shown.

View File

@@ -35,7 +35,7 @@ const allX64 = {
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
}
const claudeCodeVenderPath = '@anthropic-ai/claude-code/vendor'
const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor'
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
const platformToArch = {
@@ -88,7 +88,7 @@ exports.default = async function (context) {
const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin']
const includeClaudeCodeFilters = [
'!node_modules/' + claudeCodeVenderPath + '/' + `${archType}-${platformToArch[platform]}/**`
'!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + `${archType}-${platformToArch[platform]}/**`
]
if (arch === Arch.arm64) {

View File

@@ -3,6 +3,7 @@ import cors from 'cors'
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { LONG_POLL_TIMEOUT_MS } from './config/timeouts'
import { authMiddleware } from './middleware/auth'
import { errorHandler } from './middleware/error'
import { setupOpenAPIDocumentation } from './middleware/openapi'
@@ -14,7 +15,6 @@ import { modelsRoutes } from './routes/models'
const logger = loggerService.withContext('ApiServer')
const LONG_POLL_TIMEOUT_MS = 120 * 60_000 // 120 minutes
const extendMessagesTimeout: express.RequestHandler = (req, res, next) => {
req.setTimeout(LONG_POLL_TIMEOUT_MS)
res.setTimeout(LONG_POLL_TIMEOUT_MS)

View File

@@ -0,0 +1,3 @@
export const LONG_POLL_TIMEOUT_MS = 120 * 60_000 // 120 minutes
export const MESSAGE_STREAM_TIMEOUT_MS = LONG_POLL_TIMEOUT_MS

View File

@@ -1,8 +1,9 @@
import { loggerService } from '@logger'
import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts'
import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController'
import { agentService, sessionMessageService, sessionService } from '@main/services/agents'
import { Request, Response } from 'express'
import { agentService, sessionMessageService, sessionService } from '../../../../services/agents'
const logger = loggerService.withContext('ApiServerMessagesHandlers')
// Helper function to verify agent and session exist and belong together
@@ -25,6 +26,8 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
}
export const createMessage = async (req: Request, res: Response): Promise<void> => {
let clearAbortTimeout: (() => void) | undefined
try {
const { agentId, sessionId } = req.params
@@ -42,7 +45,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
const abortController = new AbortController()
const {
abortController,
registerAbortHandler,
clearAbortTimeout: helperClearAbortTimeout
} = createStreamAbortController({
timeoutMs: MESSAGE_STREAM_TIMEOUT_MS
})
clearAbortTimeout = helperClearAbortTimeout
const { stream, completion } = await sessionMessageService.createSessionMessage(
session,
messageData,
@@ -54,6 +64,10 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
let responseEnded = false
let streamFinished = false
const cleanupAbortTimeout = () => {
clearAbortTimeout?.()
}
const finalizeResponse = () => {
if (responseEnded) {
return
@@ -64,6 +78,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
}
responseEnded = true
cleanupAbortTimeout()
try {
// res.write('data: {"type":"finish"}\n\n')
res.write('data: [DONE]\n\n')
@@ -92,12 +107,51 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
* - Clean up event listeners to prevent memory leaks
* - Mark the response as ended to prevent further writes
*/
const handleDisconnect = () => {
registerAbortHandler((abortReason) => {
cleanupAbortTimeout()
if (responseEnded) return
logger.info('Streaming client disconnected', { agentId, sessionId })
responseEnded = true
if (abortReason === STREAM_TIMEOUT_REASON) {
logger.error('Streaming message timeout', { agentId, sessionId })
try {
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
message: 'Stream timeout',
type: 'timeout_error',
code: 'stream_timeout'
}
})}\n\n`
)
} catch (writeError) {
logger.error('Error writing timeout to SSE stream', { error: writeError })
}
} else if (abortReason === 'Client disconnected') {
logger.info('Streaming client disconnected', { agentId, sessionId })
} else {
logger.warn('Streaming aborted', { agentId, sessionId, reason: abortReason })
}
reader.cancel(abortReason ?? 'stream aborted').catch(() => {})
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
}
if (!res.writableEnded) {
res.end()
}
})
const handleDisconnect = () => {
if (abortController.signal.aborted) return
abortController.abort('Client disconnected')
reader.cancel('Client disconnected').catch(() => {})
}
req.on('close', handleDisconnect)
@@ -135,6 +189,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing stream error to SSE', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
res.end()
}
}
@@ -166,41 +221,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing completion error to SSE stream', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
res.end()
})
// Set a timeout to prevent hanging indefinitely
const timeout = setTimeout(
() => {
if (!responseEnded) {
logger.error('Streaming message timeout', { agentId, sessionId })
try {
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
message: 'Stream timeout',
type: 'timeout_error',
code: 'stream_timeout'
}
})}\n\n`
)
} catch (writeError) {
logger.error('Error writing timeout to SSE stream', { error: writeError })
}
abortController.abort('stream timeout')
reader.cancel('stream timeout').catch(() => {})
responseEnded = true
res.end()
}
},
10 * 60 * 1000
) // 10 minutes timeout
// Clear timeout when response ends
res.on('close', () => clearTimeout(timeout))
res.on('finish', () => clearTimeout(timeout))
res.on('close', cleanupAbortTimeout)
res.on('finish', cleanupAbortTimeout)
} catch (error: any) {
clearAbortTimeout?.()
logger.error('Error in streaming message handler', {
error,
agentId: req.params.agentId,

View File

@@ -1,9 +1,9 @@
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import { Provider } from '@types'
import express, { Request, Response } from 'express'
import { Provider } from '../../../renderer/src/types/provider'
import { MessagesService, messagesService } from '../services/messages'
import { messagesService } from '../services/messages'
import { getProviderById, validateModelId } from '../utils'
const logger = loggerService.withContext('ApiServerMessagesRoutes')
@@ -11,7 +11,7 @@ const logger = loggerService.withContext('ApiServerMessagesRoutes')
const router = express.Router()
const providerRouter = express.Router({ mergeParams: true })
// Helper functions for shared logic
// Helper function for basic request validation
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
const request: MessageCreateParams = req.body
@@ -31,147 +31,53 @@ async function validateRequestBody(req: Request): Promise<{ valid: boolean; erro
return { valid: true }
}
async function handleStreamingResponse(
res: Response,
request: MessageCreateParams,
provider: Provider,
messagesService: MessagesService
): Promise<void> {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
const flushableResponse = res as Response & { flush?: () => void }
const flushStream = () => {
if (typeof flushableResponse.flush !== 'function') {
return
}
try {
flushableResponse.flush()
} catch (flushError: unknown) {
logger.warn('Failed to flush streaming response', {
error: flushError
})
}
}
try {
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
res.write(`event: ${chunk.type}\n`)
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
flushStream()
}
res.write('data: [DONE]\n\n')
flushStream()
} catch (streamError: any) {
logger.error('Stream error', { error: streamError })
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
type: 'api_error',
message: 'Stream processing error'
}
})}\n\n`
)
} finally {
res.end()
}
}
function handleErrorResponse(res: Response, error: any): Response {
logger.error('Message processing error', { error })
let statusCode = 500
let errorType = 'api_error'
let errorMessage = 'Internal server error'
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
const anthropicError = error?.error
if (anthropicStatus) {
statusCode = anthropicStatus
}
if (anthropicError?.type) {
errorType = anthropicError.type
}
if (anthropicError?.message) {
errorMessage = anthropicError.message
} else if (error instanceof Error && error.message) {
errorMessage = error.message
}
if (!anthropicStatus && error instanceof Error) {
if (error.message.includes('API key') || error.message.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
statusCode = 502
errorType = 'api_error'
} else if (error.message.includes('validation') || error.message.includes('invalid')) {
statusCode = 400
errorType = 'invalid_request_error'
}
}
return res.status(statusCode).json({
type: 'error',
error: {
type: errorType,
message: errorMessage,
requestId: error?.request_id
}
})
}
async function processMessageRequest(
req: Request,
res: Response,
provider: Provider,
interface HandleMessageProcessingOptions {
req: Request
res: Response
provider: Provider
request: MessageCreateParams
modelId?: string
): Promise<Response | void> {
}
async function handleMessageProcessing({
req,
res,
provider,
request,
modelId
}: HandleMessageProcessingOptions): Promise<void> {
try {
const request: MessageCreateParams = req.body
// Use provided modelId or keep original model
if (modelId) {
request.model = modelId
}
// Validate request
const validation = messagesService.validateRequest(request)
if (!validation.isValid) {
return res.status(400).json({
res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: validation.errors.join('; ')
}
})
}
logger.silly('Processing message request', {
request,
provider: provider.id
})
// Handle streaming
if (request.stream) {
await handleStreamingResponse(res, request, provider, messagesService)
return
}
// Handle non-streaming
const response = await messagesService.processMessage(request, provider)
return res.json(response)
const extraHeaders = messagesService.prepareHeaders(req.headers)
const { client, anthropicRequest } = await messagesService.processMessage({
provider,
request,
extraHeaders,
modelId
})
if (request.stream) {
await messagesService.handleStreaming(client, anthropicRequest, { response: res }, provider)
return
}
const response = await client.messages.create(anthropicRequest)
res.json(response)
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
res.status(statusCode).json(errorResponse)
}
}
@@ -328,10 +234,11 @@ router.post('/', async (req: Request, res: Response) => {
const provider = modelValidation.provider!
const modelId = modelValidation.modelId!
// Use shared processing function
return await processMessageRequest(req, res, provider, modelId)
return handleMessageProcessing({ req, res, provider, request, modelId })
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
return res.status(statusCode).json(errorResponse)
}
})
@@ -483,10 +390,13 @@ providerRouter.post('/', async (req: Request, res: Response) => {
})
}
// Use shared processing function (no modelId override needed)
return await processMessageRequest(req, res, provider)
const request: MessageCreateParams = req.body
return handleMessageProcessing({ req, res, provider, request })
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
return res.status(statusCode).json(errorResponse)
}
})

View File

@@ -104,9 +104,7 @@ const router = express
logger.info('Models response ready', {
filter,
total: response.total
})
logger.debug('Model IDs returned', {
total: response.total,
modelIds: response.data.map((m) => m.id)
})

View File

@@ -1,33 +1,93 @@
import Anthropic from '@anthropic-ai/sdk'
import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import anthropicService from '@main/services/AnthropicService'
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
import { Provider } from '@types'
import { Response } from 'express'
const logger = loggerService.withContext('MessagesService')
const EXCLUDED_FORWARD_HEADERS: ReadonlySet<string> = new Set([
'host',
'x-api-key',
'authorization',
'sentry-trace',
'baggage',
'content-length',
'connection'
])
export interface ValidationResult {
isValid: boolean
errors: string[]
}
export interface ErrorResponse {
type: 'error'
error: {
type: string
message: string
requestId?: string
}
}
export interface StreamConfig {
response: Response
onChunk?: (chunk: MessageStreamEvent) => void
onError?: (error: any) => void
onComplete?: () => void
}
export interface ProcessMessageOptions {
provider: Provider
request: MessageCreateParams
extraHeaders?: Record<string, string | string[]>
modelId?: string
}
export interface ProcessMessageResult {
client: Anthropic
anthropicRequest: MessageCreateParams
}
export class MessagesService {
// oxlint-disable-next-line no-unused-vars
validateRequest(request: MessageCreateParams): ValidationResult {
// TODO: Implement comprehensive request validation
const errors: string[] = []
if (!request.model) {
if (!request.model || typeof request.model !== 'string') {
errors.push('Model is required')
}
if (!request.max_tokens || request.max_tokens < 1) {
errors.push('max_tokens is required and must be at least 1')
if (typeof request.max_tokens !== 'number' || !Number.isFinite(request.max_tokens) || request.max_tokens < 1) {
errors.push('max_tokens is required and must be a positive number')
}
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
errors.push('messages is required and must be a non-empty array')
} else {
request.messages.forEach((message, index) => {
if (!message || typeof message !== 'object') {
errors.push(`messages[${index}] must be an object`)
return
}
if (!('role' in message) || typeof message.role !== 'string' || message.role.trim().length === 0) {
errors.push(`messages[${index}].role is required`)
}
const content: unknown = message.content
if (content === undefined || content === null) {
errors.push(`messages[${index}].content is required`)
return
}
if (typeof content === 'string' && content.trim().length === 0) {
errors.push(`messages[${index}].content cannot be empty`)
} else if (Array.isArray(content) && content.length === 0) {
errors.push(`messages[${index}].content must include at least one item when using an array`)
}
})
}
return {
@@ -36,79 +96,224 @@ export class MessagesService {
}
}
async getClient(provider: Provider): Promise<Anthropic> {
async getClient(provider: Provider, extraHeaders?: Record<string, string | string[]>): Promise<Anthropic> {
// Create Anthropic client for the provider
if (provider.authType === 'oauth') {
const oauthToken = await anthropicService.getValidAccessToken()
return getSdkClient(provider, oauthToken)
return getSdkClient(provider, oauthToken, extraHeaders)
}
return getSdkClient(provider)
return getSdkClient(provider, null, extraHeaders)
}
async processMessage(request: MessageCreateParams, provider: Provider): Promise<Message> {
logger.debug('Preparing Anthropic message request', {
model: request.model,
messageCount: request.messages.length,
stream: request.stream,
maxTokens: request.max_tokens,
provider: provider.id
})
prepareHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[]> {
const extraHeaders: Record<string, string | string[]> = {}
// Create Anthropic client for the provider
const client = await this.getClient(provider)
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
// Prepare request with the actual model ID
const normalizedKey = key.toLowerCase()
if (EXCLUDED_FORWARD_HEADERS.has(normalizedKey)) {
continue
}
extraHeaders[normalizedKey] = value
}
return extraHeaders
}
createAnthropicRequest(request: MessageCreateParams, provider: Provider, modelId?: string): MessageCreateParams {
const anthropicRequest: MessageCreateParams = {
...request,
stream: false
stream: !!request.stream
}
if (provider.authType === 'oauth') {
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system || '')
// Override model if provided
if (modelId) {
anthropicRequest.model = modelId
}
const response = await client.messages.create(anthropicRequest)
// Add Claude Code system message for OAuth providers
if (provider.type === 'anthropic' && provider.authType === 'oauth') {
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system)
}
logger.info('Anthropic message completed', {
model: request.model,
provider: provider.id
})
return response
return anthropicRequest
}
async *processStreamingMessage(
async handleStreaming(
client: Anthropic,
request: MessageCreateParams,
config: StreamConfig,
provider: Provider
): AsyncIterable<RawMessageStreamEvent> {
logger.debug('Preparing streaming Anthropic message request', {
model: request.model,
messageCount: request.messages.length,
provider: provider.id
): Promise<void> {
const { response, onChunk, onError, onComplete } = config
// Set streaming headers
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
response.setHeader('Cache-Control', 'no-cache, no-transform')
response.setHeader('Connection', 'keep-alive')
response.setHeader('X-Accel-Buffering', 'no')
response.flushHeaders()
const flushableResponse = response as Response & { flush?: () => void }
const flushStream = () => {
if (typeof flushableResponse.flush !== 'function') {
return
}
try {
flushableResponse.flush()
} catch (flushError: unknown) {
logger.warn('Failed to flush streaming response', { error: flushError })
}
}
const writeSse = (eventType: string | undefined, payload: unknown) => {
if (response.writableEnded || response.destroyed) {
return
}
if (eventType) {
response.write(`event: ${eventType}\n`)
}
const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
response.write(`data: ${data}\n\n`)
flushStream()
}
try {
const stream = client.messages.stream(request)
for await (const chunk of stream) {
if (response.writableEnded || response.destroyed) {
logger.warn('Streaming response ended before stream completion', {
provider: provider.id,
model: request.model
})
break
}
writeSse(chunk.type, chunk)
if (onChunk) {
onChunk(chunk)
}
}
writeSse(undefined, '[DONE]')
if (onComplete) {
onComplete()
}
} catch (streamError: any) {
logger.error('Stream error', {
error: streamError,
provider: provider.id,
model: request.model,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost
})
writeSse(undefined, {
type: 'error',
error: {
type: 'api_error',
message: 'Stream processing error'
}
})
if (onError) {
onError(streamError)
}
} finally {
if (!response.writableEnded) {
response.end()
}
}
}
transformError(error: any): { statusCode: number; errorResponse: ErrorResponse } {
let statusCode = 500
let errorType = 'api_error'
let errorMessage = 'Internal server error'
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
const anthropicError = error?.error
if (anthropicStatus) {
statusCode = anthropicStatus
}
if (anthropicError?.type) {
errorType = anthropicError.type
}
if (anthropicError?.message) {
errorMessage = anthropicError.message
} else if (error instanceof Error && error.message) {
errorMessage = error.message
}
// Infer error type from message if not from Anthropic API
if (!anthropicStatus && error instanceof Error) {
const errorMessageText = error.message ?? ''
if (errorMessageText.includes('API key') || errorMessageText.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
} else if (errorMessageText.includes('rate limit') || errorMessageText.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
} else if (errorMessageText.includes('timeout') || errorMessageText.includes('connection')) {
statusCode = 502
errorType = 'api_error'
} else if (errorMessageText.includes('validation') || errorMessageText.includes('invalid')) {
statusCode = 400
errorType = 'invalid_request_error'
}
}
const safeErrorMessage =
typeof errorMessage === 'string' && errorMessage.length > 0 ? errorMessage : 'Internal server error'
return {
statusCode,
errorResponse: {
type: 'error',
error: {
type: errorType,
message: safeErrorMessage,
requestId: error?.request_id
}
}
}
}
async processMessage(options: ProcessMessageOptions): Promise<ProcessMessageResult> {
const { provider, request, extraHeaders, modelId } = options
const client = await this.getClient(provider, extraHeaders)
const anthropicRequest = this.createAnthropicRequest(request, provider, modelId)
const messageCount = Array.isArray(request.messages) ? request.messages.length : 0
logger.info('Processing anthropic messages request', {
provider: provider.id,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost,
model: anthropicRequest.model,
stream: !!anthropicRequest.stream,
// systemPrompt: JSON.stringify(!!request.system),
// messages: JSON.stringify(request.messages),
messageCount,
toolCount: Array.isArray(request.tools) ? request.tools.length : 0
})
// Create Anthropic client for the provider
const client = await this.getClient(provider)
// Prepare streaming request
const streamingRequest: MessageCreateParams = {
...request,
stream: true
// Return client and request for route layer to handle streaming/non-streaming
return {
client,
anthropicRequest
}
if (provider.authType === 'oauth') {
streamingRequest.system = buildClaudeCodeSystemMessage(request.system || '')
}
const stream = client.messages.stream(streamingRequest)
for await (const chunk of stream) {
yield chunk
}
logger.info('Completed streaming Anthropic message', {
model: request.model,
provider: provider.id
})
}
}

View File

@@ -27,9 +27,20 @@ export class ModelsService {
for (const model of models) {
const provider = providers.find((p) => p.id === model.provider)
if (!provider || (provider.isAnthropicModel && !provider.isAnthropicModel(model))) {
logger.debug(`Processing model ${model.id} from provider ${model.provider}`, {
isAnthropicModel: provider?.isAnthropicModel
})
if (
!provider ||
(filter.providerType === 'anthropic' && provider.isAnthropicModel && !provider.isAnthropicModel(model))
) {
continue
}
// Special case: For "aihubmix", it should be covered by above condition, but just in case
if (provider.id === 'aihubmix' && filter.providerType === 'anthropic' && !model.id.includes('claude')) {
continue
}
const openAIModel = transformModelToOpenAI(model, provider)
const fullModelId = openAIModel.id // This is already in format "provider:model_id"

View File

@@ -0,0 +1,64 @@
export type StreamAbortHandler = (reason: unknown) => void
export interface StreamAbortController {
abortController: AbortController
registerAbortHandler: (handler: StreamAbortHandler) => void
clearAbortTimeout: () => void
}
export const STREAM_TIMEOUT_REASON = 'stream timeout'
interface CreateStreamAbortControllerOptions {
timeoutMs: number
}
export const createStreamAbortController = (options: CreateStreamAbortControllerOptions): StreamAbortController => {
const { timeoutMs } = options
const abortController = new AbortController()
const signal = abortController.signal
let timeoutId: NodeJS.Timeout | undefined
let abortHandler: StreamAbortHandler | undefined
const clearAbortTimeout = () => {
if (!timeoutId) {
return
}
clearTimeout(timeoutId)
timeoutId = undefined
}
const handleAbort = () => {
clearAbortTimeout()
if (!abortHandler) {
return
}
abortHandler(signal.reason)
}
signal.addEventListener('abort', handleAbort, { once: true })
const registerAbortHandler = (handler: StreamAbortHandler) => {
abortHandler = handler
if (signal.aborted) {
abortHandler(signal.reason)
}
}
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
if (!signal.aborted) {
abortController.abort(STREAM_TIMEOUT_REASON)
}
}, timeoutMs)
}
return {
abortController,
registerAbortHandler,
clearAbortTimeout
}
}

View File

@@ -7,13 +7,13 @@ const logger = loggerService.withContext('ApiServerUtils')
// Cache configuration
const PROVIDERS_CACHE_KEY = 'api-server:providers'
const PROVIDERS_CACHE_TTL = 1 * 60 * 1000 // 1 minutes
const PROVIDERS_CACHE_TTL = 10 * 1000 // 10 seconds
export async function getAvailableProviders(): Promise<Provider[]> {
try {
// Try to get from cache first (faster)
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
if (cachedSupportedProviders) {
if (cachedSupportedProviders && cachedSupportedProviders.length > 0) {
logger.debug('Providers resolved from cache', {
count: cachedSupportedProviders.length
})

View File

@@ -30,6 +30,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
@@ -108,6 +109,7 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')

View File

@@ -37,6 +37,7 @@ import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService'
import { jsService } from './services/JsService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService'
@@ -45,6 +46,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'
@@ -91,6 +93,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()
@@ -140,7 +143,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) => {
@@ -463,6 +466,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()
@@ -526,6 +530,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@@ -737,10 +742,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
)
// Register JavaScript execution handler
ipcMain.handle(IpcChannel.Js_Execute, async (_, code: string, timeout?: number) => {
return await jsService.executeScript(code, { timeout })
})
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
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))
@@ -781,7 +792,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
@@ -872,6 +882,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

@@ -3,7 +3,7 @@ import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import { z } from 'zod'
import * as z from 'zod'
const logger = loggerService.withContext('DifyKnowledgeServer')

View File

@@ -6,6 +6,7 @@ import BraveSearchServer from './brave-search'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import JsServer from './js'
import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking'
@@ -42,6 +43,9 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
case BuiltinMCPServerNames.js: {
return new JsServer().server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import { net } from 'electron'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { z } from 'zod'
import * as z from 'zod'
export const RequestPayloadSchema = z.object({
url: z.url(),

View File

@@ -8,7 +8,7 @@ import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
import { z } from 'zod'
import * as z from 'zod'
const logger = loggerService.withContext('MCP:FileSystemServer')

139
src/main/mcpServers/js.ts Normal file
View File

@@ -0,0 +1,139 @@
// port from https://github.com/jlucaso1/mcp-javascript-sandbox
import { loggerService } from '@logger'
import { jsService } from '@main/services/JsService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types'
import * as z from 'zod'
const TOOL_NAME = 'run_javascript_code'
const DEFAULT_TIMEOUT = 60_000
export const RequestPayloadSchema = z.object({
javascript_code: z.string().min(1).describe('The JavaScript code to execute in the sandbox.'),
timeout: z
.number()
.int()
.positive()
.max(5 * 60_000)
.optional()
.describe('Execution timeout in milliseconds (default 60000, max 300000).')
})
const logger = loggerService.withContext('MCPServer:JavaScript')
function formatExecutionResult(result: {
stdout: string
stderr: string
error?: string | undefined
exitCode: number
}) {
let combinedOutput = ''
if (result.stdout) {
combinedOutput += result.stdout
}
if (result.stderr) {
combinedOutput += `--- stderr ---\n${result.stderr}\n--- stderr ---\n`
}
if (result.error) {
combinedOutput += `--- Execution Error ---\n${result.error}\n--- Execution Error ---\n`
}
const isError = Boolean(result.error) || Boolean(result.stderr?.trim()) || result.exitCode !== 0
return {
combinedOutput: combinedOutput.trim(),
isError
}
}
class JsServer {
public server: Server
constructor() {
this.server = new Server(
{
name: 'MCP QuickJS Runner',
version: '1.0.0',
description: 'An MCP server that provides a tool to execute JavaScript code in a QuickJS WASM sandbox.'
},
{
capabilities: {
resources: {},
tools: {}
}
}
)
this.setupHandlers()
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: TOOL_NAME,
description:
'Executes the provided JavaScript code in a secure WASM sandbox (QuickJS). Returns stdout and stderr. Non-zero exit code indicates an error.',
inputSchema: z.toJSONSchema(RequestPayloadSchema)
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (name !== TOOL_NAME) {
return {
content: [{ type: 'text', text: `Tool not found: ${name}` }],
isError: true
}
}
const parseResult = RequestPayloadSchema.safeParse(args)
if (!parseResult.success) {
return {
content: [{ type: 'text', text: `Invalid arguments: ${parseResult.error.message}` }],
isError: true
}
}
const { javascript_code, timeout } = parseResult.data
try {
logger.debug('Executing JavaScript code via JsService')
const result = await jsService.executeScript(javascript_code, {
timeout: timeout ?? DEFAULT_TIMEOUT
})
const { combinedOutput, isError } = formatExecutionResult(result)
return {
content: [
{
type: 'text',
text: combinedOutput
}
],
isError
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`JavaScript execution failed: ${message}`)
return {
content: [
{
type: 'text',
text: `Server error during tool execution: ${message}`
}
],
isError: true
}
}
})
}
}
export default JsServer

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

@@ -725,7 +725,10 @@ class FileStorage {
}
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
const resolved = await shell.openPath(path)
if (resolved !== '') {
throw new Error(resolved)
}
}
/**
@@ -1229,6 +1232,19 @@ class FileStorage {
return false
}
}
public showInFolder = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
if (!fs.existsSync(path)) {
const msg = `File or folder does not exist: ${path}`
logger.error(msg)
throw new Error(msg)
}
try {
shell.showItemInFolder(path)
} catch (error) {
logger.error('Failed to show item in folder:', error as Error)
}
}
}
export const fileStorage = new FileStorage()

View File

@@ -0,0 +1,115 @@
import { loggerService } from '@logger'
import type { JsExecutionResult } from './workers/JsWorker'
// oxlint-disable-next-line default
import createJsWorker from './workers/JsWorker?nodeWorker'
interface ExecuteScriptOptions {
timeout?: number
}
type WorkerResponse =
| {
success: true
result: JsExecutionResult
}
| {
success: false
error: string
}
const DEFAULT_TIMEOUT = 60_000
const logger = loggerService.withContext('JsService')
export class JsService {
private static instance: JsService | null = null
private constructor() {}
public static getInstance(): JsService {
if (!JsService.instance) {
JsService.instance = new JsService()
}
return JsService.instance
}
public async executeScript(code: string, options: ExecuteScriptOptions = {}): Promise<JsExecutionResult> {
const { timeout = DEFAULT_TIMEOUT } = options
if (!code || typeof code !== 'string') {
throw new Error('JavaScript code must be a non-empty string')
}
// Limit code size to 1MB to prevent memory issues
const MAX_CODE_SIZE = 1_000_000
if (code.length > MAX_CODE_SIZE) {
throw new Error(`JavaScript code exceeds maximum size of ${MAX_CODE_SIZE / 1_000_000}MB`)
}
return new Promise<JsExecutionResult>((resolve, reject) => {
const worker = createJsWorker({
workerData: { code },
argv: [],
trackUnmanagedFds: false
})
let settled = false
let timeoutId: NodeJS.Timeout | null = null
const cleanup = async () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
try {
await worker.terminate()
} catch {
// ignore termination errors
}
}
const settleSuccess = async (result: JsExecutionResult) => {
if (settled) return
settled = true
await cleanup()
resolve(result)
}
const settleError = async (error: Error) => {
if (settled) return
settled = true
await cleanup()
reject(error)
}
worker.once('message', async (message: WorkerResponse) => {
if (message.success) {
await settleSuccess(message.result)
} else {
await settleError(new Error(message.error))
}
})
worker.once('error', async (error) => {
logger.error(`JsWorker error: ${error instanceof Error ? error.message : String(error)}`)
await settleError(error instanceof Error ? error : new Error(String(error)))
})
worker.once('exit', async (exitCode) => {
if (!settled && exitCode !== 0) {
await settleError(new Error(`JsWorker exited with code ${exitCode}`))
}
})
timeoutId = setTimeout(() => {
logger.warn(`JavaScript execution timed out after ${timeout}ms`)
settleError(new Error('JavaScript execution timed out')).catch((err) => {
logger.error('Error during timeout cleanup:', err instanceof Error ? err : new Error(String(err)))
})
}, timeout)
})
}
}
export const jsService = JsService.getInstance()

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

@@ -1,4 +1,5 @@
import { session, shell, webContents } from 'electron'
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
@@ -36,3 +37,61 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
}
})
}
const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (contents.getType?.() !== 'webview') {
return
}
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
if (!input) {
return
}
const key = input.key?.toLowerCase()
if (!key) {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
if (!isFindShortcut && !isEscape && !isEnter) {
return
}
// Prevent default to override the guest page's native find dialog
// and keep shortcuts routed to our custom search overlay
event.preventDefault()
const host = contents.hostWebContents
if (!host || host.isDestroyed()) {
return
}
host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id,
key,
control: Boolean(input.control),
meta: Boolean(input.meta),
shift: Boolean(input.shift),
alt: Boolean(input.alt)
})
}
contents.on('before-input-event', handleBeforeInput)
contents.once('destroyed', () => {
contents.removeListener('before-input-event', handleBeforeInput)
})
}
export function initWebviewHotkeys() {
webContents.getAllWebContents().forEach((contents) => {
if (contents.isDestroyed()) return
attachKeyboardHandler(contents)
})
app.on('web-contents-created', (_, contents) => {
attachKeyboardHandler(contents)
})
}

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,341 +0,0 @@
# Agent Message Architecture Design Document
## Overview
This document describes the architecture for handling agent messages in Cherry Studio, including how agent-specific messages are generated, transformed to AI SDK format, stored, and sent to the UI. The system is designed to be agent-agnostic, allowing multiple agent types (Claude Code, OpenAI, etc.) to integrate seamlessly.
## Core Design Principles
1. **Agent Agnosticism**: The core message handling system should work with any agent type without modification
2. **Data Preservation**: All raw agent data must be preserved alongside transformed UI-friendly formats
3. **Streaming First**: Support real-time streaming of agent responses to the UI
4. **Type Safety**: Strong TypeScript interfaces ensure consistency across the pipeline
## Architecture Components
### 1. Agent Service Layer
Each agent (e.g., ClaudeCodeService) implements the `AgentServiceInterface`:
```typescript
interface AgentServiceInterface {
invoke(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream
}
```
#### Responsibilities:
- Spawn and manage agent-specific processes (e.g., Claude Code CLI)
- Parse agent-specific output formats (e.g., SDKMessage for Claude Code)
- Transform agent messages to AI SDK format
- Emit standardized `AgentStreamEvent` objects
### 2. Agent Stream Events
The standardized event interface that all agents emit:
```typescript
interface AgentStreamEvent {
type: 'chunk' | 'error' | 'complete'
chunk?: UIMessageChunk // AI SDK format for UI
rawAgentMessage?: any // Agent-specific raw message
error?: Error
agentResult?: any // Complete agent-specific result
}
```
### 3. Session Message Service
The `SessionMessageService` acts as the orchestration layer:
#### Responsibilities:
- Manages session lifecycle and persistence
- Collects streaming chunks and raw agent messages
- Stores structured data in the database
- Forwards events to the API layer
### 4. Database Storage
Session messages are stored with complete structured data:
```typescript
interface SessionMessageContent {
aiSDKChunks: UIMessageChunk[] // UI-friendly format
rawAgentMessages: any[] // Original agent messages
agentResult?: any // Complete agent result
agentType: string // Agent identifier
}
```
## Data Flow
```mermaid
graph TD
A[User Input] --> B[API Handler]
B --> C[SessionMessageService]
C --> D[Agent Service]
D --> E[Agent Process]
E --> F[Raw Agent Output]
F --> G[Transform to AI SDK]
G --> H[Emit AgentStreamEvent]
H --> I[SessionMessageService]
I --> J[Store in Database]
I --> K[Forward to Client]
K --> L[UI Rendering]
```
## Message Transformation Process
### Step 1: Raw Agent Message Generation
Each agent generates messages in its native format:
**Claude Code Example:**
```typescript
// SDKMessage from Claude Code CLI
{
type: 'assistant',
uuid: 'msg_123',
session_id: 'session_456',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Hello, I can help...' },
{ type: 'tool_use', id: 'tool_1', name: 'read_file', input: {...} }
]
}
}
```
### Step 2: Transformation to AI SDK Format
The agent service transforms native messages to AI SDK `UIMessageChunk`:
```typescript
// In ClaudeCodeService
const emitChunks = (sdkMessage: SDKMessage) => {
// Transform to AI SDK format
const chunks = transformSDKMessageToUIChunk(sdkMessage)
for (const chunk of chunks) {
stream.emit('data', {
type: 'chunk',
chunk, // AI SDK format
rawAgentMessage: sdkMessage // Preserve original
})
}
}
```
**Transformed AI SDK Chunk:**
```typescript
{
type: 'text-delta',
id: 'msg_123',
delta: 'Hello, I can help...',
providerMetadata: {
claudeCode: {
originalSDKMessage: {...},
uuid: 'msg_123',
session_id: 'session_456'
}
}
}
```
### Step 3: Session Message Processing
The SessionMessageService collects and processes events:
```typescript
// Collect streaming data
const streamedChunks: UIMessageChunk[] = []
const rawAgentMessages: any[] = []
claudeStream.on('data', async (event: AgentStreamEvent) => {
switch (event.type) {
case 'chunk':
streamedChunks.push(event.chunk)
if (event.rawAgentMessage) {
rawAgentMessages.push(event.rawAgentMessage)
}
// Forward to client
sessionStream.emit('data', { type: 'chunk', chunk: event.chunk })
break
case 'complete':
// Store complete structured data
const content = {
aiSDKChunks: streamedChunks,
rawAgentMessages: rawAgentMessages,
agentResult: event.agentResult,
agentType: event.agentResult?.agentType || 'unknown'
}
// Save to database...
break
}
})
```
### Step 4: Client Streaming
The API handler converts events to Server-Sent Events (SSE):
```typescript
// In API handler
messageStream.on('data', (event: any) => {
switch (event.type) {
case 'chunk':
// Send AI SDK chunk as SSE
res.write(`data: ${JSON.stringify(event.chunk)}\n\n`)
break
case 'complete':
res.write('data: [DONE]\n\n')
res.end()
break
}
})
```
## Adding New Agent Types
To add support for a new agent (e.g., OpenAI):
### 1. Create Agent Service
```typescript
class OpenAIService implements AgentServiceInterface {
invokeStream(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream {
const stream = new OpenAIStream()
// Call OpenAI API
const openaiResponse = await openai.chat.completions.create({
messages: [{ role: 'user', content: prompt }],
stream: true
})
// Transform OpenAI format to AI SDK
for await (const chunk of openaiResponse) {
const aiSDKChunk = transformOpenAIToAISDK(chunk)
stream.emit('data', {
type: 'chunk',
chunk: aiSDKChunk,
rawAgentMessage: chunk // Preserve OpenAI format
})
}
return stream
}
}
```
### 2. Create Transform Function
```typescript
function transformOpenAIToAISDK(openaiChunk: OpenAIChunk): UIMessageChunk {
return {
type: 'text-delta',
id: openaiChunk.id,
delta: openaiChunk.choices[0].delta.content,
providerMetadata: {
openai: {
original: openaiChunk,
model: openaiChunk.model
}
}
}
}
```
### 3. Register Agent Type
Update the agent type enum and factory:
```typescript
export type AgentType = 'claude-code' | 'openai' | 'anthropic-api'
function createAgentService(type: AgentType): AgentServiceInterface {
switch (type) {
case 'claude-code':
return new ClaudeCodeService()
case 'openai':
return new OpenAIService()
// ...
}
}
```
## Benefits of This Architecture
1. **Extensibility**: Easy to add new agent types without modifying core logic
2. **Data Integrity**: Raw agent data is never lost during transformation
3. **Debugging**: Complete message history available for troubleshooting
4. **Performance**: Streaming support for real-time responses
5. **Type Safety**: Strong interfaces prevent runtime errors
6. **UI Consistency**: All agents provide data in standard AI SDK format
## Key Interfaces Reference
### AgentStreamEvent
```typescript
interface AgentStreamEvent {
type: 'chunk' | 'error' | 'complete'
chunk?: UIMessageChunk
rawAgentMessage?: any
error?: Error
agentResult?: any
}
```
### SessionMessageEntity
```typescript
interface SessionMessageEntity {
id: number
session_id: string
parent_id?: number
role: 'user' | 'assistant' | 'system' | 'tool'
type: string
content: string | SessionMessageContent
metadata?: Record<string, any>
created_at: string
updated_at: string
}
```
### SessionMessageContent
```typescript
interface SessionMessageContent {
aiSDKChunks: UIMessageChunk[]
rawAgentMessages: any[]
agentResult?: any
agentType: string
}
```
## Testing Strategy
### Unit Tests
- Test each transform function independently
- Verify event emission sequences
- Validate data structure preservation
### Integration Tests
- Test complete flow from input to database
- Verify streaming behavior
- Test error handling and recovery
### Agent-Specific Tests
- Validate agent-specific transformations
- Test edge cases for each agent type
- Verify metadata preservation
## Future Enhancements
1. **Message Replay**: Ability to replay sessions from stored raw messages
2. **Format Migration**: Tools to migrate between agent formats
3. **Analytics**: Aggregate metrics from raw agent data
4. **Caching**: Cache transformed chunks for performance
5. **Compression**: Compress raw messages for storage efficiency
## Conclusion
This architecture provides a robust, extensible foundation for handling messages from multiple AI agents while maintaining data integrity and providing a consistent interface for the UI. The separation of concerns between agent-specific logic and core message handling ensures the system can evolve to support new agents and features without breaking existing functionality.

View File

@@ -59,16 +59,23 @@ export abstract class BaseService {
}
if (ids && ids.length > 0) {
for (const id of ids) {
const server = await mcpApiService.getServerInfo(id)
if (server) {
server.tools.forEach((tool: MCPTool) => {
tools.push({
id: `mcp_${id}_${tool.name}`,
name: tool.name,
type: 'mcp',
description: tool.description || '',
requirePermissions: true
try {
const server = await mcpApiService.getServerInfo(id)
if (server) {
server.tools.forEach((tool: MCPTool) => {
tools.push({
id: `mcp_${id}_${tool.name}`,
name: tool.name,
type: 'mcp',
description: tool.description || '',
requirePermissions: true
})
})
}
} catch (error) {
logger.warn('Failed to list MCP tools', {
id,
error: error as Error
})
}
}

View File

@@ -6,7 +6,7 @@ import type {
ListOptions
} from '@types'
import { TextStreamPart } from 'ai'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, not } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { sessionMessagesTable } from '../database/schema'
@@ -276,7 +276,7 @@ export class SessionMessageService extends BaseService {
const result = await this.database
.select({ agent_session_id: sessionMessagesTable.agent_session_id })
.from(sessionMessagesTable)
.where(eq(sessionMessagesTable.session_id, sessionId))
.where(and(eq(sessionMessagesTable.session_id, sessionId), not(eq(sessionMessagesTable.agent_session_id, ''))))
.orderBy(desc(sessionMessagesTable.created_at))
.limit(1)

View File

@@ -1,4 +1,4 @@
import type { SDKMessage } from '@anthropic-ai/claude-code'
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { describe, expect, it } from 'vitest'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'

View File

@@ -2,7 +2,7 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-code'
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
import { validateModelId } from '@main/apiServer/utils'
@@ -27,7 +27,7 @@ class ClaudeCodeService implements AgentServiceInterface {
constructor() {
// Resolve Claude Code CLI robustly (works in dev and in asar)
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-code/cli.js')
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-agent-sdk/cli.js')
if (app.isPackaged) {
this.claudeExecutablePath = this.claudeExecutablePath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
}
@@ -78,10 +78,21 @@ class ClaudeCodeService implements AgentServiceInterface {
const apiConfig = await apiConfigService.get()
const loginShellEnv = await getLoginShellEnvironment()
const loginShellEnvWithoutProxies = Object.fromEntries(
Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy'))
) as Record<string, string>
const env = {
...loginShellEnv,
ANTHROPIC_API_KEY: apiConfig.apiKey,
ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
...loginShellEnvWithoutProxies,
// TODO: fix the proxy api server
// ANTHROPIC_API_KEY: apiConfig.apiKey,
// ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
// ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
ANTHROPIC_API_KEY: modelInfo.provider.apiKey,
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
ANTHROPIC_MODEL: modelInfo.modelId,
ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId,
ELECTRON_RUN_AS_NODE: '1',
ELECTRON_NO_ATTACH_CONSOLE: '1'
}
@@ -93,13 +104,20 @@ class ClaudeCodeService implements AgentServiceInterface {
abortController,
cwd,
env,
model: modelInfo.modelId,
// model: modelInfo.modelId,
pathToClaudeCodeExecutable: this.claudeExecutablePath,
stderr: (chunk: string) => {
logger.warn('claude stderr', { chunk })
errorChunks.push(chunk)
},
appendSystemPrompt: session.instructions,
systemPrompt: session.instructions
? {
type: 'preset',
preset: 'claude_code',
append: session.instructions
}
: { type: 'preset', preset: 'claude_code' },
settingSources: ['project'],
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,
maxTurns: session.configuration?.max_turns,
@@ -128,6 +146,8 @@ class ClaudeCodeService implements AgentServiceInterface {
if (lastAgentSessionId) {
options.resume = lastAgentSessionId
// TODO: use fork session when we support branching sessions
// options.forkSession = true
}
logger.info('Starting Claude Code SDK query', {
@@ -140,8 +160,18 @@ class ClaudeCodeService implements AgentServiceInterface {
resume: options.resume
})
// Start async processing
this.processSDKQuery(prompt, options, aiStream, errorChunks)
// Start async processing on the next tick so listeners can subscribe first
setImmediate(() => {
this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => {
logger.error('Unhandled Claude Code stream error', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
aiStream.emit('data', {
type: 'error',
error: error instanceof Error ? error : new Error(String(error))
})
})
})
return aiStream
}
@@ -194,6 +224,11 @@ class ClaudeCodeService implements AgentServiceInterface {
message,
event: JSON.stringify(message.event)
})
} else {
logger.silly('Claude response', {
message,
event: JSON.stringify(message)
})
}
// Transform SDKMessage to UIMessageChunks
@@ -244,6 +279,11 @@ class ClaudeCodeService implements AgentServiceInterface {
errorChunks.push(errorObj instanceof Error ? errorObj.message : String(errorObj))
const errorMessage = errorChunks.join('\n\n')
logger.error('SDK query failed', {
duration,
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
stderr: errorChunks
})
// Emit error event
stream.emit('data', {
type: 'error',

View File

@@ -20,7 +20,7 @@
* emitting `text-*` parts and a synthetic `finish-step`.
*/
import { SDKMessage } from '@anthropic-ai/claude-code'
import { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type { BetaStopReason } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { loggerService } from '@logger'
import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai'
@@ -591,6 +591,15 @@ function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>):
raw: message
}
})
} else if (message.subtype === 'compact_boundary') {
chunks.push({
type: 'raw',
rawValue: {
type: 'compact',
session_id: message.session_id,
raw: message
}
})
}
return chunks
}

View File

@@ -4,7 +4,7 @@ import {
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import EventEmitter from 'events'
import { z } from 'zod'
import * as z from 'zod'
export interface OAuthStorageData {
clientInfo?: OAuthClientInformation

View File

@@ -1,7 +1,7 @@
import { loadOcrImage } from '@main/utils/ocr'
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
import { net } from 'electron'
import { z } from 'zod'
import * as z from 'zod'
import { OcrBaseService } from './OcrBaseService'

View File

@@ -0,0 +1,118 @@
import { mkdtemp, open, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WASI } from 'node:wasi'
import { parentPort, workerData } from 'node:worker_threads'
import loadWasm from '../../../../resources/wasm/qjs-wasi.wasm?loader'
interface WorkerPayload {
code: string
}
export interface JsExecutionResult {
stdout: string
stderr: string
error?: string
exitCode: number
}
if (!parentPort) {
throw new Error('JsWorker requires a parent port')
}
async function runQuickJsInSandbox(jsCode: string): Promise<JsExecutionResult> {
let tempDir: string | undefined
let stdoutHandle: Awaited<ReturnType<typeof open>> | undefined
let stderrHandle: Awaited<ReturnType<typeof open>> | undefined
let stdoutPath: string | undefined
let stderrPath: string | undefined
try {
tempDir = await mkdtemp(join(tmpdir(), 'quickjs-wasi-'))
stdoutPath = join(tempDir, 'stdout.log')
stderrPath = join(tempDir, 'stderr.log')
stdoutHandle = await open(stdoutPath, 'w')
stderrHandle = await open(stderrPath, 'w')
const wasi = new WASI({
version: 'preview1',
args: ['qjs', '-e', jsCode],
env: {}, // Empty environment for security - don't expose host env vars
stdin: 0,
stdout: stdoutHandle.fd,
stderr: stderrHandle.fd,
returnOnExit: true
})
const instance = await loadWasm(wasi.getImportObject() as WebAssembly.Imports)
let exitCode = 0
try {
exitCode = wasi.start(instance)
} catch (wasiError: any) {
return {
stdout: '',
stderr: `WASI start error: ${wasiError?.message ?? String(wasiError)}`,
error: `Sandbox execution failed during start: ${wasiError?.message ?? String(wasiError)}`,
exitCode: -1
}
}
// Close handles before reading files to prevent descriptor leak
const _stdoutHandle = stdoutHandle
stdoutHandle = undefined
await _stdoutHandle.close()
const _stderrHandle = stderrHandle
stderrHandle = undefined
await _stderrHandle.close()
const capturedStdout = await readFile(stdoutPath, 'utf8')
const capturedStderr = await readFile(stderrPath, 'utf8')
let executionError: string | undefined
if (exitCode !== 0) {
executionError = `QuickJS process exited with code ${exitCode}. Check stderr for details.`
}
return {
stdout: capturedStdout,
stderr: capturedStderr,
error: executionError,
exitCode
}
} catch (error: any) {
return {
stdout: '',
stderr: '',
error: `Sandbox setup or execution failed: ${error?.message ?? String(error)}`,
exitCode: -1
}
} finally {
if (stdoutHandle) await stdoutHandle.close()
if (stderrHandle) await stderrHandle.close()
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
}
}
}
async function execute(code: string) {
return runQuickJsInSandbox(code)
}
const payload = workerData as WorkerPayload | undefined
if (!payload?.code || typeof payload.code !== 'string') {
parentPort.postMessage({ success: false, error: 'JavaScript code must be provided to the worker' })
} else {
execute(payload.code)
.then((result) => {
parentPort?.postMessage({ success: true, result })
})
.catch((error: any) => {
const errorMessage = error instanceof Error ? error.message : String(error)
parentPort?.postMessage({ success: false, error: errorMessage })
})
}

View File

@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
@@ -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)
@@ -199,7 +200,8 @@ const api = {
}
ipcRenderer.on('file-change', listener)
return () => ipcRenderer.off('file-change', listener)
}
},
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
@@ -221,7 +223,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,
@@ -286,6 +288,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),
@@ -333,6 +345,9 @@ const api = {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
},
js: {
execute: (code: string, timeout?: number) => ipcRenderer.invoke(IpcChannel.Js_Execute, code, timeout)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
},
@@ -351,6 +366,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 }) => {
@@ -377,7 +393,16 @@ const api = {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),

View File

@@ -24,6 +24,8 @@ export class AiSdkToChunkAdapter {
private isFirstChunk = true
private enableWebSearch: boolean = false
private onSessionUpdate?: (sessionId: string) => void
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
constructor(
private onChunk: (chunk: Chunk) => void,
@@ -38,6 +40,17 @@ export class AiSdkToChunkAdapter {
this.onSessionUpdate = onSessionUpdate
}
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 的流结果对象
@@ -65,6 +78,8 @@ export class AiSdkToChunkAdapter {
webSearchResults: [],
reasoningId: ''
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset link converter state at the start of stream
this.isFirstChunk = true
@@ -77,6 +92,7 @@ export class AiSdkToChunkAdapter {
if (this.enableWebSearch) {
const remainingText = flushLinkConverterBuffer()
if (remainingText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: remainingText
@@ -91,6 +107,7 @@ export class AiSdkToChunkAdapter {
}
} finally {
reader.releaseLock()
this.resetTimingState()
}
}
@@ -152,6 +169,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
@@ -176,16 +194,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
@@ -276,44 +296,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':
@@ -349,6 +362,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

@@ -14,6 +14,7 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
@@ -21,7 +22,6 @@ import LegacyAiProvider from './legacy/index'
import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas'
import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
import { buildPlugins } from './plugins/PluginBuilder'
import { buildClaudeCodeSystemMessage } from './provider/config/anthropic'
import { createAiSdkProvider } from './provider/factory'
import {
getActualProvider,
@@ -122,13 +122,9 @@ export default class ModernAiProvider {
}
if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') {
const claudeCodeSystemMessage = buildClaudeCodeSystemMessage(params.system)
const claudeCodeSystemMessage = buildClaudeCodeSystemModelMessage(params.system)
params.system = undefined // 清除原有system避免重复
if (Array.isArray(params.messages)) {
params.messages = [...claudeCodeSystemMessage, ...params.messages]
} else {
params.messages = claudeCodeSystemMessage
}
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
}
if (config.topicId && getEnableDeveloperMode()) {

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

@@ -166,9 +166,7 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
...this.provider.extra_headers
}
}) as TSdkInstance
}

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'
@@ -159,14 +160,14 @@ export async function buildStreamTextParams(
abortSignal: options.requestOptions?.signal,
headers: options.requestOptions?.headers,
providerOptions,
stopWhen: stepCountIs(10),
stopWhen: stepCountIs(20),
maxRetries: 0
}
if (tools) {
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

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn()
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({ copilot: { defaultHeaders: {} } })
}
}))
import type { Model, Provider } from '@renderer/types'
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
import { providerToAiSdkConfig } from '../providerConfig'
const createWindowKeyv = () => {
const store = new Map<string, string>()
return {
get: (key: string) => store.get(key),
set: (key: string, value: string) => {
store.set(key, value)
}
}
}
const createCopilotProvider = (): Provider => ({
id: 'copilot',
type: 'openai',
name: 'GitHub Copilot',
apiKey: 'test-key',
apiHost: 'https://api.githubcopilot.com',
models: [],
isSystem: true
})
const createModel = (id: string, name = id): Model => ({
id,
name,
provider: 'copilot',
group: 'copilot'
})
describe('Copilot responses routing', () => {
beforeEach(() => {
;(globalThis as any).window = {
...(globalThis as any).window,
keyv: createWindowKeyv()
}
})
it('detects official GPT-5 Codex identifiers case-insensitively', () => {
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'gpt-5-codex'))).toBe(true)
expect(isCopilotResponsesModel(createModel('GPT-5-CODEX', 'GPT-5-CODEX'))).toBe(true)
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'custom-name'))).toBe(true)
expect(isCopilotResponsesModel(createModel('custom-id', 'custom-name'))).toBe(false)
})
it('configures gpt-5-codex with the Copilot provider', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION)
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
expect(config.options.headers?.['copilot-vision-request']).toBe('true')
})
it('uses the Copilot provider for other models and keeps headers', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-4'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
})
})

View File

@@ -1,24 +0,0 @@
import { SystemModelMessage } from 'ai'
export function buildClaudeCodeSystemMessage(system?: string): Array<SystemModelMessage> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
if (!system || system.trim() === defaultClaudeCodeSystem) {
return [
{
role: 'system',
content: defaultClaudeCodeSystem
}
]
}
return [
{
role: 'system',
content: defaultClaudeCodeSystem
},
{
role: 'system',
content: system
}
]
}

View File

@@ -0,0 +1,25 @@
import type { Model } from '@renderer/types'
export const COPILOT_EDITOR_VERSION = 'vscode/1.104.1'
export const COPILOT_PLUGIN_VERSION = 'copilot-chat/0.26.7'
export const COPILOT_INTEGRATION_ID = 'vscode-chat'
export const COPILOT_USER_AGENT = 'GitHubCopilotChat/0.26.7'
export const COPILOT_DEFAULT_HEADERS = {
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
'User-Agent': COPILOT_USER_AGENT,
'Editor-Version': COPILOT_EDITOR_VERSION,
'Editor-Plugin-Version': COPILOT_PLUGIN_VERSION,
'editor-version': COPILOT_EDITOR_VERSION,
'editor-plugin-version': COPILOT_PLUGIN_VERSION,
'copilot-vision-request': 'true'
} as const
// Models that require the OpenAI Responses endpoint when routed through GitHub Copilot (#10560)
const COPILOT_RESPONSES_MODEL_IDS = ['gpt-5-codex']
export function isCopilotResponsesModel(model: Model): boolean {
const normalizedId = model.id?.trim().toLowerCase()
const normalizedName = model.name?.trim().toLowerCase()
return COPILOT_RESPONSES_MODEL_IDS.some((target) => normalizedId === target || normalizedName === target)
}

View File

@@ -28,7 +28,8 @@ const STATIC_PROVIDER_MAPPING: Record<string, ProviderId> = {
gemini: 'google', // Google Gemini -> google
'azure-openai': 'azure', // Azure OpenAI -> azure
'openai-response': 'openai', // OpenAI Responses -> openai
grok: 'xai' // Grok -> xai
grok: 'xai', // Grok -> xai
copilot: 'github-copilot-openai-compatible'
}
/**

View File

@@ -21,6 +21,7 @@ import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
@@ -109,6 +110,9 @@ function formatProviderApiHost(provider: Provider): Provider {
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
@@ -151,6 +155,26 @@ export function providerToAiSdkConfig(
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === 'copilot'
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
headers: {
...COPILOT_DEFAULT_HEADERS,
...storedHeaders,
...actualProvider.extra_headers
},
name: actualProvider.id,
includeUsage: true
})
return {
providerId: 'github-copilot-openai-compatible',
options
}
}
// 处理OpenAI模式
const extraOptions: any = {}
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
@@ -172,15 +196,6 @@ export function providerToAiSdkConfig(
}
}
}
// copilot
if (actualProvider.id === 'copilot') {
extraOptions.headers = {
...extraOptions.headers,
'editor-version': 'vscode/1.97.2',
'copilot-vision-request': 'true'
}
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
extraOptions.apiVersion = actualProvider.apiVersion
@@ -229,7 +244,6 @@ export function providerToAiSdkConfig(
}
}
// 如果AI SDK支持该provider使用原生配置
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
return {
@@ -277,9 +291,17 @@ export async function prepareSpecialProviderConfig(
) {
switch (provider.id) {
case 'copilot': {
const defaultHeaders = store.getState().copilot.defaultHeaders
const { token } = await window.api.copilot.getToken(defaultHeaders)
const defaultHeaders = store.getState().copilot.defaultHeaders ?? {}
const headers = {
...COPILOT_DEFAULT_HEADERS,
...defaultHeaders
}
const { token } = await window.api.copilot.getToken(headers)
config.options.apiKey = token
config.options.headers = {
...headers,
...config.options.headers
}
break
}
case 'cherryai': {

View File

@@ -32,6 +32,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
supportsImageGeneration: true,
aliases: ['vertexai-anthropic']
},
{
id: 'github-copilot-openai-compatible',
name: 'GitHub Copilot OpenAI Compatible',
import: () => import('@opeoginni/github-copilot-openai-compatible'),
creatorFunctionName: 'createGitHubCopilotOpenAICompatible',
supportsImageGeneration: false,
aliases: ['copilot', 'github-copilot']
},
{
id: 'bedrock',
name: 'Amazon Bedrock',

View File

@@ -4,7 +4,7 @@ import type { Assistant, KnowledgeReference } from '@renderer/types'
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { isEmpty } from 'lodash'
import { z } from 'zod'
import * as z from 'zod'
/**
* 知识库搜索工具

View File

@@ -1,7 +1,7 @@
import store from '@renderer/store'
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
import { MemoryProcessor } from '../../services/MemoryProcessor'

View File

@@ -3,7 +3,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
/**
* 使用预提取关键词的网络搜索工具

View File

@@ -6,6 +6,7 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -52,7 +53,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
// Don't disable reasoning for models that require it
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
if (
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -100,6 +106,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// reasoningEffort有效的情况
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(provider)) {
switch (provider.id) {
@@ -142,6 +149,16 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
// Grok 4 Fast doesn't support effort levels, always use enabled: true
if (isGrok4FastReasoningModel(model)) {
return {
reasoning: {
enabled: true // Ignore effort level, just enable reasoning
}
}
}
// Other OpenRouter models that support effort levels
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -412,6 +429,13 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
/**
* Get XAI-specific reasoning parameters
* This function should only be called for XAI provider models
* @param assistant - The assistant configuration
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
@@ -419,6 +443,11 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort) {
return {}
}
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -87,7 +87,8 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const [tools, setTools] = useState<ActionTool[]>([])
const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python'
const executableLanguages = ['python', 'py', 'javascript', 'js']
return codeExecution.enabled && executableLanguages.includes(language.toLowerCase())
}, [codeExecution.enabled, language])
const sourceViewRef = useRef<CodeEditorHandles>(null)
@@ -152,21 +153,49 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
setIsRunning(true)
setExecutionResult(null)
pyodideService
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((result) => {
setExecutionResult(result)
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
const isPython = ['python', 'py'].includes(language.toLowerCase())
const isJavaScript = ['javascript', 'js'].includes(language.toLowerCase())
if (isPython) {
pyodideService
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((result) => {
setExecutionResult(result)
})
})
.finally(() => {
setIsRunning(false)
})
}, [children, codeExecution.timeoutMinutes])
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
})
.finally(() => {
setIsRunning(false)
})
} else if (isJavaScript) {
window.api.js
.execute(children, codeExecution.timeoutMinutes * 60000)
.then((result) => {
if (result.error) {
setExecutionResult({
text: `Error: ${result.error}\n${result.stderr || ''}`
})
} else {
setExecutionResult({
text: result.stdout || (result.stderr ? `stderr: ${result.stderr}` : 'Execution completed')
})
}
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
})
.finally(() => {
setIsRunning(false)
})
}
}, [children, codeExecution.timeoutMinutes, language])
const showPreviewTools = useMemo(() => {
return viewMode !== 'source' && hasSpecialView

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

@@ -10,6 +10,7 @@ interface ShowParams {
providerId: string
title?: string
showHealthCheck?: boolean
providerType?: 'llm' | 'webSearch' | 'preprocess'
}
interface Props extends ShowParams {
@@ -19,7 +20,7 @@ interface Props extends ShowParams {
/**
* API Key 列表弹窗容器组件
*/
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
@@ -32,14 +33,20 @@ const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealt
}
const ListComponent = useMemo(() => {
if (isWebSearchProviderId(providerId)) {
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
const type =
providerType ||
(isWebSearchProviderId(providerId) ? 'webSearch' : isPreprocessProviderId(providerId) ? 'preprocess' : 'llm')
switch (type) {
case 'webSearch':
return <WebSearchApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'preprocess':
return <DocPreprocessApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'llm':
default:
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
if (isPreprocessProviderId(providerId)) {
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}, [providerId, showHealthCheck])
}, [providerId, showHealthCheck, providerType])
return (
<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

@@ -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 }) => {

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