Compare commits

..

211 Commits
v1.7.0 ... main

Author SHA1 Message Date
SuYao
6b0bb64795
fix: convert 'developer' role to 'system' for unsupported providers (#12325)
AI SDK v5 uses 'developer' role for reasoning models, but some providers
like Azure DeepSeek R1 only support 'system', 'user', 'assistant', 'tool'
roles, causing HTTP 422 errors.

This fix adds a custom fetch wrapper that converts 'developer' role back
to 'system' for providers that don't support it.

Fixes #12321

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-07 01:03:37 +08:00
Shemol
116ee6f94b
fix: TokenFlux models list empty in drawing panel (#12326)
Use fixed base URL for TokenFlux image API instead of provider.apiHost.

After migration 191, apiHost was changed to include /openai/v1 suffix
for chat API compatibility, but image API needs the base URL without
this suffix, causing /openai/v1/v1/images/models (wrong path).

Fixes #12284

Signed-off-by: SherlockShemol <shemol@163.com>
2026-01-06 22:19:03 +08:00
George·Dong
af7896b900
fix(prompts): standardize tool use example format to use 'A:' label consistently (#12313)
- Changed all 'Assistant:' labels to 'A:' in tool use examples for consistency
- Added missing blank line before final response in both files
- Affects promptToolUsePlugin.ts and prompt.ts
- Resolves #12310
2026-01-06 21:45:27 +08:00
beyondkmp
bb9b73557b
fix: use ipinfo lite API with token for IP country detection (#12312)
* fix: use ipinfo lite API with token for IP country detection

Switch from ipinfo.io/json to api.ipinfo.io/lite/me endpoint with
authentication token to improve reliability and avoid rate limiting.

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

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

* fix: use country_code field from ipinfo lite API response

The lite API returns country_code instead of country field.

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

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

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 17:33:19 +08:00
Phantom
a5038ac844
fix: Add reasoning control for Deepseek hybrid inference models when reasoning effort is 'none' (#12314)
fix: Add reasoning control for Deepseek hybrid inference models when
reasoning effort is 'none'

It prevents warning
2026-01-06 17:28:34 +08:00
beyondkmp
9e45f801a8
chore: optimize build excludes to reduce package size (#12311)
- Exclude config, patches directories
- Exclude app-upgrade-config.json
- Exclude unnecessary node_modules files (*.cpp, node-addon-api, prebuild-install)

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

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 15:30:22 +08:00
yudong
313dac0f64
fix: Changed the ID of the doubao-seed-1-8 from '251215' to '251228' (#12307)
Co-authored-by: wangyudong <wangyudong@qiyi.com>
2026-01-06 15:17:22 +08:00
SuYao
76ee67d4d7
fix: prevent OOM when handling large base64 image data (#12244)
* fix: prevent OOM when handling large base64 image data

- Add memory-safe parseDataUrl utility using string operations instead of regex
- Truncate large base64 data in ErrorBlock detail modal to prevent freezing
- Update ImageViewer, FileStorage, messageConverter to use shared parseDataUrl
- Deprecate parseDataUrlMediaType in favor of shared utility
- Add GB support to formatFileSize
- Add comprehensive unit tests for parseDataUrl (18 tests)

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

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

* refactor: simplify parseDataUrl API to return DataUrlParts | null

- Change return type from discriminated union to simple nullable type
- Update all call sites to use optional chaining (?.)
- Update tests to use toBeNull() for failure cases
- More idiomatic and consistent with codebase patterns (e.g., parseJSON)

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-06 00:34:14 +08:00
George·Dong
2a31fa2ad5
refactor: switch yarn to pnpm (#12260)
* refactor: switch workflows from yarn to pnpm

Replace Yarn usage with pnpm in CI workflows to standardize package
management and leverage pnpm's store/cache behavior.

- Use pnpm/action-setup to install pnpm (v) instead of enabling corepack
  and preparing Yarn.
- Retrieve pnpm store path and update cache actions to cache the pnpm
  store and use pnpm-lock.yaml for cache keys and restores.
- Replace yarn commands with pnpm equivalents across workflows:
  install, i18n:sync/translate, format, build:* and tsx invocation.
- Avoid committing lockfile changes by resetting pnpm-lock.yaml instead
  of yarn.lock when checking for changes.
- Update install flags: use pnpm install --frozen-lockfile / --install
  semantics where appropriate.

These changes unify dependency tooling, improve caching correctness,
and ensure CI uses pnpm-specific lockfile and cache paths.

* build: switch pre-commit hook to pnpm lint-staged

Update .husky/pre-commit to run pnpm lint-staged instead of yarn.
This aligns the pre-commit hook with the project's package manager
and ensures lint-staged runs using pnpm's environment and caching.

* chore(ci): remove pinned pnpm version from GH Action steps

Remove the explicit `with: version: 9` lines from multiple GitHub Actions workflows
(auto-i18n.yml, nightly-build.yml, pr-ci.yml, update-app-upgrade-config.yml,
sync-to-gitcode.yml, release.yml). The workflows still call `pnpm/action-setup@v4`
but no longer hardcode a pnpm version.

This simplifies maintenance and allows the action to resolve an appropriate pnpm
version (or use its default) without needing updates whenever the pinned
version becomes outdated. It reduces churn when bumping pnpm across CI configs
and prevents accidental pin drift between workflow files.

* build: Update pnpm to 10.27.0 and add onlyBuiltDependencies config

* Update @cherrystudio/openai to 6.15.0 and consolidate overrides

* Add @langchain/core to overrides

* Add override for openai-compatible 1.0.27

* build: optimize pnpm config and add missing dependencies

- Comment out shamefully-hoist in .npmrc for better pnpm compatibility
- Add React-related packages to optimizeDeps in electron.vite.config.ts
- Add missing peer dependencies and packages that were previously hoisted

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

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

* build: refine pnpm configuration and dependency management

- Simplify .npmrc to only essential electron mirror config
- Move platform-specific dependencies to devDependencies
- Pin sharp version to 0.34.3 for consistency
- Update sharp-libvips versions to 1.2.4

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

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

* reduce app size

* format

* build: remove unnecessary disableOxcRecommendation option from react plugin configuration

* docs: Replace yarn commands with pnpm in documentation and scripts

* Revert "build: optimize pnpm config and add missing dependencies"

This reverts commit acffad31f8.

* build: import dependencies from yarn.lock

* build: Add some phantom dependencies and reorganize dependencies

* build: Keep consistent by removing types of semver

It's not in the previous package.json

* build: Add some phantom dependencies

Keep same version with yarn.lock

* build: Add form-data dependency version 4.0.4

* Add chalk dependency

* build: downgrade some dependencies

Reference: .yarn-state-copy.yml. These phantom dependencies should use top-level package of that version in node_modules

* build: Add phantom dependencies

* build: pin tiptap dependencies to exact versions

Ensure consistent dependency resolution by removing caret ranges and pinning all @tiptap packages to exact version 3.2.0

* chore: pin embedjs dependencies to exact versions

* build: pin @modelcontextprotocol/sdk to exact version 1.23.0

Remove caret from version specifier to prevent automatic upgrades and ensure consistent dependencies

* chore: update @types/node dependency to 22.17.2

Update package.json and pnpm-lock.yaml to use @types/node version 22.17.2 instead of 22.19.3 to maintain consistency across dependencies

* build: move some dependencies to dev deps and pin dependency versions to exact numbers

Remove caret (^) from version ranges to ensure consistent dependency resolution across environments

* chore: move dependencies from prod to dev and update lockfile

Move @ant-design/icons, chalk, form-data, and open from dependencies to devDependencies
Update pnpm-lock.yaml to reflect dependency changes

* build: update package dependencies

- Add new dependencies: md5, @libsql/win32-x64-msvc, @strongtz/win32-arm64-msvc, bonjour-service, emoji-picker-element-data, gray-matter, js-yaml
- Remove redundant dependencies from devDependencies

* build: add cors, katex and pako dependencies

add new dependencies to support cross-origin requests, mathematical notation rendering and data compression

* move some js deps to dev deps

* test: update snapshot tests for Spinner and InputEmbeddingDimension

* chore: exclude .zed directory from biome formatting

* Update @ai-sdk/openai-compatible patch hash

* chore: update @kangfenmao/keyv-storage to version 0.1.3 in package.json and pnpm-lock.yaml

---------

Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
2026-01-05 22:16:34 +08:00
SuYao
c4f372feba
fix(notes): prevent sticky folder z-index from overlapping webview (#12289)
Add `isolation: isolate` to NotesSidebar container to create a new
stacking context, preventing sticky folder elements (z-index: 1000+)
from overlapping MinApp webview when switching pages.

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-05 18:16:08 +08:00
Nicolae Fericitu
ad164f2c1b
fix(i18n): update and refine Romanian translation (#12282)
I have corrected several typos and refined the terminology in the ro-ro.json file for better linguistic accuracy. This update ensures translation consistency throughout the user interface.
2026-01-05 15:23:49 +08:00
Phantom
ca3ddff00e
fix: replace nullish coalescing with logical OR in reasoning_content (#12281)
The change replaces ?? with || to avoid that reasoning_content is set as empty string
2026-01-05 14:42:58 +08:00
Calvin Wade
b4aeced1f9 fix: thinking time on stop (#11900)
* fix: preserve thinking time when stopping reply

Fixes #11886

Signed-off-by: Calvin <calvinvwei@gmail.com>

* fix: also preserve thinking time when stopping during thinking

This extends the previous fix to also handle the case when the user
stops the reply while thinking is still in progress (not just after
thinking is complete).

Signed-off-by: Calvin <calvinvwei@gmail.com>

* fix: auto-complete thinking when text output starts

This fixes the issue where the thinking timer continues running after
thinking is complete and text output begins. Some AI providers don't
send a reasoning-end event explicitly, so we now auto-complete thinking
when a text-start event is received with accumulated reasoning content.

Fixes #11796

Signed-off-by: Calvin <calvinvwei@gmail.com>

* refactor: extract emitThinkingCompleteIfNeeded to reduce duplication

Extract the shared logic for emitting THINKING_COMPLETE chunk into a
reusable method. This removes code duplication between text-start and
reasoning-end event handlers as suggested in code review.

Signed-off-by: Calvin <calvinvwei@gmail.com>

---------

Signed-off-by: Calvin <calvinvwei@gmail.com>
2026-01-04 19:44:25 +08:00
kangfenmao
d27d750bc5 feat(i18n): add "open" label for app data directory in multiple languages 2026-01-04 19:36:46 +08:00
kangfenmao
a2639053ef chore(release): v1.7.9
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-04 18:36:03 +08:00
George·Dong
68a75dc4e3
feat(code-tools): add 302.AI as Claude Code provider (#12254)
* feat(code-tools): add 302.AI as Claude Code provider

* feat(agent): add 302.AI anthropicApiHost to enable Agent support

302.AI now supports Claude Code (Agent) functionality by configuring
the anthropicApiHost endpoint. Users can use 302.AI's Claude models
(claude-sonnet-4-20250514, claude-opus-4-20250514) with Agent.

* feat(migrate): add migration 192 to set 302ai API host
2026-01-04 18:07:49 +08:00
kangfenmao
4c67e5b43a fix: update links in README and AboutSettings for correct documentation paths 2026-01-04 16:07:56 +08:00
Tsingv
2383fd06db
fix: resolve unexpected miniwindow loop closure on Mac (#12106)
fix: resolve unexpected miniwindow loop closure on MacOS 26+
2026-01-04 14:29:00 +08:00
Zhaolin Liang
f8519f0bf0
fix: HTML preview tab controls not working in fullscreen (#12152) 2026-01-04 13:49:40 +08:00
github-actions[bot]
2012378341
🤖 Weekly Auto I18N Sync: Jan 04, 2026 (#12262)
feat(bot): Weekly automated script run

Co-authored-by: EurFelux <59059173+EurFelux@users.noreply.github.com>
2026-01-04 13:47:59 +08:00
LiuVaayne
86adb2e11c
feat(browser): add user data persistence and multi-tab support (#12082)
- Add session-based user data persistence using Electron partitions
- Implement multi-tab support with tab management operations
- Add new tools: create_tab, list_tabs, close_tab, switch_tab
- Update existing tools (open, execute, fetch, reset) to support tabId parameter
- Refactor controller to manage sessions with multiple tabs
- Add comprehensive documentation in README.md
- Add TypeScript interfaces for SessionInfo and TabInfo

BREAKING CHANGE: Controller now manages sessions with tabs instead of single windows per session
2026-01-04 13:28:48 +08:00
Phantom
680bda3993
fix(translate): Fix ActionTranslate duplicate execution and getLanguageByLangcode logic (#12241)
* refactor(translate): remove default temperature setting

* refactor(translate): Remove temperature setting from language detection
prompt

* refactor: Set default translate languages from user settings

Initialize target language from user's language setting, falling back to
zh-CN if unknown. Set alter language to en-US by default. Remove
redundant logic that was duplicated in the effect.

* fix(translate): translate action won't trigger twice

* fix(translate): Fix getLanguageByLangcode to return built-in language
when not loaded

Previously, the function would always return UNKNOWN if the languages
were not loaded, even for built-in language codes. This change ensures
built-in languages are returned if found, regardless of the load state.

* fix: Expose translation languages loaded state and fix initialization

Add `isLoaded` flag to `useTranslate` hook to track when translation
languages are available. Use this flag to prevent premature
initialization in `ActionTranslate` component, ensuring language lookups
succeed before creating assistants and topics.

Add error logging for failed custom language loading and update fallback
warning messages for better debugging.

* fix: set initialized when finished
2026-01-04 13:25:08 +08:00
dependabot[bot]
acd1ecc09c
ci(deps): bump peter-evans/create-pull-request from 6 to 8 (#12224)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 6 to 8.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v8)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  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>
2026-01-04 08:49:16 +08:00
beyondkmp
e3d1996254
fix: prevent zoom reset during in-page navigation (#12257)
Fixes an Electron bug where zoom factor resets during route changes by listening to the 'did-navigate-in-page' event and reapplying the configured zoom factor.

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

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-03 22:07:53 +08:00
Nicolae Fericitu
56cf347909
feat(i18n): add professional Romanian localization (ro-RO) (#12216)
* feat(i18n): add Romanian localization (ro-RO)

Added the ro-ro.json file to provide Romanian language support for the Cherry Studio interface.

This commit introduces a high-quality, professional translation for the Romanian language. The localization has been carefully reviewed to ensure linguistic accuracy and terminology consistency, so no further adjustments from other contributors are required at this stage. Thank you!

* chore: move ro-ro.json to translate folder and register in index.ts

Moved the Romanian translation file to the translate directory and updated the i18n index to support the automated workflow as requested.

* Delete src/renderer/src/i18n/locales/ro-ro.json

* chore: add ro-ro.json to translate folder

Finalized the relocation of the Romanian translation file to the translate directory to support the automated i18n workflow.

* chore(i18n): remove trailing comma in index.ts

Biome formatter removed trailing comma for consistency.

* feat(i18n): add Romanian (ro-RO) to language selector

Add Romanian language option in settings with Română label and 🇷🇴 flag.

* fix(i18n): add Romanian language support

- Add ro-RO to LanguageVarious type
- Add Romanian to language selector in settings
- Add emoji picker fallback to English (no Romanian CLDR data)

* feat: Add Romanian to auto-translation language map

* fix: Add Romanian language support in main

* fix: Add Romanian (ro-RO) locale support for AntdProvider

* fix: Add Romanian language support to smooth stream segmenter

* fix: Add Romanian translations for assistant preset groups

---------

Co-authored-by: George·Dong <GeorgeDong32@qq.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2026-01-03 21:22:19 +08:00
Zhaolin Liang
2a3955919e
fix: prevent crash when switching between agent and assistant (#12252)
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
2026-01-03 17:20:52 +08:00
fullex
ca2b0ac28d refactor: merge messageThunk.v2.ts into messageThunk.ts
Remove the confusing V2 naming from message thunk functions to avoid conflicts with the upcoming real V2 data refactoring.

  Changes:
  - Inline V2 function implementations directly into messageThunk.ts
  - Replace V2 function calls with direct dbService calls
  - Remove messageThunk.v2.ts file
  - Remove misleading "V2 DATA&UI REFACTORING" header comments

  The V2 suffix was originally added for agent session support, not for a data layer refactoring. This cleanup clears the naming space for the actual V2 refactoring work.
2026-01-03 16:36:53 +08:00
Hizome
078cf39313
fix: implement navigation in agent mode (#12238)
fix: add navigation in agentm mode

Co-authored-by: harry <harry@mock.com>
2026-01-03 16:14:12 +08:00
SuYao
48a582820f
feat: update-t2i-image (#12236)
* chore: comment

* chore: comment 2

* fix: comment

* chore: var name
2026-01-02 16:26:28 +08:00
Northword
77e024027c
fix(miniapps): switch to new google ai studio logo (#12229) 2026-01-01 19:13:24 +08:00
Phantom
d391e55a8a
refactor(ovms): lazy-load OVMS support check with SWR (#12226) 2026-01-01 16:40:12 +08:00
Here_is_Daiyu
f878c8ab3b
Update minimax API documentation link (#12220) 2026-01-01 16:36:52 +08:00
Phantom
33cdcaa558
fix(ovms): add platform check to prevent errors on non-Windows systems (#12125)
* fix(ovms): make ovms manager windows-only and lazy load it

Add platform check in OvmsManager constructor to throw error on non-Windows platforms
Lazy load ovmsManager instance and handle IPC registration only on Windows
Update will-quit handler to conditionally cleanup ovms resources

* feat(preload): add windows-only OVMS API and improve type safety

Extract OVMS API methods into a separate windowsOnlyApi object for better organization
Add explicit return type for getDeviceType method

* feat(system): add system utils and refine ovms support check

- Add new system utility functions for device type, hostname and CPU name
- Refactor OVMS support check to require both Windows and Intel CPU
- Update IPC handlers to use new system utils and provide proper OVMS fallbacks

* Revert "feat(preload): add windows-only OVMS API and improve type safety"

This reverts commit d7c5c2b9a4.

* feat(ovms): add support check for ovms provider

Add new IPC channel and handler to check if OVMS is supported on the current system. This replaces the previous device type and CPU name checks with a more maintainable solution.

* fix(OvmsManager): improve intel cpu check for ovms manager

Move isOvmsSupported check before class definition and update error message to reflect intel cpu requirement

* fix: use isOvmsSupported flag for ovms cleanup check

Replace platform check with feature flag to properly determine if ovms cleanup should run

* fix: improve warning message for undefined ovmsManager

* fix(system): handle edge cases in getCpuName function

Add error handling and null checks to prevent crashes when CPU information is unavailable

* feat(runtime): add ovms support check during app init

Add isOvmsSupported state to runtime store and check support status during app initialization. Move ovms support check from ProviderList component to useAppInit hook for centralized management.
2025-12-31 22:24:53 +08:00
beyondkmp
bc9eeb9f30
feat: add fuzzy search for file list with relevance scoring (#12131)
* feat: add fuzzy search for file list with relevance scoring

- Add fuzzy option to DirectoryListOptions (default: true)
- Implement isFuzzyMatch for subsequence matching
- Add getFuzzyMatchScore for relevance-based sorting
- Remove searchByContent method (content-based search)
- Increase maxDepth to 10 and maxEntries to 20

* perf: optimize fuzzy search with ripgrep glob pre-filtering

- Add queryToGlobPattern to convert query to glob pattern
- Use ripgrep --iglob for initial filtering instead of loading all files
- Reduces memory footprint and improves performance for large directories

* feat: add greedy substring match fallback for fuzzy search

- Add isGreedySubstringMatch for flexible matching
- Fallback to greedy match when glob pre-filter returns empty
- Allows 'updatercontroller' to match 'updateController.ts'

* fix: improve greedy substring match algorithm

- Search from longest to shortest substring for better matching
- Fix issue where 'updatercontroller' couldn't match 'updateController'

* docs: add fuzzy search documentation (en/zh)

* refactor: extract MAX_ENTRIES_PER_SEARCH constant

* refactor: use logarithmic scaling for path length penalty

- Replace linear penalty (0.8 * length) with logarithmic scaling
- Prevents long paths from dominating the score
- Add PATH_LENGTH_PENALTY_FACTOR constant with explanation

* refactor: extract scoring constants with documentation

- Add named constants for scoring factors (SCORE_SEGMENT_MATCH, etc.)
- Update en/zh documentation with scoring strategy explanation

* refactor: move PATH_LENGTH_PENALTY_FACTOR to class level constant

* refactor: extract buildRipgrepBaseArgs helper method

- Reduce code duplication for ripgrep argument building
- Consolidate directory exclusion patterns and depth handling

* refactor: rename MAX_ENTRIES_PER_SEARCH to MAX_SEARCH_RESULTS

* fix: escape ! character in glob pattern for negation support

* fix: avoid duplicate scoring for filename starts and contains

* docs: clarify fuzzy search filtering and scoring strategies

* fix: limit word boundary bonus to single match

* fix: add dedicated scoring for greedy substring match

- Add getGreedyMatchScore function that rewards fewer fragments and tighter matches
- Add isFuzzyMatch validation before scoring in fuzzy glob path
- Use greedy scoring for fallback path to properly rank longest matches first

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-30 19:42:56 +08:00
jardel
068cf1083c
fix: use HTML content for markdown copy button (#12187) 2025-12-30 13:35:15 +08:00
nujabse
ed4353b054
fix: align MCP tool ids for permissions (#12127)
* fix(agents): align MCP tool IDs for permissions

Normalize legacy MCP allowlist entries so auto-approval matches SDK tool names.

Signed-off-by: mathholic <h.p.zhumeng@gmail.com>

* fix: normalize mcp tool ids in sessions

Signed-off-by: macmini <h.p.zhumeng@gmail.com>

* fix: align mcp tool ids with buildFunctionCallToolName

---------

Signed-off-by: mathholic <h.p.zhumeng@gmail.com>
Signed-off-by: macmini <h.p.zhumeng@gmail.com>
2025-12-30 13:33:09 +08:00
LiuVaayne
528d6d37f2
refactor: simplify buildFunctionCallToolName to use mcp__{server}__{tool} format (#12186) 2025-12-29 18:52:58 +08:00
LiuVaayne
efbe64e5da
feat(tokenflux): add Anthropic host support using OpenRouter package (#12188)
* feat(tokenflux): add Anthropic host support using OpenRouter package

- Add anthropicApiHost to TokenFlux provider config
- Map TokenFlux to OpenRouter in STATIC_PROVIDER_MAPPING for full compatibility

* feat(tokenflux): update API URLs and add migration

- Update apiHost to https://api.tokenflux.ai/openai/v1
- Update anthropicApiHost to https://api.tokenflux.ai/anthropic
- Add migration 191 to update existing TokenFlux users

* fix(tokenflux): add to Anthropic compatible providers list

Enable Anthropic API host configuration in TokenFlux provider settings UI
2025-12-29 18:24:57 +08:00
tylinux
cccf9bb7be
feat: add latest zhipu models (#12169) 2025-12-28 19:11:08 +08:00
kangfenmao
c242860abc chore(release): v1.7.8
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-28 17:40:41 +08:00
fullex
cb93eee29d chore: mark multiple services and components as 'will deprecated' for v2 refactor
- Added deprecation notices to various services and components, indicating they are scheduled for removal in v2.0.0.
- Noted that feature PRs affecting these files are currently blocked, and only critical bug fixes will be accepted during the migration phase.
- Provided context and status links for ongoing v2 refactoring efforts.

This change is part of the preparation for the upcoming major version update.
2025-12-28 17:38:37 +08:00
SuYao
5ff173fcc7
fix(ollama): improve reasoningEffort handling in providerOptions (#12089)
* fix(ollama): improve reasoningEffort handling in providerOptions

* fix(ollama): update reasoning effort handling and add support for gpt-oss models

* fix(ollama): update think option to support 'low', 'medium', and 'high' values

* fix(ollama): update comment to clarify accepted reasoning effort values for gpt-oss models
2025-12-28 17:04:45 +08:00
Phantom
b78df05f28
fix(AssistantsTab): prevent deleting last assistant and add error message (#12162)
feat(AssistantsTab): prevent deleting last assistant and add error message

Add validation to prevent deleting the last assistant and show an error message when attempted. Also simplify the active assistant assignment logic when deleting an assistant.
2025-12-28 15:30:01 +08:00
Zhaolin Liang
c13dc6eab5
fix: shortcut icons sorting disorder (#12151)
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
2025-12-27 20:04:51 +08:00
Shemol
2008d70707
fix(memory): fix global memory settings submit failure (#12147) 2025-12-27 18:00:20 +08:00
Shemol
723fa11647
perf(ModelList): use Map for O(1) model status lookup (#12161)
- Replace Array.find() with Map.get() for modelStatus lookup
- Add useMemo to create modelStatusMap from modelStatuses array
- Stabilize onEditModel callback with useCallback to prevent memo invalidation

Fixes #12035

Signed-off-by: SherlockShemol <shemol@163.com>
2025-12-27 13:57:33 +08:00
Phantom
9586f38157
build: upgrade electron-vite to 5.0.0 with HMR support (#12120) 2025-12-27 12:27:11 +08:00
fullex
401d66f3dd
fix(windows): remember size not working for SelectionAction window (#12132)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 23:43:38 +08:00
defi-failure
99b431ec92
fix: remove trailing api version in ANTHROPIC_BASE_URL (#12145) 2025-12-26 17:37:58 +08:00
Shemol
ab3bce33b8
docs: fix copy -> cp in development guide (#12142)
Signed-off-by: SherlockShemol <shemol@163.com>
2025-12-26 17:05:45 +08:00
kangfenmao
0f0e18231d fix: update ollama provider type and increment store version to 190
- Changed ollama provider type from 'openai' to 'ollama' in SYSTEM_PROVIDERS_CONFIG.
- Incremented persisted reducer version from 189 to 190.
- Added migration logic for version 190 to update existing provider types in state.
2025-12-26 11:44:51 +08:00
jardel
4ae9bf8ff4
fix: allow more file extensions (#12099)
Co-authored-by: icarus <eurfelux@gmail.com>
2025-12-25 16:59:13 +08:00
kangfenmao
05dfb459a6 chore: release v1.7.7 2025-12-25 14:41:52 +08:00
Caelan
0669253abb
feat:dmx-painting-add-extend_params (#12098)
* dmx-painting-add-extend_params

* format-code

* 更新类型
2025-12-25 13:46:33 +08:00
fullex
4ba0f2d25c
fix: correct aihubmix anthropic API path (#12115)
Remove incorrect /anthropic suffix from aihubmix provider's anthropicApiHost configuration.
The correct API endpoint should be https://aihubmix.com/v1/messages, not https://aihubmix.com/anthropic/v1/messages.

Fixes issue where Claude API requests to aihubmix provider were failing due to incorrect URL path.
2025-12-25 13:26:32 +08:00
Kejiang Ma
f7312697e7
feat: close ovms process when app quit (#12101)
* feat:close ovms process while app quit

* add await for execAsync

* update 'will-quit' event
2025-12-24 15:26:19 +08:00
Phantom
d9171e0596
fix(openrouter): support GPT-5.1/5.2 reasoning effort 'none' for OpenRouter and improve error handling (#12088) 2025-12-24 14:18:41 +08:00
beyondkmp
89a6d817f1
fix(display): improve font selector for long font names (#12100)
* fix(display): improve font selector for long font names

- Increase Select width from 200px to 280px
- Increase SelectRow container width from 300px to 380px
- Add Tooltip to show full font name on hover
- Add text-overflow ellipsis for long font names

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

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

* refactor(DisplaySettings): replace span with div and use CSS class for truncation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-12-24 13:25:37 +08:00
SuYao
09e58d3756
fix: interleaved thinking support (#12084)
* fix: update @ai-sdk/openai-compatible to version 1.0.28 and adjust related patches

* fix: add sendReasoning option to OpenAICompatibleProviderOptions and update message conversion logic

* fix: add interval thinking model support and related tests

* fix: add sendReasoning option to OpenAICompatibleProviderOptions and update related logic

* fix: remove MiniMax reasoning model support and update interval thinking model regex

* chore: add comment

* fix: rename interval thinking model references to interleaved thinking model
2025-12-23 20:08:53 +08:00
kangfenmao
e093a18deb refactor(settings): update MCP logo opacity and remove unused notes settings
- Adjust MCP logo opacity in MCPSettings and McpTool components for improved visual consistency.
- Remove notes settings entry from SettingsPage to streamline the settings interface.
2025-12-23 15:01:58 +08:00
亢奋猫
265934be5a
refactor(notes): move notes settings to popup in NotesPage (#12075)
* refactor(notes): move notes settings to popup in NotesPage

- Move NotesSettings.tsx from settings directory to notes directory
- Add "More Settings" menu item to notes dropdown menu
- Show settings in GeneralPopup when clicking "More Settings"
- Remove notes settings entry from SettingsPage sidebar and routes

* fix(notes): adjust margin in NotesSidebar component for improved layout

- Update margin-bottom from 20px to 12px in the NotesSidebar component to enhance visual spacing.

* refactor(notes): simplify styles object in HeaderNavbar component

- Consolidate styles object for body padding in HeaderNavbar to improve readability and maintainability.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-23 14:57:03 +08:00
亢奋猫
5f0006dced
refactor(websearch): redesign settings with two-column layout (#12068)
- Refactor WebSearchSettings to use two-column layout (left sidebar + right content)
- Add local search provider settings with internal browser window support
- Add "Set as Default" button in provider settings page
- Show default indicator tag in provider list
- Prevent selection of providers without API key configured
- Add logos for local search providers (Google, Bing, Baidu)

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-23 13:22:02 +08:00
亢奋猫
6815ab65d1
fix(memory): fix retrieval issues and enable database backup (#12073)
* fix(memory): fix retrieval issues and enable database backup

- Fix memory retrieval by storing model references instead of API client configs
  (baseURL was missing v1 suffix causing retrieval failures)
- Move memory database to DATA_PATH/Memory for proper backup support
- Add migration to convert legacy embedderApiClient/llmApiClient to model references
- Simplify IPC handlers by removing unnecessary async/await wrappers
- Rename and relocate MemorySettingsModal for better organization

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

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

* refactor(UserSelector): simplify user label rendering and remove unused dependencies

- Update UserSelector component to directly use user IDs as labels instead of rendering them through a function.
- Remove unnecessary dependency on the renderLabel function to streamline the code.

* refactor(UserSelector): remove unused dependencies and simplify user avatar logic

- Eliminate the getUserAvatar function and directly use user IDs for rendering.
- Remove the HStack and Avatar components from the renderLabel function to streamline the UserSelector component.

* refactor(ipc): simplify IPC handler for deleting all memories for a user and streamline error logging

- Remove unnecessary async/await from the Memory_DeleteAllMemoriesForUser handler.
- Simplify error logging in useAppInit hook for memory service configuration updates.
- Update persisted reducer version from 191 to 189 in the store configuration.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-23 13:21:29 +08:00
George·Dong
6bdaba8a15
feat: add GLM-4.7 and MiniMax-M2.1 model support (#12071) 2025-12-23 12:16:03 +08:00
SuYao
d1c93e4eae
fix: update default assistant settings to disable temperature (#12069)
* fix: update default assistant settings to disable temperature

* fix: typecheck

* fix: typecheck

* refactor(settings): use DEFAULT_ASSISTANT_SETTINGS constant for reset

Replace hardcoded default settings with DEFAULT_ASSISTANT_SETTINGS constant to improve maintainability

* fix(AssistantService): set default maxTokens to DEFAULT_MAX_TOKENS

* docs(AssistantService): add jsdoc for getAssistantSettings function

* refactor(AssistantService): use default settings constants for fallback values

* refactor(AssistantService): update default assistant settings type

Add defaultModel field and mark settings as const satisfies AssistantSettings

* refactor(AssistantService): reorder and add new default assistant settings

Add reasoning_effort_cache and qwenThinkMode fields

* docs(AssistantService): add jsdoc comments for default assistant settings

Explain purpose of DEFAULT_ASSISTANT_SETTINGS template and clarify difference between template values and actual settings

* docs(AssistantService): move default assistant settings docs to function

The documentation about current settings inheritance was moved from createTranslateAssistant to the dedicated getDefaultAssistantSettings function where it belongs. This improves code organization and makes the documentation more accurate by placing it with the relevant function.

* docs(AssistantService): clarify getDefaultAssistant behavior in jsdoc

Explain the difference between this temporary instance and the actual default assistant from Redux store

* fix: change default enableTemperature value to false

The default value for enableTemperature was incorrectly set to true, which could lead to unexpected behavior. This change aligns it with the intended default behavior.

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-12-23 12:13:01 +08:00
SuYao
7a862974c2
fix(options): add support for persistent server configuration in OpenAI provider options (#12058)
* fix(options): add support for persistent server configuration in OpenAI provider options
* fix(options): disable storing in OpenAI provider options
2025-12-22 16:13:31 +08:00
SuYao
26a3bd0259
feat: add openrouter support and update migration version to 188 (#12059)
* feat: add openrouter support and update migration version to 188
2025-12-21 20:15:17 +08:00
亢奋猫
e16413de76
feat(icons): add MCP logo and replace Hammer icon (#12061)
Replace the generic Hammer icon with the official MCP (Model Context
Protocol) logo in settings sidebar, tab container, and MCP settings page.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-21 20:01:41 +08:00
槑囿脑袋
fc3e92e2f7
refactor: change qrcode landrop to lantransfer (#11968)
* refactor: change qrcode landrop to lantransfer

* chore: update docs and tests

* fix: pr review

* fix: pr review

* chore: remove qrcode dependency

* fix: pr review

* fix: format

* fix: test
2025-12-21 17:39:23 +08:00
atoz03
9a435b8abb
feat(history-search): show keyword-adjacent snippets and align matching text (#12034)
* fix(history-search): show keyword-adjacent snippets and align matching text

  - Limit search results to title plus nearby lines with ellipses
  - Merge multi-keyword hit ranges and truncate long lines
  - Match against sanitized visible text to avoid URL/image false hits

* fix(history): 针对review 的改进:避免搜索高亮嵌套并优化命名与省略逻辑注释
2025-12-21 17:32:32 +08:00
Kejiang Ma
c4f94848e8
feat:upgrade ovms to 2025.4, add preset-model Qwen3-4B-int4-ov (#12045) 2025-12-21 17:22:59 +08:00
Phantom
c747b8e2a4
fix(prompt): remove unprofessional reward text and improve language instruction clarity (#12054)
* fix(toolUsePlugin): correct prompt formatting and instructions

- Remove misleading reward statement from tool use prompt
- Fix typo in XML tag format instruction ("MARK" to "MAKE")
- Reorganize response rules section for better clarity

* refactor(tool-use): consolidate default system prompt into shared module

Move DEFAULT_SYSTEM_PROMPT to core plugin module and reuse it in renderer
Update prompt to allow multiple tool uses per message and add response language rule
2025-12-21 17:20:16 +08:00
GeekMr
a35bf4afa1
fix(azure-openai): normalize Azure endpoint (#12055)
Co-authored-by: William Wang <WilliamOnline1721@hotmail.com>
2025-12-21 17:15:17 +08:00
sxjeru
9f948e1ce7
fix(parameterBuilder): enhance urlContext validation for supported providers and models (#12046)
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
* fix(parameterBuilder): enhance urlContext validation for supported providers and models

Signed-off-by: sxjeru <sxjeru@gmail.com>

* fix(parameterBuilder): improve urlContext validation logic for supported models

Signed-off-by: sxjeru <sxjeru@gmail.com>

---------

Signed-off-by: sxjeru <sxjeru@gmail.com>
2025-12-20 20:14:40 +08:00
LiuVaayne
4508fe2877
🐛 fix(mcp): check system npx/uvx before falling back to bundled binaries (#12018) 2025-12-20 18:22:33 +08:00
Phantom
3045f924ce
fix(models): include GPT5.2 series in verbosity check (#12003) 2025-12-19 12:13:56 +08:00
kangfenmao
a6ba5d34e0 chore(release): v1.7.6 2025-12-18 22:19:11 +08:00
George·Dong
8ab375161d
fix: disable reasoning mode for translation to improve efficiency (#11998)
* fix: disable reasoning mode for translation to improve efficiency

- 修改 getDefaultTranslateAssistant 函数,将默认推理选项设置为 'none'
- 避免 PR #11942 引入的 'default' 选项导致翻译重新启用思考模式
- 显著提升翻译速度和性能
- 符合翻译场景不需要复杂推理的业务逻辑

* fix(AssistantService): adjust reasoning effort

Set reasoning effort to 'none' only if supported by model, otherwise use 'default'.

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-12-18 20:16:09 +08:00
GeekMr
42260710d8
fix(azure): restore deployment-based URLs for non-v1 apiVersion and add tests (#11966)
* fix: support Azure OpenAI deployment URLs

* test: stabilize renderer setup

---------

Co-authored-by: William Wang <WilliamOnline1721@hotmail.com>
2025-12-18 18:12:26 +08:00
kangfenmao
5e8646c6a5 fix: update API path for image generation requests in OpenAIBaseClient 2025-12-18 14:45:30 +08:00
Phantom
7e93e8b9b2
feat(gemini): add support for Gemini 3 Flash and Pro model detection (#11984)
* feat(gemini): update model types and add support for gemini3 variants

add new model type identifiers for gemini3 flash and pro variants
implement utility functions to detect gemini3 flash and pro models
update reasoning configuration and tests for new gemini variants

* docs(i18n): update chinese translation for minimal_description

* chore: update @ai-sdk/google and @ai-sdk/google-vertex dependencies

- Update @ai-sdk/google to version 2.0.49 with patch for model path fix
- Update @ai-sdk/google-vertex to version 3.0.94 with updated dependencies

* feat(gemini): add thinking level mapping for Gemini 3 models

Implement mapping between reasoning effort options and Gemini's thinking levels. Enable thinking config for Gemini 3 models to support advanced reasoning features.

* chore: update yarn.lock with patched @ai-sdk/google dependency

* test(reasoning): update tests for Gemini model type classification and reasoning options

Update test cases to reflect new Gemini model type classifications (gemini2_flash, gemini3_flash, gemini2_pro, gemini3_pro) and their corresponding reasoning effort options. Add tests for Gemini 3 models and adjust existing ones to match current behavior.

* docs(reasoning): remove outdated TODO comment about model support
2025-12-18 14:35:36 +08:00
SuYao
eb7a2cc85a
feat: add support for Xiaomi MiMo model (#11961)
* feat: add support for Xiaomi MiMo model

- Implemented support for the MiMo model in reasoning logic.
- Added MiMo model configuration in default models.
- Included MiMo logos for both models and providers.
- Updated provider configurations to include Xiaomi MiMo.
- Enhanced reasoning effort and options to accommodate MiMo.
- Added migration logic for state management to include MiMo.
- Updated versioning in store to reflect changes.

* chore(i18n): add specific provider name

* fix(provider): add xiaomi mimo anthropic apihost

* chore: url

* fix: add tool use capability
2025-12-18 13:49:09 +08:00
dependabot[bot]
fd6986076a
chore(deps): bump jws from 4.0.0 to 4.0.1 (#11977)
Bumps [jws](https://github.com/brianloveswords/node-jws) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 4.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 13:34:39 +08:00
LiuVaayne
6309cc179d
feat(mcp): add Nowledge Mem builtin MCP server (#11875)
*  feat(mcp): add Nowledge Mem builtin MCP server

Add @cherry/nowLedgeMem as a new builtin MCP server that connects
to local Nowledge Mem service via HTTP at 127.0.0.1:14242/mcp.

- Add nowLedgeMem to BuiltinMCPServerNames type definitions
- Add HTTP transport handling in MCPService with APP header
- Add server config to builtinMCPServers array
- Add i18n translations (en-us, zh-cn, zh-tw)

* Fix Nowledge Mem server name typos across codebase

* 🌐 i18n: add missing translations for Nowledge Mem and Git Bash settings

Translate [to be translated] markers across 8 locale files:
- zh-tw, de-de, fr-fr, es-es, pt-pt, ru-ru: nowledgeMem description
- fr-fr, es-es, pt-pt, ru-ru, el-gr, ja-jp: xhigh reasoning chain option
- el-gr, ja-jp: Git Bash configuration strings

* 🐛 fix: address PR review comments for Nowledge Mem MCP

- Fix log message typo: use server.name instead of hardcoded "NowLedgeMem"
- Rename i18n key from "nowledgeMem" to "nowledge_mem" for consistency
- Update descriptions to warn about external dependency requirement
2025-12-18 13:34:06 +08:00
SuYao
c04529a23c
refactor: improve budget calculation logic (#11973)
* refactor: improve budget calculation logic

* Update src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* [WIP] Address feedback on budget calculation logic refactor (#11974)

* Initial plan

* fix: revert budget calculation to linear interpolation formula

Reverted the budget calculation in getAnthropicThinkingBudget from
`tokenLimit.max * effortRatio` back to the original linear interpolation
formula `(tokenLimit.max - tokenLimit.min) * effortRatio + tokenLimit.min`.

The new formula was causing lower budgets for all effort ratios (e.g.,
LOW effort changed from 2609 to 1638 tokens, a 37% reduction). The linear
interpolation formula ensures budgets range from min (at effortRatio=0) to
max (at effortRatio=1), matching the behavior in other parts of the codebase
(lines 221, 597).

Updated tests to reflect the correct expected values with the linear
interpolation formula.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* fix(test): reasoning

* fix: test

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-12-18 13:30:41 +08:00
George·Dong
0f1b3afa72
feat: 添加火山引擎 Doubao-Seed-1.8 模型支持 (#11972)
- 新增模型定义: doubao-seed-1-8-251215
- 支持思考模式: reasoning_effort (minimal/low/medium/high)
- 支持 Function Call
- 支持图像理解 (Vision)
- 更新正则表达式支持 seed-1.8 变体
- 添加完整测试覆盖

修改文件:
- src/renderer/src/config/models/default.ts
- src/renderer/src/config/models/reasoning.ts
- src/renderer/src/aiCore/utils/reasoning.ts
- src/renderer/src/config/models/vision.ts
- src/renderer/src/config/models/tooluse.ts
- src/renderer/src/config/models/__tests__/reasoning.test.ts
2025-12-18 13:30:23 +08:00
Phantom
0cf0072b51
feat: add default reasoning effort option to resolve confusion between undefined and none (#11942)
* feat(reasoning): add default reasoning effort option and update i18n

Add 'default' reasoning effort option to all reasoning models to represent no additional configuration. Update translations for new option and modify reasoning logic to handle default case. Also update store version and migration for new reasoning_effort field.

Update test cases and reasoning configuration to include default option. Add new lightbulb question icon for default reasoning state.

* fix(ThinkingButton): correct isThinkingEnabled condition to exclude 'default'

The condition now properly disables thinking when effort is 'default' to match intended behavior. Click thinking button will not switch reasoning effort to 'none'.

* refactor(types): improve reasoning_effort_cache documentation

Update comments to clarify the purpose and future direction of reasoning_effort_cache
Remove TODO and replace with FIXME suggesting external cache service

* feat(i18n): add reasoning effort descriptions and update thinking button logic

add descriptions for reasoning effort options in multiple languages
move reasoning effort label maps to component for better maintainability

* fix(aiCore): handle default reasoning_effort value consistently across providers

Ensure consistent behavior when reasoning_effort is 'default' or undefined by returning empty object

* test(reasoning): fix failing tests after 'default' option introduction

Fixed two test cases that were failing after the introduction of the 'default'
reasoning effort option:

1. getAnthropicReasoningParams test: Updated to explicitly set reasoning_effort
   to 'none' instead of empty settings, as undefined/empty now represents
   'default' behavior (no configuration override)

2. getGeminiReasoningParams test: Similarly updated to set reasoning_effort
   to 'none' for the disabled thinking test case

This aligns with the new semantic where:
- undefined/'default' = use model's default behavior (returns {})
- 'none' = explicitly disable reasoning (returns disabled config)
2025-12-18 13:00:23 +08:00
beyondkmp
150bb3e3a0
fix: auto-discover and persist Git Bash path on Windows for scoop (#11921)
* feat: auto-discover and persist Git Bash path on Windows

- Add autoDiscoverGitBash function to find and cache Git Bash path when needed
- Modify System_CheckGitBash IPC handler to auto-discover and persist path
- Update Claude Code service with fallback auto-discovery mechanism
- Git Bash path is now cached after first discovery, improving UX for Windows users

* udpate

* fix: remove redundant validation of auto-discovered Git Bash path

The autoDiscoverGitBash function already returns a validated path, so calling validateGitBashPath again is unnecessary.

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

* udpate

* test: add unit tests for autoDiscoverGitBash function

Add comprehensive test coverage for autoDiscoverGitBash including:
- Discovery with no existing config path
- Validation of existing config paths
- Handling of invalid existing paths
- Config persistence verification
- Real-world scenarios (standard Git, portable Git, user-configured paths)

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

* fix: remove unnecessary async keyword from System_CheckGitBash handler

The handler doesn't use await since autoDiscoverGitBash is synchronous.
Removes async for consistency with other IPC handlers.

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

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

* fix: rename misleading test to match actual behavior

Renamed "should not call configManager.set multiple times on single discovery"
to "should persist on each discovery when config remains undefined" to
accurately describe that each call to autoDiscoverGitBash persists when
the config mock returns undefined.

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

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

* refactor: use generic type parameter instead of type assertion

Replace `as string | undefined` with `get<string | undefined>()` for
better type safety when retrieving GitBashPath from config.

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

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

* refactor: simplify Git Bash path resolution in Claude Code service

Remove redundant validateGitBashPath call since autoDiscoverGitBash
already handles validation of configured paths before attempting
discovery. Also remove unused ConfigKeys and configManager imports.

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

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

* fix: attempt auto-discovery when configured Git Bash path is invalid

Previously, if a user had an invalid configured path (e.g., Git was
moved or uninstalled), autoDiscoverGitBash would return null without
attempting to find a valid installation. Now it logs a warning and
attempts auto-discovery, providing a better user experience by
automatically fixing invalid configurations.

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

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

* fix: ensure CLAUDE_CODE_GIT_BASH_PATH env var takes precedence over config

Previously, if a valid config path existed, the environment variable
CLAUDE_CODE_GIT_BASH_PATH was never checked. Now the precedence order is:

1. CLAUDE_CODE_GIT_BASH_PATH env var (highest - runtime override)
2. Configured path from settings
3. Auto-discovery via findGitBash

This allows users to temporarily override the configured path without
modifying their persistent settings.

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

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

* refactor: improve code quality and test robustness

- Remove duplicate logging in Claude Code service (autoDiscoverGitBash logs internally)
- Simplify Git Bash path initialization with ternary expression
- Add afterEach cleanup to restore original env vars in tests
- Extract mockExistingPaths helper to reduce test code duplication

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

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

* feat: track Git Bash path source to distinguish manual vs auto-discovered

- Add GitBashPathSource type and GitBashPathInfo interface to shared constants
- Add GitBashPathSource config key to persist path origin ('manual' | 'auto')
- Update autoDiscoverGitBash to mark discovered paths as 'auto'
- Update setGitBashPath IPC to mark user-set paths as 'manual'
- Add getGitBashPathInfo API to retrieve path with source info
- Update AgentModal UI to show different text based on source:
  - Manual: "Using custom path" with clear button
  - Auto: "Auto-discovered" without clear button

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

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

* refactor: simplify Git Bash config UI as form field

- Replace large Alert components with compact form field
- Use static isWin constant instead of async platform detection
- Show Git Bash field only on Windows with auto-fill support
- Disable save button when Git Bash path is missing on Windows
- Add "Auto-discovered" hint for auto-detected paths
- Remove hasGitBash state, simplify checkGitBash logic

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

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

* ui: add explicit select button for Git Bash path

Replace click-on-input interaction with a dedicated "Select" button
for clearer UX

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

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

* refactor: simplify Git Bash UI by removing clear button

- Remove handleClearGitBash function (no longer needed)
- Remove clear button from UI (auto-discover fills value, user can re-select)
- Remove auto-discovered hint (SourceHint)
- Remove unused SourceHint styled component

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

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

* feat: add reset button to restore auto-discovered Git Bash path

- Add handleResetGitBash to clear manual setting and re-run auto-discovery
- Show "Reset" button only when source is 'manual'
- Show "Auto-discovered" hint when path was found automatically
- User can re-select if auto-discovered path is not suitable

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

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

* fix: re-run auto-discovery when resetting Git Bash path

When setGitBashPath(null) is called (reset), now automatically
re-runs autoDiscoverGitBash() to restore the auto-discovered path.

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

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

* feat(i18n): add Git Bash config translations

Add translations for:
- autoDiscoveredHint: hint text for auto-discovered paths
- placeholder: input placeholder for bash.exe selection
- tooltip: help tooltip text
- error.required: validation error message

Supported languages: en-US, zh-CN, zh-TW

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

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

* update i18n

* fix: auto-discover Git Bash when getting path info

When getGitBashPathInfo() is called and no path is configured,
automatically trigger autoDiscoverGitBash() first. This handles
the upgrade scenario from old versions that don't have Git Bash
path configured.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-18 09:57:23 +08:00
kangfenmao
739096deca chore(release): v1.7.5
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:13:51 +08:00
LiuVaayne
1d5dafa325
refactor: rewrite filesystem MCP server with improved tool set (#11937)
* refactor: rewrite filesystem MCP server with new tool set

- Replace existing filesystem MCP with modular architecture
- Implement 6 new tools: glob, ls, grep, read, write, delete
- Add comprehensive TypeScript types and Zod schemas
- Maintain security with path validation and allowed directories
- Improve error handling and user feedback
- Add result limits for performance (100 files/matches max)
- Format output with clear, helpful messages
- Keep backward compatibility with existing import patterns

BREAKING CHANGE: Tools renamed from snake_case to lowercase
- read_file → read
- write_file → write
- list_directory → ls
- search_files → glob
- New tools: grep, delete
- Removed: edit_file, create_directory, directory_tree, move_file, get_file_info

* 🐛 fix: remove filesystem allowed directories restriction

* 🐛 fix: relax binary detection for text files

*  feat: add edit tool with fuzzy matching to filesystem MCP server

- Add edit tool with 9 fallback replacers from opencode for robust
  string replacement (SimpleReplacer, LineTrimmedReplacer,
  BlockAnchorReplacer, WhitespaceNormalizedReplacer, etc.)
- Add Levenshtein distance algorithm for similarity matching
- Improve descriptions for all tools (read, write, glob, grep, ls, delete)
  following opencode patterns for better LLM guidance
- Register edit tool in server and export from tools index

* ♻️ refactor: replace allowedDirectories with baseDir in filesystem MCP server

- Change server to use single baseDir (from WORKSPACE_ROOT env or userData/workspace default)
- Remove list_allowed_directories tool as restriction mechanism is removed
- Add ripgrep integration for faster grep searches with JS fallback
- Simplify validatePath() by removing allowlist checks
- Display paths relative to baseDir in tool outputs

* 📝 docs: standardize filesystem MCP server tool descriptions

- Unify description format to bullet-point style across all tools
- Add absolute path requirement to ls, glob, grep schemas and descriptions
- Update glob and grep to output absolute paths instead of relative paths
- Add missing error case documentation for edit tool (old_string === new_string)
- Standardize optional path parameter descriptions

* ♻️ refactor: use ripgrep for glob tool and extract shared utilities

- Extract shared ripgrep utilities (runRipgrep, getRipgrepAddonPath) to types.ts
- Rewrite glob tool to use `rg --files --glob` for reliable file matching
- Update grep tool to import shared ripgrep utilities

* 🐛 fix: handle ripgrep exit code 2 with valid results in glob tool

- Process ripgrep stdout when content exists, regardless of exit code
- Exit code 2 can indicate partial errors while still returning valid results
- Remove fallback directory listing (had buggy regex for root-level files)
- Update tool description to clarify patterns without "/" match at any depth

* 🔥 chore: remove filesystem.ts.backup file

Remove unnecessary backup file from mcpServers directory

* 🐛 fix: use correct default workspace path in filesystem MCP server

Change default baseDir from userData/workspace to userData/Data/Workspace
to match the app's data storage convention (Data/Files, Data/Notes, etc.)

Addresses PR #11937 review feedback.

* 🐛 fix: pass WORKSPACE_ROOT to FileSystemServer constructor

The envs object passed to createInMemoryMCPServer was not being used
for the filesystem server. Now WORKSPACE_ROOT is passed as a constructor
parameter, following the same pattern as other MCP servers.

* \feat: add link to documentation for MCP server configuration requirement

Wrap the configuration requirement tag in a link to the documentation for better user guidance on MCP server settings.

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-12-17 23:08:42 +08:00
Phantom
bdfda7afb1
fix: correct typo in Gemini 3 Pro Image Preview model name (#11969) 2025-12-17 22:27:17 +08:00
kangfenmao
ef25eef0eb feat(knowledge): use prompt injection for forced knowledge base search
Change the default knowledge base retrieval behavior from tool call to prompt injection mode.
This provides faster response times when knowledge base search is forced.
Intent recognition mode (tool call) is still available as an opt-in option.

- Remove toolChoiceMiddleware for forced knowledge base search
- Add prompt injection for knowledge base references in KnowledgeService
- Move transformMessagesAndFetch to ApiService, delete OrchestrateService
- Export getMessageContent from searchOrchestrationPlugin
- Add setCitationBlockId callback to citationCallbacks
- Default knowledgeRecognition to 'off' (prompt mode)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 22:14:20 +08:00
亢奋猫
c676a93595
fix(installer): auto-install VC++ Redistributable without user prompt (#11927) 2025-12-17 19:23:56 +08:00
亢奋猫
e85009fcd6
feat(assistants): merge import/subscribe popups and add export to manage (#11946)
feat(assistants): merge import and subscribe popups, add export to manage

- Merge import and subscribe buttons into single unified popup
- Add export functionality to manage assistant presets
- Change delete mode to manage mode with both export and delete options
- Show import count in success message
- Default to manage mode when opening manage popup
- Fix unsubscribe button to clear URL properly
- Fix file import not working issue

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 17:54:44 +08:00
亢奋猫
99d7223a0a
feat(topics): add topic manage mode for batch operations (#11952)
* feat(topics): add topic manage mode for batch operations

- Add topic manage mode with batch delete and move operations
- Implement search functionality within manage mode with keyword matching
- Create reusable AssistantAvatar component for consistent icon display
- Add assistant icons to move-to dropdown menus
- Include selection badge with clear selection tooltip
- Add delete confirmation dialog with danger button styling

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

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

* refactor(TopicManageMode): convert styled components to Tailwind CSS

- Replace styled-components with Tailwind CSS for ManagePanel, ManagePanelContent, ManageIconButton, and other UI elements.
- Update button styling to use Tailwind classes for improved consistency and maintainability.
- Enhance component structure with functional components and props for better reusability.

* style(Topics): update HeaderIconButton dimensions and border radius

- Increased dimensions of HeaderIconButton from 28px to 32px for improved visibility.
- Updated border radius to use a CSS variable for consistency with other UI elements.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:47:43 +08:00
kangfenmao
bdd272b7cd chore: update migration logic for version 186
- Incremented the version number in the persisted reducer from 185 to 186.
- Added migration logic for version 186 to handle API server settings and OpenAI configuration updates, ensuring compatibility with existing user settings.

This change prepares the application for the new migration requirements and maintains backward compatibility.
2025-12-17 15:41:17 +08:00
亢奋猫
782f8496e0
feat: add tool use mode setting to default assistant settings (#11943)
* feat: add tool use mode setting to default assistant settings

- Add toolUseMode selector (prompt/function) to DefaultAssistantSettings
- Add dividers between model parameter sections for better UI
- Reduce slider margins for compact layout
- Add migration (v185) to reset toolUseMode to 'function' for existing users

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

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

* fix: reset toolUseMode for all assistants during migration

- Update migration logic to reset toolUseMode to 'function' for all assistants with a 'prompt' setting.
- Ensure compatibility with function calling models by checking model type before resetting.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:37:11 +08:00
Pleasure1234
bfeef7ef91
fix: refactor provider headers logic in providerConfig (#11849)
Simplifies and centralizes header construction by merging defaultAppHeaders and extra_headers, and sets X-Api-Key for OpenAI providers. Removes redundant header assignment logic for improved maintainability.
2025-12-17 15:21:06 +08:00
SuYao
784fdd4fed
fix: 修复跨平台恢复场景下的笔记目录验证和默认路径重置逻辑 (#11950)
* fix: 修复跨平台恢复场景下的笔记目录验证和默认路径重置逻辑

* fix: 优化跨平台恢复场景下的笔记目录验证逻辑,跳过默认路径的验证
2025-12-17 13:36:13 +08:00
Pleasure1234
432b31c7b1
fix: Bind OAuth callback server to localhost (#11956)
Updated the server to listen explicitly on 127.0.0.1 instead of all interfaces. The log message was also updated to reflect the new binding address.
2025-12-17 10:11:11 +08:00
亢奋猫
f2b4a2382b
refactor: rename i18n commands for better consistency (#11938)
* refactor: rename i18n commands for better consistency

- Rename `check:i18n` to `i18n:check`
- Rename `sync:i18n` to `i18n:sync`
- Rename `update:i18n` to `i18n:translate` (clearer purpose)
- Rename `auto:i18n` to `i18n:all` (runs check, sync, and translate)
- Update lint script to use new `i18n:check` command name

This follows the common naming convention of grouping related commands
under a namespace prefix (e.g., `test:main`, `test:renderer`).

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

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

* refactor: update i18n command names and improve documentation

- Renamed i18n commands for consistency: `sync:i18n` to `i18n:sync`, `check:i18n` to `i18n:check`, and `auto:i18n` to `i18n:translate`.
- Updated relevant documentation and scripts to reflect new command names.
- Improved formatting and clarity in i18n-related guides and scripts.

This change enhances the clarity and usability of i18n commands across the project.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:08:05 +08:00
kangfenmao
b66787280a chore: release v1.7.4
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 18:57:58 +08:00
LiuVaayne
d41229c69b
Add browser CDP MCP server with session management (#11844)
*  feat: add CDP browser MCP server

* ♻️ refactor: add navigation timeout for browser cdp

* 🐛 fix: reuse window for execute and add debugger logging

*  feat: add show option and multiline execute for browser cdp

*  feat: support multiple sessions for browser cdp

* ♻️ refactor: add LRU and idle cleanup for browser cdp sessions

* Refactor browser-cdp for readability and set Firefox UA

* 🐛 fix: type electron mock for cdp tests

* ♻️ refactor: rename browser_cdp MCP server to browser

Simplify the MCP server name from @cherry/browser-cdp to just browser
for cleaner tool naming in the MCP protocol.

*  feat: add fetch tool to browser MCP server

Add a new `fetch` tool that uses the CDP-controlled browser to fetch URLs
and return content in various formats (html, txt, markdown, json).

Also ignore .conductor folder in biome and eslint configs.

* ♻️ refactor: split browser MCP server into modular folder structure

Reorganize browser.ts (525 lines) into browser/ folder with separate
files for better maintainability. Each tool now has its own file with
schema, definition, and handler.

* ♻️ refactor: use switch statement in browser server request handler

* ♻️ refactor: extract helpers and use handler registry pattern

- Add successResponse/errorResponse helpers in tools/utils.ts
- Add closeWindow helper to consolidate window cleanup logic
- Add ensureDebuggerAttached helper to consolidate debugger setup
- Add toolHandlers map for registry-based handler lookup
- Simplify server.ts to use dynamic handler dispatch

* 🐛 fix: improve browser MCP server robustness

- Add try-catch for JSON.parse in fetch() to handle invalid JSON gracefully
- Add Zod schema validation to reset tool for consistency with other tools
- Fix memory leak in open() by ensuring event listeners cleanup on timeout
- Add JSDoc comments for key methods and classes

* ♻️ refactor: rename browser MCP to @cherry/browser

Follow naming convention of other builtin MCP servers.

* 🌐 i18n: translate pending strings across 8 locales

Translate all "[to be translated]" markers including:
- CDP browser MCP server description (all 8 locales)
- "Extra High" reasoning chain length option (6 locales)
- Git Bash configuration strings (el-gr, ja-jp)
2025-12-16 09:29:30 +08:00
LiuVaayne
aeebd343d7
feat: add ExaMCP free web search provider (#11874)
*  feat: add ExaMCP free web search provider

Add a new web search provider that uses Exa's free MCP API endpoint,
requiring no API key. This provides users with a free alternative
to the existing Exa provider.

- Add 'exa-mcp' to WebSearchProviderIds
- Create ExaMcpProvider using JSON-RPC/SSE protocol
- Add provider config and migration for existing users
- Use same Exa logo in settings UI

* Add robust text chunk parser for ExaMcpProvider results
2025-12-16 09:28:42 +08:00
Phantom
71df9d61fd
fix(translate): default to first supported reasoning effort when translating (#11869)
* feat(translate): add reasoning effort option to translate service

Add support for configuring reasoning effort level in translation requests. This allows better control over the translation quality and processing time based on model capabilities.

* test: add comprehensive tests for getModelSupportedReasoningEffort

* test(reasoning): update model test cases and comments

- Remove test case for 'gpt-4o-deep-research' as it needs to be an actual OpenAI model
- Add provider requirement comment for Grok 4 Fast recognition
- Simplify array assertions in test cases
- Add comment about Qwen models working well for name-based fallback

* docs(reasoning): add detailed jsdoc for getModelSupportedReasoningEffort

* refactor(openai): replace getThinkModelType with getModelSupportedReasoningEffort

Simplify reasoning effort validation by using getModelSupportedReasoningEffort

* refactor(models): rename getModelSupportedReasoningEffort to getModelSupportedReasoningEffortOptions

Update function name and all related references to better reflect its purpose of returning reasoning effort options
2025-12-15 15:43:00 +08:00
George·Dong
4d3d5ae4ce
fix/line-number-wrongly-copied (#11857)
* fix(code-viewer): copy selected code without line numbers

* fix(context-menu): strip line numbers from code selection

* style(codeviewer): fix format

* fix: preserve indentation and format when copying mixed content (text + code blocks)

- Replace regex-based extraction with DOM structure-based approach
- Remove line number elements while preserving all other content
- Use TreeWalker to handle mixed content (text paragraphs + code blocks)
- Preserve indentation and newlines in code blocks
- Simplify CodeViewer.tsx by removing duplicate context menu logic

Fixes #11790

* style: remove unused comment

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: optimize TreeWalker performance

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-15 15:41:54 +08:00
kangfenmao
a1f0addafb fix: update MCPSettings layout and styling
- Increased maxHeight of the modal body to 70vh for improved visibility.
- Changed LogList to extend Scrollbar for better scrolling experience.
- Updated LogItem background color to use a more appropriate variable for consistency.
2025-12-15 10:10:03 +08:00
Peter Dave Hello
e78f25ff91
i18n: Improve zh-tw Traditional Chinese locale (#11915) 2025-12-15 10:04:06 +08:00
George·Dong
68f70e3b16
fix: add capabilities support for Doubao Seed Code models (#11910)
- Add tool calling support in tooluse.ts
- Add reasoning support in reasoning.ts  
- Add vision support in vision.ts

Doubao Seed Code models (doubao-seed-code-preview-251028 and future models)
now support function calling, deep thinking (enabled/disabled), and image understanding.
2025-12-15 03:12:01 +08:00
SuYao
fd921103dd
fix: preserve thinking block (#11901)
* fix: preserve thinking block

* test: add coverage for reasoning parts in assistant messages (#11902)

* Initial plan

* test: add test coverage for reasoning parts in assistant messages

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-12-14 20:05:45 +08:00
kangfenmao
a1e44a6827 fix: adjust marginRight calculation in Chat component for improved layout
Updated the marginRight property in the Chat component to include the border width when the topic position is 'right' and topics are shown. This change enhances the layout by ensuring proper spacing in the UI.
2025-12-13 23:18:45 +08:00
SuYao
ee7eee24da
fix: max search result (#11883)
* fix: add search result limit

* fix: typo

* Update src/renderer/src/aiCore/utils/websearch.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: test

* Apply suggestion from @GeorgeDong32

Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com>
2025-12-13 22:58:59 +08:00
defi-failure
f0ec2354dc
chore: fix sync to gitcode action retry logic (#11881) 2025-12-13 22:51:11 +08:00
SuYao
5bd550bfb4
Fix/cannot get dimension (#11879)
* fix: use ModernAiProvider for embedding dimensions

* fix(ollama)

* Update src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: defi-failure <159208748+defi-failure@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-13 21:09:38 +08:00
SuYao
dc0c47c64d
feat: support gpt 5.2 series (#11873)
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
* feat: support gpt 5.2

* feat: support param when set to 'none'

* chore version & simply type

* fix: comment

* fix: typecheck

* replace placeholder

* simplify func

* feat: add gpt-5.1-codex-max
2025-12-12 22:53:10 +08:00
defi-failure
66feee714b
fix: use ModernAiProvider for embedding dimensions (#11876) 2025-12-12 18:48:38 +08:00
MyPrototypeWhat
96aba33077
fix: correct token calculation in prompt tool use plugin (#11867)
* fix: correct token calculation in prompt tool use plugin

- Fix duplicate token accumulation in recursive stream handling
- Accumulate usage for finish-step without tool calls
- Filter out recursive stream's start/finish events (only one per conversation)
- Make accumulateUsage method public for reuse

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

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

* refactor: simplify pipeRecursiveStream method in StreamEventManager

- Removed unnecessary context parameter from pipeRecursiveStream method
- Streamlined the invocation of pipeRecursiveStream in recursive call handling

This change enhances code clarity and reduces complexity in stream management.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 18:23:49 +08:00
Pleasure1234
97f6275104
fix: update Ollama provider options for Qwen model support (#11850)
* fix: update Ollama provider options for Qwen model support

Pass the model to buildOllamaProviderOptions and enable 'think' option only for supported Qwen models. This improves reasoning capability handling for Ollama providers.

* fix: empty array

* feat: ollama oss

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-12-12 16:48:12 +08:00
kangfenmao
b906849c17 fix: update anthropicBaseURL and geminiBaseURL for cherryin provider to include versioning 2025-12-12 16:11:20 +08:00
kangfenmao
f742ebed1f chore(version): 1.7.3 2025-12-12 15:20:40 +08:00
Copilot
d7b9a6e09a
fix: remove cloneDeep to prevent stack overflow with base64 images (#11761)
* Initial plan

* fix: replace cloneDeep with shallow copy to prevent stack overflow with base64 images

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* fix: remove unnecessary cloneDeep in ApiService to prevent stack overflow

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* fix: update message conversion logic for image enhancement models to preserve system messages and collapse user content

* Revert "fix: replace cloneDeep with shallow copy to prevent stack overflow with base64 images"

This reverts commit e203f72fc6.

* fix: 添加数据URL媒体类型解析和URL媒体类型推测功能

* Update src/renderer/src/aiCore/prepareParams/messageConverter.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: remove dead code

* fix: resolve PR review issues for Proxy API Server

- Fix silent data loss: upgrade image load failure from warn to error level with context
- Update JSDoc: rewrite convertMessagesToSdkMessages documentation to accurately describe collapse behavior
- Add test coverage: base64 extraction from data URLs with proper mediaType handling
- Add edge case tests: collapse logic for no assistant, empty images, multiple assistants, conversation endings
- Document mutation safety: add comments explaining shallow copy is intentional in ApiService

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

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

* fix comment

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 15:16:31 +08:00
defi-failure
be9a8b8699
fix: infinite loop in knowledge queue processing (#11856)
* fix: infinite loop in knowledge queue processing

* fix: address review comments
2025-12-12 15:15:49 +08:00
kangfenmao
512d872ac3 fix: prevent closing quick panel in multiple selection mode
- Updated InputbarCore to avoid closing the quick panel when in multiple selection mode or if triggered by input.
- Removed unnecessary useEffect in KnowledgeBaseButton that dynamically updated the quick panel list based on selected bases.
2025-12-12 15:13:32 +08:00
Kejiang Ma
95f5853d7d
feat: OVMS remove intel ultra limit (#11854) 2025-12-12 13:31:05 +08:00
SuYao
c1bf6cfbb7
fix: add gpustack provider for qwen3 enable think (#11843)
* add gpustack provider

* fix: test

* fix: type
2025-12-11 18:16:47 +08:00
亢奋猫
595a0f194a
feat(assistants): enhance ManageAssistantPresetsPopup with sort and batch delete modes (#11835)
* feat(assistants): enhance ManageAssistantPresetsPopup with sort and batch delete modes

- Merge sorting and batch delete functionality into a single popup
- Add Segmented control to switch between sort and delete modes
- Sort mode: drag and drop to reorder assistants using DraggableList
- Delete mode: select and batch delete assistants with checkbox
- Add "+100" button for quick batch selection when there are many presets
- Add manage button to AssistantPresetsPage header
- Update AssistantPresetCard menu to use the new ManageAssistantPresetsPopup

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

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

* feat(assistants): improve selection logic in ManageAssistantPresetsPopup

- Update the "+100" button functionality to select the next 100 unselected presets starting from the last selected preset.
- Enhance user experience by ensuring that the selection continues from the correct index, allowing for more intuitive batch selection of presets.

* feat(assistants): adjust initial mode in ManageAssistantPresetsPopup based on preset count

- Modify the initial state of the mode to switch between 'delete' and 'sort' based on the number of presets available, enhancing user experience by optimizing the default action for larger preset collections.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 16:50:52 +08:00
Copilot
a91c69982c
feat: Add print to PDF and save as HTML for mini program webviews (#11104)
* Initial plan

*  feat: Add print to PDF and save as HTML for mini program webviews

- Add IPC channels for Webview_PrintToPDF and Webview_SaveAsHTML
- Implement printWebviewToPDF and saveWebviewAsHTML functions in WebviewService
- Add keyboard shortcuts handlers (Cmd/Ctrl+P for print, Cmd/Ctrl+S for save)
- Update WebviewContainer to handle keyboard shortcuts and trigger print/save actions
- Add preload API methods for webview print and save operations

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* ♻️ refactor: Improve webview ID handling and remove unused import

- Get webviewId dynamically when shortcuts are triggered instead of caching
- Remove unused 'join' import from WebviewService
- Add null check for webviewId in shortcut handler

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* 🐛 fix: Improve doctype serialization for better cross-browser compatibility

- Use direct DOCTYPE construction instead of XMLSerializer
- More reliable across different browser contexts
- Based on code review feedback

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

*  enhance: Complete DOCTYPE support with publicId and systemId

- Add full DOCTYPE construction including publicId and systemId
- Fix grammar in comment (custom -> with custom logic)
- Ensures proper HTML5 and XHTML document types are preserved

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* 🔒 security: Add input validation and improve code structure

- Extract shortcut checking logic into isHandledShortcut helper
- Add try-catch in executeJavaScript for safety
- Escape quotes in DOCTYPE publicId and systemId
- Add optional chaining for documentElement
- Provide fallback if doctype properties are unexpected

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* 🐛 fix: Correct DOCTYPE declaration format and quote escaping

- Use single quotes and proper escaping for DOCTYPE attributes
- Add SYSTEM keyword for systemId-only cases
- Ensure PUBLIC is only used with publicId
- Fix quote escaping to use backslashes instead of HTML entities
- Properly handle all DOCTYPE declaration formats per HTML/SGML spec

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* 🐛 fix: Correct JavaScript string escaping for quotes

- Fix quote escaping from \\\\\' to \\'
- Ensures proper backslash escaping in JavaScript template string
- Produces correct \' in the final DOCTYPE string

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

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

*  enhance: Use page title for default filename and improve PDF pagination

- Get page title from webview and use it as default filename for both PDF and HTML export
- Sanitize filename by removing invalid characters and limiting length
- Add preferCSSPageSize option to printToPDF for better multi-page support
- Fallback to timestamp-based filename if title is unavailable

Addresses reviewer feedback and fixes PDF export only capturing first page issue.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* 🎨 style: Fix formatting issues

- Remove trailing whitespace in WebviewContainer.tsx
- Run biome format and lint to ensure code style compliance

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: SuYao <sy20010504@gmail.com>
2025-12-11 16:42:33 +08:00
Calvin Wade
6b25fbb901
fix: stop thinking timer when reply is aborted (#11794)
Fixes #11772
When user stops the reply after thinking is complete but text is still
streaming, all blocks in STREAMING status are now updated to PAUSED,
which properly stops the thinking timer.

Signed-off-by: Calvin <calvinvwei@gmail.com>
2025-12-11 16:25:31 +08:00
kangfenmao
c52a2dbc48 chore: update typecheck command to use concurrently and add dependency
- Modified the typecheck script in package.json to run node and web type checks concurrently for improved efficiency.
- Added 'concurrently' as a new dependency in package.json and updated yarn.lock accordingly.
2025-12-11 16:20:37 +08:00
亢奋猫
367c4fe6b6
refactor(ui): improve settings tab and assistant item UI (#11819)
* refactor(ui): improve settings tab and assistant item UI

- Remove SettingsTab from HomeTabs, open settings via navbar drawer instead
- Add menu icon to assistant/agent items for quick access to settings popup
- Remove SessionSettingsTab component (consolidated into settings popup)
- Restore avatar display in bubble message style
- Update topic/session item styles for consistency

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

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

* refactor(ui): simplify MessageHeader component logic

- Removed unnecessary header visibility check in MessageHeader.
- Updated justifyContent logic for UserWrap to account for multi-select mode.

This change enhances the clarity and maintainability of the MessageHeader component.

* refactor(ui): streamline ChatNavbar and SettingsTab components

- Removed unused chat state from ChatNavbar.
- Updated SettingsTab to conditionally render settings based on active topic or session.
- Enhanced clarity and maintainability by reducing unnecessary checks and improving component logic.

This change improves the overall user experience and code readability.

* refactor(ui): enhance AgentItem and ChatNavbar components for improved UI

- Updated AgentItem to conditionally hide the assistant icon based on settings.
- Enhanced ChatNavbar to display the assistant's emoji and name with a new layout.
- Introduced memoization for assistant name to optimize rendering.

These changes improve the user interface and maintainability of the components.

* refactor(ui): update HtmlArtifactsPopup to start in fullscreen mode

- Changed initial state of isFullscreen in HtmlArtifactsPopup from false to true.

This adjustment enhances the user experience by providing a more immersive view upon opening the popup.

* refactor(types): remove 'settings' tab from Tab type

- Updated the Tab type in chat.ts to remove the 'settings' option, simplifying the available tabs for the chat interface.

This change streamlines the chat functionality and improves code clarity.

* refactor(ui): enhance UserWrap styling in MessageHeader component

- Added flex property to UserWrap to improve layout flexibility.

This change enhances the responsiveness and layout management of the MessageHeader component.

* refactor(ui): update HtmlArtifactsPopup to prevent drag on ViewControls

- Added "nodrag" class to ViewControls to prevent drag events on double click.

This change improves the user interaction by ensuring that double-clicking on the ViewControls does not trigger drag actions.

* refactor(ui): adjust spacing in AgentLabel component

- Updated the gap between items in the AgentLabel component from 1 to 2 for improved layout consistency.

This change enhances the visual spacing and overall user interface of the AgentSettings page.

* refactor(ui): remove unused useSessions hook from AgentItem component

- Eliminated the useSessions hook from the AgentItem component to streamline the code and improve performance.

This change enhances the maintainability of the AgentItem component by removing unnecessary dependencies.

* refactor(ui): optimize MessageHeader component layout and logic

- Introduced a memoized userNameJustifyContent calculation to streamline the justifyContent logic for UserWrap.
- Simplified the HStack component by replacing inline logic with the new memoized value.

These changes enhance the maintainability and clarity of the MessageHeader component.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 16:11:21 +08:00
kangfenmao
5f3af646f4 fix: update CherryIN API URL and add thinking budget parameter
- Changed the Gemini base URL in providerToAiSdkConfig to point to '/v1beta/models' for CherryIN provider.
- Added a default thinking budget of -1 in getGeminiReasoningParams to enhance reasoning configuration.
2025-12-11 15:58:37 +08:00
beyondkmp
ed695a8620
feat: Support custom git bash path (#11813)
* feat: allow custom Git Bash path for Claude Code

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* format code

* format code

* update i18n

* fix: correct Git Bash invalid path translation key

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* test: cover null inputs for validateGitBashPath

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* refactor: rely on findGitBash for env override check

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: validate env override for Git Bash path

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* chore: align Git Bash path getter with platform guard

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* test: cover env override behavior in findGitBash

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* refactor: unify Git Bash path detection logic

- Add customPath parameter to findGitBash() for config-based paths
- Simplify checkGitBash IPC handler by delegating to findGitBash
- Change validateGitBashPath success log level from info to debug
- Only show success Alert when custom path is configured
- Add tests for customPath parameter priority handling

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

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

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 15:04:04 +08:00
LiuVaayne
8cd4b1b747
🐛 fix: stabilize MCP log IPC registration (#11830) 2025-12-11 15:02:26 +08:00
SuYao
9ac7e2c78d
feat: enhance web search tool switching logic to support provider-specific context (#11769)
* feat: enhance web search tool switching logic to support provider-specific context

* Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: consolidate control flow in switchWebSearchTool (#11771)

* Initial plan

* refactor: make control flow consistent in switchWebSearchTool

Replace early returns with break statements in all switch cases to ensure
consistent control flow. Move fallback logic into default case for clarity.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: format

* fix: ensure switchWebSearchTool is always called for cherryin providers

- Add missing else branch to prevent silent failure when provider extraction fails
- Add empty string check for extracted providerId from split operation
- Ensures web search functionality is preserved in all edge cases

Addresses PR review feedback from #11769

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

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

* refactor: simplify repetitive switchWebSearchTool calls

- Extract providerId determination logic before calling switchWebSearchTool
- Call switchWebSearchTool only once at the end with updated providerId
- Reduce code duplication while maintaining all edge case handling

Addresses review feedback from @kangfenmao

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

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

* refactor: eliminate code duplication in switchWebSearchTool

- Extract helper functions: ensureToolsObject, applyToolBasedSearch, applyProviderOptionsSearch
- Replace switch statement and fallback if-else chain with providerHandlers map
- Use array-based priority order for fallback logic
- Reduce code from 73 lines to 80 lines but with much better maintainability
- Eliminates 12 instances of "if (!params.tools) params.tools = {}"
- Single source of truth for each provider's configuration logic

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

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 15:01:01 +08:00
fullex
c4fd48376d
feat(SelectionAssistant): open URL for search action (#11770)
* feat(SelectionAssistant): open URL for search action

When selected text is a valid URI or file path, directly open it
instead of searching. This enhances the search action to be smarter
about handling URLs and file paths.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: format

* feat: increase maximum custom and enabled items in settings actions list

Updated the maximum number of custom items from 8 to 10 and enabled items from 6 to 8 in the settings actions list to enhance user customization options.

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-11 14:51:32 +08:00
defi-failure
600a045ff7
chore: add gitcode release sync workflow (#11807)
* chore: add gitcode release sync workflow

* fix(ci): address review feedback for gitcode sync workflow

- Use Authorization header instead of token in URL query parameter
- Add file existence check before copying signed Windows artifacts
- Remove inappropriate `|| true` from artifact listing
- Use heredoc for safe GITHUB_OUTPUT writing
- Add error context logging in upload_file function
- Add curl timeout for API requests (connect: 30s, max: 60s)
- Add cleanup step for temp files with `if: always()`
- Add env var validation for GitCode credentials

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 14:05:41 +08:00
kangfenmao
880673c4eb fix(AssistantPresetCard): update group handling to use isArray for better type safety 2025-12-11 11:57:16 +08:00
Phantom
03db02d5f7
fix(ThinkingButton): show correct icon when isFixedReasoning (#11825) 2025-12-11 11:29:18 +08:00
Ying-xi
fda2287475
fix(knowledge): prioritize query & refine intent prompt (#11828)
Fixes logic issues in knowledge base search:

1. Inverted search priority in KnowledgeService to use specific sub-queries over generic rewrites.

2. Updated SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY to explicitly allow decomposed questions, improving intent recognition for complex queries.
2025-12-11 11:21:10 +08:00
亢奋猫
76524d68c6
feat: add CherryIN API host selection settings (#11797)
feat(provider): add CherryIN settings and migration support

- Introduced CherryINSettings component for configuring CherryIN API hosts.
- Updated ProviderSetting to conditionally render CherryINSettings based on provider ID and user language.
- Enhanced providerToAiSdkConfig to include CherryIN API host URLs.
- Incremented store version to 183 and added migration logic to set default CherryIN API hosts.
2025-12-11 11:19:28 +08:00
LiuVaayne
96085707ce
feat: add MCP server log viewer (#11826)
*  feat: add MCP server log viewer

* 🧹 chore: format files

* 🐛 fix: resolve MCP log viewer type errors

* 🧹 chore: sync i18n for MCP log viewer

* 💄 fix: improve MCP log modal contrast in dark mode

* 🌐 fix: translate MCP log viewer strings

Add translations for noLogs and viewLogs keys in:
- German (de-de)
- Greek (el-gr)
- Spanish (es-es)
- French (fr-fr)
- Japanese (ja-jp)
- Portuguese (pt-pt)
- Russian (ru-ru)

* 🌐 fix: update MCP log viewer translations and key references

Added "logs" key to various language files and updated references in the MCP settings component to improve consistency across translations. This includes updates for English, Chinese (Simplified and Traditional), German, Greek, Spanish, French, Japanese, Portuguese, and Russian.

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-12-11 11:18:56 +08:00
zane
711f805a5b
fix(aiCore): omit empty content in assistant messages with tool_calls (#11818)
* fix(aiCore): omit empty content in assistant messages with tool_calls

When an assistant message contains tool_calls but no text content,
the content field was being set to undefined or empty string.
This caused API errors on strict OpenAI-compatible endpoints like CherryIn:
"messages: text content blocks must be non-empty"

The fix conditionally includes the content field only when there is
actual text content, which conforms to the OpenAI API specification.

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

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

* fix(aiCore): omit empty assistant message in new aiCore StreamEventManager

When building recursive params after tool execution, only add the assistant
message when textBuffer has content. This avoids sending empty/invalid
assistant messages to strict OpenAI-compatible APIs like CherryIn, which
causes "text content blocks must be non-empty" errors.

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

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

* revert: remove legacy OpenAIApiClient fix (legacy is deprecated)

The legacy aiCore code is no longer used. Only the fix in the new aiCore
architecture (StreamEventManager.ts) is needed.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:46:13 +08:00
LiuVaayne
6df60a69c3
⬆️ chore(deps): upgrade @anthropic-ai/claude-agent-sdk to 0.1.62 (#11824)
Upgrade from 0.1.53 to 0.1.62 and recreate the spawn->fork patch
for proper IPC communication in Electron.
2025-12-10 23:42:04 +08:00
Phantom
058a2c763b
fix: restore API version control with trailing # delimiter (addresses #11750) (#11773)
* feat(utils): add isWithTrailingSharp URL helper function

Add new utility function to check if URLs end with trailing '#' character
Includes comprehensive test cases covering various URL patterns and edge cases

* fix(api): check whether to auto append api version or not when formatting api host

- extract api version to variable in GeminiAPIClient for consistency
- simplify getBaseURL in OpenAIBaseClient by removing formatApiHost
- modify provider api host formatting to respect trailing #
- add tests for url parsing with trailing # characters

* fix: update provider config tests for new isWithTrailingSharp function

- Add isWithTrailingSharp to vi.mock in providerConfig tests
- Update test expectations to match new formatApiHost calling behavior
- All tests now pass with the new trailing # delimiter functionality

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

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

* fix(anthropic): prevent duplicate api version in base url

The Anthropic SDK automatically appends /v1 to endpoints, so we need to avoid duplication by removing the version from baseURL and explicitly setting the path in listModels

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 13:42:15 +08:00
kangfenmao
7507443d8b Revert "chore: remove unused icon files and related script from package.json"
This reverts commit 8ede7b197f.
2025-12-09 17:40:25 +08:00
kangfenmao
8ede7b197f chore: remove unused icon files and related script from package.json
Deleted multiple icon files from the build/icons directory and removed the generate:icons script from package.json as they are no longer needed.
2025-12-09 14:06:48 +08:00
Phantom
086190228a
fix(aiCore): correct provider adaptation with model parameter (#11758)
Ensure the model parameter is properly passed to adaptProvider when provider is specified
2025-12-09 10:42:18 +08:00
Phantom
adbadf5da6
fix(models): include model name as fallback for id field (#11760)
Add model's name as an additional fallback option when determining the id field in adaptSdkModel to handle cases where neither id nor modelId is available
2025-12-09 10:35:39 +08:00
SuYao
73fc74d875
fix: add support for OpenRouter embeddings in listModels method (#11774)
* feat: add support for OpenRouter embeddings in listModels method

* fix: broken url

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: format

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-09 10:29:35 +08:00
fullex
bc00c11a00
fix(windows): add manual window resize for SelectionAction window (#11766)
Implement custom window resize functionality for the SelectionAction window
on Windows only. This is a workaround for an Electron bug where native
window resize doesn't work with frame: false + transparent: true.

- Add IPC channel and API for window resize
- Implement resize handler in SelectionService
- Add 8 resize handles (4 edges + 4 corners) in SelectionActionApp
- Only enable on Windows, other platforms use native resize

Bug reference: https://github.com/electron/electron/issues/42738
All workaround code is documented and can be removed once the bug is fixed.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-09 09:03:35 +08:00
kangfenmao
f8c33db450 fix: run typecheck sequentially to avoid tsgo GC crash on Windows
Some checks failed
Stale Issue Management / stale (push) Has been cancelled
Replace concurrently with sequential execution to prevent Go runtime
GC worker crashes when running tsgo in parallel on Windows.
2025-12-08 15:36:19 +08:00
kangfenmao
61c171dafc refactor: update GitHub Actions workflow for app-upgrade-config
- Renamed job from 'propose-update' to 'update-config' for clarity.
- Replaced pull request creation step with direct commit and push to streamline the update process.
- Configured Git user for automated commits to improve workflow reliability.
2025-12-08 13:34:42 +08:00
kangfenmao
e1e6702425 chore: release v1.7.2
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 13:17:06 +08:00
kangfenmao
e6003463ac feat: enhance update dialog functionality and state management
- Added ability to ignore updates in the UpdateDialogPopup, updating the state accordingly.
- Updated UpdateAppButton to conditionally render based on the ignore state.
- Refactored runtime state to include an ignore flag for better update management.
- Minor UI adjustments in UpdateAppButton for improved user experience.
2025-12-08 13:06:32 +08:00
SagoLu
0cc4c96bc0
fix: Quick Assistant cannot register shortcuts leading to inability to call out #11071(#11071)
* 修复快捷助手无法呼出的问题

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: SuYao <sy20010504@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 11:43:45 +08:00
Phantom
d35434b6d6
feat: improve ImageViewer context menu UX (#11547)
- Reorder menu items to prioritize "Copy as Image" as the first action
- Rename "Copy" to "Copy Image Source" for better clarity
- Remove unused ImageIcon import
- Add i18n support for "preview.copy.src" across all locales

This change improves the user experience by making the most common
action (copy image) the first option in the context menu, while also
clarifying what each copy action does.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-08 11:38:19 +08:00
defi-failure
ef5b97813c
fix: add explicit thinking token support for gemini-3-pro-image (#11744) 2025-12-08 11:31:05 +08:00
jo1yne06
4c4f832bc7
feat: update AiOnly default models (#11745)
Co-authored-by: fengjunhao <765838796@qq.com>
2025-12-08 11:29:48 +08:00
Phantom
9f7e47304d
refactor: improve temperature and top_p parameter handling (#11663)
* refactor(model-params): split temperature and top_p support checks into separate functions

Replace deprecated isNotSupportTemperatureAndTopP with isSupportTemperatureModel and isSupportTopPModel
Add comprehensive tests for new model parameter support functions

* refactor(model-parameters): improve temperature and topP parameter handling

- Add fallback to DEFAULT_ASSISTANT_SETTINGS when enableTemperature/enableTopP is undefined
- Simplify conditional logic in parameter validation
- Update documentation to better explain parameter selection rules

* refactor(models): remove deprecated isNotSupportTemperatureAndTopP function

The function was marked as deprecated and its usage has been replaced by isSupportTemperatureModel and isSupportTopPModel. Also removed corresponding test cases.

* feat(models): add mutual exclusivity check for temperature and top_p

Add new utility function to enforce mutual exclusivity between temperature and top_p parameters for Claude 4.5 reasoning models. Update model parameter preparation logic to use this new check and add corresponding tests.
2025-12-08 11:26:44 +08:00
Xiang, Haihao
1a737f5137
fix: sync Upload UI with editImageFiles in NewApiPage (#11653)
Resolved issue where Upload component UI was not synchronized with
editImageFiles state in NewApiPage. Switched to controlled fileList and
handled file removal via onRemove to ensure consistent UI updates.
2025-12-08 11:09:18 +08:00
dependabot[bot]
82ec18c0fb
ci(deps): bump actions/checkout from 4 to 6 (#11595)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [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...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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-12-08 11:05:02 +08:00
dependabot[bot]
0cabdefb9a
ci(deps): bump actions/github-script from 7 to 8 (#11596)
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-12-08 11:04:26 +08:00
Phantom
224ab6a69b
feat(models): update AI model configurations to latest versions (#11735)
- Update Claude models to 4.5 series
- Replace GPT-4.5 with GPT-5 series
- Upgrade Gemini models to 2.5/3.0 versions
- Consolidate DeepSeek and Qwen models
- Remove deprecated model versions
2025-12-08 11:00:11 +08:00
Phantom
bba7ecae6e
feat(agent): add tooltip for model selection and improve i18n (#11738)
* refactor(settings): rename actions prop to contentAfter for clarity

The prop name 'actions' was misleading as it could imply functionality rather than layout. 'contentAfter' better describes its purpose of displaying content after the title.

* feat(agent): add tooltip for model selection in agent settings

Add tooltip to explain that only Anthropic endpoint models are supported for agents

* feat(i18n): add model tooltip and translate upload strings

Add tooltip message about Anthropic endpoint model requirement for Agent feature
Translate previously untranslated upload-related strings in multiple languages
2025-12-08 10:58:56 +08:00
Phantom
516b8479d6
style: update gemini logo images (#11731)
* style: update gemini logo images and fix model logo condition

Update the Gemini logo images in both apps and models directories
Remove or fix the always-true isLight condition in getModelLogoById

* style: downsample gemini icon

* style(minapp): Add bordered property for gemini minapp

Add FIXME comment to indicate 'bodered' should be 'bordered' and update config to use correct property
2025-12-07 21:04:40 +08:00
chenxue
b58a2fce03
feat(aihubmix): fix website domain (#11734)
fix domain
2025-12-07 21:03:19 +08:00
beyondkmp
ebfc60b039
fix(windows): improve Git Bash detection for portable installations (#11671)
* fix(windows): improve Git Bash detection for portable installations

Enhance Git Bash detection on Windows to support portable Git installations
and custom installation paths. The previous implementation only checked fixed
paths and failed to detect Git when installed to custom locations or added
to PATH manually.

Key improvements:
- Use where.exe to find git.exe in PATH and derive bash.exe location
- Support CHERRY_STUDIO_GIT_BASH_PATH environment variable override
- Add security check to skip executables in current directory
- Implement three-tier fallback strategy (env var -> git derivation -> common paths)
- Add detailed logging for troubleshooting

This fixes the issue where users with portable Git installations could run
git.exe from command line but the app failed to detect Git Bash.

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

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

* fix(windows): improve Git Bash detection for portable installations

Enhance Git Bash detection on Windows to support portable Git installations
and custom installation paths. The previous implementation only checked fixed
paths and failed to detect Git when installed to custom locations or added
to PATH manually.

Key improvements:
- Move findExecutable and findGitBash to utils/process.ts for better code organization
- Use where.exe to find git.exe in PATH and derive bash.exe location
- Add security check to skip executables in current directory
- Implement two-tier fallback strategy (git derivation -> common paths)
- Add detailed logging for troubleshooting
- Remove environment variable override to simplify implementation

This fixes the issue where users with portable Git installations could run
git.exe from command line but the app failed to detect Git Bash.

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

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

* fix(windows): improve Git Bash detection for portable installations

Enhance Git Bash detection on Windows to support portable Git installations
and custom installation paths. The previous implementation only checked fixed
paths and failed to detect Git when installed to custom locations or added
to PATH manually.

Key improvements:
- Move findExecutable and findGitBash to utils/process.ts for better code organization
- Use where.exe to find git.exe in PATH and derive bash.exe location
- Add security check to skip executables in current directory
- Implement two-tier fallback strategy (git derivation -> common paths)
- Add detailed logging for troubleshooting
- Remove environment variable override to simplify implementation

This fixes the issue where users with portable Git installations could run
git.exe from command line but the app failed to detect Git Bash.

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

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

* update iswin

* test: add comprehensive test coverage for findExecutable and findGitBash

Add 33 test cases covering:
- Git found in common paths (Program Files, Program Files (x86))
- Git found via where.exe in PATH
- Windows/Unix line ending handling (CRLF/LF)
- Whitespace trimming from where.exe output
- Security checks to skip executables in current directory
- Multiple Git installation structures (Standard, Portable, MSYS2)
- Bash.exe path derivation from git.exe location
- Common paths fallback when git.exe not found
- LOCALAPPDATA environment variable handling
- Priority order (derivation over common paths)
- Error scenarios (Git not installed, bash.exe missing)
- Real-world scenarios (official installer, portable, Scoop)

All tests pass with proper mocking of fs, path, and child_process modules.

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

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

* refactor: clarify path navigation comments in findGitBash

Replace confusing arrow notation showing intermediate directories with
clearer descriptions of the navigation intent:
- "navigate up 2 levels" instead of showing "-> Git/cmd -> Git ->"
- "bash.exe in same directory" for portable installations
- Emphasizes the intent rather than the intermediate steps

Makes the code more maintainable by clearly stating what each path
pattern is checking for.

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

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

* test: skip process utility tests on non-Windows platforms

Use describe.skipIf to skip all tests when not on Windows since
findExecutable and findGitBash have platform guards that return null
on non-Windows systems. Remove redundant platform mocking in nested
describe blocks since the entire suite is already Windows-only.

This fixes test failures on macOS and Linux where all 33 tests were
failing because the functions correctly return null on those platforms.

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

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

* format

* fix: improve Git Bash detection error handling and logging

- Add try-catch wrapper in IPC handler to handle unexpected errors
- Fix inaccurate comment: usr/bin/bash.exe is for MSYS2, not Git 2.x
- Change log level from INFO to DEBUG for internal "not found" message
- Keep WARN level only in IPC handler for user-facing message

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-07 16:42:00 +08:00
Phantom
8f39ecf762
fix(models): update assistant default model when editing model capabilities (#11732)
* fix(ProviderSettings): update assistant default model when model changes

Ensure assistant's default model is updated when the underlying model is modified to maintain consistency

* refactor(EditModelPopup): simplify assistant model update logic

Replace manual model updates with a single map operation to update both model and defaultModel fields. This makes the code more concise and easier to maintain.

* refactor(EditModelPopup): remove unused dispatch import and variable

* feat(EditModelPopup): add support for translate and quick model updates

Update the EditModelPopup component to handle updates for translate and quick models in addition to the default model. This ensures consistency across all model types when changes are made.
2025-12-07 14:01:11 +08:00
Copilot
8d1d09b1ec
fix: eliminate UI freeze on multi-file selection via batch processing (#11377)
* Initial plan

* fix: improve file upload performance with batch processing and progress feedback

- Add batch processing (5 files concurrently) to uploadNotes function
- Use Promise.allSettled for parallel file processing
- Add setTimeout(0) between batches to yield to event loop
- Show loading toast when uploading more than 5 files
- Add translation keys for uploading progress (en, zh-cn, zh-tw)

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* feat: add batch upload and file watcher control functionalities

* feat: add hint node type and implement TreeNode component for notes

- Updated NotesTreeNode type to include 'hint' as a node type.
- Implemented TreeNode component to handle rendering of notes and folders, including hint nodes.
- Added drag-and-drop functionality for organizing notes.
- Created context hooks for managing notes actions, selection, editing, drag-and-drop, search, and UI state.
- Developed file upload handling for drag-and-drop and file selection.
- Enhanced menu options for notes with actions like create, rename, delete, and export.
- Integrated auto-renaming feature for notes based on content.

* clean comment

* feat: add pause and resume functionality to file watcher; enhance error handling in useInPlaceEdit hook

* fix: adjust padding in item container style for improved layout

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>
2025-12-07 11:44:57 +08:00
Phantom
3cedb95db3
fix(stream-options): add user-configurable stream options for OpenAI API (#11693)
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
* refactor(types): rename OpenAISummaryText to OpenAIReasoningSummary for clarity

* refactor: move OpenAISettingsGroup to independent folder

* refactor(OpenAISettingsGroup): extract settings components into separate files

Move ReasoningSummarySetting, ServiceTierSetting and VerbositySetting into individual components to improve code organization and maintainability

* feat(stream-options): add stream options configuration for OpenAI completions

add includeUsage option to control token usage reporting in streamed responses
update provider config and settings UI to support new stream options
add migration for existing providers to set default stream options
extend toOptionValue utility to handle boolean values

* refactor(stream-options): move stream options includeUsage to settings store

- Remove streamOptions from Provider type and move includeUsage to settings.openAI
- Update migration to initialize streamOptions in settings
- Modify providerToAiSdkConfig to read includeUsage from settings
- Update StreamOptionsSetting component to use settings store

* feat(i18n): add missing translations for 'on' and stream options

Add translations for the 'on' state and stream options including token usage in multiple languages

* docs(select): update docs

* test(providerConfig): add tests for stream options includeUsage

add test cases to verify includeUsage stream option behavior for OpenAI provider

* Update src/renderer/src/i18n/translate/ru-ru.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup/VerbositySetting.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/utils/select.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* test(select): add tests for toOptionValue and toRealValue functions

* fix(providerConfig): handle undefined streamOptions in openAI settings

Prevent potential runtime errors by safely accessing nested streamOptions properties

* test(providerConfig): add tests for Copilot provider includeUsage settings

* fix(OpenAISettingsGroup): handle potential undefined streamOptions in selector

* docs(aiCoreTypes): add comment for OpenAICompletionsStreamOptions

* refactor(select): improve type safety in toOptionValue function

Use Exclude to prevent string literals from overlapping with special values

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-05 19:52:37 +08:00
SuYao
9d6d827f88
fix(migrate): normalize provider type for AI gateway (#11703) 2025-12-05 17:42:44 +08:00
Copilot
968210faa7
fix: correct OVMS API URL path formation (#11701)
* Initial plan

* fix: correct OVMS API URL path from '../v1/config' to 'config'

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* fix: update OVMSClient to use formatted API URL for model listing

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>
2025-12-05 17:40:29 +08:00
Phantom
ea36b918f1
feat(translate): support document files and refactor file reading logic (#11615)
* refactor(FileStorage): extract file reading logic into reusable method

Move common file reading functionality from readFile and readExternalFile into a new private readFileCore method
Improve error logging by distinguishing between document and text file failures
Add comprehensive JSDoc documentation for all file reading methods

* feat(translate): support document files and increase size limit

Add support for document file types in translation file selection. Increase maximum file size limit to 20MB for documents while keeping text files at 5MB. Implement separate handling for document and text file reading.
2025-12-05 13:56:54 +08:00
SuYao
92bb05950d
fix: enhance provider handling and API key rotation logic in AiProvider (#11586)
* fix: enhance provider handling and API key rotation logic in AiProvider

* fix

* fix(api): enhance API key handling and logging for providers
2025-12-05 13:25:54 +08:00
Phantom
a566cd65f4
fix: normalize provider model data (#11580)
* fix: normalize provider model data

* fix(tests): correct provider type in ModelAdapter test
2025-12-05 00:29:38 +08:00
Phantom
cd699825ed
feat(settings): add Slovak language support for spell check (#11664)
* refactor(settings): move spell check languages to constants and add type

Add Slovak language option and define SpellCheckOption type for better type safety

* fix(settings): disable spell check selector on Mac platforms

The spell check selector should not be shown on Mac platforms as it's not supported. This change adds a platform check to hide the selector when running on macOS.
2025-12-04 23:55:33 +08:00
Phantom
86a16f5762
fix(prompts): clarify language detection rules for edge cases (#11696)
* fix(prompts): clarify language detection rules for edge cases

Update LANG_DETECT_PROMPT to explicitly handle cases where the input text describes a language but is written in a different language. Add examples to illustrate the expected behavior.

* fix(prompts): correct language code mapping for Chinese input

Update the language detection prompt to properly map '英语' to 'zh-cn' instead of 'en-us' since it's a Chinese word
2025-12-04 22:55:31 +08:00
Peijie Diao
6343628739
fix(topic): clear related message_blocks when clearing topic messages (#11665)
Ensure message_blocks rows are removed when clearing a topic's messages to avoid orphaned block entries.

Signed-off-by: Do1e <i@do1e.cn>
2025-12-04 22:32:37 +08:00
Copilot
a2a6c62f48
Fix custom parameters placement for Vercel AI Gateway (#11605)
* Initial plan

* Fix custom parameters placement for Vercel AI Gateway

For AI Gateway provider, custom parameters are now placed at the body level
instead of being nested inside providerOptions.gateway. This fixes the issue
where parameters like 'tools' were being incorrectly added to
providerOptions.gateway when they should be at the same level as providerOptions.

Fixes #4197

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* Revert "Fix custom parameters placement for Vercel AI Gateway"

This reverts commit b14e48dd78.

* fix: rename 'ai-gateway' to 'gateway' across the codebase and update related configurations

* fix: resolve PR review issues for custom parameters field

- Fix Migration 174: use string literal 'ai-gateway' instead of non-existent constant for historical compatibility
- Fix Migration 180: update model.provider references to prevent orphaned models when renaming provider ID
- Add logging in mapVertexAIGatewayModelToProviderId when unknown model type is encountered
- Replace `any` with `Record<string, unknown>` in buildAIGatewayOptions return type for better type safety
- Add gateway mapping to getAiSdkProviderId mock in options.test.ts to match production behavior

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

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

* chore: version

* fix(options): enhance custom parameters handling for proxy providers

* fix(options): add support for cherryin provider with custom parameters handling

* chore

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 21:19:30 +08:00
SuYao
981bb9f451
fix: update deepseek logic to match deepseek v3.2 (#11648)
* fix: update deepseek dependency to version 1.0.31 and improve provider creation logging

* chore

* feat: deepseek official hybrid infer

* fix: deepseek-v3.2-speciale tooluse and reasoning

* fix: 添加固定推理模型支持并更新相关逻辑

* refactor: simplify logic

* feat: aihubmix

* all system_providers

* feat: cherryin

* temp fix

* fix: address PR review feedback for DeepSeek v3.2 implementation

- Add default case in buildCherryInProviderOptions to fallback to genericProviderOptions
- Add clarifying comment for switch fall-through in reasoning.ts
- Add comprehensive test coverage for isFixedReasoningModel (negative cases)
- Add test coverage for new provider whitelist (deepseek, cherryin, new-api, aihubmix, sophnet, dmxapi)
- Add test coverage for isDeepSeekHybridInferenceModel prefix patterns
- Verify function calling logic works correctly via regex matching after removing provider-based checks
- Use includes() for deepseek-chat matching to support potential variants

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

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

* fix: remove unnecessary fall-through case for unknown providers in getReasoningEffort

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 19:13:51 +08:00
fullex
9637fb8a43
fix(a11y): improve screen reader (NVDA) support with aria-label attributes (#11678)
* fix(a11y): improve screen reader support with aria-label attributes

Add aria-label attributes to all interactive buttons and toolbar elements
to improve accessibility for screen reader users (NVDA, etc.).

Changes:
- Add aria-label with i18n translations to all ActionIconButton components
- Add role="button", tabIndex, and keyboard handlers for non-semantic elements
- Fix hardcoded English aria-labels in WindowControls to use i18n
- Add aria-pressed for toggle buttons to indicate state
- Add aria-expanded for expandable menus
- Add aria-disabled for disabled buttons

Components updated:
- SendMessageButton, CopyButton, SelectionToolbar
- CodeToolbar, RichEditor toolbar, MinimalToolbar
- WindowControls
- 12 Inputbar tool buttons (WebSearch, Attachment, KnowledgeBase, etc.)

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

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

* fix(a11y): enhance accessibility in CodeToolbar snapshot

Added aria-label, role, and tabindex attributes to improve screen reader support for interactive elements in the CodeToolbar component. This change aligns with ongoing efforts to enhance accessibility across the application.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 14:46:37 +08:00
KazooTTT
fb20173194
fix(inputbar): block enter send while generating (#11672)
* fix(inputbar): block enter send while generating

  - reuse unified send disable state for keyboard and button
  - prevent enter sending when loading or searching

* refactor: optimize InputbarCore component's useHotkeys hook

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor(InputbarCore): rename cannotSend to noContent for clarity

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-04 14:44:52 +08:00
dependabot[bot]
387e8f77f5
ci(deps): bump peter-evans/repository-dispatch from 3 to 4 (#11594)
Bumps [peter-evans/repository-dispatch](https://github.com/peter-evans/repository-dispatch) from 3 to 4.
- [Release notes](https://github.com/peter-evans/repository-dispatch/releases)
- [Commits](https://github.com/peter-evans/repository-dispatch/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/repository-dispatch
  dependency-version: '4'
  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-12-04 13:30:21 +08:00
beyondkmp
4f701d3e45
fix(apiServer): use 127.0.0.1 instead of localhost for better compatibility (#11673)
* fix(apiServer): use 127.0.0.1 instead of localhost for better compatibility

- Change default host from localhost to 127.0.0.1 in config and settings
- Add buildApiServerUrl helper to properly construct API server URLs
- Update OpenAPI documentation server URL
- Update test files to use 127.0.0.1

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

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

* fix(migration): migrate existing localhost config to 127.0.0.1

- Add migration 180 to automatically update localhost to 127.0.0.1
- Handle both plain host and hosts with http/https protocol
- Increment store version to 180

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

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

* refactor(apiServer): simplify buildApiServerUrl implementation

- Remove complex URL parsing and protocol handling
- Use simple string concatenation for URL building
- Assume http protocol since API server is local

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

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

* refactor: remove buildApiServerUrl helper and simplify migration

- Remove buildApiServerUrl helper function
- Use 127.0.0.1 directly in URL construction
- Simplify migration 180 to unconditionally set host to 127.0.0.1

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

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

* refactor(apiServer): fix critical bugs and improve code structure

🔴 Critical Fixes:
- Fix config.ts to use stored host value instead of ignoring it
- Fix hardcoded 127.0.0.1 URLs to use apiServerConfig.host

🟡 Improvements:
- Extract API_SERVER_DEFAULTS to shared constants in packages/shared/config/constant.ts
- Apply consistent fallback pattern using API_SERVER_DEFAULTS.HOST and API_SERVER_DEFAULTS.PORT
- Update all imports to use shared constants across main and renderer processes

Files changed:
- packages/shared/config/constant.ts: Add API_SERVER_DEFAULTS constants
- src/main/apiServer/config.ts: Use stored host with fallback
- src/main/apiServer/middleware/openapi.ts: Use constants
- src/renderer/src/pages/settings/ToolSettings/ApiServerSettings/ApiServerSettings.tsx: Use config host and constants
- src/renderer/src/store/settings.ts: Use constants in initial state
- src/renderer/src/store/migrate.ts: Use constants in migration

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

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

* update

* fix(apiServer): use relative URL in OpenAPI spec for better compatibility

- Change server URL from hardcoded defaults to relative path '/'
- This ensures Swagger UI "Try it out" works correctly regardless of configured host/port
- Remove unused API_SERVER_DEFAULTS import

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 10:57:42 +08:00
Phantom
aeabc28451
chore(feishu-notify): modify notification card (#11656)
refactor(feishu-notify): simplify issue card layout by removing redundant elements

Remove unnecessary div elements and consolidate title display into the card header
2025-12-03 22:32:18 +08:00
槑囿脑袋
f571dd7af0
fix: ollama url (#11611)
* fix: ollama url

* feat: add Ollama provider integration and update dependencies

* fix: update Ollama provider handling and API host formatting

* feat: support Ollama Cloud

* test: formatOllamaApiHost

* chore

* fix: update Ollama provider check to use isOllamaProvider function

* fix: address PR review issues for Ollama provider

Critical fixes:
- Fix regex escape bug: /\v1$/ → /\/v1$/ in OpenAIBaseClient.ts
- Add comprehensive error handling for Ollama fetch API (network errors, non-200 responses, invalid JSON)

Minor improvements:
- Fix inconsistent optional chaining in formatOllamaApiHost
- Add null check in migration 180 for undefined state.llm.providers

All checks passed: lint, typecheck, tests (2313 tests)

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

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

---------

Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-03 21:03:07 +08:00
SuYao
33457686ac
fix: update Inputbar components to support dynamic textarea height adjustment (#11587)
* fix: update Inputbar components to support dynamic textarea height adjustment

* fix: align drag handler maxHeight with hook configuration (500px)

- Update hardcoded maxHeight from 400 to 500 in InputbarCore drag handler
- This ensures consistency with useTextareaResize hook maxHeight parameter
- Resolves PR comment about maxHeight inconsistency between hook and drag handler

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-03 20:54:06 +08:00
Phantom
6696bcacb8
fix(settings): fix wrong type caused by as assertion in OpenAI settings (#11631)
* fix(settings): fix wrong type caused by as assertion and migration

Add migration step 180 to properly handle 'undefined' string values in OpenAI settings
Update selector components to use value conversion helpers for summaryText and verbosity

* feat(models): add null as supported verbosity level for OpenAI models

Update model utils and types to include null as a valid verbosity level option alongside undefined. This provides more flexibility in controlling verbosity behavior, with null representing an explicit "off" state. Tests and UI components are updated to reflect this change.

* fix(verbosity): fix wrong verbosity type definition and handling in #11281

* style: format

* fix(store): correct verbosity check in migration config

The condition was incorrectly checking for 'undefined' string instead of undefined value, and was assigning to summaryText instead of verbosity. This fixes the migration logic to properly handle the verbosity setting.

* docs(aiCore): improve comments for verbosity and summary types

Update type comments to better explain the behavior of verbosity and summary parameters in OpenAI API requests
2025-12-03 18:20:55 +08:00
Phantom
a1e95b55f8
fix: remove stale anthropic-beta header for oauth (#11600)
Fixes: [Bug]: Error when using claude-neptune-v3
Fixes #11597
2025-12-03 17:20:12 +08:00
Peijie Diao
600199dfcf
fix: topic name remains after deleting last topic (#11649)
* fix: topic name remains after deleting last topic

- Fix logic error when deleting the last topic
- Only clear messages in the last topic before the commit
- Now creates new topic before deleting the original one to ensure proper topic name update

Signed-off-by: Do1e <i@do1e.cn>

* fix(topic): integrate the same code in `handleConfirmDelete`

Signed-off-by: Do1e <i@do1e.cn>

---------

Signed-off-by: Do1e <i@do1e.cn>
2025-12-03 16:59:46 +08:00
Zhaokun
77fd90ef7d
fix: Selected area in code block changes after scrolling (#11469)
* fix: improve code block copy in collapsed state with virtual scroll

- Add saveSelection mechanism to track selection across virtual scroll updates
- Implement custom copy handler to extract complete content from raw data
- Auto-expand code block when multi-line selection is detected in collapsed state
- Only enable auto-expand when codeCollapsible setting is enabled
- Add comprehensive logging for debugging selection and copy issues

Fixes issue where copying code in collapsed state would lose content from
virtualized rows that are not rendered in DOM. The solution captures
selection position (line + offset) during scroll and uses it to extract
complete content from the original source when copying.

* fix(CodeViewer): scope selection and copy to viewer container to prevent multiple blocks appearing selected\n\n- Add selectionBelongsToViewer() to ensure selection anchors are within this viewer\n- Guard saveSelection, copy handler, and selectionchange auto-expand logic\n- Avoids cross-viewer selection bleed when multiple CodeViewer instances exist on a page\n\nFollow-up to 37c2b5ecb (virtual scroll selection/copy).

* fix(CodeViewer): clear saved selection when active selection belongs to another viewer\n\n- Early-return in selectionchange handler when selection is outside this viewer\n- Complements scoping guards to avoid misleading multi-selection states

* fix(CodeViewer): change logger info to debug for selection and copy events

- Adjust logging level from info to debug for various selection and copy operations to reduce log verbosity.
- Ensure selection belongs to the current viewer before processing.

* fix(CodeViewer): remove invisible character from import statement

* fix(CodeViewer): complete useCallback deps to avoid stale closure

- saveSelection deps -> [selectionBelongsToViewer]
- handleCopy deps -> [selectionBelongsToViewer, expanded, saveSelection, rawLines]
- no behavior change; satisfy exhaustive-deps; reduce risk of stale refs

* fix(CodeViewer): improve selection handling for virtual scrolling and enhance comments

* fix(CodeViewer): handle clipboardData unavailability and remove unused ref

- Add null check for event.clipboardData to prevent silent copy failure
- When clipboardData is unavailable, fall back to browser default copy behavior
- Remove unused isRestoringSelectionRef and its dead code check
- Improve copy reliability in edge cases where clipboard API may be unavailable
2025-12-03 16:14:37 +08:00
SuYao
fb45d94efb
Fix/input schema (#11635)
* fix: update @modelcontextprotocol/sdk to v1.23.0 and enhance MCP tool schemas

* fix: add dotenv type definitions and implement parseKeyValueString utility with tests
2025-12-02 16:03:31 +08:00
f14XuanLv
3aedf6f138
fix: avoid sending empty anthropic-beta header (#11619)
Signed-off-by: f14xuanlv <2606574933@qq.com>
2025-12-02 12:53:09 +08:00
Phantom
3e6dc56196
fix(api): add withoutTrailingSharp utility and fix # handling in formatApiHost (#11604)
* docs(providerConfig): improve jsdoc for formatProviderApiHost function

* refactor(aiCore): improve provider handling with adaptProvider function

Introduce adaptProvider to centralize provider transformations and replace direct usage of handleSpecialProviders and formatProviderApiHost. This improves maintainability and provides consistent behavior across all provider usage scenarios.

* refactor(ProviderSettings): simplify api host formatting logic by using adaptProvider

Replace multiple format functions with a single adaptProvider utility to centralize host formatting logic and improve maintainability

* feat(api): add withoutTrailingSharp utility and update formatApiHost

add utility function to remove trailing # from URLs and update formatApiHost to use it
add comprehensive tests for new functionality

* feat(ProviderSetting): add help tooltip for api url selector

Add HelpTooltip component next to host selector to provide additional guidance about API URL configuration
2025-12-01 16:27:33 +08:00
kangfenmao
b3a58ec321 chore: update release notes for v1.7.1 2025-11-30 19:57:44 +08:00
Phantom
0097ca80e2
docs: improve CLAUDE.md PR workflow guidelines (#11548)
docs: update CLAUDE.md with PR workflow details

Add critical section about Pull Request workflow requirements including reading the template, following all sections, never skipping, and proper formatting
2025-11-30 18:39:47 +08:00
Phantom
d968df4612
fix(ApiService): properly handle and throw stream errors in API check (#11577)
* fix(ApiService): handle stream errors properly in checkApi

Ensure stream errors are properly caught and thrown when checking API availability

* docs(types): add type safety comment for ResponseError

Add FIXME comment highlighting weak type safety in ResponseError type
2025-11-30 18:34:56 +08:00
Copilot
2bd680361a
fix: set CLAUDE_CONFIG_DIR to avoid path encoding issues on Windows with non-ASCII usernames (#11550)
* Initial plan

* fix: set CLAUDE_CONFIG_DIR to avoid path encoding issues on Windows with non-ASCII usernames

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-30 17:58:37 +08:00
SuYao
cc676d4bef
fix: improve BashTool command display and enhance ToolTitle layout (#11572)
* fix: improve BashTool command display and enhance ToolTitle layout

* style(ant.css): fix overflow in collapse header text

* fix(i18n): translate toolPendingFallback in multiple languages

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-11-30 17:58:23 +08:00
Phantom
3b1155b538
fix: make knowledge base tool always visible regardless of sidebar settings (#11553)
* refactor(knowledgeBaseTool): remove unused sidebar icon visibility check

* refactor(Inputbar): remove unused knowledge icon visibility logic

Simplify knowledge base selection by directly using assistant.knowledge_bases
2025-11-30 17:51:17 +08:00
Phantom
03ff6e1ca6
fix: stabilize home scroll behavior (#11576)
* feat(dom): extend scrollIntoView with Chromium-specific options

Add ChromiumScrollIntoViewOptions interface to support additional scroll container options

* refactor(hooks): optimize timer and scroll position hooks

- Use useMemo for scrollKey in useScrollPosition to avoid unnecessary recalculations
- Refactor useTimer to use useCallback for all functions to prevent unnecessary recreations
- Reorganize function order and improve cleanup logic in useTimer

* fix: stabilize home scroll behavior

* Update src/renderer/src/pages/home/Messages/ChatNavigation.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(utils/dom): add null check for element in scrollIntoView

Prevent potential runtime errors by gracefully handling falsy elements with a warning log

* fix(hooks): use ref for scroll key to avoid stale closure

* fix(useScrollPosition): add cleanup for scroll handler to prevent memory leaks

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 17:31:32 +08:00
Phantom
706fac898a
fix(i18n): clarify image-generation endpoint type as OpenAI-based (#11554)
* fix(i18n): remove image-generation translations and clarify endpoint type

Update English locale to specify OpenAI for image generation
Add comments to clarify image-generation endpoint type relationship

* fix(i18n): correct portuguese translations in pt-pt.json
2025-11-30 15:39:09 +08:00
Copilot
f5c144404d
fix: persist inputbar text using global variable cache to prevent loss on tab switch (#11558)
* fix: implement draft persistence using CacheService in Inputbar components

* fix: enhance draft persistence in Inputbar components using CacheService

* fix: update cache handling for mentioned models in Inputbar component

* fix: improve validation of cached models in Inputbar component

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-11-30 15:37:45 +08:00
Phantom
50a217a638
fix: separate undefined vs none reasoning effort (#11562)
fix(reasoning): handle reasoning effort none case properly

separate undefined and none reasoning effort cases
clean up redundant model checks and add fallback logging
2025-11-30 15:37:18 +08:00
Phantom
444c13e1e3
fix: correct trace token usage (#11575)
fix: correct ai sdk token usage mapping
2025-11-30 15:35:23 +08:00
Phantom
255b19d6ee
fix(model): resolve doubao provider model inference issue (#11552)
Fix the issue where doubao provider could not infer models using the name field.
Refactored `isDeepSeekHybridInferenceModel` to use `withModelIdAndNameAsId`
utility function to check both model.id and model.name, avoiding duplicate
calls in `isReasoningModel`.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-30 15:14:12 +08:00
Phantom
f1f4831157
fix: prevent NaN thinking timers (#11556)
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
* fix: prevent NaN thinking timers

* test: cover thinking timer fallback and cleanup
2025-11-29 20:29:47 +08:00
xerxesliu
876f59d650
fix: resolve copy image failure for JPEG format pictures (#11529)
- Convert all image formats to PNG before writing to clipboard to ensure compatibility
- Refactor handleCopyImage to unify image source handling (Base64, File, URL)
- Add convertImageToPng utility function using canvas API for robust conversion
- Remove fallback logic that attempted to write unsupported JPEG format
2025-11-29 14:40:49 +08:00
Phantom
c23e88ecd1
fix: handle Gemini API version correctly for Cloudflare Gateway URLs (#11543)
* refactor(api): extract version regex into constant for reuse

Move the version matching regex pattern into a module-level constant to improve code reuse and maintainability. The functionality remains unchanged.

* refactor(api): rename regex constant and use dynamic regex construction

Use a string pattern for version regex to allow dynamic construction and improve maintainability. Rename constant to better reflect its purpose.

* feat(api): add getLastApiVersion utility function

Implement a utility function to extract the last API version segment from URLs. This is useful for handling cases where multiple version segments exist in the path and we need to determine the most specific version being used.

Add comprehensive test cases covering various URL patterns and edge cases.

* feat(api): add utility to remove trailing API version from URLs

Add withoutTrailingApiVersion function to clean up URLs by removing version segments
at the end of paths. This helps standardize API endpoint URLs when version is not needed.

* refactor(api): rename isSupportedAPIVerion to supportApiVersion for clarity

* fix(gemini): handle api version dynamically for non-vertex providers

Use getLastApiVersion utility to determine the latest API version for non-vertex providers instead of hardcoding to v1beta

* feat(api): add function to extract trailing API version from URL

Add getTrailingApiVersion utility function to specifically extract API version segments
that appear at the end of URLs. This complements existing version-related utilities
and helps handle cases where we only care about the final version in the path.

* refactor(gemini): use getTrailingApiVersion instead of getLastApiVersion

The function name was changed to better reflect its purpose of extracting the trailing API version from the URL. The logic was also simplified and made more explicit.

* refactor(api): remove unused getLastApiVersion function

The function was removed as it was no longer needed, simplifying the API version handling to only use trailing version detection. The trailing version regex was extracted to a constant for reuse.
2025-11-29 14:37:26 +08:00
Phantom
284d0f99e1
fix(anthropic): comment out CONTEXT_100M_HEADER to handle via user preferences (#11545)
See #11540 and #11397 for context on moving this to assistant settings
2025-11-29 14:33:10 +08:00
defi-failure
13ac5d564a
fix: match tool-call chunk with tool id (#11533) 2025-11-28 20:46:52 +08:00
497 changed files with 60763 additions and 35152 deletions

View File

@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: 🐈‍⬛ Checkout - name: 🐈‍⬛ Checkout
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@ -32,38 +32,37 @@ jobs:
with: with:
node-version: 22 node-version: 22
- name: 📦 Install corepack - name: 📦 Install pnpm
run: corepack enable && corepack prepare yarn@4.9.1 --activate uses: pnpm/action-setup@v4
- name: 📂 Get yarn cache directory path - name: 📂 Get pnpm store directory
id: yarn-cache-dir-path id: pnpm-cache
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: 💾 Cache yarn dependencies - name: 💾 Cache pnpm dependencies
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-pnpm-
- name: 📦 Install dependencies - name: 📦 Install dependencies
run: | run: |
yarn install pnpm install
- name: 🏃‍♀️ Translate - name: 🏃‍♀️ Translate
run: yarn sync:i18n && yarn auto:i18n run: pnpm i18n:sync && pnpm i18n:translate
- name: 🔍 Format - name: 🔍 Format
run: yarn format run: pnpm format
- name: 🔍 Check for changes - name: 🔍 Check for changes
id: git_status id: git_status
run: | run: |
# Check if there are any uncommitted changes # Check if there are any uncommitted changes
git reset -- package.json yarn.lock # 不提交 package.json 和 yarn.lock 的更改 git reset -- package.json pnpm-lock.yaml # 不提交 package.json 和 pnpm-lock.yaml 的更改
git diff --exit-code --quiet || echo "::set-output name=has_changes::true" git diff --exit-code --quiet || echo "::set-output name=has_changes::true"
git status --porcelain git status --porcelain
@ -73,7 +72,7 @@ jobs:
- name: 🚀 Create Pull Request if changes exist - name: 🚀 Create Pull Request if changes exist
if: steps.git_status.outputs.has_changes == 'true' if: steps.git_status.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6 uses: peter-evans/create-pull-request@v8
with: with:
token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions
commit-message: "feat(bot): Weekly automated script run" commit-message: "feat(bot): Weekly automated script run"

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ jobs:
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
- name: Dispatch update-download-version workflow to cherry-studio-docs - name: Dispatch update-download-version workflow to cherry-studio-docs
uses: peter-evans/repository-dispatch@v3 uses: peter-evans/repository-dispatch@v4
with: with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }} token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: CherryHQ/cherry-studio-docs repository: CherryHQ/cherry-studio-docs

View File

@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Check Beijing Time - name: Check Beijing Time
id: check_time id: check_time
@ -42,7 +42,7 @@ jobs:
- name: Add pending label if in quiet hours - name: Add pending label if in quiet hours
if: steps.check_time.outputs.should_delay == 'true' if: steps.check_time.outputs.should_delay == 'true'
uses: actions/github-script@v7 uses: actions/github-script@v8
with: with:
script: | script: |
github.rest.issues.addLabels({ github.rest.issues.addLabels({
@ -118,7 +118,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6

View File

@ -51,7 +51,7 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
ref: main ref: main
@ -65,25 +65,24 @@ jobs:
run: | run: |
brew install python-setuptools brew install python-setuptools
- name: Install corepack - name: Install pnpm
run: corepack enable && corepack prepare yarn@4.9.1 --activate uses: pnpm/action-setup@v4
- name: Get yarn cache directory path - name: Get pnpm store directory
id: yarn-cache-dir-path id: pnpm-cache
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies - name: Cache pnpm dependencies
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-pnpm-
- name: Install Dependencies - name: Install Dependencies
run: yarn install run: pnpm install
- name: Generate date tag - name: Generate date tag
id: date id: date
@ -94,7 +93,7 @@ jobs:
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
sudo apt-get install -y rpm sudo apt-get install -y rpm
yarn build:linux pnpm build:linux
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=8192
@ -106,7 +105,7 @@ jobs:
- name: Build Mac - name: Build Mac
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
run: | run: |
yarn build:mac pnpm build:mac
env: env:
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@ -123,7 +122,7 @@ jobs:
- name: Build Windows - name: Build Windows
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: | run: |
yarn build:win pnpm build:win
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=8192

View File

@ -21,44 +21,43 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: 22 node-version: 22
- name: Install corepack - name: Install pnpm
run: corepack enable && corepack prepare yarn@4.9.1 --activate uses: pnpm/action-setup@v4
- name: Get yarn cache directory path - name: Get pnpm store directory
id: yarn-cache-dir-path id: pnpm-cache
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies - name: Cache pnpm dependencies
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-pnpm-
- name: Install Dependencies - name: Install Dependencies
run: yarn install run: pnpm install
- name: Lint Check - name: Lint Check
run: yarn test:lint run: pnpm test:lint
- name: Format Check - name: Format Check
run: yarn format:check run: pnpm format:check
- name: Type Check - name: Type Check
run: yarn typecheck run: pnpm typecheck
- name: i18n Check - name: i18n Check
run: yarn check:i18n run: pnpm i18n:check
- name: Test - name: Test
run: yarn test run: pnpm test

View File

@ -25,7 +25,7 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@ -56,31 +56,30 @@ jobs:
run: | run: |
brew install python-setuptools brew install python-setuptools
- name: Install corepack - name: Install pnpm
run: corepack enable && corepack prepare yarn@4.9.1 --activate uses: pnpm/action-setup@v4
- name: Get yarn cache directory path - name: Get pnpm store directory
id: yarn-cache-dir-path id: pnpm-cache
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies - name: Cache pnpm dependencies
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-pnpm-
- name: Install Dependencies - name: Install Dependencies
run: yarn install run: pnpm install
- name: Build Linux - name: Build Linux
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
sudo apt-get install -y rpm sudo apt-get install -y rpm
yarn build:linux pnpm build:linux
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -94,7 +93,7 @@ jobs:
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
run: | run: |
sudo -H pip install setuptools sudo -H pip install setuptools
yarn build:mac pnpm build:mac
env: env:
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@ -111,7 +110,7 @@ jobs:
- name: Build Windows - name: Build Windows
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: | run: |
yarn build:win pnpm build:win
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=8192

304
.github/workflows/sync-to-gitcode.yml vendored Normal file
View File

@ -0,0 +1,304 @@
name: Sync Release to GitCode
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v1.0.0)'
required: true
clean:
description: 'Clean node_modules before build'
type: boolean
default: false
permissions:
contents: read
jobs:
build-and-sync-to-gitcode:
runs-on: [self-hosted, windows-signing]
steps:
- name: Get tag name
id: get-tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
fi
- name: Check out Git repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ steps.get-tag.outputs.tag }}
- name: Set package.json version
shell: bash
run: |
TAG="${{ steps.get-tag.outputs.tag }}"
VERSION="${TAG#v}"
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Clean node_modules
if: ${{ github.event.inputs.clean == 'true' }}
shell: bash
run: rm -rf node_modules
- name: Install Dependencies
shell: bash
run: pnpm install
- name: Build Windows with code signing
shell: bash
run: pnpm build:win
env:
WIN_SIGN: true
CHERRY_CERT_PATH: ${{ secrets.CHERRY_CERT_PATH }}
CHERRY_CERT_KEY: ${{ secrets.CHERRY_CERT_KEY }}
CHERRY_CERT_CSP: ${{ secrets.CHERRY_CERT_CSP }}
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: ${{ 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: List built Windows artifacts
shell: bash
run: |
echo "Built Windows artifacts:"
ls -la dist/*.exe dist/*.blockmap dist/latest*.yml
- name: Download GitHub release assets
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
run: |
echo "Downloading release assets for $TAG_NAME..."
mkdir -p release-assets
cd release-assets
# Download all assets from the release
gh release download "$TAG_NAME" \
--repo "${{ github.repository }}" \
--pattern "*" \
--skip-existing
echo "Downloaded GitHub release assets:"
ls -la
- name: Replace Windows files with signed versions
shell: bash
run: |
echo "Replacing Windows files with signed versions..."
# Verify signed files exist first
if ! ls dist/*.exe 1>/dev/null 2>&1; then
echo "ERROR: No signed .exe files found in dist/"
exit 1
fi
# Remove unsigned Windows files from downloaded assets
# *.exe, *.exe.blockmap, latest.yml (Windows only)
rm -f release-assets/*.exe release-assets/*.exe.blockmap release-assets/latest.yml 2>/dev/null || true
# Copy signed Windows files with error checking
cp dist/*.exe release-assets/ || { echo "ERROR: Failed to copy .exe files"; exit 1; }
cp dist/*.exe.blockmap release-assets/ || { echo "ERROR: Failed to copy .blockmap files"; exit 1; }
cp dist/latest.yml release-assets/ || { echo "ERROR: Failed to copy latest.yml"; exit 1; }
echo "Final release assets:"
ls -la release-assets/
- name: Get release info
id: release-info
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
run: |
# Always use gh cli to avoid special character issues
RELEASE_NAME=$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json name -q '.name')
# Use delimiter to safely handle special characters in release name
{
echo 'name<<EOF'
echo "$RELEASE_NAME"
echo 'EOF'
} >> $GITHUB_OUTPUT
# Extract releaseNotes from electron-builder.yml (from releaseNotes: | to end of file, remove 4-space indent)
sed -n '/releaseNotes: |/,$ { /releaseNotes: |/d; s/^ //; p }' electron-builder.yml > release_body.txt
- name: Create GitCode release and upload files
shell: bash
env:
GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }}
GITCODE_OWNER: ${{ vars.GITCODE_OWNER }}
GITCODE_REPO: ${{ vars.GITCODE_REPO }}
GITCODE_API_URL: ${{ vars.GITCODE_API_URL }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
RELEASE_NAME: ${{ steps.release-info.outputs.name }}
LANG: C.UTF-8
LC_ALL: C.UTF-8
run: |
# Validate required environment variables
if [ -z "$GITCODE_TOKEN" ]; then
echo "ERROR: GITCODE_TOKEN is not set"
exit 1
fi
if [ -z "$GITCODE_OWNER" ]; then
echo "ERROR: GITCODE_OWNER is not set"
exit 1
fi
if [ -z "$GITCODE_REPO" ]; then
echo "ERROR: GITCODE_REPO is not set"
exit 1
fi
API_URL="${GITCODE_API_URL:-https://api.gitcode.com/api/v5}"
echo "Creating GitCode release..."
echo "Tag: $TAG_NAME"
echo "Repo: $GITCODE_OWNER/$GITCODE_REPO"
# Step 1: Create release
# Use --rawfile to read body directly from file, avoiding shell variable encoding issues
jq -n \
--arg tag "$TAG_NAME" \
--arg name "$RELEASE_NAME" \
--rawfile body release_body.txt \
'{
tag_name: $tag,
name: $name,
body: $body,
target_commitish: "main"
}' > /tmp/release_payload.json
RELEASE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
--connect-timeout 30 --max-time 60 \
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases" \
-H "Content-Type: application/json; charset=utf-8" \
-H "Authorization: Bearer ${GITCODE_TOKEN}" \
--data-binary "@/tmp/release_payload.json")
HTTP_CODE=$(echo "$RELEASE_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$RELEASE_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "Release created successfully"
else
echo "Warning: Release creation returned HTTP $HTTP_CODE"
echo "$RESPONSE_BODY"
exit 1
fi
# Step 2: Upload files to release
echo "Uploading files to GitCode release..."
# Function to upload a single file with retry
upload_file() {
local file="$1"
local filename=$(basename "$file")
local max_retries=3
local retry=0
local curl_status=0
echo "Uploading: $filename"
# URL encode the filename
encoded_filename=$(printf '%s' "$filename" | jq -sRr @uri)
while [ $retry -lt $max_retries ]; do
# Get upload URL
curl_status=0
UPLOAD_INFO=$(curl -s --connect-timeout 30 --max-time 60 \
-H "Authorization: Bearer ${GITCODE_TOKEN}" \
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}") || curl_status=$?
if [ $curl_status -eq 0 ]; then
UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty')
if [ -n "$UPLOAD_URL" ]; then
# Write headers to temp file to avoid shell escaping issues
echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt
# Upload file using PUT with headers from file
curl_status=0
UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-K /tmp/upload_headers.txt \
--data-binary "@${file}" \
"$UPLOAD_URL") || curl_status=$?
if [ $curl_status -eq 0 ]; then
HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo " Uploaded: $filename"
return 0
else
echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries"
echo " Response: $RESPONSE_BODY"
fi
else
echo " Upload request failed (curl exit $curl_status), retry $((retry + 1))/$max_retries"
fi
else
echo " Failed to get upload URL, retry $((retry + 1))/$max_retries"
echo " Response: $UPLOAD_INFO"
fi
else
echo " Failed to get upload URL (curl exit $curl_status), retry $((retry + 1))/$max_retries"
echo " Response: $UPLOAD_INFO"
fi
retry=$((retry + 1))
[ $retry -lt $max_retries ] && sleep 3
done
echo " Failed: $filename after $max_retries retries"
exit 1
}
# Upload non-yml/json files first
for file in release-assets/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [[ ! "$filename" =~ \.(yml|yaml|json)$ ]]; then
upload_file "$file"
fi
fi
done
# Upload yml/json files last
for file in release-assets/*; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [[ "$filename" =~ \.(yml|yaml|json)$ ]]; then
upload_file "$file"
fi
fi
done
echo "GitCode release sync completed!"
- name: Cleanup temp files
if: always()
shell: bash
run: |
rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt
rm -rf release-assets/

View File

@ -19,10 +19,9 @@ on:
permissions: permissions:
contents: write contents: write
pull-requests: write
jobs: jobs:
propose-update: update-config:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.draft == false) if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.draft == false)
@ -135,7 +134,7 @@ jobs:
- name: Checkout default branch - name: Checkout default branch
if: steps.check.outputs.should_run == 'true' if: steps.check.outputs.should_run == 'true'
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
ref: ${{ github.event.repository.default_branch }} ref: ${{ github.event.repository.default_branch }}
path: main path: main
@ -143,7 +142,7 @@ jobs:
- name: Checkout x-files/app-upgrade-config branch - name: Checkout x-files/app-upgrade-config branch
if: steps.check.outputs.should_run == 'true' if: steps.check.outputs.should_run == 'true'
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
ref: x-files/app-upgrade-config ref: x-files/app-upgrade-config
path: cs path: cs
@ -155,14 +154,14 @@ jobs:
with: with:
node-version: 22 node-version: 22
- name: Enable Corepack - name: Install pnpm
if: steps.check.outputs.should_run == 'true' if: steps.check.outputs.should_run == 'true'
run: corepack enable && corepack prepare yarn@4.9.1 --activate uses: pnpm/action-setup@v4
- name: Install dependencies - name: Install dependencies
if: steps.check.outputs.should_run == 'true' if: steps.check.outputs.should_run == 'true'
working-directory: main working-directory: main
run: yarn install --immutable run: pnpm install --frozen-lockfile
- name: Update upgrade config - name: Update upgrade config
if: steps.check.outputs.should_run == 'true' if: steps.check.outputs.should_run == 'true'
@ -171,7 +170,7 @@ jobs:
RELEASE_TAG: ${{ steps.meta.outputs.tag }} RELEASE_TAG: ${{ steps.meta.outputs.tag }}
IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }} IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }}
run: | run: |
yarn tsx scripts/update-app-upgrade-config.ts \ pnpm tsx scripts/update-app-upgrade-config.ts \
--tag "$RELEASE_TAG" \ --tag "$RELEASE_TAG" \
--config ../cs/app-upgrade-config.json \ --config ../cs/app-upgrade-config.json \
--is-prerelease "$IS_PRERELEASE" --is-prerelease "$IS_PRERELEASE"
@ -187,25 +186,20 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
fi fi
- name: Create pull request - name: Commit and push changes
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed == 'true' if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7 working-directory: cs
with: run: |
path: cs git config user.name "github-actions[bot]"
base: x-files/app-upgrade-config git config user.email "github-actions[bot]@users.noreply.github.com"
branch: chore/update-app-upgrade-config/${{ steps.meta.outputs.safe_tag }} git add app-upgrade-config.json
commit-message: "🤖 chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}" git commit -m "chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}" -m "Automated update triggered by \`${{ steps.meta.outputs.trigger }}\`.
title: "chore: update app-upgrade-config for ${{ steps.meta.outputs.tag }}"
body: |
Automated update triggered by `${{ steps.meta.outputs.trigger }}`.
- Source tag: `${{ steps.meta.outputs.tag }}` - Source tag: \`${{ steps.meta.outputs.tag }}\`
- Pre-release: `${{ steps.meta.outputs.prerelease }}` - Pre-release: \`${{ steps.meta.outputs.prerelease }}\`
- Latest: `${{ steps.meta.outputs.latest }}` - Latest: \`${{ steps.meta.outputs.latest }}\`
- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
labels: | git push origin x-files/app-upgrade-config
automation
app-upgrade
- name: No changes detected - name: No changes detected
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed != 'true' if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed != 'true'

View File

@ -1 +1 @@
yarn lint-staged pnpm lint-staged

View File

@ -1,140 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 73045a7d38faafdc7f7d2cd79d7ff0e2b031056b..8d948c9ac4ea4b474db9ef3c5491961e7fcf9a07 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -421,6 +421,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -598,6 +609,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -765,6 +787,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({
arguments: import_v43.z.string()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}),
finish_reason: import_v43.z.string().nullish()
@@ -795,6 +825,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union(
arguments: import_v43.z.string().nullish()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: import_v43.z.string().nullish()
diff --git a/dist/index.mjs b/dist/index.mjs
index 1c2b9560bbfbfe10cb01af080aeeed4ff59db29c..2c8ddc4fc9bfc5e7e06cfca105d197a08864c427 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -405,6 +405,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -582,6 +593,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -749,6 +771,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({
arguments: z3.string()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}),
finish_reason: z3.string().nullish()
@@ -779,6 +809,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([
arguments: z3.string().nullish()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: z3.string().nullish()

Binary file not shown.

View File

@ -1,9 +0,0 @@
enableImmutableInstalls: false
httpTimeout: 300000
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
npmRegistryServer: https://registry.npmjs.org
npmPublishRegistry: https://registry.npmjs.org

View File

@ -10,42 +10,53 @@ This file provides guidance to AI coding assistants when working with code in th
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`. - **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. - **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications. - **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
- **Lint, test, and format before completion**: Coding tasks are only complete after running `yarn lint`, `yarn test`, and `yarn format` successfully. - **Lint, test, and format before completion**: Coding tasks are only complete after running `pnpm lint`, `pnpm test`, and `pnpm format` successfully.
- **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`). - **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
- **Follow PR template**: When submitting pull requests, follow the template in `.github/pull_request_template.md` to ensure complete context and documentation.
## Pull Request Workflow (CRITICAL)
When creating a Pull Request, you MUST:
1. **Read the PR template first**: Always read `.github/pull_request_template.md` before creating the PR
2. **Follow ALL template sections**: Structure the `--body` parameter to include every section from the template
3. **Never skip sections**: Include all sections even if marking them as N/A or "None"
4. **Use proper formatting**: Match the template's markdown structure exactly (headings, checkboxes, code blocks)
## Development Commands ## Development Commands
- **Install**: `yarn install` - Install all project dependencies - **Install**: `pnpm install` - Install all project dependencies
- **Development**: `yarn dev` - Runs Electron app in development mode with hot reload - **Development**: `pnpm dev` - Runs Electron app in development mode with hot reload
- **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger - **Debug**: `pnpm debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
- **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck) - **Build Check**: `pnpm build:check` - **REQUIRED** before commits (lint + test + typecheck)
- If having i18n sort issues, run `yarn sync:i18n` first to sync template - If having i18n sort issues, run `pnpm i18n:sync` first to sync template
- If having formatting issues, run `yarn format` first - If having formatting issues, run `pnpm format` first
- **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes - **Test**: `pnpm test` - Run all tests (Vitest) across main and renderer processes
- **Single Test**: - **Single Test**:
- `yarn test:main` - Run tests for main process only - `pnpm test:main` - Run tests for main process only
- `yarn test:renderer` - Run tests for renderer process only - `pnpm test:renderer` - Run tests for renderer process only
- **Lint**: `yarn lint` - Fix linting issues and run TypeScript type checking - **Lint**: `pnpm lint` - Fix linting issues and run TypeScript type checking
- **Format**: `yarn format` - Auto-format code using Biome - **Format**: `pnpm format` - Auto-format code using Biome
## Project Architecture ## Project Architecture
### Electron Structure ### Electron Structure
- **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.) - **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.)
- **Renderer Process** (`src/renderer/`): React UI with Redux state management - **Renderer Process** (`src/renderer/`): React UI with Redux state management
- **Preload Scripts** (`src/preload/`): Secure IPC bridge - **Preload Scripts** (`src/preload/`): Secure IPC bridge
### Key Components ### Key Components
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers. - **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc. - **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces. - **Build System**: Electron-Vite with experimental rolldown-vite, pnpm workspaces.
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state. - **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
### Logging ### Logging
```typescript ```typescript
import { loggerService } from '@logger' import { loggerService } from "@logger";
const logger = loggerService.withContext('moduleName') const logger = loggerService.withContext("moduleName");
// Renderer: loggerService.initWindowSource('windowName') first // Renderer: loggerService.initWindowSource('windowName') first
logger.info('message', CONTEXT) logger.info("message", CONTEXT);
``` ```

View File

@ -34,7 +34,7 @@
</a> </a>
</h1> </h1>
<p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p> <p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/docs/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<div align="center"> <div align="center">
@ -243,7 +243,7 @@ The Enterprise Edition addresses core challenges in team collaboration by centra
## Version Comparison ## Version Comparison
| Feature | Community Edition | Enterprise Edition | | Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | | :---------------- | :----------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers | | **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee | | **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup | | **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
@ -275,7 +275,7 @@ We believe the Enterprise Edition will become your team's AI productivity engine
# 📊 GitHub Stats # 📊 GitHub Stats
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image') ![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star History # ⭐️ Star History

View File

@ -23,7 +23,7 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**", "!**/.claude/**", "!**/.vscode/**"], "includes": ["**", "!**/.claude/**", "!**/.vscode/**", "!**/.conductor/**"],
"maxSize": 2097152 "maxSize": 2097152
}, },
"formatter": { "formatter": {
@ -50,7 +50,8 @@
"!*.json", "!*.json",
"!src/main/integration/**", "!src/main/integration/**",
"!**/tailwind.css", "!**/tailwind.css",
"!**/package.json" "!**/package.json",
"!.zed/**"
], ],
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 2, "indentWidth": 2,

View File

@ -12,8 +12,13 @@
; https://github.com/electron-userland/electron-builder/issues/1122 ; https://github.com/electron-userland/electron-builder/issues/1122
!ifndef BUILD_UNINSTALLER !ifndef BUILD_UNINSTALLER
; Check VC++ Redistributable based on architecture stored in $1
Function checkVCRedist Function checkVCRedist
${If} $1 == "arm64"
ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\ARM64" "Installed"
${Else}
ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${EndIf}
FunctionEnd FunctionEnd
Function checkArchitectureCompatibility Function checkArchitectureCompatibility
@ -97,29 +102,47 @@
Call checkVCRedist Call checkVCRedist
${If} $0 != "1" ${If} $0 != "1"
MessageBox MB_YESNO "\ ; VC++ is required - install automatically since declining would abort anyway
NOTE: ${PRODUCT_NAME} requires $\r$\n\ ; Select download URL based on system architecture (stored in $1)
'Microsoft Visual C++ Redistributable'$\r$\n\ ${If} $1 == "arm64"
to function properly.$\r$\n$\r$\n\ StrCpy $2 "https://aka.ms/vs/17/release/vc_redist.arm64.exe"
Download and install now?" /SD IDYES IDYES InstallVCRedist IDNO DontInstall StrCpy $3 "$TEMP\vc_redist.arm64.exe"
InstallVCRedist: ${Else}
inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable..." "https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" StrCpy $2 "https://aka.ms/vs/17/release/vc_redist.x64.exe"
ExecWait "$TEMP\vc_redist.x64.exe /install /norestart" StrCpy $3 "$TEMP\vc_redist.x64.exe"
;IfErrors InstallError ContinueInstall ; vc_redist exit code is unreliable :(
Call checkVCRedist
${If} $0 == "1"
Goto ContinueInstall
${EndIf} ${EndIf}
;InstallError: inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable..." \
MessageBox MB_ICONSTOP "\ $2 $3 /END
There was an unexpected error installing$\r$\n\ Pop $0 ; Get download status from inetc::get
Microsoft Visual C++ Redistributable.$\r$\n\ ${If} $0 != "OK"
The installation of ${PRODUCT_NAME} cannot continue." MessageBox MB_ICONSTOP|MB_YESNO "\
DontInstall: Failed to download Microsoft Visual C++ Redistributable.$\r$\n$\r$\n\
Error: $0$\r$\n$\r$\n\
Would you like to open the download page in your browser?$\r$\n\
$2" IDYES openDownloadUrl IDNO skipDownloadUrl
openDownloadUrl:
ExecShell "open" $2
skipDownloadUrl:
Abort Abort
${EndIf} ${EndIf}
ContinueInstall:
ExecWait "$3 /install /quiet /norestart"
; Note: vc_redist exit code is unreliable, verify via registry check instead
Call checkVCRedist
${If} $0 != "1"
MessageBox MB_ICONSTOP|MB_YESNO "\
Microsoft Visual C++ Redistributable installation failed.$\r$\n$\r$\n\
Would you like to open the download page in your browser?$\r$\n\
$2$\r$\n$\r$\n\
The installation of ${PRODUCT_NAME} cannot continue." IDYES openInstallUrl IDNO skipInstallUrl
openInstallUrl:
ExecShell "open" $2
skipInstallUrl:
Abort
${EndIf}
${EndIf}
Pop $4 Pop $4
Pop $3 Pop $3
Pop $2 Pop $2

View File

@ -11,7 +11,7 @@
### Install ### Install
```bash ```bash
yarn pnpm install
``` ```
### Development ### Development
@ -20,35 +20,35 @@ yarn
Download and install [Node.js v22.x.x](https://nodejs.org/en/download) Download and install [Node.js v22.x.x](https://nodejs.org/en/download)
### Setup Yarn ### Setup pnpm
```bash ```bash
corepack enable corepack enable
corepack prepare yarn@4.9.1 --activate corepack prepare pnpm@10.27.0 --activate
``` ```
### Install Dependencies ### Install Dependencies
```bash ```bash
yarn install pnpm install
``` ```
### ENV ### ENV
```bash ```bash
copy .env.example .env cp .env.example .env
``` ```
### Start ### Start
```bash ```bash
yarn dev pnpm dev
``` ```
### Debug ### Debug
```bash ```bash
yarn debug pnpm debug
``` ```
Then input chrome://inspect in browser Then input chrome://inspect in browser
@ -56,18 +56,18 @@ Then input chrome://inspect in browser
### Test ### Test
```bash ```bash
yarn test pnpm test
``` ```
### Build ### Build
```bash ```bash
# For windows # For windows
$ yarn build:win $ pnpm build:win
# For macOS # For macOS
$ yarn build:mac $ pnpm build:mac
# For Linux # For Linux
$ yarn build:linux $ pnpm build:linux
``` ```

View File

@ -71,7 +71,7 @@ Tools like i18n Ally cannot parse dynamic content within template strings, resul
```javascript ```javascript
// Not recommended - Plugin cannot resolve // Not recommended - Plugin cannot resolve
const message = t(`fruits.${fruit}`) const message = t(`fruits.${fruit}`);
``` ```
#### 2. **No Real-time Rendering in Editor** #### 2. **No Real-time Rendering in Editor**
@ -91,14 +91,14 @@ For example:
```ts ```ts
// src/renderer/src/i18n/label.ts // src/renderer/src/i18n/label.ts
const themeModeKeyMap = { const themeModeKeyMap = {
dark: 'settings.theme.dark', dark: "settings.theme.dark",
light: 'settings.theme.light', light: "settings.theme.light",
system: 'settings.theme.system' system: "settings.theme.system",
} as const } as const;
export const getThemeModeLabel = (key: string): string => { export const getThemeModeLabel = (key: string): string => {
return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key;
} };
``` ```
By avoiding template strings, you gain better developer experience, more reliable translation checks, and a more maintainable codebase. By avoiding template strings, you gain better developer experience, more reliable translation checks, and a more maintainable codebase.
@ -107,7 +107,7 @@ By avoiding template strings, you gain better developer experience, more reliabl
The project includes several scripts to automate i18n-related tasks: The project includes several scripts to automate i18n-related tasks:
### `check:i18n` - Validate i18n Structure ### `i18n:check` - Validate i18n Structure
This script checks: This script checks:
@ -116,10 +116,10 @@ This script checks:
- Whether keys are properly sorted - Whether keys are properly sorted
```bash ```bash
yarn check:i18n pnpm i18n:check
``` ```
### `sync:i18n` - Synchronize JSON Structure and Sort Order ### `i18n:sync` - Synchronize JSON Structure and Sort Order
This script uses `zh-cn.json` as the source of truth to sync structure across all language files, including: This script uses `zh-cn.json` as the source of truth to sync structure across all language files, including:
@ -128,14 +128,14 @@ This script uses `zh-cn.json` as the source of truth to sync structure across al
3. Sorting keys automatically 3. Sorting keys automatically
```bash ```bash
yarn sync:i18n pnpm i18n:sync
``` ```
### `auto:i18n` - Automatically Translate Pending Texts ### `i18n:translate` - Automatically Translate Pending Texts
This script fills in texts marked as `[to be translated]` using machine translation. This script fills in texts marked as `[to be translated]` using machine translation.
Typically, after adding new texts in `zh-cn.json`, run `sync:i18n`, then `auto:i18n` to complete translations. Typically, after adding new texts in `zh-cn.json`, run `i18n:sync`, then `i18n:translate` to complete translations.
Before using this script, set the required environment variables: Before using this script, set the required environment variables:
@ -148,30 +148,20 @@ MODEL="qwen-plus-latest"
Alternatively, add these variables directly to your `.env` file. Alternatively, add these variables directly to your `.env` file.
```bash ```bash
yarn auto:i18n pnpm i18n:translate
```
### `update:i18n` - Object-level Translation Update
Updates translations in language files under `src/renderer/src/i18n/translate` at the object level, preserving existing translations and only updating new content.
**Not recommended** — prefer `auto:i18n` for translation tasks.
```bash
yarn update:i18n
``` ```
### Workflow ### Workflow
1. During development, first add the required text in `zh-cn.json` 1. During development, first add the required text in `zh-cn.json`
2. Confirm it displays correctly in the Chinese environment 2. Confirm it displays correctly in the Chinese environment
3. Run `yarn sync:i18n` to propagate the keys to other language files 3. Run `pnpm i18n:sync` to propagate the keys to other language files
4. Run `yarn auto:i18n` to perform machine translation 4. Run `pnpm i18n:translate` to perform machine translation
5. Grab a coffee and let the magic happen! 5. Grab a coffee and let the magic happen!
## Best Practices ## Best Practices
1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages. 1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages.
2. **Run Check Script Before Commit**: Use `yarn check:i18n` to catch i18n issues early. 2. **Run Check Script Before Commit**: Use `pnpm i18n:check` to catch i18n issues early.
3. **Translate in Small Increments**: Avoid accumulating a large backlog of untranslated content. 3. **Translate in Small Increments**: Avoid accumulating a large backlog of untranslated content.
4. **Keep Keys Semantically Clear**: Keys should clearly express their purpose, e.g., `user.profile.avatar.upload.error` 4. **Keep Keys Semantically Clear**: Keys should clearly express their purpose, e.g., `user.profile.avatar.upload.error`

View File

@ -37,8 +37,8 @@ The `x-files/app-upgrade-config/app-upgrade-config.json` file is synchronized by
1. **Guard + metadata preparation** the `Check if should proceed` and `Prepare metadata` steps compute the target tag, prerelease flag, whether the tag is the newest release, and a `safe_tag` slug used for branch names. When any rule fails, the workflow stops without touching the config. 1. **Guard + metadata preparation** the `Check if should proceed` and `Prepare metadata` steps compute the target tag, prerelease flag, whether the tag is the newest release, and a `safe_tag` slug used for branch names. When any rule fails, the workflow stops without touching the config.
2. **Checkout source branches** the default branch is checked out into `main/`, while the long-lived `x-files/app-upgrade-config` branch lives in `cs/`. All modifications happen in the latter directory. 2. **Checkout source branches** the default branch is checked out into `main/`, while the long-lived `x-files/app-upgrade-config` branch lives in `cs/`. All modifications happen in the latter directory.
3. **Install toolchain** Node.js 22, Corepack, and frozen Yarn dependencies are installed inside `main/`. 3. **Install toolchain** Node.js 22, Corepack, and frozen pnpm dependencies are installed inside `main/`.
4. **Run the update script** `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>` updates the JSON in-place. 4. **Run the update script** `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>` updates the JSON in-place.
- The script normalizes the tag (e.g., strips `v` prefix), detects the release channel (`latest`, `rc`, `beta`), and loads segment rules from `config/app-upgrade-segments.json`. - The script normalizes the tag (e.g., strips `v` prefix), detects the release channel (`latest`, `rc`, `beta`), and loads segment rules from `config/app-upgrade-segments.json`.
- It validates that prerelease flags and semantic suffixes agree, enforces locked segments, builds mirror feed URLs, and performs release-availability checks (GitHub HEAD request for every channel; GitCode GET for latest channels, falling back to `https://releases.cherry-ai.com` when gitcode is delayed). - It validates that prerelease flags and semantic suffixes agree, enforces locked segments, builds mirror feed URLs, and performs release-availability checks (GitHub HEAD request for every channel; GitCode GET for latest channels, falling back to `https://releases.cherry-ai.com` when gitcode is delayed).
- After updating the relevant channel entry, the script rewrites the config with semver-sort order and a new `lastUpdated` timestamp. - After updating the relevant channel entry, the script rewrites the config with semver-sort order and a new `lastUpdated` timestamp.
@ -223,10 +223,10 @@ interface ChannelConfig {
Starting from this change, `.github/workflows/update-app-upgrade-config.yml` listens to GitHub release events (published + prerelease). The workflow: Starting from this change, `.github/workflows/update-app-upgrade-config.yml` listens to GitHub release events (published + prerelease). The workflow:
1. Checks out the default branch (for scripts) and the `x-files/app-upgrade-config` branch (where the config is hosted). 1. Checks out the default branch (for scripts) and the `x-files/app-upgrade-config` branch (where the config is hosted).
2. Runs `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json` to regenerate the config directly inside the `x-files/app-upgrade-config` working tree. 2. Runs `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json` to regenerate the config directly inside the `x-files/app-upgrade-config` working tree.
3. If the file changed, it opens a PR against `x-files/app-upgrade-config` via `peter-evans/create-pull-request`, with the generated diff limited to `app-upgrade-config.json`. 3. If the file changed, it opens a PR against `x-files/app-upgrade-config` via `peter-evans/create-pull-request`, with the generated diff limited to `app-upgrade-config.json`.
You can run the same script locally via `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages arent published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes. You can run the same script locally via `pnpm update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages aren't published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes.
## Version Matching Logic ## Version Matching Logic

View File

@ -0,0 +1,129 @@
# Fuzzy Search for File List
This document describes the fuzzy search implementation for file listing in Cherry Studio.
## Overview
The fuzzy search feature allows users to find files by typing partial or approximate file names/paths. It uses a two-tier file filtering strategy (ripgrep glob pre-filtering with greedy substring fallback) combined with subsequence-based scoring for optimal performance and flexibility.
## Features
- **Ripgrep Glob Pre-filtering**: Primary filtering using glob patterns for fast native-level filtering
- **Greedy Substring Matching**: Fallback file filtering strategy when ripgrep glob pre-filtering returns no results
- **Subsequence-based Segment Scoring**: During scoring, path segments gain additional weight when query characters appear in order
- **Relevance Scoring**: Results are sorted by a relevance score derived from multiple factors
## Matching Strategies
### 1. Ripgrep Glob Pre-filtering (Primary)
The query is converted to a glob pattern for ripgrep to do initial filtering:
```
Query: "updater"
Glob: "*u*p*d*a*t*e*r*"
```
This leverages ripgrep's native performance for the initial file filtering.
### 2. Greedy Substring Matching (Fallback)
When the glob pre-filter returns no results, the system falls back to greedy substring matching. This allows more flexible matching:
```
Query: "updatercontroller"
File: "packages/update/src/node/updateController.ts"
Matching process:
1. Find "update" (longest match from start)
2. Remaining "rcontroller" → find "r" then "controller"
3. All parts matched → Success
```
## Scoring Algorithm
Results are ranked by a relevance score based on named constants defined in `FileStorage.ts`:
| Constant | Value | Description |
|----------|-------|-------------|
| `SCORE_FILENAME_STARTS` | 100 | Filename starts with query (highest priority) |
| `SCORE_FILENAME_CONTAINS` | 80 | Filename contains exact query substring |
| `SCORE_SEGMENT_MATCH` | 60 | Per path segment that matches query |
| `SCORE_WORD_BOUNDARY` | 20 | Query matches start of a word |
| `SCORE_CONSECUTIVE_CHAR` | 15 | Per consecutive character match |
| `PATH_LENGTH_PENALTY_FACTOR` | 4 | Logarithmic penalty for longer paths |
### Scoring Strategy
The scoring prioritizes:
1. **Filename matches** (highest): Files where the query appears in the filename are most relevant
2. **Path segment matches**: Multiple matching segments indicate stronger relevance
3. **Word boundaries**: Matching at word starts (e.g., "upd" matching "update") is preferred
4. **Consecutive matches**: Longer consecutive character sequences score higher
5. **Path length**: Shorter paths are preferred (logarithmic penalty prevents long paths from dominating)
### Example Scoring
For query `updater`:
| File | Score Factors |
|------|---------------|
| `RCUpdater.js` | Short path + filename contains "updater" |
| `updateController.ts` | Multiple segment matches |
| `UpdaterHelper.plist` | Long path penalty |
## Configuration
### DirectoryListOptions
```typescript
interface DirectoryListOptions {
recursive?: boolean // Default: true
maxDepth?: number // Default: 10
includeHidden?: boolean // Default: false
includeFiles?: boolean // Default: true
includeDirectories?: boolean // Default: true
maxEntries?: number // Default: 20
searchPattern?: string // Default: '.'
fuzzy?: boolean // Default: true
}
```
## Usage
```typescript
// Basic fuzzy search
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'updater',
fuzzy: true,
maxEntries: 20
})
// Disable fuzzy search (exact glob matching)
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'update',
fuzzy: false
})
```
## Performance Considerations
1. **Ripgrep Pre-filtering**: Most queries are handled by ripgrep's native glob matching, which is extremely fast
2. **Fallback Only When Needed**: Greedy substring matching (which loads all files) only runs when glob matching returns empty results
3. **Result Limiting**: Only top 20 results are returned by default
4. **Excluded Directories**: Common large directories are automatically excluded:
- `node_modules`
- `.git`
- `dist`, `build`
- `.next`, `.nuxt`
- `coverage`, `.cache`
## Implementation Details
The implementation is located in `src/main/services/FileStorage.ts`:
- `queryToGlobPattern()`: Converts query to ripgrep glob pattern
- `isFuzzyMatch()`: Subsequence matching algorithm
- `isGreedySubstringMatch()`: Greedy substring matching fallback
- `getFuzzyMatchScore()`: Calculates relevance score
- `listDirectoryWithRipgrep()`: Main search orchestration

View File

@ -34,7 +34,7 @@
</a> </a>
</h1> </h1>
<p align="center"> <p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/zh-cn">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br> <a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
</p> </p>
<!-- 题头徽章组合 --> <!-- 题头徽章组合 -->
@ -281,7 +281,7 @@ https://docs.cherry-ai.com
# 📊 GitHub 统计 # 📊 GitHub 统计
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image') ![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star 记录 # ⭐️ Star 记录

View File

@ -11,7 +11,7 @@
### Install ### Install
```bash ```bash
yarn pnpm install
``` ```
### Development ### Development
@ -20,35 +20,35 @@ yarn
Download and install [Node.js v22.x.x](https://nodejs.org/en/download) Download and install [Node.js v22.x.x](https://nodejs.org/en/download)
### Setup Yarn ### Setup pnpm
```bash ```bash
corepack enable corepack enable
corepack prepare yarn@4.9.1 --activate corepack prepare pnpm@10.27.0 --activate
``` ```
### Install Dependencies ### Install Dependencies
```bash ```bash
yarn install pnpm install
``` ```
### ENV ### ENV
```bash ```bash
copy .env.example .env cp .env.example .env
``` ```
### Start ### Start
```bash ```bash
yarn dev pnpm dev
``` ```
### Debug ### Debug
```bash ```bash
yarn debug pnpm debug
``` ```
Then input chrome://inspect in browser Then input chrome://inspect in browser
@ -56,18 +56,18 @@ Then input chrome://inspect in browser
### Test ### Test
```bash ```bash
yarn test pnpm test
``` ```
### Build ### Build
```bash ```bash
# For windows # For windows
$ yarn build:win $ pnpm build:win
# For macOS # For macOS
$ yarn build:mac $ pnpm build:mac
# For Linux # For Linux
$ yarn build:linux $ pnpm build:linux
``` ```

View File

@ -67,7 +67,7 @@ i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反
```javascript ```javascript
// 不推荐 - 插件无法解析 // 不推荐 - 插件无法解析
const message = t(`fruits.${fruit}`) const message = t(`fruits.${fruit}`);
``` ```
2. **编辑器无法实时渲染** 2. **编辑器无法实时渲染**
@ -85,14 +85,14 @@ i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反
```ts ```ts
// src/renderer/src/i18n/label.ts // src/renderer/src/i18n/label.ts
const themeModeKeyMap = { const themeModeKeyMap = {
dark: 'settings.theme.dark', dark: "settings.theme.dark",
light: 'settings.theme.light', light: "settings.theme.light",
system: 'settings.theme.system' system: "settings.theme.system",
} as const } as const;
export const getThemeModeLabel = (key: string): string => { export const getThemeModeLabel = (key: string): string => {
return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key;
} };
``` ```
通过避免模板字符串,可以获得更好的开发体验、更可靠的翻译检查以及更易维护的代码库。 通过避免模板字符串,可以获得更好的开发体验、更可靠的翻译检查以及更易维护的代码库。
@ -101,7 +101,7 @@ export const getThemeModeLabel = (key: string): string => {
项目中有一系列脚本来自动化 i18n 相关任务: 项目中有一系列脚本来自动化 i18n 相关任务:
### `check:i18n` - 检查i18n结构 ### `i18n:check` - 检查 i18n 结构
此脚本会检查: 此脚本会检查:
@ -111,10 +111,10 @@ export const getThemeModeLabel = (key: string): string => {
- 是否已经有序 - 是否已经有序
```bash ```bash
yarn check:i18n pnpm i18n:check
``` ```
### `sync:i18n` - 同步json结构与排序 ### `i18n:sync` - 同步 json 结构与排序
此脚本以`zh-cn.json`文件为基准,将结构同步到其他语言文件,包括: 此脚本以`zh-cn.json`文件为基准,将结构同步到其他语言文件,包括:
@ -123,14 +123,14 @@ yarn check:i18n
3. 自动排序 3. 自动排序
```bash ```bash
yarn sync:i18n pnpm i18n:sync
``` ```
### `auto:i18n` - 自动翻译待翻译文本 ### `i18n:translate` - 自动翻译待翻译文本
次脚本自动将标记为待翻译的文本通过机器翻译填充。 次脚本自动将标记为待翻译的文本通过机器翻译填充。
通常,在`zh-cn.json`中添加所需文案后,执行`sync:i18n`即可自动完成翻译。 通常,在`zh-cn.json`中添加所需文案后,执行`i18n:sync`即可自动完成翻译。
使用该脚本前,需要配置环境变量,例如: 使用该脚本前,需要配置环境变量,例如:
@ -143,29 +143,19 @@ MODEL="qwen-plus-latest"
你也可以通过直接编辑`.env`文件来添加环境变量。 你也可以通过直接编辑`.env`文件来添加环境变量。
```bash ```bash
yarn auto:i18n pnpm i18n:translate
```
### `update:i18n` - 对象级别翻译更新
对`src/renderer/src/i18n/translate`中的语言文件进行对象级别的翻译更新,保留已有翻译,只更新新增内容。
**不建议**使用该脚本,更推荐使用`auto:i18n`进行翻译。
```bash
yarn update:i18n
``` ```
### 工作流 ### 工作流
1. 开发阶段,先在`zh-cn.json`中添加所需文案 1. 开发阶段,先在`zh-cn.json`中添加所需文案
2. 确认在中文环境下显示无误后,使用`yarn sync:i18n`将文案同步到其他语言文件 2. 确认在中文环境下显示无误后,使用`pnpm i18n:sync`将文案同步到其他语言文件
3. 使用`yarn auto:i18n`进行自动翻译 3. 使用`pnpm i18n:translate`进行自动翻译
4. 喝杯咖啡,等翻译完成吧! 4. 喝杯咖啡,等翻译完成吧!
## 最佳实践 ## 最佳实践
1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言 1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言
2. **提交前运行检查脚本**:使用`yarn check:i18n`检查i18n是否有问题 2. **提交前运行检查脚本**:使用`pnpm i18n:check`检查 i18n 是否有问题
3. **小步提交翻译**:避免积累大量未翻译文本 3. **小步提交翻译**:避免积累大量未翻译文本
4. **保持 key 语义明确**key 应能清晰表达其用途,如`user.profile.avatar.upload.error` 4. **保持 key 语义明确**key 应能清晰表达其用途,如`user.profile.avatar.upload.error`

View File

@ -37,8 +37,8 @@
1. **检查与元数据准备**`Check if should proceed` 和 `Prepare metadata` 步骤会计算 tag、prerelease 标志、是否最新版本以及用于分支名的 `safe_tag`。若任意校验失败,工作流立即退出。 1. **检查与元数据准备**`Check if should proceed` 和 `Prepare metadata` 步骤会计算 tag、prerelease 标志、是否最新版本以及用于分支名的 `safe_tag`。若任意校验失败,工作流立即退出。
2. **检出分支**:默认分支被检出到 `main/`,长期维护的 `x-files/app-upgrade-config` 分支则在 `cs/` 中,所有改动都发生在 `cs/` 2. **检出分支**:默认分支被检出到 `main/`,长期维护的 `x-files/app-upgrade-config` 分支则在 `cs/` 中,所有改动都发生在 `cs/`
3. **安装工具链**:安装 Node.js 22、启用 Corepack并在 `main/` 目录执行 `yarn install --immutable`。 3. **安装工具链**:安装 Node.js 22、启用 Corepack并在 `main/` 目录执行 `pnpm install --frozen-lockfile`。
4. **运行更新脚本**:执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>`。 4. **运行更新脚本**:执行 `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>`。
- 脚本会标准化 tag去掉 `v` 前缀等)、识别渠道、加载 `config/app-upgrade-segments.json` 中的分段规则。 - 脚本会标准化 tag去掉 `v` 前缀等)、识别渠道、加载 `config/app-upgrade-segments.json` 中的分段规则。
- 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。 - 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。
- 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON并刷新 `lastUpdated` - 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON并刷新 `lastUpdated`
@ -223,10 +223,10 @@ interface ChannelConfig {
`.github/workflows/update-app-upgrade-config.yml` 会在 GitHub Release包含正常发布与 Pre Release触发 `.github/workflows/update-app-upgrade-config.yml` 会在 GitHub Release包含正常发布与 Pre Release触发
1. 同时 Checkout 仓库默认分支(用于脚本)和 `x-files/app-upgrade-config` 分支(真实托管配置的分支)。 1. 同时 Checkout 仓库默认分支(用于脚本)和 `x-files/app-upgrade-config` 分支(真实托管配置的分支)。
2. 在默认分支目录执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json`,直接重写 `x-files/app-upgrade-config` 分支里的配置文件。 2. 在默认分支目录执行 `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json`,直接重写 `x-files/app-upgrade-config` 分支里的配置文件。
3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `x-files/app-upgrade-config` 的 PRDiff 仅包含该文件。 3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `x-files/app-upgrade-config` 的 PRDiff 仅包含该文件。
如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。 如需本地调试,可执行 `pnpm update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
## 版本匹配逻辑 ## 版本匹配逻辑

View File

@ -0,0 +1,129 @@
# 文件列表模糊搜索
本文档描述了 Cherry Studio 中文件列表的模糊搜索实现。
## 概述
模糊搜索功能允许用户通过输入部分或近似的文件名/路径来查找文件。它使用两层文件过滤策略ripgrep glob 预过滤 + 贪婪子串匹配回退),结合基于子序列的评分,以获得最佳性能和灵活性。
## 功能特性
- **Ripgrep Glob 预过滤**:使用 glob 模式进行快速原生级过滤的主要过滤策略
- **贪婪子串匹配**:当 ripgrep glob 预过滤无结果时的回退文件过滤策略
- **基于子序列的段评分**:评分时,当查询字符按顺序出现时,路径段获得额外权重
- **相关性评分**:结果按多因素相关性分数排序
## 匹配策略
### 1. Ripgrep Glob 预过滤(主要)
查询被转换为 glob 模式供 ripgrep 进行初始过滤:
```
查询: "updater"
Glob: "*u*p*d*a*t*e*r*"
```
这利用了 ripgrep 的原生性能进行初始文件过滤。
### 2. 贪婪子串匹配(回退)
当 glob 预过滤无结果时,系统回退到贪婪子串匹配。这允许更灵活的匹配:
```
查询: "updatercontroller"
文件: "packages/update/src/node/updateController.ts"
匹配过程:
1. 找到 "update"(从开头的最长匹配)
2. 剩余 "rcontroller" → 找到 "r" 然后 "controller"
3. 所有部分都匹配 → 成功
```
## 评分算法
结果根据 `FileStorage.ts` 中定义的命名常量进行相关性分数排名:
| 常量 | 值 | 描述 |
|------|-----|------|
| `SCORE_FILENAME_STARTS` | 100 | 文件名以查询开头(最高优先级)|
| `SCORE_FILENAME_CONTAINS` | 80 | 文件名包含精确查询子串 |
| `SCORE_SEGMENT_MATCH` | 60 | 每个匹配查询的路径段 |
| `SCORE_WORD_BOUNDARY` | 20 | 查询匹配单词开头 |
| `SCORE_CONSECUTIVE_CHAR` | 15 | 每个连续字符匹配 |
| `PATH_LENGTH_PENALTY_FACTOR` | 4 | 较长路径的对数惩罚 |
### 评分策略
评分优先级:
1. **文件名匹配**(最高):查询出现在文件名中的文件最相关
2. **路径段匹配**:多个匹配段表示更强的相关性
3. **词边界**:在单词开头匹配(如 "upd" 匹配 "update")更优先
4. **连续匹配**:更长的连续字符序列得分更高
5. **路径长度**:较短路径更优先(对数惩罚防止长路径主导评分)
### 评分示例
对于查询 `updater`
| 文件 | 评分因素 |
|------|----------|
| `RCUpdater.js` | 短路径 + 文件名包含 "updater" |
| `updateController.ts` | 多个路径段匹配 |
| `UpdaterHelper.plist` | 长路径惩罚 |
## 配置
### DirectoryListOptions
```typescript
interface DirectoryListOptions {
recursive?: boolean // 默认: true
maxDepth?: number // 默认: 10
includeHidden?: boolean // 默认: false
includeFiles?: boolean // 默认: true
includeDirectories?: boolean // 默认: true
maxEntries?: number // 默认: 20
searchPattern?: string // 默认: '.'
fuzzy?: boolean // 默认: true
}
```
## 使用方法
```typescript
// 基本模糊搜索
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'updater',
fuzzy: true,
maxEntries: 20
})
// 禁用模糊搜索(精确 glob 匹配)
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'update',
fuzzy: false
})
```
## 性能考虑
1. **Ripgrep 预过滤**:大多数查询由 ripgrep 的原生 glob 匹配处理,速度极快
2. **仅在需要时回退**:贪婪子串匹配(加载所有文件)仅在 glob 匹配返回空结果时运行
3. **结果限制**:默认只返回前 20 个结果
4. **排除目录**:自动排除常见的大型目录:
- `node_modules`
- `.git`
- `dist`、`build`
- `.next`、`.nuxt`
- `coverage`、`.cache`
## 实现细节
实现位于 `src/main/services/FileStorage.ts`
- `queryToGlobPattern()`:将查询转换为 ripgrep glob 模式
- `isFuzzyMatch()`:子序列匹配算法
- `isGreedySubstringMatch()`:贪婪子串匹配回退
- `getFuzzyMatchScore()`:计算相关性分数
- `listDirectoryWithRipgrep()`:主搜索协调

View File

@ -0,0 +1,850 @@
# Cherry Studio 局域网传输协议规范
> 版本: 1.0
> 最后更新: 2025-12
本文档定义了 Cherry Studio 桌面客户端Electron与移动端Expo之间的局域网文件传输协议。
---
## 目录
1. [协议概述](#1-协议概述)
2. [服务发现Bonjour/mDNS](#2-服务发现bonjourmdns)
3. [TCP 连接与握手](#3-tcp-连接与握手)
4. [消息格式规范](#4-消息格式规范)
5. [文件传输协议](#5-文件传输协议)
6. [心跳与连接保活](#6-心跳与连接保活)
7. [错误处理](#7-错误处理)
8. [常量与配置](#8-常量与配置)
9. [完整时序图](#9-完整时序图)
10. [移动端实现指南](#10-移动端实现指南)
---
## 1. 协议概述
### 1.1 架构角色
| 角色 | 平台 | 职责 |
| -------------------- | --------------- | ---------------------------- |
| **Client客户端** | Electron 桌面端 | 扫描服务、发起连接、发送文件 |
| **Server服务端** | Expo 移动端 | 发布服务、接受连接、接收文件 |
### 1.2 协议栈v1
```
┌─────────────────────────────────────┐
│ 应用层(文件传输) │
├─────────────────────────────────────┤
│ 消息层(控制: JSON \n
│ (数据: 二进制帧) │
├─────────────────────────────────────┤
│ 传输层TCP
├─────────────────────────────────────┤
│ 发现层Bonjour/mDNS
└─────────────────────────────────────┘
```
### 1.3 通信流程概览
```
1. 服务发现 → 移动端发布 mDNS 服务,桌面端扫描发现
2. TCP 握手 → 建立连接,交换设备信息(`version=1`
3. 文件传输 → 控制消息使用 JSON`file_chunk` 使用二进制帧分块传输
4. 连接保活 → ping/pong 心跳
```
---
## 2. 服务发现Bonjour/mDNS
### 2.1 服务类型
| 属性 | 值 |
| ------------ | -------------------- |
| 服务类型 | `cherrystudio` |
| 协议 | `tcp` |
| 完整服务标识 | `_cherrystudio._tcp` |
### 2.2 服务发布(移动端)
移动端需要通过 mDNS/Bonjour 发布服务:
```typescript
// 服务发布参数
{
name: "Cherry Studio Mobile", // 设备名称
type: "cherrystudio", // 服务类型
protocol: "tcp", // 协议
port: 53317, // TCP 监听端口
txt: { // TXT 记录(可选)
version: "1",
platform: "ios" // 或 "android"
}
}
```
### 2.3 服务发现(桌面端)
桌面端扫描并解析服务信息:
```typescript
// 发现的服务信息结构
type LocalTransferPeer = {
id: string; // 唯一标识符
name: string; // 设备名称
host?: string; // 主机名
fqdn?: string; // 完全限定域名
port?: number; // TCP 端口
type?: string; // 服务类型
protocol?: "tcp" | "udp"; // 协议
addresses: string[]; // IP 地址列表
txt?: Record<string, string>; // TXT 记录
updatedAt: number; // 发现时间戳
};
```
### 2.4 IP 地址选择策略
当服务有多个 IP 地址时,优先选择 IPv4
```typescript
// 优先选择 IPv4 地址
const preferredAddress = addresses.find((addr) => isIPv4(addr)) || addresses[0];
```
---
## 3. TCP 连接与握手
### 3.1 连接建立
1. 客户端使用发现的 `host:port` 建立 TCP 连接
2. 连接成功后立即发送握手消息
3. 等待服务端响应握手确认
### 3.2 握手消息(协议版本 v1
#### Client → Server: `handshake`
```typescript
type LanTransferHandshakeMessage = {
type: "handshake";
deviceName: string; // 设备名称
version: string; // 协议版本,当前为 "1"
platform?: string; // 平台:'darwin' | 'win32' | 'linux'
appVersion?: string; // 应用版本
};
```
**示例:**
```json
{
"type": "handshake",
"deviceName": "Cherry Studio 1.7.2",
"version": "1",
"platform": "darwin",
"appVersion": "1.7.2"
}
```
### 4. 消息格式规范(混合协议)
v1 使用"控制 JSON + 二进制数据帧"的混合协议(流式传输模式,无 per-chunk ACK
- **控制消息**握手、心跳、file_start/ack、file_end、file_completeUTF-8 JSON`\n` 分隔
- **数据消息**`file_chunk`):二进制帧,使用 Magic + 总长度做分帧,不经 Base64
### 4.1 控制消息编码JSON + `\n`
| 属性 | 规范 |
| ---------- | ------------ |
| 编码格式 | UTF-8 |
| 序列化格式 | JSON |
| 消息分隔符 | `\n`0x0A |
```typescript
function sendControlMessage(socket: Socket, message: object): void {
socket.write(`${JSON.stringify(message)}\n`);
}
```
### 4.2 `file_chunk` 二进制帧格式
为解决 TCP 分包/粘包并消除 Base64 开销,`file_chunk` 采用带总长度的二进制帧:
```
┌──────────┬──────────┬────────┬───────────────┬──────────────┬────────────┬───────────┐
│ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │
│ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (UTF-8) │ (4B BE) │ (raw) │
└──────────┴──────────┴────────┴───────────────┴──────────────┴────────────┴───────────┘
```
| 字段 | 大小 | 说明 |
| -------------- | ---- | ------------------------------------------- |
| Magic | 2B | 常量 `0x43 0x53` ("CS"), 用于区分 JSON 消息 |
| TotalLen | 4B | Big-endian帧总长度不含 Magic/TotalLen |
| Type | 1B | `0x01` 代表 `file_chunk` |
| TransferId Len | 2B | Big-endiantransferId 字符串长度 |
| TransferId | nB | UTF-8 transferId长度由上一字段给出 |
| ChunkIdx | 4B | Big-endian块索引从 0 开始 |
| Data | mB | 原始文件二进制数据(未编码) |
> 计算帧总长度:`TotalLen = 1 + 2 + transferIdLen + 4 + dataLen`(即 Type~Data 的长度和)。
### 4.3 消息解析策略
1. 读取 socket 数据到缓冲区;
2. 若前两字节为 `0x43 0x53` → 按二进制帧解析:
- 至少需要 6 字节头Magic + TotalLen不足则等待更多数据
- 读取 `TotalLen` 判断帧整体长度,缓冲区不足则继续等待
- 解析 Type/TransferId/ChunkIdx/Data并传入文件接收逻辑
3. 否则若首字节为 `{` → 按 JSON + `\n` 解析控制消息
4. 其它数据丢弃 1 字节并继续循环,避免阻塞。
### 4.4 消息类型汇总v1
| 类型 | 方向 | 编码 | 用途 |
| ---------------- | --------------- | -------- | ----------------------- |
| `handshake` | Client → Server | JSON+\n | 握手请求version=1 |
| `handshake_ack` | Server → Client | JSON+\n | 握手响应 |
| `ping` | Client → Server | JSON+\n | 心跳请求 |
| `pong` | Server → Client | JSON+\n | 心跳响应 |
| `file_start` | Client → Server | JSON+\n | 开始文件传输 |
| `file_start_ack` | Server → Client | JSON+\n | 文件传输确认 |
| `file_chunk` | Client → Server | 二进制帧 | 文件数据块(无 Base64流式无 per-chunk ACK |
| `file_end` | Client → Server | JSON+\n | 文件传输结束 |
| `file_complete` | Server → Client | JSON+\n | 传输完成结果 |
```
{"type":"message_type",...其他字段...}\n
```
---
## 5. 文件传输协议
### 5.1 传输流程
```
Client (Sender) Server (Receiver)
| |
|──── 1. file_start ────────────────>|
| (文件元数据) |
| |
|<─── 2. file_start_ack ─────────────|
| (接受/拒绝) |
| |
|══════ 循环发送数据块(流式,无 ACK ═════|
| |
|──── 3. file_chunk [0] ────────────>|
| |
|──── 3. file_chunk [1] ────────────>|
| |
| ... 重复直到所有块发送完成 ... |
| |
|══════════════════════════════════════
| |
|──── 5. file_end ──────────────────>|
| (所有块已发送) |
| |
|<─── 6. file_complete ──────────────|
| (最终结果) |
```
### 5.2 消息定义
#### 5.2.1 `file_start` - 开始传输
**方向:** Client → Server
```typescript
type LanTransferFileStartMessage = {
type: "file_start";
transferId: string; // UUID唯一传输标识
fileName: string; // 文件名(含扩展名)
fileSize: number; // 文件总字节数
mimeType: string; // MIME 类型
checksum: string; // 整个文件的 SHA-256 哈希hex
totalChunks: number; // 总数据块数
chunkSize: number; // 每块大小(字节)
};
```
**示例:**
```json
{
"type": "file_start",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"fileName": "backup.zip",
"fileSize": 524288000,
"mimeType": "application/zip",
"checksum": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456",
"totalChunks": 8192,
"chunkSize": 65536
}
```
#### 5.2.2 `file_start_ack` - 传输确认
**方向:** Server → Client
```typescript
type LanTransferFileStartAckMessage = {
type: "file_start_ack";
transferId: string; // 对应的传输 ID
accepted: boolean; // 是否接受传输
message?: string; // 拒绝原因
};
```
**接受示例:**
```json
{
"type": "file_start_ack",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"accepted": true
}
```
**拒绝示例:**
```json
{
"type": "file_start_ack",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"accepted": false,
"message": "Insufficient storage space"
}
```
#### 5.2.3 `file_chunk` - 数据块
**方向:** Client → Server**二进制帧**,见 4.2
- 不再使用 JSON/`\n`,也不再使用 Base64
- 帧结构:`Magic` + `TotalLen` + `Type` + `TransferId` + `ChunkIdx` + `Data`
- `Type` 固定 `0x01``Data` 为原始文件二进制数据
- 传输完整性依赖 `file_start.checksum`(全文件 SHA-256分块校验和可选不在帧中发送
#### 5.2.4 `file_chunk_ack` - 数据块确认v1 流式不使用)
v1 采用流式传输,不发送 per-chunk ACK。本节类型仅保留作为向后兼容参考实际不会发送。
#### 5.2.5 `file_end` - 传输结束
**方向:** Client → Server
```typescript
type LanTransferFileEndMessage = {
type: "file_end";
transferId: string; // 传输 ID
};
```
**示例:**
```json
{
"type": "file_end",
"transferId": "550e8400-e29b-41d4-a716-446655440000"
}
```
#### 5.2.6 `file_complete` - 传输完成
**方向:** Server → Client
```typescript
type LanTransferFileCompleteMessage = {
type: "file_complete";
transferId: string; // 传输 ID
success: boolean; // 是否成功
filePath?: string; // 保存路径(成功时)
error?: string; // 错误信息(失败时)
};
```
**成功示例:**
```json
{
"type": "file_complete",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"success": true,
"filePath": "/storage/emulated/0/Documents/backup.zip"
}
```
**失败示例:**
```json
{
"type": "file_complete",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"success": false,
"error": "File checksum verification failed"
}
```
### 5.3 校验和算法
#### 整个文件校验和(保持不变)
```typescript
async function calculateFileChecksum(filePath: string): Promise<string> {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest("hex");
}
```
#### 数据块校验和
v1 默认 **不传输分块校验和**,依赖最终文件 checksum。若需要可在应用层自定义非协议字段
### 5.4 校验流程
**发送端Client**
1. 发送前计算整个文件的 SHA-256 → `file_start.checksum`
2. 分块直接发送原始二进制(无 Base64
**接收端Server**
1. 收到 `file_chunk` 后直接使用二进制数据
2. 边收边落盘并增量计算 SHA-256推荐
3. 所有块接收完成后,计算/完成增量哈希,得到最终 SHA-256
4. 与 `file_start.checksum` 比对,结果写入 `file_complete`
### 5.5 数据块大小计算
```typescript
const CHUNK_SIZE = 512 * 1024; // 512KB
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
// 最后一个块可能小于 CHUNK_SIZE
const lastChunkSize = fileSize % CHUNK_SIZE || CHUNK_SIZE;
```
---
## 6. 心跳与连接保活
### 6.1 心跳消息
#### `ping`
**方向:** Client → Server
```typescript
type LanTransferPingMessage = {
type: "ping";
payload?: string; // 可选载荷
};
```
```json
{
"type": "ping",
"payload": "heartbeat"
}
```
#### `pong`
**方向:** Server → Client
```typescript
type LanTransferPongMessage = {
type: "pong";
received: boolean; // 确认收到
payload?: string; // 回传 ping 的载荷
};
```
```json
{
"type": "pong",
"received": true,
"payload": "heartbeat"
}
```
### 6.2 心跳策略
- 握手成功后立即发送一次 `ping` 验证连接
- 可选:定期发送心跳保持连接活跃
- `pong` 应返回 `ping` 中的 `payload`(可选)
---
## 7. 错误处理
### 7.1 超时配置
| 操作 | 超时时间 | 说明 |
| ---------- | -------- | --------------------- |
| TCP 连接 | 10 秒 | 连接建立超时 |
| 握手等待 | 10 秒 | 等待 `handshake_ack` |
| 传输完成 | 60 秒 | 等待 `file_complete` |
### 7.2 错误场景处理
| 场景 | Client 处理 | Server 处理 |
| --------------- | ------------------ | ---------------------- |
| TCP 连接失败 | 通知 UI允许重试 | - |
| 握手超时 | 断开连接,通知 UI | 关闭 socket |
| 握手被拒绝 | 显示拒绝原因 | - |
| 数据块处理失败 | 中止传输,清理状态 | 清理临时文件 |
| 连接意外断开 | 清理状态,通知 UI | 清理临时文件 |
| 存储空间不足 | - | 发送 `accepted: false` |
### 7.3 资源清理
**Client 端:**
```typescript
function cleanup(): void {
// 1. 销毁文件读取流
if (readStream) {
readStream.destroy();
}
// 2. 清理传输状态
activeTransfer = undefined;
// 3. 关闭 socket如需要
socket?.destroy();
}
```
**Server 端:**
```typescript
function cleanup(): void {
// 1. 关闭文件写入流
if (writeStream) {
writeStream.end();
}
// 2. 删除未完成的临时文件
if (tempFilePath) {
fs.unlinkSync(tempFilePath);
}
// 3. 清理传输状态
activeTransfer = undefined;
}
```
---
## 8. 常量与配置
### 8.1 协议常量
```typescript
// 协议版本v1 = 控制 JSON + 二进制 chunk + 流式传输)
export const LAN_TRANSFER_PROTOCOL_VERSION = "1";
// 服务发现
export const LAN_TRANSFER_SERVICE_TYPE = "cherrystudio";
export const LAN_TRANSFER_SERVICE_FULL_NAME = "_cherrystudio._tcp";
// TCP 端口
export const LAN_TRANSFER_TCP_PORT = 53317;
// 文件传输(与二进制帧一致)
export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB
export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟
// 超时设置
export const LAN_TRANSFER_HANDSHAKE_TIMEOUT_MS = 10_000; // 10秒
export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000; // 30秒
export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000; // 60秒
```
### 8.2 支持的文件类型
当前仅支持 ZIP 文件:
```typescript
export const LAN_TRANSFER_ALLOWED_EXTENSIONS = [".zip"];
export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [
"application/zip",
"application/x-zip-compressed",
];
```
---
## 9. 完整时序图
### 9.1 完整传输流程v1流式传输
```
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Renderer│ │ Main │ │ Mobile │
│ (UI) │ │ Process │ │ Server │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ ════════════ 服务发现阶段 ════════════ │
│ │ │
│ startScan() │ │
│────────────────────────────────────>│ │
│ │ mDNS browse │
│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─>│
│ │ │
│ │<─ ─ ─ service discovered ─ ─ ─ ─ ─ ─│
│ │ │
<────── onServicesUpdated ───────────│ │
│ │ │
│ ════════════ 握手连接阶段 ════════════ │
│ │ │
│ connect(peer) │ │
│────────────────────────────────────>│ │
│ │──────── TCP Connect ───────────────>│
│ │ │
│ │──────── handshake ─────────────────>│
│ │ │
│ │<─────── handshake_ack ──────────────│
│ │ │
│ │──────── ping ──────────────────────>│
│ │<─────── pong ───────────────────────│
│ │ │
<────── connect result ──────────────│ │
│ │ │
│ ════════════ 文件传输阶段 ════════════ │
│ │ │
│ sendFile(path) │ │
│────────────────────────────────────>│ │
│ │──────── file_start ────────────────>│
│ │ │
│ │<─────── file_start_ack ─────────────│
│ │ │
│ │ │
│ │══════ 循环发送数据块 ═══════════════│
│ │ │
│ │──────── file_chunk[0] (binary) ────>│
<────── progress event ──────────────│ │
│ │ │
│ │──────── file_chunk[1] (binary) ────>│
<────── progress event ──────────────│ │
│ │ │
│ │ ... 重复 ... │
│ │ │
│ │══════════════════════════════════════│
│ │ │
│ │──────── file_end ──────────────────>│
│ │ │
│ │<─────── file_complete ──────────────│
│ │ │
<────── complete event ──────────────│ │
<────── sendFile result ─────────────│ │
│ │ │
```
---
## 10. 移动端实现指南v1 要点)
### 10.1 必须实现的功能
1. **mDNS 服务发布**
- 发布 `_cherrystudio._tcp` 服务
- 提供 TCP 端口号 `53317`
- 可选TXT 记录(版本、平台信息)
2. **TCP 服务端**
- 监听指定端口
- 支持单连接或多连接
3. **消息解析**
- 控制消息UTF-8 + `\n` JSON
- 数据消息二进制帧Magic+TotalLen 分帧)
4. **握手处理**
- 验证 `handshake` 消息
- 发送 `handshake_ack` 响应
- 响应 `ping` 消息
5. **文件接收(流式模式)**
- 解析 `file_start`,准备接收
- 接收 `file_chunk` 二进制帧,直接写入文件/缓冲并增量哈希
- v1 不发送 per-chunk ACK流式传输
- 处理 `file_end`,完成增量哈希并校验 checksum
- 发送 `file_complete` 结果
### 10.2 推荐的库
**React Native / Expo**
- mDNS: `react-native-zeroconf``@homielab/react-native-bonjour`
- TCP: `react-native-tcp-socket`
- Crypto: `expo-crypto``react-native-quick-crypto`
### 10.3 接收端伪代码
```typescript
class FileReceiver {
private transfer?: {
id: string;
fileName: string;
fileSize: number;
checksum: string;
totalChunks: number;
receivedChunks: number;
tempPath: string;
// v1: 边收边写文件,避免大文件 OOM
// stream: FileSystem writable stream (平台相关封装)
};
handleMessage(message: any) {
switch (message.type) {
case "handshake":
this.handleHandshake(message);
break;
case "ping":
this.sendPong(message);
break;
case "file_start":
this.handleFileStart(message);
break;
// v1: file_chunk 为二进制帧,不再走 JSON 分支
case "file_end":
this.handleFileEnd(message);
break;
}
}
handleFileStart(msg: LanTransferFileStartMessage) {
// 1. 检查存储空间
// 2. 创建临时文件
// 3. 初始化传输状态
// 4. 发送 file_start_ack
}
// v1: 二进制帧处理在 socket data 流中解析,随后调用 handleBinaryFileChunk
handleBinaryFileChunk(transferId: string, chunkIndex: number, data: Buffer) {
// 直接使用二进制数据,按 chunkSize/lastChunk 计算长度
// 写入文件流并更新增量 SHA-256
this.transfer.receivedChunks++;
// v1: 流式传输,不发送 per-chunk ACK
}
handleFileEnd(msg: LanTransferFileEndMessage) {
// 1. 合并所有数据块
// 2. 验证完整文件 checksum
// 3. 写入最终位置
// 4. 发送 file_complete
}
}
```
---
## 附录 ATypeScript 类型定义
完整的类型定义位于 `packages/shared/config/types.ts`
```typescript
// 握手消息
export interface LanTransferHandshakeMessage {
type: "handshake";
deviceName: string;
version: string;
platform?: string;
appVersion?: string;
}
export interface LanTransferHandshakeAckMessage {
type: "handshake_ack";
accepted: boolean;
message?: string;
}
// 心跳消息
export interface LanTransferPingMessage {
type: "ping";
payload?: string;
}
export interface LanTransferPongMessage {
type: "pong";
received: boolean;
payload?: string;
}
// 文件传输消息 (Client -> Server)
export interface LanTransferFileStartMessage {
type: "file_start";
transferId: string;
fileName: string;
fileSize: number;
mimeType: string;
checksum: string;
totalChunks: number;
chunkSize: number;
}
export interface LanTransferFileChunkMessage {
type: "file_chunk";
transferId: string;
chunkIndex: number;
data: string; // Base64 encoded (v1: 二进制帧模式下不使用)
}
export interface LanTransferFileEndMessage {
type: "file_end";
transferId: string;
}
// 文件传输响应消息 (Server -> Client)
export interface LanTransferFileStartAckMessage {
type: "file_start_ack";
transferId: string;
accepted: boolean;
message?: string;
}
// v1 流式不发送 per-chunk ACK以下类型仅用于向后兼容参考
export interface LanTransferFileChunkAckMessage {
type: "file_chunk_ack";
transferId: string;
chunkIndex: number;
received: boolean;
error?: string;
}
export interface LanTransferFileCompleteMessage {
type: "file_complete";
transferId: string;
success: boolean;
filePath?: string;
error?: string;
}
// 常量
export const LAN_TRANSFER_TCP_PORT = 53317;
export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024;
export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000;
```
---
## 附录 B版本历史
| 版本 | 日期 | 变更 |
| ---- | ------- | ---------------------------------------- |
| 1.0 | 2025-12 | 初始发布版本,支持二进制帧格式与流式传输 |

View File

@ -28,6 +28,12 @@ files:
- "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}" - "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}"
- "!**/{.editorconfig,.jekyll-metadata}" - "!**/{.editorconfig,.jekyll-metadata}"
- "!src" - "!src"
- "!config"
- "!patches"
- "!app-upgrade-config.json"
- "!**/node_modules/**/*.cpp"
- "!**/node_modules/node-addon-api/**"
- "!**/node_modules/prebuild-install/**"
- "!scripts" - "!scripts"
- "!local" - "!local"
- "!docs" - "!docs"
@ -134,108 +140,44 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
<!--LANG:en--> <!--LANG:en-->
A New Era of Intelligence with Cherry Studio 1.7.0 Cherry Studio 1.7.9 - New Features & Bug Fixes
Today we're releasing Cherry Studio 1.7.0 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts. ✨ New Features
- [Agent] Add 302.AI provider support
- [Browser] Browser data now persists and supports multiple tabs
- [Language] Add Romanian language support
- [Search] Add fuzzy search for file list
- [Models] Add latest Zhipu models
- [Image] Improve text-to-image functionality
For years, AI assistants have been reactive — waiting for your commands, responding to your questions. With Agent, we're changing that. Now, AI can truly work alongside you: understanding complex goals, breaking them into steps, and executing them independently. 🐛 Bug Fixes
- [Mac] Fix mini window unexpected closing issue
This is what we've been building toward. And it's just the beginning. - [Preview] Fix HTML preview controls not working in fullscreen
- [Translate] Fix translation duplicate execution issue
🤖 Meet Agent - [Zoom] Fix page zoom reset issue during navigation
Imagine having a brilliant colleague who never sleeps. Give Agent a goal — write a report, analyze data, refactor code — and watch it work. It reasons through problems, breaks them into steps, calls the right tools, and adapts when things change. - [Agent] Fix crash when switching between agent and assistant
- [Agent] Fix navigation in agent mode
- **Think → Plan → Act**: From goal to execution, fully autonomous - [Copy] Fix markdown copy button issue
- **Deep Reasoning**: Multi-turn thinking that solves real problems - [Windows] Fix compatibility issues on non-Windows systems
- **Tool Mastery**: File operations, web search, code execution, and more
- **Skill Plugins**: Extend with custom commands and capabilities
- **You Stay in Control**: Real-time approval for sensitive actions
- **Full Visibility**: Every thought, every decision, fully transparent
🌐 Expanding Ecosystem
- **New Providers**: HuggingFace, Mistral, CherryIN, AI Gateway, Intel OVMS, Didi MCP
- **New Models**: Claude 4.5 Haiku, DeepSeek v3.2, GLM-4.6, Doubao, Ling series
- **MCP Integration**: Alibaba Cloud, ModelScope, Higress, MCP.so, TokenFlux and more
📚 Smarter Knowledge Base
- **OpenMinerU**: Self-hosted document processing
- **Full-Text Search**: Find anything instantly across your notes
- **Enhanced Tool Selection**: Smarter configuration for better AI assistance
📝 Notes, Reimagined
- Full-text search with highlighted results
- AI-powered smart rename
- Export as image
- Auto-wrap for tables
🖼️ Image & OCR
- Intel OVMS painting capabilities
- Intel OpenVINO NPU-accelerated OCR
🌍 Now in 10+ Languages
- Added German support
- Enhanced internationalization
⚡ Faster & More Polished
- Electron 38 upgrade
- New MCP management interface
- Dozens of UI refinements
❤️ Fully Open Source
Commercial restrictions removed. Cherry Studio now follows standard AGPL v3 — free for teams of any size.
The Agent Era is here. We can't wait to see what you'll create.
<!--LANG:zh-CN--> <!--LANG:zh-CN-->
Cherry Studio 1.7.0开启智能新纪元 Cherry Studio 1.7.9 - 新功能与问题修复
今天,我们正式发布 Cherry Studio 1.7.0 —— 迄今最具雄心的版本,带来全新的 Agent能够自主思考、规划和行动的 AI。 ✨ 新功能
- [Agent] 新增 302.AI 服务商支持
- [浏览器] 浏览器数据现在可以保存,支持多标签页
- [语言] 新增罗马尼亚语支持
- [搜索] 文件列表新增模糊搜索功能
- [模型] 新增最新智谱模型
- [图片] 优化文生图功能
多年来AI 助手一直是被动的——等待你的指令回应你的问题。Agent 改变了这一切。现在AI 能够真正与你并肩工作:理解复杂目标,将其拆解为步骤,并独立执行。 🐛 问题修复
- [Mac] 修复迷你窗口意外关闭的问题
这是我们一直在构建的未来。而这,仅仅是开始。 - [预览] 修复全屏模式下 HTML 预览控件无法使用的问题
- [翻译] 修复翻译重复执行的问题
🤖 认识 Agent - [缩放] 修复页面导航时缩放被重置的问题
想象一位永不疲倦的得力伙伴。给 Agent 一个目标——撰写报告、分析数据、重构代码——然后看它工作。它会推理问题、拆解步骤、调用工具,并在情况变化时灵活应对。 - [智能体] 修复在智能体和助手间切换时崩溃的问题
- [智能体] 修复智能体模式下的导航问题
- **思考 → 规划 → 行动**:从目标到执行,全程自主 - [复制] 修复 Markdown 复制按钮问题
- **深度推理**:多轮思考,解决真实问题 - [兼容性] 修复非 Windows 系统的兼容性问题
- **工具大师**:文件操作、网络搜索、代码执行,样样精通
- **技能插件**:自定义命令,无限扩展
- **你掌控全局**:敏感操作,实时审批
- **完全透明**:每一步思考,每一个决策,清晰可见
🌐 生态持续壮大
- **新增服务商**Hugging Face、Mistral、Perplexity、SophNet、AI Gateway、Cerebras AI
- **新增模型**Gemini 3、Gemini 3 Pro支持图像预览、GPT-5.1、Claude Opus 4.5
- **MCP 集成**百炼、魔搭、Higress、MCP.so、TokenFlux 等平台
📚 更智能的知识库
- **OpenMinerU**:本地自部署文档处理
- **全文搜索**:笔记内容一搜即达
- **增强工具选择**:更智能的配置,更好的 AI 协助
📝 笔记,焕然一新
- 全文搜索,结果高亮
- AI 智能重命名
- 导出为图片
- 表格自动换行
🖼️ 图像与 OCR
- Intel OVMS 绘图能力
- Intel OpenVINO NPU 加速 OCR
🌍 支持 10+ 种语言
- 新增德语支持
- 全面增强国际化
⚡ 更快、更精致
- 升级 Electron 38
- 新的 MCP 管理界面
- 数十处 UI 细节打磨
❤️ 完全开源
商用限制已移除。Cherry Studio 现遵循标准 AGPL v3 协议——任意规模团队均可自由使用。
Agent 纪元已至。期待你的创造。
<!--LANG:END--> <!--LANG:END-->

View File

@ -1,6 +1,6 @@
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import { CodeInspectorPlugin } from 'code-inspector-plugin' import { CodeInspectorPlugin } from 'code-inspector-plugin'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { defineConfig } from 'electron-vite'
import { resolve } from 'path' import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
@ -17,7 +17,7 @@ const isProd = process.env.NODE_ENV === 'production'
export default defineConfig({ export default defineConfig({
main: { main: {
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')], plugins: [...visualizerPlugin('main')],
resolve: { resolve: {
alias: { alias: {
'@main': resolve('src/main'), '@main': resolve('src/main'),
@ -51,8 +51,7 @@ export default defineConfig({
plugins: [ plugins: [
react({ react({
tsDecorators: true tsDecorators: true
}), })
externalizeDepsPlugin()
], ],
resolve: { resolve: {
alias: { alias: {
@ -68,18 +67,7 @@ export default defineConfig({
plugins: [ plugins: [
(async () => (await import('@tailwindcss/vite')).default())(), (async () => (await import('@tailwindcss/vite')).default())(),
react({ react({
tsDecorators: true, tsDecorators: true
plugins: [
[
'@swc/plugin-styled-components',
{
displayName: true, // 开发环境下启用组件名称
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
]
]
}), }),
...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin ...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin
...visualizerPlugin('renderer') ...visualizerPlugin('renderer')

View File

@ -61,6 +61,7 @@ export default defineConfig([
'tests/**', 'tests/**',
'.yarn/**', '.yarn/**',
'.gitignore', '.gitignore',
'.conductor/**',
'scripts/cloudflare-worker.js', 'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**', 'src/main/integration/nutstore/sso/lib/**',
'src/main/integration/cherryai/index.js', 'src/main/integration/cherryai/index.js',

View File

@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.7.0", "version": "1.7.9",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@ -9,27 +9,13 @@
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
}, },
"workspaces": {
"packages": [
"local",
"packages/*"
],
"installConfig": {
"hoistingLimits": [
"packages/database",
"packages/mcp-trace/trace-core",
"packages/mcp-trace/trace-node",
"packages/mcp-trace/trace-web",
"packages/extension-table-plus"
]
}
},
"scripts": { "scripts": {
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "dotenv electron-vite dev", "dev": "dotenv electron-vite dev",
"dev:watch": "dotenv electron-vite dev -- -w",
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222", "debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"build:check": "yarn lint && yarn test", "build:check": "pnpm lint && pnpm test",
"build:unpack": "dotenv npm run build && electron-builder --dir", "build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64", "build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64", "build:win:x64": "dotenv npm run build && electron-builder --win --x64",
@ -41,108 +27,116 @@
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64", "build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64", "build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
"release": "node scripts/version.js", "release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push", "publish": "pnpm build:check && pnpm release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -", "pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
"agents:generate": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts", "agents:generate": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts",
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts", "agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts", "agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts", "agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build", "generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build", "analyze:renderer": "VISUALIZER_RENDERER=true pnpm build",
"analyze:main": "VISUALIZER_MAIN=true yarn build", "analyze:main": "VISUALIZER_MAIN=true pnpm build",
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts", "i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts",
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts", "i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts", "i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts", "i18n:all": "pnpm i18n:check && pnpm i18n:sync && pnpm i18n:translate",
"update:languages": "tsx scripts/update-languages.ts", "update:languages": "tsx scripts/update-languages.ts",
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts", "update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
"test": "vitest run --silent", "test": "vitest run --silent",
"test:main": "vitest run --project main", "test:main": "vitest run --project main",
"test:renderer": "vitest run --project renderer", "test:renderer": "vitest run --project renderer",
"test:aicore": "vitest run --project aiCore", "test:aicore": "vitest run --project aiCore",
"test:update": "yarn test:renderer --update", "test:update": "pnpm test:renderer --update",
"test:coverage": "vitest run --coverage --silent", "test:coverage": "vitest run --coverage --silent",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "yarn playwright test", "test:e2e": "pnpm playwright test",
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache", "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
"test:scripts": "vitest scripts", "test:scripts": "vitest scripts",
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n && yarn format:check", "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && pnpm typecheck && pnpm i18n:check && pnpm format:check",
"format": "biome format --write && biome lint --write", "format": "biome format --write && biome lint --write",
"format:check": "biome format && biome lint", "format:check": "biome format && biome lint",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
"claude": "dotenv -e .env -- claude", "claude": "dotenv -e .env -- claude",
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --preid alpha --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public", "release:aicore:alpha": "pnpm --filter @cherrystudio/ai-core version prerelease --preid alpha && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag alpha --access public",
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --preid beta --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public", "release:aicore:beta": "pnpm --filter @cherrystudio/ai-core version prerelease --preid beta && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag beta --access public",
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --access public", "release:aicore": "pnpm --filter @cherrystudio/ai-core version patch && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --access public",
"release:ai-sdk-provider": "yarn workspace @cherrystudio/ai-sdk-provider version patch --immediate && yarn workspace @cherrystudio/ai-sdk-provider build && yarn workspace @cherrystudio/ai-sdk-provider npm publish --access public" "release:ai-sdk-provider": "pnpm --filter @cherrystudio/ai-sdk-provider version patch && pnpm --filter @cherrystudio/ai-sdk-provider build && pnpm --filter @cherrystudio/ai-sdk-provider publish --access public"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.53#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch", "@anthropic-ai/claude-agent-sdk": "0.1.62",
"@libsql/client": "0.14.0", "@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7", "@napi-rs/system-ocr": "1.0.2",
"@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", "@paymoapp/electron-shutdown-handler": "1.1.2",
"@paymoapp/electron-shutdown-handler": "^1.1.2", "express": "5.1.0",
"@strongtz/win32-arm64-msvc": "^0.4.7", "font-list": "2.0.0",
"emoji-picker-element-data": "^1", "graceful-fs": "4.2.11",
"express": "^5.1.0", "gray-matter": "4.0.3",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"node-stream-zip": "^1.15.0", "node-stream-zip": "1.15.0",
"officeparser": "^4.2.0", "officeparser": "4.2.0",
"os-proxy-config": "^1.1.2", "os-proxy-config": "1.1.2",
"qrcode.react": "^4.2.0", "selection-hook": "1.0.12",
"selection-hook": "^1.0.12", "sharp": "0.34.3",
"sharp": "^0.34.3", "swagger-jsdoc": "6.2.8",
"socket.io": "^4.8.1", "swagger-ui-express": "5.0.1",
"swagger-jsdoc": "^6.2.8", "tesseract.js": "6.0.1",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0" "turndown": "7.2.0"
}, },
"devDependencies": { "devDependencies": {
"js-yaml": "4.1.0",
"bonjour-service": "1.3.0",
"emoji-picker-element-data": "1",
"@agentic/exa": "^7.3.3", "@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3", "@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3", "@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.61", "@ai-sdk/amazon-bedrock": "^3.0.61",
"@ai-sdk/anthropic": "^2.0.49", "@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/azure": "2.0.87",
"@ai-sdk/cerebras": "^1.0.31", "@ai-sdk/cerebras": "^1.0.31",
"@ai-sdk/gateway": "^2.0.15", "@ai-sdk/gateway": "^2.0.15",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.43#~/.yarn/patches/@ai-sdk-google-npm-2.0.43-689ed559b3.patch", "@ai-sdk/google": "2.0.49",
"@ai-sdk/google-vertex": "^3.0.79", "@ai-sdk/google-vertex": "^3.0.94",
"@ai-sdk/huggingface": "^0.0.10", "@ai-sdk/huggingface": "^0.0.10",
"@ai-sdk/mistral": "^2.0.24", "@ai-sdk/mistral": "^2.0.24",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.72#~/.yarn/patches/@ai-sdk-openai-npm-2.0.72-234e68da87.patch", "@ai-sdk/openai": "2.0.85",
"@ai-sdk/perplexity": "^2.0.20", "@ai-sdk/perplexity": "^2.0.20",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.17",
"@ai-sdk/test-server": "^0.0.1", "@ai-sdk/test-server": "^0.0.1",
"@ai-sdk/xai": "2.0.36",
"@ant-design/cssinjs": "1.23.0",
"@ant-design/icons": "5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3", "@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0", "@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", "@anthropic-ai/vertex-sdk": "0.11.4",
"@aws-sdk/client-bedrock": "^3.910.0", "@aws-sdk/client-bedrock": "^3.910.0",
"@aws-sdk/client-bedrock-runtime": "^3.910.0", "@aws-sdk/client-bedrock-runtime": "^3.910.0",
"@aws-sdk/client-s3": "^3.910.0", "@aws-sdk/client-s3": "^3.910.0",
"@biomejs/biome": "2.2.4", "@biomejs/biome": "2.2.4",
"@cherrystudio/ai-core": "workspace:^1.0.9", "@cherrystudio/ai-core": "workspace:^1.0.9",
"@cherrystudio/embedjs": "^0.1.31", "@cherrystudio/embedjs": "0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31", "@cherrystudio/embedjs-interfaces": "0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31", "@cherrystudio/embedjs-libsql": "0.1.31",
"@cherrystudio/embedjs-loader-image": "^0.1.31", "@cherrystudio/embedjs-loader-csv": "0.1.31",
"@cherrystudio/embedjs-loader-markdown": "^0.1.31", "@cherrystudio/embedjs-loader-image": "0.1.31",
"@cherrystudio/embedjs-loader-msoffice": "^0.1.31", "@cherrystudio/embedjs-loader-markdown": "0.1.31",
"@cherrystudio/embedjs-loader-pdf": "^0.1.31", "@cherrystudio/embedjs-loader-msoffice": "0.1.31",
"@cherrystudio/embedjs-loader-sitemap": "^0.1.31", "@cherrystudio/embedjs-loader-pdf": "0.1.31",
"@cherrystudio/embedjs-loader-web": "^0.1.31", "@cherrystudio/embedjs-loader-sitemap": "0.1.31",
"@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-loader-web": "0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-loader-xml": "0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31", "@cherrystudio/embedjs-ollama": "0.1.31",
"@cherrystudio/embedjs-openai": "0.1.31",
"@cherrystudio/embedjs-utils": "0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^", "@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/openai": "^6.9.0", "@cherrystudio/openai": "6.15.0",
"@codemirror/lang-json": "6.0.1",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -155,18 +149,21 @@
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1", "@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch", "@floating-ui/dom": "1.7.3",
"@google/genai": "1.0.1",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@kangfenmao/keyv-storage": "^0.1.0", "@kangfenmao/keyv-storage": "^0.1.3",
"@langchain/community": "^1.0.0", "@langchain/community": "^1.0.0",
"@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch", "@langchain/core": "1.0.2",
"@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", "@langchain/openai": "1.0.0",
"@langchain/textsplitters": "0.1.0",
"@mistralai/mistralai": "^1.7.5", "@mistralai/mistralai": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.17.5", "@modelcontextprotocol/sdk": "1.23.0",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15", "@notionhq/client": "^2.2.15",
"@openrouter/ai-sdk-provider": "^1.2.8", "@openrouter/ai-sdk-provider": "^1.2.8",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "2.0.1",
"@opentelemetry/core": "2.0.0", "@opentelemetry/core": "2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0",
@ -177,6 +174,7 @@
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.12.0", "@shikijs/markdown-it": "^3.12.0",
"@swc/core": "^1.15.8",
"@swc/plugin-styled-components": "^8.0.4", "@swc/plugin-styled-components": "^8.0.4",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
@ -185,21 +183,25 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@tiptap/extension-collaboration": "^3.2.0", "@tiptap/core": "3.2.0",
"@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch", "@tiptap/extension-code-block": "3.2.0",
"@tiptap/extension-drag-handle-react": "^3.2.0", "@tiptap/extension-collaboration": "3.2.0",
"@tiptap/extension-image": "^3.2.0", "@tiptap/extension-drag-handle": "3.2.0",
"@tiptap/extension-list": "^3.2.0", "@tiptap/extension-drag-handle-react": "3.2.0",
"@tiptap/extension-mathematics": "^3.2.0", "@tiptap/extension-heading": "3.2.0",
"@tiptap/extension-mention": "^3.2.0", "@tiptap/extension-image": "3.2.0",
"@tiptap/extension-node-range": "^3.2.0", "@tiptap/extension-link": "3.2.0",
"@tiptap/extension-table-of-contents": "^3.2.0", "@tiptap/extension-list": "3.2.0",
"@tiptap/extension-typography": "^3.2.0", "@tiptap/extension-mathematics": "3.2.0",
"@tiptap/extension-underline": "^3.2.0", "@tiptap/extension-mention": "3.2.0",
"@tiptap/pm": "^3.2.0", "@tiptap/extension-node-range": "3.2.0",
"@tiptap/react": "^3.2.0", "@tiptap/extension-table-of-contents": "3.2.0",
"@tiptap/starter-kit": "^3.2.0", "@tiptap/extension-typography": "3.2.0",
"@tiptap/suggestion": "^3.2.0", "@tiptap/extension-underline": "3.2.0",
"@tiptap/pm": "3.2.0",
"@tiptap/react": "3.2.0",
"@tiptap/starter-kit": "3.2.0",
"@tiptap/suggestion": "3.2.0",
"@tiptap/y-tiptap": "^3.0.0", "@tiptap/y-tiptap": "^3.0.0",
"@truto/turndown-plugin-gfm": "^1.0.2", "@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4", "@tryfabric/martian": "^1.2.4",
@ -207,16 +209,20 @@
"@types/content-type": "^1.1.9", "@types/content-type": "^1.1.9",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/diff": "^7", "@types/diff": "^7",
"@types/dotenv": "^8.2.3",
"@types/express": "^5", "@types/express": "^5",
"@types/fs-extra": "^11", "@types/fs-extra": "^11",
"@types/hast": "^3.0.4",
"@types/he": "^1", "@types/he": "^1",
"@types/html-to-text": "^9", "@types/html-to-text": "^9",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/json-schema": "7.0.15",
"@types/lodash": "^4.17.5", "@types/lodash": "^4.17.5",
"@types/markdown-it": "^14", "@types/markdown-it": "^14",
"@types/md5": "^2.3.5", "@types/md5": "^2.3.5",
"@types/mdast": "4.0.4",
"@types/mime-types": "^3", "@types/mime-types": "^3",
"@types/node": "^22.17.1", "@types/node": "22.17.2",
"@types/pako": "^1.0.2", "@types/pako": "^1.0.2",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -227,9 +233,10 @@
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
"@types/unist": "3.0.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/word-extractor": "^1", "@types/word-extractor": "^1",
"@typescript/native-preview": "latest", "@typescript/native-preview": "7.0.0-dev.20250915.1",
"@uiw/codemirror-extensions-langs": "^4.25.1", "@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1", "@uiw/codemirror-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1", "@uiw/react-codemirror": "^4.25.1",
@ -241,12 +248,15 @@
"@viz-js/lang-dot": "^1.0.5", "@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0", "@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.4.4",
"adm-zip": "0.4.16",
"ai": "^5.0.98", "ai": "^5.0.98",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch", "antd": "5.27.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"axios": "^1.7.3", "axios": "^1.7.3",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"builder-util-runtime": "9.5.0",
"chalk": "4.1.2",
"chardet": "^2.1.0", "chardet": "^2.1.0",
"check-disk-space": "3.4.0", "check-disk-space": "3.4.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
@ -255,8 +265,10 @@
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14", "code-inspector-plugin": "^0.20.14",
"codemirror-lang-mermaid": "0.5.0",
"color": "^5.0.0", "color": "^5.0.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cors": "2.8.5",
"country-flag-emoji-polyfill": "0.1.8", "country-flag-emoji-polyfill": "0.1.8",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dexie": "^4.0.8", "dexie": "^4.0.8",
@ -264,6 +276,7 @@
"diff": "^8.0.2", "diff": "^8.0.2",
"docx": "^9.0.2", "docx": "^9.0.2",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"dotenv": "16.6.1",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
@ -272,12 +285,12 @@
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1", "electron-reload": "^2.0.0-alpha.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch", "electron-updater": "6.7.0",
"electron-vite": "4.0.1", "electron-vite": "5.0.0",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"emittery": "^1.0.3", "emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1", "emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "epub": "1.3.0",
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0", "eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0", "eslint-plugin-oxlint": "^1.15.0",
@ -288,6 +301,7 @@
"fast-diff": "^1.3.0", "fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0", "fast-xml-parser": "^5.2.0",
"fetch-socks": "1.3.2", "fetch-socks": "1.3.2",
"form-data": "4.0.4",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"franc-min": "^6.2.0", "franc-min": "^6.2.0",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
@ -304,6 +318,10 @@
"isbinaryfile": "5.0.4", "isbinaryfile": "5.0.4",
"jaison": "^2.0.2", "jaison": "^2.0.2",
"jest-styled-components": "^7.2.0", "jest-styled-components": "^7.2.0",
"js-base64": "3.7.7",
"json-schema": "0.4.0",
"katex": "0.16.22",
"ky": "1.8.1",
"linguist-languages": "^8.1.0", "linguist-languages": "^8.1.0",
"lint-staged": "^15.5.0", "lint-staged": "^15.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@ -311,18 +329,27 @@
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"macos-release": "^3.4.0", "macos-release": "^3.4.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"md5": "2.3.0",
"mermaid": "^11.10.1", "mermaid": "^11.10.1",
"mime": "^4.0.4", "mime": "^4.0.4",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"motion": "^12.10.5", "motion": "^12.10.5",
"nanoid": "3.3.11",
"notion-helper": "^1.3.22", "notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0", "npx-scope-finder": "^1.2.0",
"ollama-ai-provider-v2": "1.5.5",
"open": "^8.4.2",
"oxlint": "^1.22.0", "oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0", "oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0", "p-queue": "^8.1.0",
"pako": "1.0.11",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"prosemirror-model": "1.25.2",
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
"rc-input": "1.8.0",
"rc-select": "14.16.6",
"rc-virtual-list": "3.18.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
@ -349,8 +376,11 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-github-blockquote-alert": "^2.0.0", "remark-github-blockquote-alert": "^2.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "11.0.0",
"remark-stringify": "11.0.0",
"remove-markdown": "^0.6.2", "remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"semver": "7.7.1",
"shiki": "^3.12.0", "shiki": "^3.12.0",
"strict-url-sanitise": "^0.0.1", "strict-url-sanitise": "^0.0.1",
"string-width": "^7.2.0", "string-width": "^7.2.0",
@ -365,11 +395,12 @@
"tsx": "^4.20.3", "tsx": "^4.20.3",
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",
"tw-animate-css": "^1.3.8", "tw-animate-css": "^1.3.8",
"typescript": "~5.8.2", "typescript": "~5.8.3",
"undici": "6.21.2", "undici": "6.21.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "5.0.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vite": "npm:rolldown-vite@7.1.5", "vite": "npm:rolldown-vite@7.3.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"webdav": "^5.8.0", "webdav": "^5.8.0",
"winston": "^3.17.0", "winston": "^3.17.0",
@ -382,41 +413,66 @@
"zipread": "^1.3.3", "zipread": "^1.3.3",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
"resolutions": { "pnpm": {
"overrides": {
"@smithy/types": "4.7.1", "@smithy/types": "4.7.1",
"@codemirror/language": "6.11.3", "@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5", "@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1", "@codemirror/view": "6.38.1",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.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", "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.24.0", "node-abi": "4.24.0",
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.0", "openai": "npm:@cherrystudio/openai@6.15.0",
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.5.0",
"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", "tar-fs": "^2.1.4",
"undici": "6.21.2", "undici": "6.21.2",
"vite": "npm:rolldown-vite@7.1.5", "vite": "npm:rolldown-vite@7.3.0",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3", "@img/sharp-linux-arm": "0.34.3",
"@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3", "@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3", "@img/sharp-win32-x64": "0.34.3",
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0", "@langchain/core": "1.0.2",
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", "@ai-sdk/openai-compatible@1.0.27": "1.0.28"
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.72#~/.yarn/patches/@ai-sdk-openai-npm-2.0.72-234e68da87.patch",
"@ai-sdk/google@npm:^2.0.40": "patch:@ai-sdk/google@npm%3A2.0.40#~/.yarn/patches/@ai-sdk-google-npm-2.0.40-47e0eeee83.patch",
"@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch"
}, },
"packageManager": "yarn@4.9.1", "patchedDependencies": {
"@anthropic-ai/claude-agent-sdk@0.1.62": "patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch",
"@napi-rs/system-ocr@1.0.2": "patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"tesseract.js@6.0.1": "patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@2.0.49": "patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/openai@2.0.85": "patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
"@anthropic-ai/vertex-sdk@0.11.4": "patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
"@google/genai@1.0.1": "patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@langchain/core@1.0.2": "patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
"@langchain/openai@1.0.0": "patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@tiptap/extension-drag-handle@3.2.0": "patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
"antd@5.27.0": "patches/antd-npm-5.27.0-aa91c36546.patch",
"electron-updater@6.7.0": "patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
"epub@1.3.0": "patches/epub-npm-1.3.0-8325494ffe.patch",
"ollama-ai-provider-v2@1.5.5": "patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch",
"atomically@1.7.0": "patches/atomically-npm-1.7.0-e742e5293b.patch",
"file-stream-rotator@0.6.1": "patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@0.4.7": "patches/libsql-npm-0.4.7-444e260fb1.patch",
"pdf-parse@1.1.1": "patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
},
"onlyBuiltDependencies": [
"@kangfenmao/keyv-storage",
"@paymoapp/electron-shutdown-handler",
"@scarf/scarf",
"@swc/core",
"electron",
"electron-winstaller",
"esbuild",
"msw",
"protobufjs",
"registry-js",
"selection-hook",
"sharp",
"tesseract.js",
"zipfile"
]
},
"packageManager": "pnpm@10.27.0",
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [ "*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"biome format --write --no-errors-on-unmatched", "biome format --write --no-errors-on-unmatched",

View File

@ -8,7 +8,7 @@ It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Ant
```bash ```bash
npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
# or # or
yarn add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai pnpm add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
``` ```
> **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed. > **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed.

View File

@ -41,6 +41,7 @@
"ai": "^5.0.26" "ai": "^5.0.26"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai-compatible": "1.0.28",
"@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17" "@ai-sdk/provider-utils": "^3.0.17"
}, },

View File

@ -2,7 +2,6 @@ import { AnthropicMessagesLanguageModel } from '@ai-sdk/anthropic/internal'
import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal' import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal'
import type { OpenAIProviderSettings } from '@ai-sdk/openai' import type { OpenAIProviderSettings } from '@ai-sdk/openai'
import { import {
OpenAIChatLanguageModel,
OpenAICompletionLanguageModel, OpenAICompletionLanguageModel,
OpenAIEmbeddingModel, OpenAIEmbeddingModel,
OpenAIImageModel, OpenAIImageModel,
@ -10,6 +9,7 @@ import {
OpenAISpeechModel, OpenAISpeechModel,
OpenAITranscriptionModel OpenAITranscriptionModel
} from '@ai-sdk/openai/internal' } from '@ai-sdk/openai/internal'
import { OpenAICompatibleChatLanguageModel } from '@ai-sdk/openai-compatible'
import { import {
type EmbeddingModelV2, type EmbeddingModelV2,
type ImageModelV2, type ImageModelV2,
@ -69,6 +69,7 @@ export interface CherryInProviderSettings {
headers?: HeadersInput headers?: HeadersInput
/** /**
* Optional endpoint type to distinguish different endpoint behaviors. * Optional endpoint type to distinguish different endpoint behaviors.
* "image-generation" is also openai endpoint, but specifically for image generation.
*/ */
endpointType?: 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank' endpointType?: 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank'
} }
@ -117,7 +118,7 @@ const createCustomFetch = (originalFetch?: any) => {
return originalFetch ? originalFetch(url, options) : fetch(url, options) return originalFetch ? originalFetch(url, options) : fetch(url, options)
} }
} }
class CherryInOpenAIChatLanguageModel extends OpenAIChatLanguageModel { class CherryInOpenAIChatLanguageModel extends OpenAICompatibleChatLanguageModel {
constructor(modelId: string, settings: any) { constructor(modelId: string, settings: any) {
super(modelId, { super(modelId, {
...settings, ...settings,

View File

@ -40,9 +40,9 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^2.0.49", "@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/azure": "^2.0.74", "@ai-sdk/azure": "^2.0.87",
"@ai-sdk/deepseek": "^1.0.29", "@ai-sdk/deepseek": "^1.0.31",
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch", "@ai-sdk/openai-compatible": "1.0.28",
"@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17", "@ai-sdk/provider-utils": "^3.0.17",
"@ai-sdk/xai": "^2.0.36", "@ai-sdk/xai": "^2.0.36",

View File

@ -62,7 +62,7 @@ export class StreamEventManager {
const recursiveResult = await context.recursiveCall(recursiveParams) const recursiveResult = await context.recursiveCall(recursiveParams)
if (recursiveResult && recursiveResult.fullStream) { if (recursiveResult && recursiveResult.fullStream) {
await this.pipeRecursiveStream(controller, recursiveResult.fullStream, context) await this.pipeRecursiveStream(controller, recursiveResult.fullStream)
} else { } else {
console.warn('[MCP Prompt] No fullstream found in recursive result:', recursiveResult) console.warn('[MCP Prompt] No fullstream found in recursive result:', recursiveResult)
} }
@ -74,11 +74,7 @@ export class StreamEventManager {
/** /**
* *
*/ */
private async pipeRecursiveStream( private async pipeRecursiveStream(controller: StreamController, recursiveStream: ReadableStream): Promise<void> {
controller: StreamController,
recursiveStream: ReadableStream,
context?: AiRequestContext
): Promise<void> {
const reader = recursiveStream.getReader() const reader = recursiveStream.getReader()
try { try {
while (true) { while (true) {
@ -86,18 +82,14 @@ export class StreamEventManager {
if (done) { if (done) {
break break
} }
if (value.type === 'finish') { if (value.type === 'start') {
// 迭代的流不发finish但需要累加其 usage continue
if (value.usage && context?.accumulatedUsage) {
this.accumulateUsage(context.accumulatedUsage, value.usage)
} }
if (value.type === 'finish') {
break break
} }
// 对于 finish-step 类型,累加其 usage
if (value.type === 'finish-step' && value.usage && context?.accumulatedUsage) {
this.accumulateUsage(context.accumulatedUsage, value.usage)
}
// 将递归流的数据传递到当前流
controller.enqueue(value) controller.enqueue(value)
} }
} finally { } finally {
@ -135,10 +127,8 @@ export class StreamEventManager {
// 构建新的对话消息 // 构建新的对话消息
const newMessages: ModelMessage[] = [ const newMessages: ModelMessage[] = [
...(context.originalParams.messages || []), ...(context.originalParams.messages || []),
{ // 只有当 textBuffer 有内容时才添加 assistant 消息,避免空消息导致 API 错误
role: 'assistant', ...(textBuffer ? [{ role: 'assistant' as const, content: textBuffer }] : []),
content: textBuffer
},
{ {
role: 'user', role: 'user',
content: toolResultsText content: toolResultsText
@ -161,7 +151,7 @@ export class StreamEventManager {
/** /**
* usage * usage
*/ */
private accumulateUsage(target: any, source: any): void { accumulateUsage(target: any, source: any): void {
if (!target || !source) return if (!target || !source) return
// 累加各种 token 类型 // 累加各种 token 类型

View File

@ -22,10 +22,10 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
} }
/** /**
* Cherry Studio *
*/ */
const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \\ export const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \
You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
## Tool Use Formatting ## Tool Use Formatting
@ -74,10 +74,13 @@ Here are the rules you should always follow to solve your task:
4. Never re-do a tool call that you previously did with the exact same parameters. 4. Never re-do a tool call that you previously did with the exact same parameters.
5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format. 5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format.
## Response rules
Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used.
# User Instructions # User Instructions
{{ USER_SYSTEM_PROMPT }} {{ USER_SYSTEM_PROMPT }}
`
Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.`
/** /**
* 使 Cherry Studio * 使 Cherry Studio
@ -151,7 +154,8 @@ User: <tool_use_result>
<name>search</name> <name>search</name>
<result>26 million (2019)</result> <result>26 million (2019)</result>
</tool_use_result> </tool_use_result>
Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.`
A: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.`
/** /**
* Cherry Studio * Cherry Studio
@ -411,7 +415,10 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
} }
} }
// 如果没有执行工具调用直接传递原始finish-step事件 // 如果没有执行工具调用,累加 usage 后透传 finish-step 事件
if (chunk.usage && context.accumulatedUsage) {
streamEventManager.accumulateUsage(context.accumulatedUsage, chunk.usage)
}
controller.enqueue(chunk) controller.enqueue(chunk)
// 清理状态 // 清理状态

View File

@ -6,6 +6,7 @@ import { type Tool } from 'ai'
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
import type { ProviderOptionsMap } from '../../../options/types' import type { ProviderOptionsMap } from '../../../options/types'
import type { AiRequestContext } from '../../'
import type { OpenRouterSearchConfig } from './openrouter' import type { OpenRouterSearchConfig } from './openrouter'
/** /**
@ -35,7 +36,6 @@ export interface WebSearchPluginConfig {
anthropic?: AnthropicSearchConfig anthropic?: AnthropicSearchConfig
xai?: ProviderOptionsMap['xai']['searchParameters'] xai?: ProviderOptionsMap['xai']['searchParameters']
google?: GoogleSearchConfig google?: GoogleSearchConfig
'google-vertex'?: GoogleSearchConfig
openrouter?: OpenRouterSearchConfig openrouter?: OpenRouterSearchConfig
} }
@ -44,7 +44,6 @@ export interface WebSearchPluginConfig {
*/ */
export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
google: {}, google: {},
'google-vertex': {},
openai: {}, openai: {},
'openai-chat': {}, 'openai-chat': {},
xai: { xai: {
@ -97,55 +96,84 @@ export type WebSearchToolInputSchema = {
'openai-chat': InferToolInput<OpenAIChatWebSearchTool> 'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
} }
export const switchWebSearchTool = (providerId: string, config: WebSearchPluginConfig, params: any) => { /**
switch (providerId) { * Helper function to ensure params.tools object exists
case 'openai': { */
if (config.openai) { const ensureToolsObject = (params: any) => {
if (!params.tools) params.tools = {} if (!params.tools) params.tools = {}
params.tools.web_search = openai.tools.webSearch(config.openai)
}
break
}
case 'openai-chat': {
if (config['openai-chat']) {
if (!params.tools) params.tools = {}
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
}
break
} }
case 'anthropic': { /**
if (config.anthropic) { * Helper function to apply tool-based web search configuration
if (!params.tools) params.tools = {} */
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic) const applyToolBasedSearch = (params: any, toolName: string, toolInstance: any) => {
} ensureToolsObject(params)
break params.tools[toolName] = toolInstance
} }
case 'google': { /**
// case 'google-vertex': * Helper function to apply provider options-based web search configuration
if (!params.tools) params.tools = {} */
params.tools.web_search = google.tools.googleSearch(config.google || {}) const applyProviderOptionsSearch = (params: any, searchOptions: any) => {
break
}
case 'xai': {
if (config.xai) {
const searchOptions = createXaiOptions({
searchParameters: { ...config.xai, mode: 'on' }
})
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
} }
break
export const switchWebSearchTool = (config: WebSearchPluginConfig, params: any, context?: AiRequestContext) => {
const providerId = context?.providerId
// Provider-specific configuration map
const providerHandlers: Record<string, () => void> = {
openai: () => {
const cfg = config.openai ?? DEFAULT_WEB_SEARCH_CONFIG.openai
applyToolBasedSearch(params, 'web_search', openai.tools.webSearch(cfg))
},
'openai-chat': () => {
const cfg = (config['openai-chat'] ?? DEFAULT_WEB_SEARCH_CONFIG['openai-chat']) as OpenAISearchPreviewConfig
applyToolBasedSearch(params, 'web_search_preview', openai.tools.webSearchPreview(cfg))
},
anthropic: () => {
const cfg = config.anthropic ?? DEFAULT_WEB_SEARCH_CONFIG.anthropic
applyToolBasedSearch(params, 'web_search', anthropic.tools.webSearch_20250305(cfg))
},
google: () => {
const cfg = (config.google ?? DEFAULT_WEB_SEARCH_CONFIG.google) as GoogleSearchConfig
applyToolBasedSearch(params, 'web_search', google.tools.googleSearch(cfg))
},
xai: () => {
const cfg = config.xai ?? DEFAULT_WEB_SEARCH_CONFIG.xai
const searchOptions = createXaiOptions({ searchParameters: { ...cfg, mode: 'on' } })
applyProviderOptionsSearch(params, searchOptions)
},
openrouter: () => {
const cfg = (config.openrouter ?? DEFAULT_WEB_SEARCH_CONFIG.openrouter) as OpenRouterSearchConfig
const searchOptions = createOpenRouterOptions(cfg)
applyProviderOptionsSearch(params, searchOptions)
}
}
// Try provider-specific handler first
const handler = providerId && providerHandlers[providerId]
if (handler) {
handler()
return params
}
// Fallback: apply based on available config keys (prioritized order)
const fallbackOrder: Array<keyof WebSearchPluginConfig> = [
'openai',
'openai-chat',
'anthropic',
'google',
'xai',
'openrouter'
]
for (const key of fallbackOrder) {
if (config[key]) {
providerHandlers[key]()
break
}
} }
case 'openrouter': {
if (config.openrouter) {
const searchOptions = createOpenRouterOptions(config.openrouter)
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
}
break
}
}
return params return params
} }

View File

@ -4,7 +4,6 @@
*/ */
import { definePlugin } from '../../' import { definePlugin } from '../../'
import type { AiRequestContext } from '../../types'
import type { WebSearchPluginConfig } from './helper' import type { WebSearchPluginConfig } from './helper'
import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper' import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper'
@ -18,15 +17,22 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
name: 'webSearch', name: 'webSearch',
enforce: 'pre', enforce: 'pre',
transformParams: async (params: any, context: AiRequestContext) => { transformParams: async (params: any, context) => {
const { providerId } = context let { providerId } = context
switchWebSearchTool(providerId, config, params)
// For cherryin providers, extract the actual provider from the model's provider string
// Expected format: "cherryin.{actualProvider}" (e.g., "cherryin.gemini")
if (providerId === 'cherryin' || providerId === 'cherryin-chat') { if (providerId === 'cherryin' || providerId === 'cherryin-chat') {
// cherryin.gemini const provider = params.model?.provider
const _providerId = params.model.provider.split('.')[1] if (provider && typeof provider === 'string' && provider.includes('.')) {
switchWebSearchTool(_providerId, config, params) const extractedProviderId = provider.split('.')[1]
if (extractedProviderId) {
providerId = extractedProviderId
} }
}
}
switchWebSearchTool(config, params, { ...context, providerId })
return params return params
} }
}) })

View File

@ -68,8 +68,8 @@
], ],
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.4", "@biomejs/biome": "2.2.4",
"@tiptap/core": "^3.2.0", "@tiptap/core": "3.2.0",
"@tiptap/pm": "^3.2.0", "@tiptap/pm": "3.2.0",
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
@ -89,5 +89,5 @@
"build": "tsdown", "build": "tsdown",
"lint": "biome format ./src/ --write && eslint --fix ./src/" "lint": "biome format ./src/ --write && eslint --fix ./src/"
}, },
"packageManager": "yarn@4.9.1" "packageManager": "pnpm@10.27.0"
} }

View File

@ -55,6 +55,8 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external', Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled', Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey', Webview_SearchHotkey = 'webview:search-hotkey',
Webview_PrintToPDF = 'webview:print-to-pdf',
Webview_SaveAsHTML = 'webview:save-as-html',
// Open // Open
Open_Path = 'open:path', Open_Path = 'open:path',
@ -90,6 +92,8 @@ export enum IpcChannel {
Mcp_AbortTool = 'mcp:abort-tool', Mcp_AbortTool = 'mcp:abort-tool',
Mcp_GetServerVersion = 'mcp:get-server-version', Mcp_GetServerVersion = 'mcp:get-server-version',
Mcp_Progress = 'mcp:progress', Mcp_Progress = 'mcp:progress',
Mcp_GetServerLogs = 'mcp:get-server-logs',
Mcp_ServerLog = 'mcp:server-log',
// Python // Python
Python_Execute = 'python:execute', Python_Execute = 'python:execute',
@ -196,6 +200,9 @@ export enum IpcChannel {
File_ValidateNotesDirectory = 'file:validateNotesDirectory', File_ValidateNotesDirectory = 'file:validateNotesDirectory',
File_StartWatcher = 'file:startWatcher', File_StartWatcher = 'file:startWatcher',
File_StopWatcher = 'file:stopWatcher', File_StopWatcher = 'file:stopWatcher',
File_PauseWatcher = 'file:pauseWatcher',
File_ResumeWatcher = 'file:resumeWatcher',
File_BatchUploadMarkdown = 'file:batchUploadMarkdown',
File_ShowInFolder = 'file:showInFolder', File_ShowInFolder = 'file:showInFolder',
// file service // file service
@ -226,6 +233,8 @@ export enum IpcChannel {
Backup_ListS3Files = 'backup:listS3Files', Backup_ListS3Files = 'backup:listS3Files',
Backup_DeleteS3File = 'backup:deleteS3File', Backup_DeleteS3File = 'backup:deleteS3File',
Backup_CheckS3Connection = 'backup:checkS3Connection', Backup_CheckS3Connection = 'backup:checkS3Connection',
Backup_CreateLanTransferBackup = 'backup:createLanTransferBackup',
Backup_DeleteTempBackup = 'backup:deleteTempBackup',
// zip // zip
Zip_Compress = 'zip:compress', Zip_Compress = 'zip:compress',
@ -236,6 +245,9 @@ export enum IpcChannel {
System_GetHostname = 'system:getHostname', System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName', System_GetCpuName = 'system:getCpuName',
System_CheckGitBash = 'system:checkGitBash', System_CheckGitBash = 'system:checkGitBash',
System_GetGitBashPath = 'system:getGitBashPath',
System_GetGitBashPathInfo = 'system:getGitBashPathInfo',
System_SetGitBashPath = 'system:setGitBashPath',
// DevTools // DevTools
System_ToggleDevTools = 'system:toggleDevTools', System_ToggleDevTools = 'system:toggleDevTools',
@ -290,6 +302,8 @@ export enum IpcChannel {
Selection_ActionWindowClose = 'selection:action-window-close', Selection_ActionWindowClose = 'selection:action-window-close',
Selection_ActionWindowMinimize = 'selection:action-window-minimize', Selection_ActionWindowMinimize = 'selection:action-window-minimize',
Selection_ActionWindowPin = 'selection:action-window-pin', Selection_ActionWindowPin = 'selection:action-window-pin',
// [Windows only] Electron bug workaround - can be removed once https://github.com/electron/electron/issues/48554 is fixed
Selection_ActionWindowResize = 'selection:action-window-resize',
Selection_ProcessAction = 'selection:process-action', Selection_ProcessAction = 'selection:process-action',
Selection_UpdateActionData = 'selection:update-action-data', Selection_UpdateActionData = 'selection:update-action-data',
@ -304,6 +318,7 @@ export enum IpcChannel {
Memory_DeleteUser = 'memory:delete-user', Memory_DeleteUser = 'memory:delete-user',
Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user', Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user',
Memory_GetUsersList = 'memory:get-users-list', Memory_GetUsersList = 'memory:get-users-list',
Memory_MigrateMemoryDb = 'memory:migrate-memory-db',
// TRACE // TRACE
TRACE_SAVE_DATA = 'trace:saveData', TRACE_SAVE_DATA = 'trace:saveData',
@ -349,6 +364,7 @@ export enum IpcChannel {
OCR_ListProviders = 'ocr:list-providers', OCR_ListProviders = 'ocr:list-providers',
// OVMS // OVMS
Ovms_IsSupported = 'ovms:is-supported',
Ovms_AddModel = 'ovms:add-model', Ovms_AddModel = 'ovms:add-model',
Ovms_StopAddModel = 'ovms:stop-addmodel', Ovms_StopAddModel = 'ovms:stop-addmodel',
Ovms_GetModels = 'ovms:get-models', Ovms_GetModels = 'ovms:get-models',
@ -369,10 +385,14 @@ export enum IpcChannel {
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content', ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content',
// WebSocket // Local Transfer
WebSocket_Start = 'webSocket:start', LocalTransfer_ListServices = 'local-transfer:list',
WebSocket_Stop = 'webSocket:stop', LocalTransfer_StartScan = 'local-transfer:start-scan',
WebSocket_Status = 'webSocket:status', LocalTransfer_StopScan = 'local-transfer:stop-scan',
WebSocket_SendFile = 'webSocket:send-file', LocalTransfer_ServicesUpdated = 'local-transfer:services-updated',
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' LocalTransfer_Connect = 'local-transfer:connect',
LocalTransfer_Disconnect = 'local-transfer:disconnect',
LocalTransfer_ClientEvent = 'local-transfer:client-event',
LocalTransfer_SendFile = 'local-transfer:send-file',
LocalTransfer_CancelTransfer = 'local-transfer:cancel-transfer'
} }

View File

@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { isBase64ImageDataUrl, isDataUrl, parseDataUrl } from '../utils'
describe('parseDataUrl', () => {
it('parses a standard base64 image data URL', () => {
const result = parseDataUrl('data:image/png;base64,iVBORw0KGgo=')
expect(result).toEqual({
mediaType: 'image/png',
isBase64: true,
data: 'iVBORw0KGgo='
})
})
it('parses a base64 data URL with additional parameters', () => {
const result = parseDataUrl('data:image/jpeg;name=foo;base64,/9j/4AAQ')
expect(result).toEqual({
mediaType: 'image/jpeg',
isBase64: true,
data: '/9j/4AAQ'
})
})
it('parses a plain text data URL (non-base64)', () => {
const result = parseDataUrl('data:text/plain,Hello%20World')
expect(result).toEqual({
mediaType: 'text/plain',
isBase64: false,
data: 'Hello%20World'
})
})
it('parses a data URL with empty media type', () => {
const result = parseDataUrl('data:;base64,SGVsbG8=')
expect(result).toEqual({
mediaType: undefined,
isBase64: true,
data: 'SGVsbG8='
})
})
it('returns null for non-data URLs', () => {
const result = parseDataUrl('https://example.com/image.png')
expect(result).toBeNull()
})
it('returns null for malformed data URL without comma', () => {
const result = parseDataUrl('data:image/png;base64')
expect(result).toBeNull()
})
it('handles empty string', () => {
const result = parseDataUrl('')
expect(result).toBeNull()
})
it('handles large base64 data without performance issues', () => {
// Simulate a 4K image base64 string (about 1MB)
const largeData = 'A'.repeat(1024 * 1024)
const dataUrl = `data:image/png;base64,${largeData}`
const start = performance.now()
const result = parseDataUrl(dataUrl)
const duration = performance.now() - start
expect(result).not.toBeNull()
expect(result?.mediaType).toBe('image/png')
expect(result?.isBase64).toBe(true)
expect(result?.data).toBe(largeData)
// Should complete in under 10ms (string operations are fast)
expect(duration).toBeLessThan(10)
})
it('parses SVG data URL', () => {
const result = parseDataUrl('data:image/svg+xml;base64,PHN2Zz4=')
expect(result).toEqual({
mediaType: 'image/svg+xml',
isBase64: true,
data: 'PHN2Zz4='
})
})
it('parses JSON data URL', () => {
const result = parseDataUrl('data:application/json,{"key":"value"}')
expect(result).toEqual({
mediaType: 'application/json',
isBase64: false,
data: '{"key":"value"}'
})
})
})
describe('isDataUrl', () => {
it('returns true for valid data URLs', () => {
expect(isDataUrl('data:image/png;base64,ABC')).toBe(true)
expect(isDataUrl('data:text/plain,hello')).toBe(true)
expect(isDataUrl('data:,simple')).toBe(true)
})
it('returns false for non-data URLs', () => {
expect(isDataUrl('https://example.com')).toBe(false)
expect(isDataUrl('file:///path/to/file')).toBe(false)
expect(isDataUrl('')).toBe(false)
})
it('returns false for malformed data URLs', () => {
expect(isDataUrl('data:')).toBe(false)
expect(isDataUrl('data:image/png')).toBe(false)
})
})
describe('isBase64ImageDataUrl', () => {
it('returns true for base64 image data URLs', () => {
expect(isBase64ImageDataUrl('data:image/png;base64,ABC')).toBe(true)
expect(isBase64ImageDataUrl('data:image/jpeg;base64,/9j/')).toBe(true)
expect(isBase64ImageDataUrl('data:image/gif;base64,R0lG')).toBe(true)
expect(isBase64ImageDataUrl('data:image/webp;base64,UklG')).toBe(true)
})
it('returns false for non-base64 image data URLs', () => {
expect(isBase64ImageDataUrl('data:image/svg+xml,<svg></svg>')).toBe(false)
})
it('returns false for non-image data URLs', () => {
expect(isBase64ImageDataUrl('data:text/plain;base64,SGVsbG8=')).toBe(false)
expect(isBase64ImageDataUrl('data:application/json,{}')).toBe(false)
})
it('returns false for regular URLs', () => {
expect(isBase64ImageDataUrl('https://example.com/image.png')).toBe(false)
expect(isBase64ImageDataUrl('file:///image.png')).toBe(false)
})
it('returns false for malformed data URLs', () => {
expect(isBase64ImageDataUrl('data:image/png')).toBe(false)
expect(isBase64ImageDataUrl('')).toBe(false)
})
})

View File

@ -88,16 +88,11 @@ export function getSdkClient(
} }
}) })
} }
let baseURL = const baseURL =
provider.type === 'anthropic' provider.type === 'anthropic'
? provider.apiHost ? provider.apiHost
: (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost : (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost
// Anthropic SDK automatically appends /v1 to all endpoints (like /v1/messages, /v1/models)
// We need to strip api version from baseURL to avoid duplication (e.g., /v3/v1/models)
// formatProviderApiHost adds /v1 for AI SDK compatibility, but Anthropic SDK needs it removed
baseURL = baseURL.replace(/\/v\d+(?:alpha|beta)?(?=\/|$)/i, '')
logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id }) logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id })
if (provider.id === 'aihubmix') { if (provider.id === 'aihubmix') {

View File

@ -7,6 +7,11 @@ export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt',
export const thirdPartyApplicationExts = ['.draftsExport'] export const thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub'] export const bookExts = ['.epub']
export const API_SERVER_DEFAULTS = {
HOST: '127.0.0.1',
PORT: 23333
}
/** /**
* A flat array of all file extensions known by the linguist database. * A flat array of all file extensions known by the linguist database.
* This is the primary source for identifying code files. * This is the primary source for identifying code files.
@ -483,3 +488,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
// resources/scripts should be maintained manually // resources/scripts should be maintained manually
export const HOME_CHERRY_DIR = '.cherrystudio' export const HOME_CHERRY_DIR = '.cherrystudio'
// Git Bash path configuration types
export type GitBashPathSource = 'manual' | 'auto'
export interface GitBashPathInfo {
path: string | null
source: GitBashPathSource | null
}

View File

@ -4,7 +4,7 @@
* *
* *
* THIS FILE IS AUTOMATICALLY GENERATED BY A SCRIPT. DO NOT EDIT IT MANUALLY! * THIS FILE IS AUTOMATICALLY GENERATED BY A SCRIPT. DO NOT EDIT IT MANUALLY!
* Run `yarn update:languages` to update this file. * Run `pnpm update:languages` to update this file.
* *
* *
*/ */

View File

@ -10,7 +10,7 @@ export type LoaderReturn = {
messageSource?: 'preprocess' | 'embedding' | 'validation' messageSource?: 'preprocess' | 'embedding' | 'validation'
} }
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' | 'refresh'
export type FileChangeEvent = { export type FileChangeEvent = {
eventType: FileChangeEventType eventType: FileChangeEventType
@ -23,6 +23,14 @@ export type MCPProgressEvent = {
progress: number // 0-1 range progress: number // 0-1 range
} }
export type MCPServerLogEntry = {
timestamp: number
level: 'debug' | 'info' | 'warn' | 'error' | 'stderr' | 'stdout'
message: string
data?: any
source?: string
}
export type WebviewKeyEvent = { export type WebviewKeyEvent = {
webviewId: number webviewId: number
key: string key: string
@ -44,3 +52,196 @@ export interface WebSocketCandidatesResponse {
interface: string interface: string
priority: number priority: number
} }
export type LocalTransferPeer = {
id: string
name: string
host?: string
fqdn?: string
port?: number
type?: string
protocol?: 'tcp' | 'udp'
addresses: string[]
txt?: Record<string, string>
updatedAt: number
}
export type LocalTransferState = {
services: LocalTransferPeer[]
isScanning: boolean
lastScanStartedAt?: number
lastUpdatedAt: number
lastError?: string
}
export type LanHandshakeRequestMessage = {
type: 'handshake'
deviceName: string
version: string
platform?: string
appVersion?: string
}
export type LanHandshakeAckMessage = {
type: 'handshake_ack'
accepted: boolean
message?: string
}
export type LocalTransferConnectPayload = {
peerId: string
metadata?: Record<string, string>
timeoutMs?: number
}
export type LanClientEvent =
| {
type: 'ping_sent'
payload: string
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'pong'
payload?: string
received?: boolean
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'socket_closed'
reason?: string
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'error'
message: string
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'file_transfer_progress'
transferId: string
fileName: string
bytesSent: number
totalBytes: number
chunkIndex: number
totalChunks: number
progress: number // 0-100
speed: number // bytes/sec
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'file_transfer_complete'
transferId: string
fileName: string
success: boolean
filePath?: string
error?: string
timestamp: number
peerId?: string
peerName?: string
}
// =============================================================================
// LAN File Transfer Protocol Types
// =============================================================================
// Constants for file transfer
export const LAN_TRANSFER_TCP_PORT = 53317
export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024 // 512KB
export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000 // 60s - wait for file_complete after file_end
export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes - global transfer timeout
// Binary protocol constants (v1)
export const LAN_TRANSFER_PROTOCOL_VERSION = '1'
export const LAN_BINARY_FRAME_MAGIC = 0x4353 // "CS" as uint16
export const LAN_BINARY_TYPE_FILE_CHUNK = 0x01
// Messages from Electron (Client/Sender) to Mobile (Server/Receiver)
/** Request to start file transfer */
export type LanFileStartMessage = {
type: 'file_start'
transferId: string
fileName: string
fileSize: number
mimeType: string // 'application/zip'
checksum: string // SHA-256 of entire file
totalChunks: number
chunkSize: number
}
/**
* File chunk data (JSON format)
* @deprecated Use binary frame format in protocol v1. This type is kept for reference only.
*/
export type LanFileChunkMessage = {
type: 'file_chunk'
transferId: string
chunkIndex: number
data: string // Base64 encoded
chunkChecksum: string // SHA-256 of this chunk
}
/** Notification that all chunks have been sent */
export type LanFileEndMessage = {
type: 'file_end'
transferId: string
}
/** Request to cancel file transfer */
export type LanFileCancelMessage = {
type: 'file_cancel'
transferId: string
reason?: string
}
// Messages from Mobile (Server/Receiver) to Electron (Client/Sender)
/** Acknowledgment of file transfer request */
export type LanFileStartAckMessage = {
type: 'file_start_ack'
transferId: string
accepted: boolean
message?: string // Rejection reason
}
/**
* Acknowledgment of file chunk received
* @deprecated Protocol v1 uses streaming mode without per-chunk acknowledgment.
* This type is kept for backward compatibility reference only.
*/
export type LanFileChunkAckMessage = {
type: 'file_chunk_ack'
transferId: string
chunkIndex: number
received: boolean
message?: string
}
/** Final result of file transfer */
export type LanFileCompleteMessage = {
type: 'file_complete'
transferId: string
success: boolean
filePath?: string // Path where file was saved on mobile
error?: string
// Enhanced error diagnostics
errorCode?: 'CHECKSUM_MISMATCH' | 'INCOMPLETE_TRANSFER' | 'DISK_ERROR' | 'CANCELLED'
receivedChunks?: number
receivedBytes?: number
}
/** Payload for sending a file via IPC */
export type LanFileSendPayload = {
filePath: string
}

View File

@ -35,3 +35,134 @@ export const defaultAppHeaders = () => {
// return value // return value
// } // }
// } // }
/**
* Extracts the trailing API version segment from a URL path.
*
* This function extracts API version patterns (e.g., `v1`, `v2beta`) from the end of a URL.
* Only versions at the end of the path are extracted, not versions in the middle.
* The returned version string does not include leading or trailing slashes.
*
* @param {string} url - The URL string to parse.
* @returns {string | undefined} The trailing API version found (e.g., 'v1', 'v2beta'), or undefined if none found.
*
* @example
* getTrailingApiVersion('https://api.example.com/v1') // 'v1'
* getTrailingApiVersion('https://api.example.com/v2beta/') // 'v2beta'
* getTrailingApiVersion('https://api.example.com/v1/chat') // undefined (version not at end)
* getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v1beta') // 'v1beta'
* getTrailingApiVersion('https://api.example.com') // undefined
*/
export function getTrailingApiVersion(url: string): string | undefined {
const match = url.match(TRAILING_VERSION_REGEX)
if (match) {
// Extract version without leading slash and trailing slash
return match[0].replace(/^\//, '').replace(/\/$/, '')
}
return undefined
}
/**
* Matches an API version at the end of a URL (with optional trailing slash).
* Used to detect and extract versions only from the trailing position.
*/
const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
/**
* Removes the trailing API version segment from a URL path.
*
* This function removes API version patterns (e.g., `/v1`, `/v2beta`) from the end of a URL.
* Only versions at the end of the path are removed, not versions in the middle.
*
* @param {string} url - The URL string to process.
* @returns {string} The URL with the trailing API version removed, or the original URL if no trailing version found.
*
* @example
* withoutTrailingApiVersion('https://api.example.com/v1') // 'https://api.example.com'
* withoutTrailingApiVersion('https://api.example.com/v2beta/') // 'https://api.example.com'
* withoutTrailingApiVersion('https://api.example.com/v1/chat') // 'https://api.example.com/v1/chat' (no change)
* withoutTrailingApiVersion('https://api.example.com') // 'https://api.example.com'
*/
export function withoutTrailingApiVersion(url: string): string {
return url.replace(TRAILING_VERSION_REGEX, '')
}
export interface DataUrlParts {
/** The media type (e.g., 'image/png', 'text/plain') */
mediaType?: string
/** Whether the data is base64 encoded */
isBase64: boolean
/** The data portion (everything after the comma). This is the raw string, not decoded. */
data: string
}
/**
* Parses a data URL into its component parts without using regex on the data portion.
* This is memory-safe for large data URLs (e.g., 4K images) as it uses indexOf instead of regex.
*
* Data URL format: data:[<mediatype>][;base64],<data>
*
* @param url - The data URL string to parse
* @returns DataUrlParts if valid, null if invalid
*
* @example
* parseDataUrl('data:image/png;base64,iVBORw0KGgo...')
* // { mediaType: 'image/png', isBase64: true, data: 'iVBORw0KGgo...' }
*
* parseDataUrl('data:text/plain,Hello')
* // { mediaType: 'text/plain', isBase64: false, data: 'Hello' }
*
* parseDataUrl('invalid-url')
* // null
*/
export function parseDataUrl(url: string): DataUrlParts | null {
if (!url.startsWith('data:')) {
return null
}
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
return null
}
const header = url.slice(5, commaIndex)
const isBase64 = header.includes(';base64')
const semicolonIndex = header.indexOf(';')
const mediaType = (semicolonIndex === -1 ? header : header.slice(0, semicolonIndex)).trim() || undefined
const data = url.slice(commaIndex + 1)
return { mediaType, isBase64, data }
}
/**
* Checks if a string is a data URL.
*
* @param url - The string to check
* @returns true if the string is a valid data URL
*/
export function isDataUrl(url: string): boolean {
return url.startsWith('data:') && url.includes(',')
}
/**
* Checks if a data URL contains base64-encoded image data.
*
* @param url - The data URL to check
* @returns true if the URL is a base64-encoded image data URL
*/
export function isBase64ImageDataUrl(url: string): boolean {
if (!url.startsWith('data:image/')) {
return false
}
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
return false
}
const header = url.slice(5, commaIndex)
return header.includes(';base64')
}

View File

@ -1,5 +1,5 @@
diff --git a/dist/index.js b/dist/index.js diff --git a/dist/index.js b/dist/index.js
index 51ce7e423934fb717cb90245cdfcdb3dae6780e6..0f7f7009e2f41a79a8669d38c8a44867bbff5e1f 100644 index d004b415c5841a1969705823614f395265ea5a8a..6b1e0dad4610b0424393ecc12e9114723bbe316b 100644
--- a/dist/index.js --- a/dist/index.js
+++ b/dist/index.js +++ b/dist/index.js
@@ -474,7 +474,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { @@ -474,7 +474,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@ -12,7 +12,7 @@ index 51ce7e423934fb717cb90245cdfcdb3dae6780e6..0f7f7009e2f41a79a8669d38c8a44867
// src/google-generative-ai-options.ts // src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs diff --git a/dist/index.mjs b/dist/index.mjs
index f4b77e35c0cbfece85a3ef0d4f4e67aa6dde6271..8d2fecf8155a226006a0bde72b00b6036d4014b6 100644 index 1780dd2391b7f42224a0b8048c723d2f81222c44..1f12ed14399d6902107ce9b435d7d8e6cc61e06b 100644
--- a/dist/index.mjs --- a/dist/index.mjs
+++ b/dist/index.mjs +++ b/dist/index.mjs
@@ -480,7 +480,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { @@ -480,7 +480,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@ -24,3 +24,14 @@ index f4b77e35c0cbfece85a3ef0d4f4e67aa6dde6271..8d2fecf8155a226006a0bde72b00b603
} }
// src/google-generative-ai-options.ts // src/google-generative-ai-options.ts
@@ -1909,8 +1909,7 @@ function createGoogleGenerativeAI(options = {}) {
}
var google = createGoogleGenerativeAI();
export {
- VERSION,
createGoogleGenerativeAI,
- google
+ google, VERSION
};
//# sourceMappingURL=index.mjs.map
\ No newline at end of file

View File

@ -0,0 +1,266 @@
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 48e2f6263c6ee4c75d7e5c28733e64f6ebe92200..00d0729c4a3cbf9a48e8e1e962c7e2b256b75eba 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -7,6 +7,7 @@ declare const openaiCompatibleProviderOptions: z.ZodObject<{
user: z.ZodOptional<z.ZodString>;
reasoningEffort: z.ZodOptional<z.ZodString>;
textVerbosity: z.ZodOptional<z.ZodString>;
+ sendReasoning: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
type OpenAICompatibleProviderOptions = z.infer<typeof openaiCompatibleProviderOptions>;
diff --git a/dist/index.js b/dist/index.js
index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e5bfe0f9a 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -41,7 +41,7 @@ function getOpenAIMetadata(message) {
var _a, _b;
return (_b = (_a = message == null ? void 0 : message.providerOptions) == null ? void 0 : _a.openaiCompatible) != null ? _b : {};
}
-function convertToOpenAICompatibleChatMessages(prompt) {
+function convertToOpenAICompatibleChatMessages({prompt, options}) {
const messages = [];
for (const { role, content, ...message } of prompt) {
const metadata = getOpenAIMetadata({ ...message });
@@ -91,6 +91,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
}
case "assistant": {
let text = "";
+ let reasoning_text = "";
const toolCalls = [];
for (const part of content) {
const partMetadata = getOpenAIMetadata(part);
@@ -99,6 +100,12 @@ function convertToOpenAICompatibleChatMessages(prompt) {
text += part.text;
break;
}
+ case "reasoning": {
+ if (options.sendReasoning) {
+ reasoning_text += part.text;
+ }
+ break;
+ }
case "tool-call": {
toolCalls.push({
id: part.toolCallId,
@@ -116,6 +123,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text || undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@@ -200,7 +208,8 @@ var openaiCompatibleProviderOptions = import_v4.z.object({
/**
* Controls the verbosity of the generated text. Defaults to `medium`.
*/
- textVerbosity: import_v4.z.string().optional()
+ textVerbosity: import_v4.z.string().optional(),
+ sendReasoning: import_v4.z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -378,7 +387,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,
verbosity: compatibleOptions.textVerbosity,
// messages:
- messages: convertToOpenAICompatibleChatMessages(prompt),
+ messages: convertToOpenAICompatibleChatMessages({prompt, options: compatibleOptions}),
// tools:
tools: openaiTools,
tool_choice: openaiToolChoice
@@ -421,6 +430,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -598,6 +618,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -765,6 +796,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({
arguments: import_v43.z.string()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}),
finish_reason: import_v43.z.string().nullish()
@@ -795,6 +834,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union(
arguments: import_v43.z.string().nullish()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: import_v43.z.string().nullish()
diff --git a/dist/index.mjs b/dist/index.mjs
index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5700264de 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -23,7 +23,7 @@ function getOpenAIMetadata(message) {
var _a, _b;
return (_b = (_a = message == null ? void 0 : message.providerOptions) == null ? void 0 : _a.openaiCompatible) != null ? _b : {};
}
-function convertToOpenAICompatibleChatMessages(prompt) {
+function convertToOpenAICompatibleChatMessages({prompt, options}) {
const messages = [];
for (const { role, content, ...message } of prompt) {
const metadata = getOpenAIMetadata({ ...message });
@@ -73,6 +73,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
}
case "assistant": {
let text = "";
+ let reasoning_text = "";
const toolCalls = [];
for (const part of content) {
const partMetadata = getOpenAIMetadata(part);
@@ -81,6 +82,12 @@ function convertToOpenAICompatibleChatMessages(prompt) {
text += part.text;
break;
}
+ case "reasoning": {
+ if (options.sendReasoning) {
+ reasoning_text += part.text;
+ }
+ break;
+ }
case "tool-call": {
toolCalls.push({
id: part.toolCallId,
@@ -98,6 +105,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text || undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@@ -182,7 +190,8 @@ var openaiCompatibleProviderOptions = z.object({
/**
* Controls the verbosity of the generated text. Defaults to `medium`.
*/
- textVerbosity: z.string().optional()
+ textVerbosity: z.string().optional(),
+ sendReasoning: z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -362,7 +371,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,
verbosity: compatibleOptions.textVerbosity,
// messages:
- messages: convertToOpenAICompatibleChatMessages(prompt),
+ messages: convertToOpenAICompatibleChatMessages({prompt, options: compatibleOptions}),
// tools:
tools: openaiTools,
tool_choice: openaiToolChoice
@@ -405,6 +414,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -582,6 +602,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -749,6 +780,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({
arguments: z3.string()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}),
finish_reason: z3.string().nullish()
@@ -779,6 +818,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([
arguments: z3.string().nullish()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: z3.string().nullish()

View File

@ -1,8 +1,8 @@
diff --git a/dist/index.js b/dist/index.js diff --git a/dist/index.js b/dist/index.js
index bf900591bf2847a3253fe441aad24c06da19c6c1..c1d9bb6fefa2df1383339324073db0a70ea2b5a2 100644 index 130094d194ea1e8e7d3027d07d82465741192124..4d13dcee8c962ca9ee8f1c3d748f8ffe6a3cfb47 100644
--- a/dist/index.js --- a/dist/index.js
+++ b/dist/index.js +++ b/dist/index.js
@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)( @@ -290,6 +290,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)(
message: import_v42.z.object({ message: import_v42.z.object({
role: import_v42.z.literal("assistant").nullish(), role: import_v42.z.literal("assistant").nullish(),
content: import_v42.z.string().nullish(), content: import_v42.z.string().nullish(),
@ -10,7 +10,7 @@ index bf900591bf2847a3253fe441aad24c06da19c6c1..c1d9bb6fefa2df1383339324073db0a7
tool_calls: import_v42.z.array( tool_calls: import_v42.z.array(
import_v42.z.object({ import_v42.z.object({
id: import_v42.z.string().nullish(), id: import_v42.z.string().nullish(),
@@ -340,6 +341,7 @@ var openaiChatChunkSchema = (0, import_provider_utils3.lazyValidator)( @@ -356,6 +357,7 @@ var openaiChatChunkSchema = (0, import_provider_utils3.lazyValidator)(
delta: import_v42.z.object({ delta: import_v42.z.object({
role: import_v42.z.enum(["assistant"]).nullish(), role: import_v42.z.enum(["assistant"]).nullish(),
content: import_v42.z.string().nullish(), content: import_v42.z.string().nullish(),
@ -18,7 +18,7 @@ index bf900591bf2847a3253fe441aad24c06da19c6c1..c1d9bb6fefa2df1383339324073db0a7
tool_calls: import_v42.z.array( tool_calls: import_v42.z.array(
import_v42.z.object({ import_v42.z.object({
index: import_v42.z.number(), index: import_v42.z.number(),
@@ -795,6 +797,13 @@ var OpenAIChatLanguageModel = class { @@ -814,6 +816,13 @@ var OpenAIChatLanguageModel = class {
if (text != null && text.length > 0) { if (text != null && text.length > 0) {
content.push({ type: "text", text }); content.push({ type: "text", text });
} }
@ -32,7 +32,7 @@ index bf900591bf2847a3253fe441aad24c06da19c6c1..c1d9bb6fefa2df1383339324073db0a7
for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) { for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) {
content.push({ content.push({
type: "tool-call", type: "tool-call",
@@ -876,6 +885,7 @@ var OpenAIChatLanguageModel = class { @@ -895,6 +904,7 @@ var OpenAIChatLanguageModel = class {
}; };
let metadataExtracted = false; let metadataExtracted = false;
let isActiveText = false; let isActiveText = false;
@ -40,7 +40,7 @@ index bf900591bf2847a3253fe441aad24c06da19c6c1..c1d9bb6fefa2df1383339324073db0a7
const providerMetadata = { openai: {} }; const providerMetadata = { openai: {} };
return { return {
stream: response.pipeThrough( stream: response.pipeThrough(
@@ -933,6 +943,21 @@ var OpenAIChatLanguageModel = class { @@ -952,6 +962,21 @@ var OpenAIChatLanguageModel = class {
return; return;
} }
const delta = choice.delta; const delta = choice.delta;
@ -62,7 +62,7 @@ index bf900591bf2847a3253fe441aad24c06da19c6c1..c1d9bb6fefa2df1383339324073db0a7
if (delta.content != null) { if (delta.content != null) {
if (!isActiveText) { if (!isActiveText) {
controller.enqueue({ type: "text-start", id: "0" }); controller.enqueue({ type: "text-start", id: "0" });
@@ -1045,6 +1070,9 @@ var OpenAIChatLanguageModel = class { @@ -1064,6 +1089,9 @@ var OpenAIChatLanguageModel = class {
} }
}, },
flush(controller) { flush(controller) {

View File

@ -1,5 +1,5 @@
diff --git a/sdk.mjs b/sdk.mjs diff --git a/sdk.mjs b/sdk.mjs
index bf429a344b7d59f70aead16b639f949b07688a81..f77d50cc5d3fb04292cb3ac7fa7085d02dcc628f 100755 index dea7766a3432a1e809f12d6daba4f2834a219689..e0b02ef73da177ba32b903887d7bbbeaa08cc6d3 100755
--- a/sdk.mjs --- a/sdk.mjs
+++ b/sdk.mjs +++ b/sdk.mjs
@@ -6250,7 +6250,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { @@ -6250,7 +6250,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
@ -11,7 +11,7 @@ index bf429a344b7d59f70aead16b639f949b07688a81..f77d50cc5d3fb04292cb3ac7fa7085d0
import { createInterface } from "readline"; import { createInterface } from "readline";
// ../src/utils/fsOperations.ts // ../src/utils/fsOperations.ts
@@ -6619,18 +6619,11 @@ class ProcessTransport { @@ -6644,18 +6644,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?`; 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); throw new ReferenceError(errorMessage);
} }

View File

@ -0,0 +1,145 @@
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 8dd9b498050dbecd8dd6b901acf1aa8ca38a49af..ed644349c9d38fe2a66b2fb44214f7c18eb97f89 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -4,7 +4,7 @@ import { z } from 'zod/v4';
type OllamaChatModelId = "athene-v2" | "athene-v2:72b" | "aya-expanse" | "aya-expanse:8b" | "aya-expanse:32b" | "codegemma" | "codegemma:2b" | "codegemma:7b" | "codellama" | "codellama:7b" | "codellama:13b" | "codellama:34b" | "codellama:70b" | "codellama:code" | "codellama:python" | "command-r" | "command-r:35b" | "command-r-plus" | "command-r-plus:104b" | "command-r7b" | "command-r7b:7b" | "deepseek-r1" | "deepseek-r1:1.5b" | "deepseek-r1:7b" | "deepseek-r1:8b" | "deepseek-r1:14b" | "deepseek-r1:32b" | "deepseek-r1:70b" | "deepseek-r1:671b" | "deepseek-coder-v2" | "deepseek-coder-v2:16b" | "deepseek-coder-v2:236b" | "deepseek-v3" | "deepseek-v3:671b" | "devstral" | "devstral:24b" | "dolphin3" | "dolphin3:8b" | "exaone3.5" | "exaone3.5:2.4b" | "exaone3.5:7.8b" | "exaone3.5:32b" | "falcon2" | "falcon2:11b" | "falcon3" | "falcon3:1b" | "falcon3:3b" | "falcon3:7b" | "falcon3:10b" | "firefunction-v2" | "firefunction-v2:70b" | "gemma" | "gemma:2b" | "gemma:7b" | "gemma2" | "gemma2:2b" | "gemma2:9b" | "gemma2:27b" | "gemma3" | "gemma3:1b" | "gemma3:4b" | "gemma3:12b" | "gemma3:27b" | "granite3-dense" | "granite3-dense:2b" | "granite3-dense:8b" | "granite3-guardian" | "granite3-guardian:2b" | "granite3-guardian:8b" | "granite3-moe" | "granite3-moe:1b" | "granite3-moe:3b" | "granite3.1-dense" | "granite3.1-dense:2b" | "granite3.1-dense:8b" | "granite3.1-moe" | "granite3.1-moe:1b" | "granite3.1-moe:3b" | "llama2" | "llama2:7b" | "llama2:13b" | "llama2:70b" | "llama3" | "llama3:8b" | "llama3:70b" | "llama3-chatqa" | "llama3-chatqa:8b" | "llama3-chatqa:70b" | "llama3-gradient" | "llama3-gradient:8b" | "llama3-gradient:70b" | "llama3.1" | "llama3.1:8b" | "llama3.1:70b" | "llama3.1:405b" | "llama3.2" | "llama3.2:1b" | "llama3.2:3b" | "llama3.2-vision" | "llama3.2-vision:11b" | "llama3.2-vision:90b" | "llama3.3" | "llama3.3:70b" | "llama4" | "llama4:16x17b" | "llama4:128x17b" | "llama-guard3" | "llama-guard3:1b" | "llama-guard3:8b" | "llava" | "llava:7b" | "llava:13b" | "llava:34b" | "llava-llama3" | "llava-llama3:8b" | "llava-phi3" | "llava-phi3:3.8b" | "marco-o1" | "marco-o1:7b" | "mistral" | "mistral:7b" | "mistral-large" | "mistral-large:123b" | "mistral-nemo" | "mistral-nemo:12b" | "mistral-small" | "mistral-small:22b" | "mixtral" | "mixtral:8x7b" | "mixtral:8x22b" | "moondream" | "moondream:1.8b" | "openhermes" | "openhermes:v2.5" | "nemotron" | "nemotron:70b" | "nemotron-mini" | "nemotron-mini:4b" | "olmo" | "olmo:7b" | "olmo:13b" | "opencoder" | "opencoder:1.5b" | "opencoder:8b" | "phi3" | "phi3:3.8b" | "phi3:14b" | "phi3.5" | "phi3.5:3.8b" | "phi4" | "phi4:14b" | "qwen" | "qwen:7b" | "qwen:14b" | "qwen:32b" | "qwen:72b" | "qwen:110b" | "qwen2" | "qwen2:0.5b" | "qwen2:1.5b" | "qwen2:7b" | "qwen2:72b" | "qwen2.5" | "qwen2.5:0.5b" | "qwen2.5:1.5b" | "qwen2.5:3b" | "qwen2.5:7b" | "qwen2.5:14b" | "qwen2.5:32b" | "qwen2.5:72b" | "qwen2.5-coder" | "qwen2.5-coder:0.5b" | "qwen2.5-coder:1.5b" | "qwen2.5-coder:3b" | "qwen2.5-coder:7b" | "qwen2.5-coder:14b" | "qwen2.5-coder:32b" | "qwen3" | "qwen3:0.6b" | "qwen3:1.7b" | "qwen3:4b" | "qwen3:8b" | "qwen3:14b" | "qwen3:30b" | "qwen3:32b" | "qwen3:235b" | "qwq" | "qwq:32b" | "sailor2" | "sailor2:1b" | "sailor2:8b" | "sailor2:20b" | "shieldgemma" | "shieldgemma:2b" | "shieldgemma:9b" | "shieldgemma:27b" | "smallthinker" | "smallthinker:3b" | "smollm" | "smollm:135m" | "smollm:360m" | "smollm:1.7b" | "tinyllama" | "tinyllama:1.1b" | "tulu3" | "tulu3:8b" | "tulu3:70b" | (string & {});
declare const ollamaProviderOptions: z.ZodObject<{
- think: z.ZodOptional<z.ZodBoolean>;
+ think: z.ZodOptional<z.ZodUnion<[z.ZodBoolean, z.ZodLiteral<"low">, z.ZodLiteral<"medium">, z.ZodLiteral<"high">]>>;
options: z.ZodOptional<z.ZodObject<{
num_ctx: z.ZodOptional<z.ZodNumber>;
repeat_last_n: z.ZodOptional<z.ZodNumber>;
@@ -27,9 +27,11 @@ interface OllamaCompletionSettings {
* the model's thinking from the model's output. When disabled, the model will not think
* and directly output the content.
*
+ * For gpt-oss models, you can also use 'low', 'medium', or 'high' to control the depth of thinking.
+ *
* Only supported by certain models like DeepSeek R1 and Qwen 3.
*/
- think?: boolean;
+ think?: boolean | 'low' | 'medium' | 'high';
/**
* Echo back the prompt in addition to the completion.
*/
@@ -146,7 +148,7 @@ declare const ollamaEmbeddingProviderOptions: z.ZodObject<{
type OllamaEmbeddingProviderOptions = z.infer<typeof ollamaEmbeddingProviderOptions>;
declare const ollamaCompletionProviderOptions: z.ZodObject<{
- think: z.ZodOptional<z.ZodBoolean>;
+ think: z.ZodOptional<z.ZodUnion<[z.ZodBoolean, z.ZodLiteral<"low">, z.ZodLiteral<"medium">, z.ZodLiteral<"high">]>>;
user: z.ZodOptional<z.ZodString>;
suffix: z.ZodOptional<z.ZodString>;
echo: z.ZodOptional<z.ZodBoolean>;
diff --git a/dist/index.js b/dist/index.js
index 35b5142ce8476ce2549ed7c2ec48e7d8c46c90d9..2ef64dc9a4c2be043e6af608241a6a8309a5a69f 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -158,7 +158,7 @@ function getResponseMetadata({
// src/completion/ollama-completion-language-model.ts
var ollamaCompletionProviderOptions = import_v42.z.object({
- think: import_v42.z.boolean().optional(),
+ think: import_v42.z.union([import_v42.z.boolean(), import_v42.z.literal('low'), import_v42.z.literal('medium'), import_v42.z.literal('high')]).optional(),
user: import_v42.z.string().optional(),
suffix: import_v42.z.string().optional(),
echo: import_v42.z.boolean().optional()
@@ -662,7 +662,7 @@ function convertToOllamaChatMessages({
const images = content.filter((part) => part.type === "file" && part.mediaType.startsWith("image/")).map((part) => part.data);
messages.push({
role: "user",
- content: userText.length > 0 ? userText : [],
+ content: userText.length > 0 ? userText : '',
images: images.length > 0 ? images : void 0
});
break;
@@ -813,9 +813,11 @@ var ollamaProviderOptions = import_v44.z.object({
* the model's thinking from the model's output. When disabled, the model will not think
* and directly output the content.
*
+ * For gpt-oss models, you can also use 'low', 'medium', or 'high' to control the depth of thinking.
+ *
* Only supported by certain models like DeepSeek R1 and Qwen 3.
*/
- think: import_v44.z.boolean().optional(),
+ think: import_v44.z.union([import_v44.z.boolean(), import_v44.z.literal('low'), import_v44.z.literal('medium'), import_v44.z.literal('high')]).optional(),
options: import_v44.z.object({
num_ctx: import_v44.z.number().optional(),
repeat_last_n: import_v44.z.number().optional(),
@@ -929,14 +931,16 @@ var OllamaRequestBuilder = class {
prompt,
systemMessageMode: "system"
}),
- temperature,
- top_p: topP,
max_output_tokens: maxOutputTokens,
...(responseFormat == null ? void 0 : responseFormat.type) === "json" && {
format: responseFormat.schema != null ? responseFormat.schema : "json"
},
think: (_a = ollamaOptions == null ? void 0 : ollamaOptions.think) != null ? _a : false,
- options: (_b = ollamaOptions == null ? void 0 : ollamaOptions.options) != null ? _b : void 0
+ options: {
+ ...temperature !== void 0 && { temperature },
+ ...topP !== void 0 && { top_p: topP },
+ ...((_b = ollamaOptions == null ? void 0 : ollamaOptions.options) != null ? _b : {})
+ }
};
}
};
diff --git a/dist/index.mjs b/dist/index.mjs
index e2a634a78d80ac9542f2cc4f96cf2291094b10cf..67b23efce3c1cf4f026693d3ff9246988a3ef26e 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -144,7 +144,7 @@ function getResponseMetadata({
// src/completion/ollama-completion-language-model.ts
var ollamaCompletionProviderOptions = z2.object({
- think: z2.boolean().optional(),
+ think: z2.union([z2.boolean(), z2.literal('low'), z2.literal('medium'), z2.literal('high')]).optional(),
user: z2.string().optional(),
suffix: z2.string().optional(),
echo: z2.boolean().optional()
@@ -662,7 +662,7 @@ function convertToOllamaChatMessages({
const images = content.filter((part) => part.type === "file" && part.mediaType.startsWith("image/")).map((part) => part.data);
messages.push({
role: "user",
- content: userText.length > 0 ? userText : [],
+ content: userText.length > 0 ? userText : '',
images: images.length > 0 ? images : void 0
});
break;
@@ -815,9 +815,11 @@ var ollamaProviderOptions = z4.object({
* the model's thinking from the model's output. When disabled, the model will not think
* and directly output the content.
*
+ * For gpt-oss models, you can also use 'low', 'medium', or 'high' to control the depth of thinking.
+ *
* Only supported by certain models like DeepSeek R1 and Qwen 3.
*/
- think: z4.boolean().optional(),
+ think: z4.union([z4.boolean(), z4.literal('low'), z4.literal('medium'), z4.literal('high')]).optional(),
options: z4.object({
num_ctx: z4.number().optional(),
repeat_last_n: z4.number().optional(),
@@ -931,14 +933,16 @@ var OllamaRequestBuilder = class {
prompt,
systemMessageMode: "system"
}),
- temperature,
- top_p: topP,
max_output_tokens: maxOutputTokens,
...(responseFormat == null ? void 0 : responseFormat.type) === "json" && {
format: responseFormat.schema != null ? responseFormat.schema : "json"
},
think: (_a = ollamaOptions == null ? void 0 : ollamaOptions.think) != null ? _a : false,
- options: (_b = ollamaOptions == null ? void 0 : ollamaOptions.options) != null ? _b : void 0
+ options: {
+ ...temperature !== void 0 && { temperature },
+ ...topP !== void 0 && { top_p: topP },
+ ...((_b = ollamaOptions == null ? void 0 : ollamaOptions.options) != null ? _b : {})
+ }
};
}
};

25417
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- 'packages/*'

View File

@ -6,12 +6,12 @@ const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries // Base URL for downloading OVMS binaries
const OVMS_RELEASE_BASE_URL = const OVMS_RELEASE_BASE_URL =
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip' 'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.4.1/ovms_windows_python_on.zip'
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip' const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.4_ex.zip'
/** /**
* error code: * error code:
* 101: Unsupported CPU (not Intel Ultra) * 101: Unsupported CPU (not Intel)
* 102: Unsupported platform (not Windows) * 102: Unsupported platform (not Windows)
* 103: Download failed * 103: Download failed
* 104: Installation failed * 104: Installation failed
@ -213,8 +213,8 @@ async function installOvms() {
console.log(`CPU Name: ${cpuName}`) console.log(`CPU Name: ${cpuName}`)
// Check if CPU name contains "Ultra" // Check if CPU name contains "Ultra"
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) { if (!cpuName.toLowerCase().includes('intel')) {
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.') console.error('OVMS installation requires an Intel CPU.')
return 101 return 101
} }

View File

@ -50,7 +50,7 @@ Usage Instructions:
- pt-pt (Portuguese) - pt-pt (Portuguese)
Run Command: Run Command:
yarn auto:i18n pnpm i18n:translate
Performance Optimization Recommendations: Performance Optimization Recommendations:
- For stable API services: MAX_CONCURRENT_TRANSLATIONS=8, TRANSLATION_DELAY_MS=50 - For stable API services: MAX_CONCURRENT_TRANSLATIONS=8, TRANSLATION_DELAY_MS=50
@ -152,7 +152,8 @@ const languageMap = {
'es-es': 'Spanish', 'es-es': 'Spanish',
'fr-fr': 'French', 'fr-fr': 'French',
'pt-pt': 'Portuguese', 'pt-pt': 'Portuguese',
'de-de': 'German' 'de-de': 'German',
'ro-ro': 'Romanian'
} }
const PROMPT = ` const PROMPT = `

View File

@ -2,14 +2,14 @@ const { Arch } = require('electron-builder')
const { downloadNpmPackage } = require('./utils') const { downloadNpmPackage } = require('./utils')
// if you want to add new prebuild binaries packages with different architectures, you can add them here // if you want to add new prebuild binaries packages with different architectures, you can add them here
// please add to allX64 and allArm64 from yarn.lock // please add to allX64 and allArm64 from pnpm-lock.yaml
const allArm64 = { const allArm64 = {
'@img/sharp-darwin-arm64': '0.34.3', '@img/sharp-darwin-arm64': '0.34.3',
'@img/sharp-win32-arm64': '0.34.3', '@img/sharp-win32-arm64': '0.34.3',
'@img/sharp-linux-arm64': '0.34.3', '@img/sharp-linux-arm64': '0.34.3',
'@img/sharp-libvips-darwin-arm64': '1.2.0', '@img/sharp-libvips-darwin-arm64': '1.2.4',
'@img/sharp-libvips-linux-arm64': '1.2.0', '@img/sharp-libvips-linux-arm64': '1.2.4',
'@libsql/darwin-arm64': '0.4.7', '@libsql/darwin-arm64': '0.4.7',
'@libsql/linux-arm64-gnu': '0.4.7', '@libsql/linux-arm64-gnu': '0.4.7',
@ -24,8 +24,8 @@ const allX64 = {
'@img/sharp-linux-x64': '0.34.3', '@img/sharp-linux-x64': '0.34.3',
'@img/sharp-win32-x64': '0.34.3', '@img/sharp-win32-x64': '0.34.3',
'@img/sharp-libvips-darwin-x64': '1.2.0', '@img/sharp-libvips-darwin-x64': '1.2.4',
'@img/sharp-libvips-linux-x64': '1.2.0', '@img/sharp-libvips-linux-x64': '1.2.4',
'@libsql/darwin-x64': '0.4.7', '@libsql/darwin-x64': '0.4.7',
'@libsql/linux-x64-gnu': '0.4.7', '@libsql/linux-x64-gnu': '0.4.7',

View File

@ -145,7 +145,7 @@ export function main() {
console.log('i18n 检查已通过') console.log('i18n 检查已通过')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
throw new Error(`检查未通过。尝试运行 yarn sync:i18n 以解决问题。`) throw new Error(`检查未通过。尝试运行 pnpm i18n:sync 以解决问题。`)
} }
} }

View File

@ -91,23 +91,6 @@ function createIssueCard(issueData) {
return { return {
elements: [ elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**🐛 New GitHub Issue #${issueNumber}**`
}
},
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**📝 Title:** ${issueTitle}`
}
},
{ {
tag: 'div', tag: 'div',
text: { text: {
@ -158,7 +141,7 @@ function createIssueCard(issueData) {
template: 'blue', template: 'blue',
title: { title: {
tag: 'plain_text', tag: 'plain_text',
content: '🆕 Cherry Studio - New Issue' content: `#${issueNumber} - ${issueTitle}`
} }
} }
} }

View File

@ -57,7 +57,7 @@ function generateLanguagesFileContent(languages: Record<string, LanguageData>):
* *
* *
* THIS FILE IS AUTOMATICALLY GENERATED BY A SCRIPT. DO NOT EDIT IT MANUALLY! * THIS FILE IS AUTOMATICALLY GENERATED BY A SCRIPT. DO NOT EDIT IT MANUALLY!
* Run \`yarn update:languages\` to update this file. * Run \`pnpm update:languages\` to update this file.
* *
* *
*/ */
@ -81,7 +81,7 @@ export const languages: Record<string, LanguageData> = ${languagesObjectString};
async function format(filePath: string): Promise<void> { async function format(filePath: string): Promise<void> {
console.log('🎨 Formatting file with Biome...') console.log('🎨 Formatting file with Biome...')
try { try {
await execAsync(`yarn biome format --write ${filePath}`) await execAsync(`pnpm biome format --write ${filePath}`)
console.log('✅ Biome formatting complete.') console.log('✅ Biome formatting complete.')
} catch (e: any) { } catch (e: any) {
console.error('❌ Biome formatting failed:', e.stdout || e.stderr) console.error('❌ Biome formatting failed:', e.stdout || e.stderr)
@ -96,7 +96,7 @@ async function format(filePath: string): Promise<void> {
async function checkTypeScript(filePath: string): Promise<void> { async function checkTypeScript(filePath: string): Promise<void> {
console.log('🧐 Checking file with TypeScript compiler...') console.log('🧐 Checking file with TypeScript compiler...')
try { try {
await execAsync(`yarn tsc --noEmit --skipLibCheck ${filePath}`) await execAsync(`pnpm tsc --noEmit --skipLibCheck ${filePath}`)
console.log('✅ TypeScript check passed.') console.log('✅ TypeScript check passed.')
} catch (e: any) { } catch (e: any) {
console.error('❌ TypeScript check failed:', e.stdout || e.stderr) console.error('❌ TypeScript check failed:', e.stdout || e.stderr)

View File

@ -18,7 +18,7 @@ if (!['patch', 'minor', 'major'].includes(versionType)) {
} }
// 更新版本 // 更新版本
exec(`yarn version ${versionType} --immediate`) exec(`pnpm version ${versionType}`)
// 读取更新后的 package.json 获取新版本号 // 读取更新后的 package.json 获取新版本号
const updatedPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')) const updatedPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'))

View File

@ -5,9 +5,17 @@ exports.default = async function (configuration) {
const { path } = configuration const { path } = configuration
if (configuration.path) { if (configuration.path) {
try { try {
const certPath = process.env.CHERRY_CERT_PATH
const keyContainer = process.env.CHERRY_CERT_KEY
const csp = process.env.CHERRY_CERT_CSP
if (!certPath || !keyContainer || !csp) {
throw new Error('CHERRY_CERT_PATH, CHERRY_CERT_KEY or CHERRY_CERT_CSP is not set')
}
console.log('Start code signing...') console.log('Start code signing...')
console.log('Signing file:', path) console.log('Signing file:', path)
const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"` const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /v /f "${certPath}" /csp "${csp}" /k "${keyContainer}" "${path}"`
execSync(signCommand, { stdio: 'inherit' }) execSync(signCommand, { stdio: 'inherit' })
console.log('Code signing completed') console.log('Code signing completed')
} catch (error) { } catch (error) {

View File

@ -1,3 +1,4 @@
import { API_SERVER_DEFAULTS } from '@shared/config/constant'
import type { ApiServerConfig } from '@types' import type { ApiServerConfig } from '@types'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
@ -6,9 +7,6 @@ import { reduxService } from '../services/ReduxService'
const logger = loggerService.withContext('ApiServerConfig') const logger = loggerService.withContext('ApiServerConfig')
const defaultHost = 'localhost'
const defaultPort = 23333
class ConfigManager { class ConfigManager {
private _config: ApiServerConfig | null = null private _config: ApiServerConfig | null = null
@ -30,8 +28,8 @@ class ConfigManager {
} }
this._config = { this._config = {
enabled: serverSettings?.enabled ?? false, enabled: serverSettings?.enabled ?? false,
port: serverSettings?.port ?? defaultPort, port: serverSettings?.port ?? API_SERVER_DEFAULTS.PORT,
host: defaultHost, host: serverSettings?.host ?? API_SERVER_DEFAULTS.HOST,
apiKey: apiKey apiKey: apiKey
} }
return this._config return this._config
@ -39,8 +37,8 @@ class ConfigManager {
logger.warn('Failed to load config from Redux, using defaults', { error }) logger.warn('Failed to load config from Redux, using defaults', { error })
this._config = { this._config = {
enabled: false, enabled: false,
port: defaultPort, port: API_SERVER_DEFAULTS.PORT,
host: defaultHost, host: API_SERVER_DEFAULTS.HOST,
apiKey: this.generateApiKey() apiKey: this.generateApiKey()
} }
return this._config return this._config

View File

@ -20,8 +20,8 @@ const swaggerOptions: swaggerJSDoc.Options = {
}, },
servers: [ servers: [
{ {
url: 'http://localhost:23333', url: '/',
description: 'Local development server' description: 'Current server'
} }
], ],
components: { components: {

View File

@ -19,7 +19,9 @@ import { agentService } from './services/agents'
import { apiServerService } from './services/ApiServerService' import { apiServerService } from './services/ApiServerService'
import { appMenuService } from './services/AppMenuService' import { appMenuService } from './services/AppMenuService'
import { configManager } from './services/ConfigManager' import { configManager } from './services/ConfigManager'
import { lanTransferClientService } from './services/lanTransfer'
import mcpService from './services/MCPService' import mcpService from './services/MCPService'
import { localTransferService } from './services/LocalTransferService'
import { nodeTraceService } from './services/NodeTraceService' import { nodeTraceService } from './services/NodeTraceService'
import powerMonitorService from './services/PowerMonitorService' import powerMonitorService from './services/PowerMonitorService'
import { import {
@ -35,6 +37,7 @@ import { versionService } from './services/VersionService'
import { windowService } from './services/WindowService' import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService' import { initWebviewHotkeys } from './services/WebviewService'
import { runAsyncFunction } from './utils' import { runAsyncFunction } from './utils'
import { isOvmsSupported } from './services/OvmsManager'
const logger = loggerService.withContext('MainEntry') const logger = loggerService.withContext('MainEntry')
@ -155,7 +158,8 @@ if (!app.requestSingleInstanceLock()) {
registerShortcuts(mainWindow) registerShortcuts(mainWindow)
registerIpc(mainWindow, app) await registerIpc(mainWindow, app)
localTransferService.startDiscovery({ resetList: true })
replaceDevtoolsFont(mainWindow) replaceDevtoolsFont(mainWindow)
@ -237,16 +241,29 @@ if (!app.requestSingleInstanceLock()) {
if (selectionService) { if (selectionService) {
selectionService.quit() selectionService.quit()
} }
lanTransferClientService.dispose()
localTransferService.dispose()
}) })
app.on('will-quit', async () => { app.on('will-quit', async () => {
// 简单的资源清理,不阻塞退出流程 // 简单的资源清理,不阻塞退出流程
if (isOvmsSupported) {
const { ovmsManager } = await import('./services/OvmsManager')
if (ovmsManager) {
await ovmsManager.stopOvms()
} else {
logger.warn('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.')
}
}
try { try {
await mcpService.cleanup() await mcpService.cleanup()
await apiServerService.stop() await apiServerService.stop()
} catch (error) { } catch (error) {
logger.warn('Error cleaning up MCP service:', error as Error) logger.warn('Error cleaning up MCP service:', error as Error)
} }
// finish the logger // finish the logger
logger.finish() logger.finish()
}) })

View File

@ -6,11 +6,19 @@ import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { generateSignature } from '@main/integration/cherryai' import { generateSignature } from '@main/integration/cherryai'
import anthropicService from '@main/services/AnthropicService' import anthropicService from '@main/services/AnthropicService'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import {
autoDiscoverGitBash,
getBinaryPath,
getGitBashPathInfo,
isBinaryExists,
runInstallScript,
validateGitBashPath
} from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom' import { handleZoomFactor } from '@main/utils/zoom'
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import type { UpgradeChannel } from '@shared/config/constant' import type { UpgradeChannel } from '@shared/config/constant'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
import type { LocalTransferConnectPayload } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import type { PluginError } from '@types' import type { PluginError } from '@types'
import type { import type {
@ -35,13 +43,15 @@ import appService from './services/AppService'
import AppUpdater from './services/AppUpdater' import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager' import BackupManager from './services/BackupManager'
import { codeToolsService } from './services/CodeToolsService' import { codeToolsService } from './services/CodeToolsService'
import { configManager } from './services/ConfigManager' import { ConfigKeys, configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService' import CopilotService from './services/CopilotService'
import DxtService from './services/DxtService' import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService' import { ExportService } from './services/ExportService'
import { fileStorage as fileManager } from './services/FileStorage' import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService' import FileService from './services/FileSystemService'
import KnowledgeService from './services/KnowledgeService' import KnowledgeService from './services/KnowledgeService'
import { lanTransferClientService } from './services/lanTransfer'
import { localTransferService } from './services/LocalTransferService'
import mcpService from './services/MCPService' import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService' import MemoryService from './services/memory/MemoryService'
import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService' import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService'
@ -49,7 +59,7 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService' import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService' import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService' import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager' import { isOvmsSupported } from './services/OvmsManager'
import powerMonitorService from './services/PowerMonitorService' import powerMonitorService from './services/PowerMonitorService'
import { proxyManager } from './services/ProxyManager' import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService' import { pythonService } from './services/PythonService'
@ -73,7 +83,6 @@ import {
import storeSyncService from './services/StoreSyncService' import storeSyncService from './services/StoreSyncService'
import { themeService } from './services/ThemeService' import { themeService } from './services/ThemeService'
import VertexAIService from './services/VertexAIService' import VertexAIService from './services/VertexAIService'
import WebSocketService from './services/WebSocketService'
import { setOpenLinkExternal } from './services/WebviewService' import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService' import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils' import { calculateDirectorySize, getResourcePath } from './utils'
@ -88,6 +97,7 @@ import {
untildify untildify
} from './utils/file' } from './utils/file'
import { updateAppDataConfig } from './utils/init' import { updateAppDataConfig } from './utils/init'
import { getCpuName, getDeviceType, getHostname } from './utils/system'
import { compress, decompress } from './utils/zip' import { compress, decompress } from './utils/zip'
const logger = loggerService.withContext('IPC') const logger = loggerService.withContext('IPC')
@ -98,7 +108,6 @@ const obsidianVaultService = new ObsidianVaultService()
const vertexAIService = VertexAIService.getInstance() const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance() const memoryService = MemoryService.getInstance()
const dxtService = new DxtService() const dxtService = new DxtService()
const ovmsManager = new OvmsManager()
const pluginService = PluginService.getInstance() const pluginService = PluginService.getInstance()
function normalizeError(error: unknown): Error { function normalizeError(error: unknown): Error {
@ -112,7 +121,7 @@ function extractPluginError(error: unknown): PluginError | null {
return null return null
} }
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater() const appUpdater = new AppUpdater()
const notificationService = new NotificationService() const notificationService = new NotificationService()
@ -490,47 +499,69 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text)) ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
// system // system
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux')) ipcMain.handle(IpcChannel.System_GetDeviceType, getDeviceType)
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname()) ipcMain.handle(IpcChannel.System_GetHostname, getHostname)
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model) ipcMain.handle(IpcChannel.System_GetCpuName, getCpuName)
ipcMain.handle(IpcChannel.System_CheckGitBash, () => { ipcMain.handle(IpcChannel.System_CheckGitBash, () => {
if (!isWin) { if (!isWin) {
return true // Non-Windows systems don't need Git Bash return true // Non-Windows systems don't need Git Bash
} }
try { try {
// Check common Git Bash installation paths // Use autoDiscoverGitBash to handle auto-discovery and persistence
const commonPaths = [ const bashPath = autoDiscoverGitBash()
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), if (bashPath) {
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), logger.info('Git Bash is available', { path: bashPath })
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe')
]
// Check if any of the common paths exist
for (const bashPath of commonPaths) {
if (fs.existsSync(bashPath)) {
logger.debug('Git Bash found', { path: bashPath })
return true return true
} }
}
// Check if git is in PATH logger.warn('Git Bash not found. Please install Git for Windows from https://git-scm.com/downloads/win')
const { execSync } = require('child_process')
try {
execSync('git --version', { stdio: 'ignore' })
logger.debug('Git found in PATH')
return true
} catch {
// Git not in PATH
}
logger.debug('Git Bash not found on Windows system')
return false return false
} catch (error) { } catch (error) {
logger.error('Error checking Git Bash', error as Error) logger.error('Unexpected error checking Git Bash', error as Error)
return false return false
} }
}) })
ipcMain.handle(IpcChannel.System_GetGitBashPath, () => {
if (!isWin) {
return null
}
const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined
return customPath ?? null
})
// Returns { path, source } where source is 'manual' | 'auto' | null
ipcMain.handle(IpcChannel.System_GetGitBashPathInfo, () => {
return getGitBashPathInfo()
})
ipcMain.handle(IpcChannel.System_SetGitBashPath, (_, newPath: string | null) => {
if (!isWin) {
return false
}
if (!newPath) {
// Clear manual setting and re-run auto-discovery
configManager.set(ConfigKeys.GitBashPath, null)
configManager.set(ConfigKeys.GitBashPathSource, null)
// Re-run auto-discovery to restore auto-discovered path if available
autoDiscoverGitBash()
return true
}
const validated = validateGitBashPath(newPath)
if (!validated) {
return false
}
// Set path with 'manual' source
configManager.set(ConfigKeys.GitBashPath, validated)
configManager.set(ConfigKeys.GitBashPathSource, 'manual')
return true
})
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => { ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools() win && win.webContents.toggleDevTools()
@ -554,6 +585,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files.bind(backupManager)) ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File.bind(backupManager)) ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection.bind(backupManager)) ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_CreateLanTransferBackup, backupManager.createLanTransferBackup.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_DeleteTempBackup, backupManager.deleteTempBackup.bind(backupManager))
// file // file
ipcMain.handle(IpcChannel.File_Open, fileManager.open.bind(fileManager)) ipcMain.handle(IpcChannel.File_Open, fileManager.open.bind(fileManager))
@ -595,6 +628,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager)) ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager)) ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager)) ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_PauseWatcher, fileManager.pauseFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_ResumeWatcher, fileManager.resumeFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_BatchUploadMarkdown, fileManager.batchUploadMarkdownFiles.bind(fileManager))
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager)) ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
// file service // file service
@ -650,36 +686,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota.bind(KnowledgeService)) ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota.bind(KnowledgeService))
// memory // memory
ipcMain.handle(IpcChannel.Memory_Add, async (_, messages, config) => { ipcMain.handle(IpcChannel.Memory_Add, (_, messages, config) => memoryService.add(messages, config))
return await memoryService.add(messages, config) ipcMain.handle(IpcChannel.Memory_Search, (_, query, config) => memoryService.search(query, config))
}) ipcMain.handle(IpcChannel.Memory_List, (_, config) => memoryService.list(config))
ipcMain.handle(IpcChannel.Memory_Search, async (_, query, config) => { ipcMain.handle(IpcChannel.Memory_Delete, (_, id) => memoryService.delete(id))
return await memoryService.search(query, config) ipcMain.handle(IpcChannel.Memory_Update, (_, id, memory, metadata) => memoryService.update(id, memory, metadata))
}) ipcMain.handle(IpcChannel.Memory_Get, (_, memoryId) => memoryService.get(memoryId))
ipcMain.handle(IpcChannel.Memory_List, async (_, config) => { ipcMain.handle(IpcChannel.Memory_SetConfig, (_, config) => memoryService.setConfig(config))
return await memoryService.list(config) ipcMain.handle(IpcChannel.Memory_DeleteUser, (_, userId) => memoryService.deleteUser(userId))
}) ipcMain.handle(IpcChannel.Memory_DeleteAllMemoriesForUser, (_, userId) =>
ipcMain.handle(IpcChannel.Memory_Delete, async (_, id) => { memoryService.deleteAllMemoriesForUser(userId)
return await memoryService.delete(id) )
}) ipcMain.handle(IpcChannel.Memory_GetUsersList, () => memoryService.getUsersList())
ipcMain.handle(IpcChannel.Memory_Update, async (_, id, memory, metadata) => { ipcMain.handle(IpcChannel.Memory_MigrateMemoryDb, () => memoryService.migrateMemoryDb())
return await memoryService.update(id, memory, metadata)
})
ipcMain.handle(IpcChannel.Memory_Get, async (_, memoryId) => {
return await memoryService.get(memoryId)
})
ipcMain.handle(IpcChannel.Memory_SetConfig, async (_, config) => {
memoryService.setConfig(config)
})
ipcMain.handle(IpcChannel.Memory_DeleteUser, async (_, userId) => {
return await memoryService.deleteUser(userId)
})
ipcMain.handle(IpcChannel.Memory_DeleteAllMemoriesForUser, async (_, userId) => {
return await memoryService.deleteAllMemoriesForUser(userId)
})
ipcMain.handle(IpcChannel.Memory_GetUsersList, async () => {
return await memoryService.getUsersList()
})
// window // window
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => { ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
@ -780,6 +799,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity) ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool) ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool)
ipcMain.handle(IpcChannel.Mcp_GetServerVersion, mcpService.getServerVersion) ipcMain.handle(IpcChannel.Mcp_GetServerVersion, mcpService.getServerVersion)
ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs)
// DXT upload handler // DXT upload handler
ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => { ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => {
@ -838,8 +858,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
) )
// search window // search window
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => { ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string, show?: boolean) => {
await searchService.openSearchWindow(uid) await searchService.openSearchWindow(uid, show)
}) })
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => { ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
await searchService.closeSearchWindow(uid) await searchService.closeSearchWindow(uid)
@ -858,6 +878,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
webview.session.setSpellCheckerEnabled(isEnable) webview.session.setSpellCheckerEnabled(isEnable)
}) })
// Webview print and save handlers
ipcMain.handle(IpcChannel.Webview_PrintToPDF, async (_, webviewId: number) => {
const { printWebviewToPDF } = await import('./services/WebviewService')
return await printWebviewToPDF(webviewId)
})
ipcMain.handle(IpcChannel.Webview_SaveAsHTML, async (_, webviewId: number) => {
const { saveWebviewAsHTML } = await import('./services/WebviewService')
return await saveWebviewAsHTML(webviewId)
})
// store sync // store sync
storeSyncService.registerIpcHandler() storeSyncService.registerIpcHandler()
@ -944,7 +975,13 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds()) ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
// OVMS // OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) => ipcMain.handle(IpcChannel.Ovms_IsSupported, () => isOvmsSupported)
if (isOvmsSupported) {
const { ovmsManager } = await import('./services/OvmsManager')
if (ovmsManager) {
ipcMain.handle(
IpcChannel.Ovms_AddModel,
(_, modelName: string, modelId: string, modelSource: string, task: string) =>
ovmsManager.addModel(modelName, modelId, modelSource, task) ovmsManager.addModel(modelName, modelId, modelSource, task)
) )
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel()) ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
@ -953,6 +990,21 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus()) ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms()) ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms()) ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
} else {
logger.error('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.')
}
} else {
const fallback = () => {
throw new Error('OVMS is only supported on Windows with intel CPU.')
}
ipcMain.handle(IpcChannel.Ovms_AddModel, fallback)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, fallback)
ipcMain.handle(IpcChannel.Ovms_GetModels, fallback)
ipcMain.handle(IpcChannel.Ovms_IsRunning, fallback)
ipcMain.handle(IpcChannel.Ovms_GetStatus, fallback)
ipcMain.handle(IpcChannel.Ovms_RunOVMS, fallback)
ipcMain.handle(IpcChannel.Ovms_StopOVMS, fallback)
}
// CherryAI // CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params)) ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
@ -1009,12 +1061,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
} catch (error) { } catch (error) {
const pluginError = extractPluginError(error) const pluginError = extractPluginError(error)
if (pluginError) { if (pluginError) {
logger.error('Failed to list installed plugins', { agentId, error: pluginError }) logger.error('Failed to list installed plugins', {
agentId,
error: pluginError
})
return { success: false, error: pluginError } return { success: false, error: pluginError }
} }
const err = normalizeError(error) const err = normalizeError(error)
logger.error('Failed to list installed plugins', { agentId, error: err }) logger.error('Failed to list installed plugins', {
agentId,
error: err
})
return { return {
success: false, success: false,
error: { error: {
@ -1070,12 +1128,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
} }
}) })
// WebSocket ipcMain.handle(IpcChannel.LocalTransfer_ListServices, () => localTransferService.getState())
ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start) ipcMain.handle(IpcChannel.LocalTransfer_StartScan, () => localTransferService.startDiscovery({ resetList: true }))
ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop) ipcMain.handle(IpcChannel.LocalTransfer_StopScan, () => localTransferService.stopDiscovery())
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus) ipcMain.handle(IpcChannel.LocalTransfer_Connect, (_, payload: LocalTransferConnectPayload) =>
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) lanTransferClientService.connectAndHandshake(payload)
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) )
ipcMain.handle(IpcChannel.LocalTransfer_Disconnect, () => lanTransferClientService.disconnect())
ipcMain.handle(IpcChannel.LocalTransfer_SendFile, (_, payload: { filePath: string }) =>
lanTransferClientService.sendFile(payload.filePath)
)
ipcMain.handle(IpcChannel.LocalTransfer_CancelTransfer, () => lanTransferClientService.cancelTransfer())
ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => { ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => {
mainWindow.webContents.forcefullyCrashRenderer() mainWindow.webContents.forcefullyCrashRenderer()

View File

@ -19,19 +19,9 @@ export default class EmbeddingsFactory {
}) })
} }
if (provider === 'ollama') { if (provider === 'ollama') {
if (baseURL.includes('v1/')) {
return new OllamaEmbeddings({ return new OllamaEmbeddings({
model: model, model: model,
baseUrl: baseURL.replace('v1/', ''), baseUrl: baseURL.replace(/\/api$/, ''),
requestOptions: {
// @ts-ignore expected
'encoding-format': 'float'
}
})
}
return new OllamaEmbeddings({
model: model,
baseUrl: baseURL,
requestOptions: { requestOptions: {
// @ts-ignore expected // @ts-ignore expected
'encoding-format': 'float' 'encoding-format': 'float'

View File

@ -0,0 +1,372 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('node:fs', () => ({
default: {
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
},
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
}))
vi.mock('electron', () => {
const sendCommand = vi.fn(async (command: string, params?: { expression?: string }) => {
if (command === 'Runtime.evaluate') {
if (params?.expression === 'document.documentElement.outerHTML') {
return { result: { value: '<html><body><h1>Test</h1><p>Content</p></body></html>' } }
}
if (params?.expression === 'document.body.innerText') {
return { result: { value: 'Test\nContent' } }
}
return { result: { value: 'ok' } }
}
return {}
})
const debuggerObj = {
isAttached: vi.fn(() => true),
attach: vi.fn(),
detach: vi.fn(),
sendCommand
}
const createWebContents = () => ({
debugger: debuggerObj,
setUserAgent: vi.fn(),
getURL: vi.fn(() => 'https://example.com/'),
getTitle: vi.fn(async () => 'Example Title'),
loadURL: vi.fn(async () => {}),
once: vi.fn(),
removeListener: vi.fn(),
on: vi.fn(),
isDestroyed: vi.fn(() => false),
canGoBack: vi.fn(() => false),
canGoForward: vi.fn(() => false),
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
executeJavaScript: vi.fn(async () => null),
setWindowOpenHandler: vi.fn()
})
const windows: any[] = []
const views: any[] = []
class MockBrowserWindow {
private destroyed = false
public webContents = createWebContents()
public isDestroyed = vi.fn(() => this.destroyed)
public close = vi.fn(() => {
this.destroyed = true
})
public destroy = vi.fn(() => {
this.destroyed = true
})
public on = vi.fn()
public setBrowserView = vi.fn()
public addBrowserView = vi.fn()
public removeBrowserView = vi.fn()
public getContentSize = vi.fn(() => [1200, 800])
public show = vi.fn()
constructor() {
windows.push(this)
}
}
class MockBrowserView {
public webContents = createWebContents()
public setBounds = vi.fn()
public setAutoResize = vi.fn()
public destroy = vi.fn()
constructor() {
views.push(this)
}
}
const app = {
isReady: vi.fn(() => true),
whenReady: vi.fn(async () => {}),
on: vi.fn(),
getPath: vi.fn((key: string) => {
if (key === 'userData') return '/mock/userData'
if (key === 'temp') return '/tmp'
return '/mock/unknown'
}),
getAppPath: vi.fn(() => '/mock/app'),
setPath: vi.fn()
}
const nativeTheme = {
on: vi.fn(),
shouldUseDarkColors: false
}
return {
BrowserWindow: MockBrowserWindow as any,
BrowserView: MockBrowserView as any,
app,
nativeTheme,
__mockDebugger: debuggerObj,
__mockSendCommand: sendCommand,
__mockWindows: windows,
__mockViews: views
}
})
import { CdpBrowserController } from '../browser'
describe('CdpBrowserController', () => {
it('executes single-line code via Runtime.evaluate', async () => {
const controller = new CdpBrowserController()
const result = await controller.execute('1+1')
expect(result).toBe('ok')
})
it('opens a URL in normal mode and returns current page info', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://foo.bar/', 5000, false)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.title).toBe('Example Title')
})
it('opens a URL in private mode', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://foo.bar/', 5000, true)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.title).toBe('Example Title')
})
it('reuses session for execute and supports multiline', async () => {
const controller = new CdpBrowserController()
await controller.open('https://foo.bar/', 5000, false)
const result = await controller.execute('const a=1; const b=2; a+b;', 5000, false)
expect(result).toBe('ok')
})
it('normal and private modes are isolated', async () => {
const controller = new CdpBrowserController()
await controller.open('https://foo.bar/', 5000, false)
await controller.open('https://foo.bar/', 5000, true)
const normalResult = await controller.execute('1+1', 5000, false)
const privateResult = await controller.execute('1+1', 5000, true)
expect(normalResult).toBe('ok')
expect(privateResult).toBe('ok')
})
it('fetches URL and returns html format with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html')
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
it('fetches URL and returns txt format with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'txt')
expect(result.tabId).toBeDefined()
expect(result.content).toBe('Test\nContent')
})
it('fetches URL and returns markdown format (default) with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/')
expect(result.tabId).toBeDefined()
expect(typeof result.content).toBe('string')
expect(result.content).toContain('Test')
})
it('fetches URL in private mode with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html', 10000, true)
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
describe('Multi-tab support', () => {
it('creates new tab with newTab parameter', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
const result2 = await controller.open('https://site2.com/', 5000, false, true)
expect(result1.tabId).toBeDefined()
expect(result2.tabId).toBeDefined()
expect(result1.tabId).not.toBe(result2.tabId)
})
it('reuses same tab without newTab parameter', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false)
const result2 = await controller.open('https://site2.com/', 5000, false)
expect(result1.tabId).toBe(result2.tabId)
})
it('fetches in new tab with newTab parameter', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
const tabs = await controller.listTabs(false)
const initialTabCount = tabs.length
await controller.fetch('https://other.com/', 'html', 10000, false, true)
const tabsAfter = await controller.listTabs(false)
expect(tabsAfter.length).toBe(initialTabCount + 1)
})
})
describe('Tab management', () => {
it('lists tabs in a window', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
const tabs = await controller.listTabs(false)
expect(tabs.length).toBeGreaterThan(0)
expect(tabs[0].tabId).toBeDefined()
})
it('lists tabs separately for normal and private modes', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(1)
expect(privateTabs.length).toBe(1)
expect(normalTabs[0].tabId).not.toBe(privateTabs[0].tabId)
})
it('closes specific tab', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
await controller.open('https://site2.com/', 5000, false, true)
const tabsBefore = await controller.listTabs(false)
expect(tabsBefore.length).toBe(2)
await controller.closeTab(false, result1.tabId)
const tabsAfter = await controller.listTabs(false)
expect(tabsAfter.length).toBe(1)
expect(tabsAfter.find((t) => t.tabId === result1.tabId)).toBeUndefined()
})
it('switches active tab', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
const result2 = await controller.open('https://site2.com/', 5000, false, true)
await controller.switchTab(false, result1.tabId)
await controller.switchTab(false, result2.tabId)
})
it('throws error when switching to non-existent tab', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await expect(controller.switchTab(false, 'non-existent-tab')).rejects.toThrow('Tab non-existent-tab not found')
})
})
describe('Reset behavior', () => {
it('resets specific tab only', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
await controller.open('https://site2.com/', 5000, false, true)
await controller.reset(false, result1.tabId)
const tabs = await controller.listTabs(false)
expect(tabs.length).toBe(1)
})
it('resets specific window only', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
await controller.reset(false)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(0)
expect(privateTabs.length).toBe(1)
})
it('resets all windows', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
await controller.reset()
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(0)
expect(privateTabs.length).toBe(0)
})
})
describe('showWindow parameter', () => {
it('passes showWindow parameter through open', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://example.com/', 5000, false, false, true)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.tabId).toBeDefined()
})
it('passes showWindow parameter through fetch', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html', 10000, false, false, true)
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
it('passes showWindow parameter through createTab', async () => {
const controller = new CdpBrowserController()
const { tabId, view } = await controller.createTab(false, true)
expect(tabId).toBeDefined()
expect(view).toBeDefined()
})
it('shows existing window when showWindow=true on subsequent calls', async () => {
const controller = new CdpBrowserController()
// First call creates window
await controller.open('https://example.com/', 5000, false, false, false)
// Second call with showWindow=true should show existing window
const result = await controller.open('https://example.com/', 5000, false, false, true)
expect(result.currentUrl).toBe('https://example.com/')
})
})
describe('Window limits and eviction', () => {
it('respects maxWindows limit', async () => {
const controller = new CdpBrowserController({ maxWindows: 1 })
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(privateTabs.length).toBe(1)
expect(normalTabs.length).toBe(0)
})
it('cleans up idle windows on next access', async () => {
const controller = new CdpBrowserController({ idleTimeoutMs: 1 })
await controller.open('https://example.com/', 5000, false)
await new Promise((r) => setTimeout(r, 10))
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
expect(normalTabs.length).toBe(0)
})
})
})

View File

@ -0,0 +1,177 @@
# Browser MCP Server
A Model Context Protocol (MCP) server for controlling browser windows via Chrome DevTools Protocol (CDP).
## Features
### ✨ User Data Persistence
- **Normal mode (default)**: Cookies, localStorage, and sessionStorage persist across browser restarts
- **Private mode**: Ephemeral browsing - no data persists (like incognito mode)
### 🔄 Window Management
- Two browsing modes: normal (persistent) and private (ephemeral)
- Lazy idle timeout cleanup (cleaned on next window access)
- Maximum window limits to prevent resource exhaustion
> **Note**: Normal mode uses a global `persist:default` partition shared by all clients. This means login sessions and stored data are accessible to any code using the MCP server.
## Architecture
### How It Works
```
Normal Mode (BrowserWindow)
├─ Persistent Storage (partition: persist:default) ← Global, shared across all clients
└─ Tabs (BrowserView) ← created via newTab or automatically
Private Mode (BrowserWindow)
├─ Ephemeral Storage (partition: private) ← No disk persistence
└─ Tabs (BrowserView) ← created via newTab or automatically
```
- **One Window Per Mode**: Normal and private modes each have their own window
- **Multi-Tab Support**: Use `newTab: true` for parallel URL requests
- **Storage Isolation**: Normal and private modes have completely separate storage
## Available Tools
### `open`
Open a URL in a browser window. Optionally return page content.
```json
{
"url": "https://example.com",
"format": "markdown",
"timeout": 10000,
"privateMode": false,
"newTab": false,
"showWindow": false
}
```
- `format`: If set (`html`, `txt`, `markdown`, `json`), returns page content in that format along with tabId. If not set, just opens the page and returns navigation info.
- `newTab`: Set to `true` to open in a new tab (required for parallel requests)
- `showWindow`: Set to `true` to display the browser window (useful for debugging)
- Returns (without format): `{ currentUrl, title, tabId }`
- Returns (with format): `{ tabId, content }` where content is in the specified format
### `execute`
Execute JavaScript code in the page context.
```json
{
"code": "document.title",
"timeout": 5000,
"privateMode": false,
"tabId": "optional-tab-id"
}
```
- `tabId`: Target a specific tab (from `open` response)
### `reset`
Reset browser windows and tabs.
```json
{
"privateMode": false,
"tabId": "optional-tab-id"
}
```
- Omit all parameters to close all windows
- Set `privateMode` to close a specific window
- Set both `privateMode` and `tabId` to close a specific tab only
## Usage Examples
### Basic Navigation
```typescript
// Open a URL in normal mode (data persists)
await controller.open('https://example.com')
```
### Fetch Page Content
```typescript
// Open URL and get content as markdown
await open({ url: 'https://example.com', format: 'markdown' })
// Open URL and get raw HTML
await open({ url: 'https://example.com', format: 'html' })
```
### Multi-Tab / Parallel Requests
```typescript
// Open multiple URLs in parallel using newTab
const [page1, page2] = await Promise.all([
controller.open('https://site1.com', 10000, false, true), // newTab: true
controller.open('https://site2.com', 10000, false, true) // newTab: true
])
// Execute on specific tab
await controller.execute('document.title', 5000, false, page1.tabId)
// Close specific tab when done
await controller.reset(false, page1.tabId)
```
### Private Browsing
```typescript
// Open a URL in private mode (no data persistence)
await controller.open('https://example.com', 10000, true)
// Cookies and localStorage won't persist after reset
```
### Data Persistence (Normal Mode)
```typescript
// Set data
await controller.open('https://example.com', 10000, false)
await controller.execute('localStorage.setItem("key", "value")', 5000, false)
// Close window
await controller.reset(false)
// Reopen - data persists!
await controller.open('https://example.com', 10000, false)
const value = await controller.execute('localStorage.getItem("key")', 5000, false)
// Returns: "value"
```
### No Persistence (Private Mode)
```typescript
// Set data in private mode
await controller.open('https://example.com', 10000, true)
await controller.execute('localStorage.setItem("key", "value")', 5000, true)
// Close private window
await controller.reset(true)
// Reopen - data is gone!
await controller.open('https://example.com', 10000, true)
const value = await controller.execute('localStorage.getItem("key")', 5000, true)
// Returns: null
```
## Configuration
```typescript
const controller = new CdpBrowserController({
maxWindows: 5, // Maximum concurrent windows
idleTimeoutMs: 5 * 60 * 1000 // 5 minutes idle timeout (lazy cleanup)
})
```
> **Note on Idle Timeout**: Idle windows are cleaned up lazily when the next window is created or accessed, not on a background timer.
## Best Practices
1. **Use Normal Mode for Authentication**: When you need to stay logged in across sessions
2. **Use Private Mode for Sensitive Operations**: When you don't want data to persist
3. **Use `newTab: true` for Parallel Requests**: Avoid race conditions when fetching multiple URLs
4. **Resource Cleanup**: Call `reset()` when done, or `reset(privateMode, tabId)` to close specific tabs
5. **Error Handling**: All tool handlers return error responses on failure
6. **Timeout Configuration**: Adjust timeouts based on page complexity
## Technical Details
- **CDP Version**: 1.3
- **User Agent**: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0
- **Storage**:
- Normal mode: `persist:default` (disk-persisted, global)
- Private mode: `private` (memory only)
- **Window Size**: 1200x800 (default)
- **Visibility**: Windows hidden by default (use `showWindow: true` to display)

View File

@ -0,0 +1,3 @@
export const TAB_BAR_HEIGHT = 92 // Height for Chrome-style tab bar (42px) + address bar (50px)
export const SESSION_KEY_DEFAULT = 'default'
export const SESSION_KEY_PRIVATE = 'private'

View File

@ -0,0 +1,909 @@
import { titleBarOverlayDark, titleBarOverlayLight } from '@main/config'
import { isMac } from '@main/constant'
import { randomUUID } from 'crypto'
import { app, BrowserView, BrowserWindow, nativeTheme } from 'electron'
import TurndownService from 'turndown'
import { SESSION_KEY_DEFAULT, SESSION_KEY_PRIVATE, TAB_BAR_HEIGHT } from './constants'
import { TAB_BAR_HTML } from './tabbar-html'
import { logger, type TabInfo, userAgent, type WindowInfo } from './types'
/**
* Controller for managing browser windows via Chrome DevTools Protocol (CDP).
* Supports two modes: normal (persistent) and private (ephemeral).
* Normal mode persists user data (cookies, localStorage, etc.) globally across all clients.
* Private mode is ephemeral - data is cleared when the window closes.
*/
export class CdpBrowserController {
private windows: Map<string, WindowInfo> = new Map()
private readonly maxWindows: number
private readonly idleTimeoutMs: number
private readonly turndownService: TurndownService
constructor(options?: { maxWindows?: number; idleTimeoutMs?: number }) {
this.maxWindows = options?.maxWindows ?? 5
this.idleTimeoutMs = options?.idleTimeoutMs ?? 5 * 60 * 1000
this.turndownService = new TurndownService()
// Listen for theme changes and update all tab bars
nativeTheme.on('updated', () => {
const isDark = nativeTheme.shouldUseDarkColors
for (const windowInfo of this.windows.values()) {
if (windowInfo.tabBarView && !windowInfo.tabBarView.webContents.isDestroyed()) {
windowInfo.tabBarView.webContents.executeJavaScript(`window.setTheme(${isDark})`).catch(() => {
// Ignore errors if tab bar is not ready
})
}
}
})
}
private getWindowKey(privateMode: boolean): string {
return privateMode ? SESSION_KEY_PRIVATE : SESSION_KEY_DEFAULT
}
private getPartition(privateMode: boolean): string {
return privateMode ? SESSION_KEY_PRIVATE : `persist:${SESSION_KEY_DEFAULT}`
}
private async ensureAppReady() {
if (!app.isReady()) {
await app.whenReady()
}
}
private touchWindow(windowKey: string) {
const windowInfo = this.windows.get(windowKey)
if (windowInfo) windowInfo.lastActive = Date.now()
}
private touchTab(windowKey: string, tabId: string) {
const windowInfo = this.windows.get(windowKey)
if (windowInfo) {
const tab = windowInfo.tabs.get(tabId)
if (tab) tab.lastActive = Date.now()
windowInfo.lastActive = Date.now()
}
}
private closeTabInternal(windowInfo: WindowInfo, tabId: string) {
try {
const tab = windowInfo.tabs.get(tabId)
if (!tab) return
if (!tab.view.webContents.isDestroyed()) {
if (tab.view.webContents.debugger.isAttached()) {
tab.view.webContents.debugger.detach()
}
}
// Remove view from window
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.removeBrowserView(tab.view)
}
// Destroy the view using safe cast
const viewWithDestroy = tab.view as BrowserView & { destroy?: () => void }
if (viewWithDestroy.destroy) {
viewWithDestroy.destroy()
}
} catch (error) {
logger.warn('Error closing tab', { error, windowKey: windowInfo.windowKey, tabId })
}
}
private async ensureDebuggerAttached(dbg: Electron.Debugger, sessionKey: string) {
if (!dbg.isAttached()) {
try {
logger.info('Attaching debugger', { sessionKey })
dbg.attach('1.3')
await dbg.sendCommand('Page.enable')
await dbg.sendCommand('Runtime.enable')
logger.info('Debugger attached and domains enabled')
} catch (error) {
logger.error('Failed to attach debugger', { error })
throw error
}
}
}
private sweepIdle() {
const now = Date.now()
const windowKeys = Array.from(this.windows.keys())
for (const windowKey of windowKeys) {
const windowInfo = this.windows.get(windowKey)
if (!windowInfo) continue
if (now - windowInfo.lastActive > this.idleTimeoutMs) {
const tabIds = Array.from(windowInfo.tabs.keys())
for (const tabId of tabIds) {
this.closeTabInternal(windowInfo, tabId)
}
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.close()
}
this.windows.delete(windowKey)
}
}
}
private evictIfNeeded(newWindowKey: string) {
if (this.windows.size < this.maxWindows) return
let lruKey: string | null = null
let lruTime = Number.POSITIVE_INFINITY
for (const [key, windowInfo] of this.windows.entries()) {
if (key === newWindowKey) continue
if (windowInfo.lastActive < lruTime) {
lruTime = windowInfo.lastActive
lruKey = key
}
}
if (lruKey) {
const windowInfo = this.windows.get(lruKey)
if (windowInfo) {
for (const [tabId] of windowInfo.tabs.entries()) {
this.closeTabInternal(windowInfo, tabId)
}
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.close()
}
}
this.windows.delete(lruKey)
logger.info('Evicted window to respect maxWindows', { evicted: lruKey })
}
}
private sendTabBarUpdate(windowInfo: WindowInfo) {
if (!windowInfo.tabBarView || !windowInfo.tabBarView.webContents || windowInfo.tabBarView.webContents.isDestroyed())
return
const tabs = Array.from(windowInfo.tabs.values()).map((tab) => ({
id: tab.id,
title: tab.title || 'New Tab',
url: tab.url,
isActive: tab.id === windowInfo.activeTabId
}))
let activeUrl = ''
let canGoBack = false
let canGoForward = false
if (windowInfo.activeTabId) {
const activeTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (activeTab && !activeTab.view.webContents.isDestroyed()) {
activeUrl = activeTab.view.webContents.getURL()
canGoBack = activeTab.view.webContents.canGoBack()
canGoForward = activeTab.view.webContents.canGoForward()
}
}
const script = `window.updateTabs(${JSON.stringify(tabs)}, ${JSON.stringify(activeUrl)}, ${canGoBack}, ${canGoForward})`
windowInfo.tabBarView.webContents.executeJavaScript(script).catch((error) => {
logger.debug('Tab bar update failed', { error, windowKey: windowInfo.windowKey })
})
}
private handleNavigateAction(windowInfo: WindowInfo, url: string) {
if (!windowInfo.activeTabId) return
const activeTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (!activeTab || activeTab.view.webContents.isDestroyed()) return
let finalUrl = url.trim()
if (!/^https?:\/\//i.test(finalUrl)) {
if (/^[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}/.test(finalUrl) || finalUrl.includes('.')) {
finalUrl = 'https://' + finalUrl
} else {
finalUrl = 'https://www.google.com/search?q=' + encodeURIComponent(finalUrl)
}
}
activeTab.view.webContents.loadURL(finalUrl).catch((error) => {
logger.warn('Navigation failed in tab bar', { error, url: finalUrl, tabId: windowInfo.activeTabId })
})
}
private handleBackAction(windowInfo: WindowInfo) {
if (!windowInfo.activeTabId) return
const activeTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (!activeTab || activeTab.view.webContents.isDestroyed()) return
if (activeTab.view.webContents.canGoBack()) {
activeTab.view.webContents.goBack()
}
}
private handleForwardAction(windowInfo: WindowInfo) {
if (!windowInfo.activeTabId) return
const activeTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (!activeTab || activeTab.view.webContents.isDestroyed()) return
if (activeTab.view.webContents.canGoForward()) {
activeTab.view.webContents.goForward()
}
}
private handleRefreshAction(windowInfo: WindowInfo) {
if (!windowInfo.activeTabId) return
const activeTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (!activeTab || activeTab.view.webContents.isDestroyed()) return
activeTab.view.webContents.reload()
}
private setupTabBarMessageHandler(windowInfo: WindowInfo) {
if (!windowInfo.tabBarView) return
windowInfo.tabBarView.webContents.on('console-message', (_event, _level, message) => {
try {
const parsed = JSON.parse(message)
if (parsed?.channel === 'tabbar-action' && parsed?.payload) {
this.handleTabBarAction(windowInfo, parsed.payload)
}
} catch {
// Not a JSON message, ignore
}
})
windowInfo.tabBarView.webContents
.executeJavaScript(`
(function() {
window.addEventListener('message', function(e) {
if (e.data && e.data.channel === 'tabbar-action') {
console.log(JSON.stringify(e.data));
}
});
})();
`)
.catch((error) => {
logger.debug('Tab bar message handler setup failed', { error, windowKey: windowInfo.windowKey })
})
}
private handleTabBarAction(windowInfo: WindowInfo, action: { type: string; tabId?: string; url?: string }) {
if (action.type === 'switch' && action.tabId) {
this.switchTab(windowInfo.privateMode, action.tabId).catch((error) => {
logger.warn('Tab switch failed', { error, tabId: action.tabId, windowKey: windowInfo.windowKey })
})
} else if (action.type === 'close' && action.tabId) {
this.closeTab(windowInfo.privateMode, action.tabId).catch((error) => {
logger.warn('Tab close failed', { error, tabId: action.tabId, windowKey: windowInfo.windowKey })
})
} else if (action.type === 'new') {
this.createTab(windowInfo.privateMode, true)
.then(({ tabId }) => this.switchTab(windowInfo.privateMode, tabId))
.catch((error) => {
logger.warn('New tab creation failed', { error, windowKey: windowInfo.windowKey })
})
} else if (action.type === 'navigate' && action.url) {
this.handleNavigateAction(windowInfo, action.url)
} else if (action.type === 'back') {
this.handleBackAction(windowInfo)
} else if (action.type === 'forward') {
this.handleForwardAction(windowInfo)
} else if (action.type === 'refresh') {
this.handleRefreshAction(windowInfo)
} else if (action.type === 'window-minimize') {
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.minimize()
}
} else if (action.type === 'window-maximize') {
if (!windowInfo.window.isDestroyed()) {
if (windowInfo.window.isMaximized()) {
windowInfo.window.unmaximize()
} else {
windowInfo.window.maximize()
}
}
} else if (action.type === 'window-close') {
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.close()
}
}
}
private createTabBarView(windowInfo: WindowInfo): BrowserView {
const tabBarView = new BrowserView({
webPreferences: {
contextIsolation: false,
sandbox: false,
nodeIntegration: false
}
})
windowInfo.window.addBrowserView(tabBarView)
const [width] = windowInfo.window.getContentSize()
tabBarView.setBounds({ x: 0, y: 0, width, height: TAB_BAR_HEIGHT })
tabBarView.setAutoResize({ width: true, height: false })
tabBarView.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(TAB_BAR_HTML)}`)
tabBarView.webContents.on('did-finish-load', () => {
// Initialize platform for proper styling
const platform = isMac ? 'mac' : process.platform === 'win32' ? 'win' : 'linux'
tabBarView.webContents.executeJavaScript(`window.initPlatform('${platform}')`).catch((error) => {
logger.debug('Platform init failed', { error, windowKey: windowInfo.windowKey })
})
// Initialize theme
const isDark = nativeTheme.shouldUseDarkColors
tabBarView.webContents.executeJavaScript(`window.setTheme(${isDark})`).catch((error) => {
logger.debug('Theme init failed', { error, windowKey: windowInfo.windowKey })
})
this.setupTabBarMessageHandler(windowInfo)
this.sendTabBarUpdate(windowInfo)
})
return tabBarView
}
private async createBrowserWindow(
windowKey: string,
privateMode: boolean,
showWindow = false
): Promise<BrowserWindow> {
await this.ensureAppReady()
const partition = this.getPartition(privateMode)
const win = new BrowserWindow({
show: showWindow,
width: 1200,
height: 800,
...(isMac
? {
titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
trafficLightPosition: { x: 8, y: 13 }
}
: {
frame: false // Frameless window for Windows and Linux
}),
webPreferences: {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
partition
}
})
win.on('closed', () => {
const windowInfo = this.windows.get(windowKey)
if (windowInfo) {
const tabIds = Array.from(windowInfo.tabs.keys())
for (const tabId of tabIds) {
this.closeTabInternal(windowInfo, tabId)
}
this.windows.delete(windowKey)
}
})
return win
}
private async getOrCreateWindow(privateMode: boolean, showWindow = false): Promise<WindowInfo> {
await this.ensureAppReady()
this.sweepIdle()
const windowKey = this.getWindowKey(privateMode)
let windowInfo = this.windows.get(windowKey)
if (!windowInfo) {
this.evictIfNeeded(windowKey)
const window = await this.createBrowserWindow(windowKey, privateMode, showWindow)
windowInfo = {
windowKey,
privateMode,
window,
tabs: new Map(),
activeTabId: null,
lastActive: Date.now(),
tabBarView: undefined
}
this.windows.set(windowKey, windowInfo)
const tabBarView = this.createTabBarView(windowInfo)
windowInfo.tabBarView = tabBarView
// Register resize listener once per window (not per tab)
// Capture windowKey to look up fresh windowInfo on each resize
windowInfo.window.on('resize', () => {
const info = this.windows.get(windowKey)
if (info) this.updateViewBounds(info)
})
logger.info('Created new window', { windowKey, privateMode })
} else if (showWindow && !windowInfo.window.isDestroyed()) {
windowInfo.window.show()
}
this.touchWindow(windowKey)
return windowInfo
}
private updateViewBounds(windowInfo: WindowInfo) {
if (windowInfo.window.isDestroyed()) return
const [width, height] = windowInfo.window.getContentSize()
// Update tab bar bounds
if (windowInfo.tabBarView && !windowInfo.tabBarView.webContents.isDestroyed()) {
windowInfo.tabBarView.setBounds({ x: 0, y: 0, width, height: TAB_BAR_HEIGHT })
}
// Update active tab view bounds
if (windowInfo.activeTabId) {
const activeTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (activeTab && !activeTab.view.webContents.isDestroyed()) {
activeTab.view.setBounds({
x: 0,
y: TAB_BAR_HEIGHT,
width,
height: Math.max(0, height - TAB_BAR_HEIGHT)
})
}
}
}
/**
* Creates a new tab in the window
* @param privateMode - If true, uses private browsing mode (default: false)
* @param showWindow - If true, shows the browser window (default: false)
* @returns Tab ID and view
*/
public async createTab(privateMode = false, showWindow = false): Promise<{ tabId: string; view: BrowserView }> {
const windowInfo = await this.getOrCreateWindow(privateMode, showWindow)
const tabId = randomUUID()
const partition = this.getPartition(privateMode)
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
partition
}
})
view.webContents.setUserAgent(userAgent)
const windowKey = windowInfo.windowKey
view.webContents.on('did-start-loading', () => logger.info(`did-start-loading`, { windowKey, tabId }))
view.webContents.on('dom-ready', () => logger.info(`dom-ready`, { windowKey, tabId }))
view.webContents.on('did-finish-load', () => logger.info(`did-finish-load`, { windowKey, tabId }))
view.webContents.on('did-fail-load', (_e, code, desc) => logger.warn('Navigation failed', { code, desc }))
view.webContents.on('destroyed', () => {
windowInfo.tabs.delete(tabId)
if (windowInfo.activeTabId === tabId) {
windowInfo.activeTabId = windowInfo.tabs.keys().next().value ?? null
if (windowInfo.activeTabId) {
const newActiveTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (newActiveTab && !windowInfo.window.isDestroyed()) {
windowInfo.window.addBrowserView(newActiveTab.view)
this.updateViewBounds(windowInfo)
}
}
}
this.sendTabBarUpdate(windowInfo)
})
view.webContents.on('page-title-updated', (_event, title) => {
tabInfo.title = title
this.sendTabBarUpdate(windowInfo)
})
view.webContents.on('did-navigate', (_event, url) => {
tabInfo.url = url
this.sendTabBarUpdate(windowInfo)
})
view.webContents.on('did-navigate-in-page', (_event, url) => {
tabInfo.url = url
this.sendTabBarUpdate(windowInfo)
})
// Handle new window requests (e.g., target="_blank" links) - open in new tab instead
view.webContents.setWindowOpenHandler(({ url }) => {
// Create a new tab and navigate to the URL
this.createTab(privateMode, true)
.then(({ tabId: newTabId }) => {
return this.switchTab(privateMode, newTabId).then(() => {
const newTab = windowInfo.tabs.get(newTabId)
if (newTab && !newTab.view.webContents.isDestroyed()) {
newTab.view.webContents.loadURL(url)
}
})
})
.catch((error) => {
logger.warn('Failed to open link in new tab', { error, url })
})
return { action: 'deny' }
})
const tabInfo: TabInfo = {
id: tabId,
view,
url: '',
title: '',
lastActive: Date.now()
}
windowInfo.tabs.set(tabId, tabInfo)
// Set as active tab and add to window
if (!windowInfo.activeTabId || windowInfo.tabs.size === 1) {
windowInfo.activeTabId = tabId
windowInfo.window.addBrowserView(view)
this.updateViewBounds(windowInfo)
}
this.sendTabBarUpdate(windowInfo)
logger.info('Created new tab', { windowKey, tabId, privateMode })
return { tabId, view }
}
/**
* Gets an existing tab or creates a new one
* @param privateMode - Whether to use private browsing mode
* @param tabId - Optional specific tab ID to use
* @param newTab - If true, always create a new tab (useful for parallel requests)
* @param showWindow - If true, shows the browser window (default: false)
*/
private async getTab(
privateMode: boolean,
tabId?: string,
newTab?: boolean,
showWindow = false
): Promise<{ tabId: string; tab: TabInfo }> {
const windowInfo = await this.getOrCreateWindow(privateMode, showWindow)
// If newTab is requested, create a fresh tab
if (newTab) {
const { tabId: freshTabId } = await this.createTab(privateMode, showWindow)
const tab = windowInfo.tabs.get(freshTabId)
if (!tab) {
throw new Error(`Tab ${freshTabId} was created but not found - it may have been closed`)
}
return { tabId: freshTabId, tab }
}
if (tabId) {
const tab = windowInfo.tabs.get(tabId)
if (tab && !tab.view.webContents.isDestroyed()) {
this.touchTab(windowInfo.windowKey, tabId)
return { tabId, tab }
}
}
// Use active tab or create new one
if (windowInfo.activeTabId) {
const activeTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (activeTab && !activeTab.view.webContents.isDestroyed()) {
this.touchTab(windowInfo.windowKey, windowInfo.activeTabId)
return { tabId: windowInfo.activeTabId, tab: activeTab }
}
}
// Create new tab
const { tabId: newTabId } = await this.createTab(privateMode, showWindow)
const tab = windowInfo.tabs.get(newTabId)
if (!tab) {
throw new Error(`Tab ${newTabId} was created but not found - it may have been closed`)
}
return { tabId: newTabId, tab }
}
/**
* Opens a URL in a browser window and waits for navigation to complete.
* @param url - The URL to navigate to
* @param timeout - Navigation timeout in milliseconds (default: 10000)
* @param privateMode - If true, uses private browsing mode (default: false)
* @param newTab - If true, always creates a new tab (useful for parallel requests)
* @param showWindow - If true, shows the browser window (default: false)
* @returns Object containing the current URL, page title, and tab ID after navigation
*/
public async open(url: string, timeout = 10000, privateMode = false, newTab = false, showWindow = false) {
const { tabId: actualTabId, tab } = await this.getTab(privateMode, undefined, newTab, showWindow)
const view = tab.view
const windowKey = this.getWindowKey(privateMode)
logger.info('Loading URL', { url, windowKey, tabId: actualTabId, privateMode })
const { webContents } = view
this.touchTab(windowKey, actualTabId)
let resolved = false
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
let onFinish: () => void
let onDomReady: () => void
let onFail: (_event: Electron.Event, code: number, desc: string) => void
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle)
webContents.removeListener('did-finish-load', onFinish)
webContents.removeListener('did-fail-load', onFail)
webContents.removeListener('dom-ready', onDomReady)
}
const loadPromise = new Promise<void>((resolve, reject) => {
onFinish = () => {
if (resolved) return
resolved = true
cleanup()
resolve()
}
onDomReady = () => {
if (resolved) return
resolved = true
cleanup()
resolve()
}
onFail = (_event: Electron.Event, code: number, desc: string) => {
if (resolved) return
resolved = true
cleanup()
reject(new Error(`Navigation failed (${code}): ${desc}`))
}
webContents.once('did-finish-load', onFinish)
webContents.once('dom-ready', onDomReady)
webContents.once('did-fail-load', onFail)
})
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error('Navigation timed out')), timeout)
})
try {
await Promise.race([view.webContents.loadURL(url), loadPromise, timeoutPromise])
} finally {
cleanup()
}
const currentUrl = webContents.getURL()
const title = await webContents.getTitle()
// Update tab info
tab.url = currentUrl
tab.title = title
return { currentUrl, title, tabId: actualTabId }
}
/**
* Executes JavaScript code in the page context using Chrome DevTools Protocol.
* @param code - JavaScript code to evaluate in the page
* @param timeout - Execution timeout in milliseconds (default: 5000)
* @param privateMode - If true, targets the private browsing window (default: false)
* @param tabId - Optional specific tab ID to target; if omitted, uses the active tab
* @returns The result value from the evaluated code, or null if no value returned
*/
public async execute(code: string, timeout = 5000, privateMode = false, tabId?: string) {
const { tabId: actualTabId, tab } = await this.getTab(privateMode, tabId)
const windowKey = this.getWindowKey(privateMode)
this.touchTab(windowKey, actualTabId)
const dbg = tab.view.webContents.debugger
await this.ensureDebuggerAttached(dbg, windowKey)
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const evalPromise = dbg.sendCommand('Runtime.evaluate', {
expression: code,
awaitPromise: true,
returnByValue: true
})
try {
const result = await Promise.race([
evalPromise,
new Promise((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error('Execution timed out')), timeout)
})
])
const evalResult = result as any
if (evalResult?.exceptionDetails) {
const message = evalResult.exceptionDetails.exception?.description || 'Unknown script error'
logger.warn('Runtime.evaluate raised exception', { message })
throw new Error(message)
}
const value = evalResult?.result?.value ?? evalResult?.result?.description ?? null
return value
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle)
}
}
public async reset(privateMode?: boolean, tabId?: string) {
if (privateMode !== undefined && tabId) {
const windowKey = this.getWindowKey(privateMode)
const windowInfo = this.windows.get(windowKey)
if (windowInfo) {
this.closeTabInternal(windowInfo, tabId)
windowInfo.tabs.delete(tabId)
// If no tabs left, close the window
if (windowInfo.tabs.size === 0) {
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.close()
}
this.windows.delete(windowKey)
logger.info('Browser CDP window closed (last tab closed)', { windowKey, tabId })
return
}
if (windowInfo.activeTabId === tabId) {
windowInfo.activeTabId = windowInfo.tabs.keys().next().value ?? null
if (windowInfo.activeTabId) {
const newActiveTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (newActiveTab && !windowInfo.window.isDestroyed()) {
windowInfo.window.addBrowserView(newActiveTab.view)
this.updateViewBounds(windowInfo)
}
}
}
this.sendTabBarUpdate(windowInfo)
}
logger.info('Browser CDP tab reset', { windowKey, tabId })
return
}
if (privateMode !== undefined) {
const windowKey = this.getWindowKey(privateMode)
const windowInfo = this.windows.get(windowKey)
if (windowInfo) {
const tabIds = Array.from(windowInfo.tabs.keys())
for (const tid of tabIds) {
this.closeTabInternal(windowInfo, tid)
}
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.close()
}
}
this.windows.delete(windowKey)
logger.info('Browser CDP window reset', { windowKey, privateMode })
return
}
const allWindowInfos = Array.from(this.windows.values())
for (const windowInfo of allWindowInfos) {
const tabIds = Array.from(windowInfo.tabs.keys())
for (const tid of tabIds) {
this.closeTabInternal(windowInfo, tid)
}
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.close()
}
}
this.windows.clear()
logger.info('Browser CDP context reset (all windows)')
}
/**
* Fetches a URL and returns content in the specified format.
* @param url - The URL to fetch
* @param format - Output format: 'html', 'txt', 'markdown', or 'json' (default: 'markdown')
* @param timeout - Navigation timeout in milliseconds (default: 10000)
* @param privateMode - If true, uses private browsing mode (default: false)
* @param newTab - If true, always creates a new tab (useful for parallel requests)
* @param showWindow - If true, shows the browser window (default: false)
* @returns Object with tabId and content in the requested format. For 'json', content is parsed object or { data: rawContent } if parsing fails
*/
public async fetch(
url: string,
format: 'html' | 'txt' | 'markdown' | 'json' = 'markdown',
timeout = 10000,
privateMode = false,
newTab = false,
showWindow = false
): Promise<{ tabId: string; content: string | object }> {
const { tabId } = await this.open(url, timeout, privateMode, newTab, showWindow)
const { tab } = await this.getTab(privateMode, tabId, false, showWindow)
const dbg = tab.view.webContents.debugger
const windowKey = this.getWindowKey(privateMode)
await this.ensureDebuggerAttached(dbg, windowKey)
let expression: string
if (format === 'json' || format === 'txt') {
expression = 'document.body.innerText'
} else {
expression = 'document.documentElement.outerHTML'
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
try {
const result = (await Promise.race([
dbg.sendCommand('Runtime.evaluate', {
expression,
returnByValue: true
}),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error('Fetch content timed out')), timeout)
})
])) as { result?: { value?: string } }
const rawContent = result?.result?.value ?? ''
let content: string | object
if (format === 'markdown') {
content = this.turndownService.turndown(rawContent)
} else if (format === 'json') {
try {
content = JSON.parse(rawContent)
} catch (parseError) {
logger.warn('JSON parse failed, returning raw content', {
url,
contentLength: rawContent.length,
error: parseError
})
content = { data: rawContent }
}
} else {
content = rawContent
}
return { tabId, content }
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle)
}
}
/**
* Lists all tabs in a window
* @param privateMode - If true, lists tabs from private window (default: false)
*/
public async listTabs(privateMode = false): Promise<Array<{ tabId: string; url: string; title: string }>> {
const windowKey = this.getWindowKey(privateMode)
const windowInfo = this.windows.get(windowKey)
if (!windowInfo) return []
return Array.from(windowInfo.tabs.values()).map((tab) => ({
tabId: tab.id,
url: tab.url,
title: tab.title
}))
}
/**
* Closes a specific tab
* @param privateMode - If true, closes tab from private window (default: false)
* @param tabId - Tab identifier to close
*/
public async closeTab(privateMode: boolean, tabId: string) {
await this.reset(privateMode, tabId)
}
/**
* Switches the active tab
* @param privateMode - If true, switches tab in private window (default: false)
* @param tabId - Tab identifier to switch to
*/
public async switchTab(privateMode: boolean, tabId: string) {
const windowKey = this.getWindowKey(privateMode)
const windowInfo = this.windows.get(windowKey)
if (!windowInfo) throw new Error(`Window not found for ${privateMode ? 'private' : 'normal'} mode`)
const tab = windowInfo.tabs.get(tabId)
if (!tab) throw new Error(`Tab ${tabId} not found`)
// Remove previous active tab view (but NOT the tabBarView)
if (windowInfo.activeTabId && windowInfo.activeTabId !== tabId) {
const prevTab = windowInfo.tabs.get(windowInfo.activeTabId)
if (prevTab && !windowInfo.window.isDestroyed()) {
windowInfo.window.removeBrowserView(prevTab.view)
}
}
windowInfo.activeTabId = tabId
// Add the new active tab view
if (!windowInfo.window.isDestroyed()) {
windowInfo.window.addBrowserView(tab.view)
this.updateViewBounds(windowInfo)
}
this.touchTab(windowKey, tabId)
this.sendTabBarUpdate(windowInfo)
logger.info('Switched active tab', { windowKey, tabId, privateMode })
}
}

View File

@ -0,0 +1,3 @@
export { CdpBrowserController } from './controller'
export { BrowserServer } from './server'
export { BrowserServer as default } from './server'

View File

@ -0,0 +1,50 @@
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { Server as MCServer } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { app } from 'electron'
import { CdpBrowserController } from './controller'
import { toolDefinitions, toolHandlers } from './tools'
export class BrowserServer {
public server: Server
private controller = new CdpBrowserController()
constructor() {
const server = new MCServer(
{
name: '@cherry/browser',
version: '0.1.0'
},
{
capabilities: {
resources: {},
tools: {}
}
}
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: toolDefinitions
}
})
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
const handler = toolHandlers[name]
if (!handler) {
throw new Error('Tool not found')
}
return handler(this.controller, args)
})
app.on('before-quit', () => {
void this.controller.reset()
})
this.server = server
}
}
export default BrowserServer

View File

@ -0,0 +1,567 @@
export const TAB_BAR_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
user-select: none;
}
/* Light theme (default) */
:root {
--bg-tabrow: #dee1e6;
--bg-toolbar: #fff;
--bg-tab-hover: rgba(0,0,0,0.04);
--bg-tab-active: #fff;
--bg-url: #f1f3f4;
--bg-url-focus: #fff;
--bg-btn-hover: rgba(0,0,0,0.08);
--bg-favicon: #9aa0a6;
--color-text: #5f6368;
--color-text-active: #202124;
--color-separator: #c4c7cc;
--shadow-url-focus: 0 1px 6px rgba(32,33,36,0.28);
--window-close-hover: #e81123;
}
/* Dark theme */
body.theme-dark {
--bg-tabrow: #202124;
--bg-toolbar: #292a2d;
--bg-tab-hover: rgba(255,255,255,0.06);
--bg-tab-active: #292a2d;
--bg-url: #35363a;
--bg-url-focus: #202124;
--bg-btn-hover: rgba(255,255,255,0.1);
--bg-favicon: #5f6368;
--color-text: #9aa0a6;
--color-text-active: #e8eaed;
--color-separator: #3c3d41;
--shadow-url-focus: 0 1px 6px rgba(0,0,0,0.5);
--window-close-hover: #e81123;
}
body {
background: var(--bg-tabrow);
display: flex;
flex-direction: column;
position: relative;
}
body.platform-mac { --traffic-light-width: 70px; --window-controls-width: 0px; }
body.platform-win, body.platform-linux { --traffic-light-width: 0px; --window-controls-width: 138px; }
/* Chrome-style tab row */
#tab-row {
display: flex;
align-items: flex-end;
padding: 8px 8px 0 8px;
padding-left: calc(8px + var(--traffic-light-width, 0px));
padding-right: calc(8px + var(--window-controls-width, 0px));
height: 42px;
flex-shrink: 0;
-webkit-app-region: drag;
background: var(--bg-tabrow);
position: relative;
z-index: 1;
}
#tabs-container {
display: flex;
align-items: flex-end;
height: 34px;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* New tab button - inside tabs container, right after last tab */
#new-tab-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
margin-left: 4px;
margin-bottom: 3px;
-webkit-app-region: no-drag;
flex-shrink: 0;
}
#new-tab-btn:hover { background: var(--bg-btn-hover); }
#new-tab-btn svg { width: 18px; height: 18px; fill: var(--color-text); }
/* Chrome-style tabs - shrink instead of scroll */
.tab {
position: relative;
display: flex;
align-items: center;
height: 34px;
min-width: 36px;
max-width: 240px;
flex: 1 1 240px;
padding: 0 6px;
background: transparent;
cursor: pointer;
-webkit-app-region: no-drag;
border-radius: 8px 8px 0 0;
transition: background 0.1s;
}
/* When tab is narrow, hide title, show favicon by default, show close on hover */
.tab.narrow .tab-title { display: none; }
.tab.narrow { justify-content: center; padding: 0; }
.tab.narrow .tab-favicon { margin-right: 0; }
.tab.narrow .tab-close { position: absolute; margin-left: 0; }
/* On narrow tab hover, hide favicon and show close button */
.tab.narrow:hover .tab-favicon { display: none; }
.tab.narrow:hover .tab-close { opacity: 1; }
/* Separator line using pseudo-element */
.tab::after {
content: '';
position: absolute;
right: 0;
top: 8px;
bottom: 8px;
width: 1px;
background: var(--color-separator);
pointer-events: none;
}
/* Hide separator for last tab */
.tab:last-of-type::after { display: none; }
/* Hide separator when tab is hovered (right side) */
.tab:hover::after { display: none; }
/* Hide separator on tab before hovered tab (left side of hovered) - managed by JS .before-hover class */
.tab.before-hover::after { display: none; }
/* Hide separator for active tab and its neighbors */
.tab.active::after { display: none; }
/* Hide separator on tab before active (left side of active) - managed by JS .before-active class */
.tab.before-active::after { display: none; }
.tab:hover { background: var(--bg-tab-hover); }
.tab.active {
background: var(--bg-tab-active);
z-index: 1;
}
/* Tab favicon placeholder */
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
border-radius: 2px;
background: var(--bg-favicon);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tab-favicon svg { width: 12px; height: 12px; fill: #fff; }
body.theme-dark .tab-favicon svg { fill: #9aa0a6; }
.tab-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
font-size: 12px;
font-weight: 400;
}
.tab.active .tab-title { color: var(--color-text-active); }
.tab-close {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
opacity: 0;
transition: opacity 0.1s, background 0.1s;
flex-shrink: 0;
}
.tab:hover .tab-close { opacity: 1; }
.tab-close:hover { background: var(--bg-btn-hover); }
.tab-close svg { width: 16px; height: 16px; fill: var(--color-text); }
.tab-close:hover svg { fill: var(--color-text-active); }
/* Chrome-style address bar */
#address-bar {
display: flex;
align-items: center;
padding: 6px 16px 8px 8px;
gap: 4px;
background: var(--bg-toolbar);
-webkit-app-region: drag;
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
background: transparent;
border: none;
flex-shrink: 0;
-webkit-app-region: no-drag;
}
.nav-btn:hover { background: var(--bg-btn-hover); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn:disabled:hover { background: transparent; }
.nav-btn svg { width: 20px; height: 20px; fill: var(--color-text); }
#url-container {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-url);
border-radius: 24px;
padding: 0 16px;
height: 36px;
-webkit-app-region: no-drag;
transition: background 0.2s, box-shadow 0.2s;
}
#url-container:focus-within {
background: var(--bg-url-focus);
box-shadow: var(--shadow-url-focus);
}
#url-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--color-text-active);
font-size: 14px;
font-family: inherit;
}
#url-input::placeholder { color: var(--color-text); }
#url-input::-webkit-input-placeholder { color: var(--color-text); }
/* Window controls for Windows/Linux - use inline-flex inside tab-row instead of fixed position */
#window-controls {
display: none;
height: 42px;
margin-left: auto;
margin-right: calc(-8px - var(--window-controls-width, 0px));
margin-top: -8px;
-webkit-app-region: no-drag;
}
body.platform-win #window-controls,
body.platform-linux #window-controls { display: flex; }
.window-control-btn {
width: 46px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.1s;
-webkit-app-region: no-drag;
}
.window-control-btn:hover { background: var(--bg-btn-hover); }
.window-control-btn.close:hover { background: var(--window-close-hover); }
.window-control-btn svg { width: 10px; height: 10px; color: var(--color-text); fill: var(--color-text); stroke: var(--color-text); }
.window-control-btn:hover svg { color: var(--color-text-active); fill: var(--color-text-active); stroke: var(--color-text-active); }
.window-control-btn.close:hover svg { color: #fff; fill: #fff; stroke: #fff; }
</style>
</head>
<body>
<div id="tab-row">
<div id="tabs-container">
<div id="new-tab-btn" title="New tab">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</div>
</div>
<!-- Window controls for Windows/Linux - inside tab-row to avoid drag region issues -->
<div id="window-controls">
<button class="window-control-btn" id="minimize-btn" title="Minimize">
<svg viewBox="0 0 10 1"><rect width="10" height="1"/></svg>
</button>
<button class="window-control-btn" id="maximize-btn" title="Maximize">
<svg viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="window-control-btn close" id="close-btn" title="Close">
<svg viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
</div>
</div>
<div id="address-bar">
<button class="nav-btn" id="back-btn" title="Back" disabled>
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<button class="nav-btn" id="forward-btn" title="Forward" disabled>
<svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg>
</button>
<button class="nav-btn" id="refresh-btn" title="Refresh">
<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</button>
<div id="url-container">
<input type="text" id="url-input" placeholder="Search or enter URL" spellcheck="false" />
</div>
</div>
<script>
const tabsContainer = document.getElementById('tabs-container');
const urlInput = document.getElementById('url-input');
const backBtn = document.getElementById('back-btn');
const forwardBtn = document.getElementById('forward-btn');
const refreshBtn = document.getElementById('refresh-btn');
window.currentUrl = '';
window.canGoBack = false;
window.canGoForward = false;
// Helper function to update before-active class for separator hiding
function updateBeforeActiveClass() {
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab, index) {
tab.classList.remove('before-active');
if (index < tabs.length - 1 && tabs[index + 1].classList.contains('active')) {
tab.classList.add('before-active');
}
});
}
// Helper function to update narrow class based on tab width
function updateNarrowClass() {
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab) {
if (tab.offsetWidth < 72) {
tab.classList.add('narrow');
} else {
tab.classList.remove('narrow');
}
});
}
var newTabBtnHtml = '<div id="new-tab-btn" title="New tab"><svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></div>';
// Track if we're in "closing mode" where tab widths should be fixed
var closingModeTimeout = null;
var isInClosingMode = false;
function enterClosingMode() {
isInClosingMode = true;
// Clear any existing timeout
if (closingModeTimeout) {
clearTimeout(closingModeTimeout);
}
// Set timeout to exit closing mode after 1 second of no activity
closingModeTimeout = setTimeout(function() {
exitClosingMode();
}, 1000);
}
function exitClosingMode() {
isInClosingMode = false;
if (closingModeTimeout) {
clearTimeout(closingModeTimeout);
closingModeTimeout = null;
}
// Remove fixed widths from tabs
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab) {
tab.style.flex = '';
tab.style.width = '';
});
}
// Exit closing mode when mouse leaves the tab row
document.getElementById('tab-row').addEventListener('mouseleave', function() {
if (isInClosingMode) {
exitClosingMode();
}
});
window.updateTabs = function(tabs, activeUrl, canGoBack, canGoForward) {
// Capture current tab widths before update if in closing mode
var previousWidths = {};
if (isInClosingMode) {
var existingTabs = tabsContainer.querySelectorAll('.tab');
existingTabs.forEach(function(tab) {
previousWidths[tab.dataset.id] = tab.offsetWidth;
});
}
if (!tabs || tabs.length === 0) {
// Window will be closed by main process when last tab is closed
// Just clear the UI in case this is called before window closes
tabsContainer.innerHTML = newTabBtnHtml;
urlInput.value = '';
document.getElementById('new-tab-btn').addEventListener('click', function() {
sendAction({ type: 'new' });
});
return;
}
tabsContainer.innerHTML = tabs.map(function(tab) {
var cls = 'tab' + (tab.isActive ? ' active' : '');
var title = (tab.title || 'New Tab').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
var url = (tab.url || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return '<div class="' + cls + '" data-id="' + tab.id + '" title="' + url + '">' +
'<div class="tab-favicon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg></div>' +
'<span class="tab-title">' + title + '</span>' +
'<div class="tab-close" data-id="' + tab.id + '">' +
'<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' +
'</div>' +
'</div>';
}).join('') + newTabBtnHtml;
// Re-attach event listener for new tab button
document.getElementById('new-tab-btn').addEventListener('click', function() {
sendAction({ type: 'new' });
});
// If in closing mode, fix the widths of remaining tabs
if (isInClosingMode) {
var newTabs = tabsContainer.querySelectorAll('.tab');
newTabs.forEach(function(tab) {
var prevWidth = previousWidths[tab.dataset.id];
if (prevWidth) {
tab.style.flex = '0 0 ' + prevWidth + 'px';
tab.style.width = prevWidth + 'px';
}
});
}
// Update before-active class for proper separator hiding
updateBeforeActiveClass();
// Update narrow class based on tab width
updateNarrowClass();
if (activeUrl !== undefined) {
window.currentUrl = activeUrl || '';
if (document.activeElement !== urlInput) {
urlInput.value = window.currentUrl;
}
}
if (canGoBack !== undefined) {
window.canGoBack = canGoBack;
backBtn.disabled = !canGoBack;
}
if (canGoForward !== undefined) {
window.canGoForward = canGoForward;
forwardBtn.disabled = !canGoForward;
}
};
function sendAction(action) {
window.postMessage({ channel: 'tabbar-action', payload: action }, '*');
}
tabsContainer.addEventListener('click', function(e) {
var closeBtn = e.target.closest('.tab-close');
if (closeBtn) {
e.stopPropagation();
enterClosingMode();
sendAction({ type: 'close', tabId: closeBtn.dataset.id });
return;
}
var tab = e.target.closest('.tab');
if (tab) {
sendAction({ type: 'switch', tabId: tab.dataset.id });
}
});
tabsContainer.addEventListener('auxclick', function(e) {
if (e.button === 1) {
var tab = e.target.closest('.tab');
if (tab) {
enterClosingMode();
sendAction({ type: 'close', tabId: tab.dataset.id });
}
}
});
// Handle hover state for separator hiding (left side of hovered tab)
tabsContainer.addEventListener('mouseover', function(e) {
var tab = e.target.closest('.tab');
// Clear all before-hover classes first
tabsContainer.querySelectorAll('.before-hover').forEach(function(t) {
t.classList.remove('before-hover');
});
if (tab) {
var prev = tab.previousElementSibling;
if (prev && prev.classList.contains('tab')) {
prev.classList.add('before-hover');
}
}
});
tabsContainer.addEventListener('mouseleave', function() {
tabsContainer.querySelectorAll('.before-hover').forEach(function(t) {
t.classList.remove('before-hover');
});
});
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
var url = urlInput.value.trim();
if (url) {
sendAction({ type: 'navigate', url: url });
}
}
});
urlInput.addEventListener('focus', function() {
urlInput.select();
});
backBtn.addEventListener('click', function() {
if (window.canGoBack) {
sendAction({ type: 'back' });
}
});
forwardBtn.addEventListener('click', function() {
if (window.canGoForward) {
sendAction({ type: 'forward' });
}
});
refreshBtn.addEventListener('click', function() {
sendAction({ type: 'refresh' });
});
// Window controls for Windows/Linux
document.getElementById('minimize-btn').addEventListener('click', function() {
sendAction({ type: 'window-minimize' });
});
document.getElementById('maximize-btn').addEventListener('click', function() {
sendAction({ type: 'window-maximize' });
});
document.getElementById('close-btn').addEventListener('click', function() {
sendAction({ type: 'window-close' });
});
// Platform initialization - called from main process
window.initPlatform = function(platform) {
document.body.classList.add('platform-' + platform);
};
// Theme initialization - called from main process
window.setTheme = function(isDark) {
if (isDark) {
document.body.classList.add('theme-dark');
} else {
document.body.classList.remove('theme-dark');
}
};
// Update narrow class on window resize
window.addEventListener('resize', function() {
updateNarrowClass();
});
</script>
</body>
</html>`

View File

@ -0,0 +1,52 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
export const ExecuteSchema = z.object({
code: z.string().describe('JavaScript code to run in page context'),
timeout: z.number().default(5000).describe('Execution timeout in ms (default: 5000)'),
privateMode: z.boolean().optional().describe('Target private session (default: false)'),
tabId: z.string().optional().describe('Target specific tab by ID')
})
export const executeToolDefinition = {
name: 'execute',
description:
'Run JavaScript in the currently open page. Use after open to: click elements, fill forms, extract content (document.body.innerText), or interact with the page. The page must be opened first with open or fetch.',
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description:
'JavaScript to evaluate. Examples: document.body.innerText (get text), document.querySelector("button").click() (click), document.title (get title)'
},
timeout: {
type: 'number',
description: 'Execution timeout in ms (default: 5000)'
},
privateMode: {
type: 'boolean',
description: 'Target private session (default: false)'
},
tabId: {
type: 'string',
description: 'Target specific tab by ID (from open response)'
}
},
required: ['code']
}
}
export async function handleExecute(controller: CdpBrowserController, args: unknown) {
const { code, timeout, privateMode, tabId } = ExecuteSchema.parse(args)
try {
const value = await controller.execute(code, timeout, privateMode ?? false, tabId)
return successResponse(typeof value === 'string' ? value : JSON.stringify(value))
} catch (error) {
logger.error('Execute failed', { error, code: code.slice(0, 100), privateMode, tabId })
return errorResponse(error as Error)
}
}

View File

@ -0,0 +1,22 @@
export { ExecuteSchema, executeToolDefinition, handleExecute } from './execute'
export { handleOpen, OpenSchema, openToolDefinition } from './open'
export { handleReset, resetToolDefinition } from './reset'
import type { CdpBrowserController } from '../controller'
import { executeToolDefinition, handleExecute } from './execute'
import { handleOpen, openToolDefinition } from './open'
import { handleReset, resetToolDefinition } from './reset'
export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition]
export const toolHandlers: Record<
string,
(
controller: CdpBrowserController,
args: unknown
) => Promise<{ content: { type: string; text: string }[]; isError: boolean }>
> = {
open: handleOpen,
execute: handleExecute,
reset: handleReset
}

View File

@ -0,0 +1,81 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
export const OpenSchema = z.object({
url: z.url().describe('URL to navigate to'),
format: z
.enum(['html', 'txt', 'markdown', 'json'])
.optional()
.describe('If set, return page content in this format. If not set, just open the page and return tabId.'),
timeout: z.number().optional().describe('Navigation timeout in ms (default: 10000)'),
privateMode: z.boolean().optional().describe('Use incognito mode, no data persisted (default: false)'),
newTab: z.boolean().optional().describe('Open in new tab, required for parallel requests (default: false)'),
showWindow: z.boolean().optional().default(true).describe('Show browser window (default: true)')
})
export const openToolDefinition = {
name: 'open',
description:
'Navigate to a URL in a browser window. If format is specified, returns { tabId, content } with page content in that format. Otherwise, returns { currentUrl, title, tabId } for subsequent operations with execute tool. Set newTab=true when opening multiple URLs in parallel.',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to navigate to'
},
format: {
type: 'string',
enum: ['html', 'txt', 'markdown', 'json'],
description: 'If set, return page content in this format. If not set, just open the page and return tabId.'
},
timeout: {
type: 'number',
description: 'Navigation timeout in ms (default: 10000)'
},
privateMode: {
type: 'boolean',
description: 'Use incognito mode, no data persisted (default: false)'
},
newTab: {
type: 'boolean',
description: 'Open in new tab, required for parallel requests (default: false)'
},
showWindow: {
type: 'boolean',
description: 'Show browser window (default: true)'
}
},
required: ['url']
}
}
export async function handleOpen(controller: CdpBrowserController, args: unknown) {
try {
const { url, format, timeout, privateMode, newTab, showWindow } = OpenSchema.parse(args)
if (format) {
const { tabId, content } = await controller.fetch(
url,
format,
timeout ?? 10000,
privateMode ?? false,
newTab ?? false,
showWindow
)
return successResponse(JSON.stringify({ tabId, content }))
} else {
const res = await controller.open(url, timeout ?? 10000, privateMode ?? false, newTab ?? false, showWindow)
return successResponse(JSON.stringify(res))
}
} catch (error) {
logger.error('Open failed', {
error,
url: args && typeof args === 'object' && 'url' in args ? args.url : undefined
})
return errorResponse(error instanceof Error ? error : String(error))
}
}

View File

@ -0,0 +1,43 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
export const ResetSchema = z.object({
privateMode: z.boolean().optional().describe('true=private window, false=normal window, omit=all windows'),
tabId: z.string().optional().describe('Close specific tab only (requires privateMode)')
})
export const resetToolDefinition = {
name: 'reset',
description:
'Close browser windows and clear state. Call when done browsing to free resources. Omit all parameters to close everything.',
inputSchema: {
type: 'object',
properties: {
privateMode: {
type: 'boolean',
description: 'true=reset private window only, false=reset normal window only, omit=reset all'
},
tabId: {
type: 'string',
description: 'Close specific tab only (requires privateMode to be set)'
}
}
}
}
export async function handleReset(controller: CdpBrowserController, args: unknown) {
try {
const { privateMode, tabId } = ResetSchema.parse(args)
await controller.reset(privateMode, tabId)
return successResponse('reset')
} catch (error) {
logger.error('Reset failed', {
error,
privateMode: args && typeof args === 'object' && 'privateMode' in args ? args.privateMode : undefined
})
return errorResponse(error instanceof Error ? error : String(error))
}
}

View File

@ -0,0 +1,14 @@
export function successResponse(text: string) {
return {
content: [{ type: 'text', text }],
isError: false
}
}
export function errorResponse(error: Error | string) {
const message = error instanceof Error ? error.message : error
return {
content: [{ type: 'text', text: message }],
isError: true
}
}

View File

@ -0,0 +1,24 @@
import { loggerService } from '@logger'
import type { BrowserView, BrowserWindow } from 'electron'
export const logger = loggerService.withContext('MCPBrowserCDP')
export const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
export interface TabInfo {
id: string
view: BrowserView
url: string
title: string
lastActive: number
}
export interface WindowInfo {
windowKey: string
privateMode: boolean
window: BrowserWindow
tabs: Map<string, TabInfo>
activeTabId: string | null
lastActive: number
tabBarView?: BrowserView
}

View File

@ -4,6 +4,7 @@ import type { BuiltinMCPServerName } from '@types'
import { BuiltinMCPServerNames } from '@types' import { BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search' import BraveSearchServer from './brave-search'
import BrowserServer from './browser'
import DiDiMcpServer from './didi-mcp' import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge' import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch' import FetchServer from './fetch'
@ -35,7 +36,7 @@ export function createInMemoryMCPServer(
return new FetchServer().server return new FetchServer().server
} }
case BuiltinMCPServerNames.filesystem: { case BuiltinMCPServerNames.filesystem: {
return new FileSystemServer(args).server return new FileSystemServer(envs.WORKSPACE_ROOT).server
} }
case BuiltinMCPServerNames.difyKnowledge: { case BuiltinMCPServerNames.difyKnowledge: {
const difyKey = envs.DIFY_KEY const difyKey = envs.DIFY_KEY
@ -48,6 +49,9 @@ export function createInMemoryMCPServer(
const apiKey = envs.DIDI_API_KEY const apiKey = envs.DIDI_API_KEY
return new DiDiMcpServer(apiKey).server return new DiDiMcpServer(apiKey).server
} }
case BuiltinMCPServerNames.browser: {
return new BrowserServer().server
}
default: default:
throw new Error(`Unknown in-memory MCP server: ${name}`) throw new Error(`Unknown in-memory MCP server: ${name}`)
} }

View File

@ -1,652 +0,0 @@
// port https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/index.ts
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { createTwoFilesPatch } from 'diff'
import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
import * as z from 'zod'
const logger = loggerService.withContext('MCP:FileSystemServer')
// Normalize all paths consistently
function normalizePath(p: string): string {
return path.normalize(p)
}
function expandHome(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1))
}
return filepath
}
// Security utilities
async function validatePath(allowedDirectories: string[], requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath)
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath)
const normalizedRequested = normalizePath(absolute)
// Check if path is within allowed directories
const isAllowed = allowedDirectories.some((dir) => normalizedRequested.startsWith(dir))
if (!isAllowed) {
throw new Error(
`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`
)
}
// Handle symlinks by checking their real path
try {
const realPath = await fs.realpath(absolute)
const normalizedReal = normalizePath(realPath)
const isRealPathAllowed = allowedDirectories.some((dir) => normalizedReal.startsWith(dir))
if (!isRealPathAllowed) {
throw new Error('Access denied - symlink target outside allowed directories')
}
return realPath
} catch (error) {
// For new files that don't exist yet, verify parent directory
const parentDir = path.dirname(absolute)
try {
const realParentPath = await fs.realpath(parentDir)
const normalizedParent = normalizePath(realParentPath)
const isParentAllowed = allowedDirectories.some((dir) => normalizedParent.startsWith(dir))
if (!isParentAllowed) {
throw new Error('Access denied - parent directory outside allowed directories')
}
return absolute
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`)
}
}
}
// Schema definitions
const ReadFileArgsSchema = z.object({
path: z.string()
})
const ReadMultipleFilesArgsSchema = z.object({
paths: z.array(z.string())
})
const WriteFileArgsSchema = z.object({
path: z.string(),
content: z.string()
})
const EditOperation = z.object({
oldText: z.string().describe('Text to search for - must match exactly'),
newText: z.string().describe('Text to replace with')
})
const EditFileArgsSchema = z.object({
path: z.string(),
edits: z.array(EditOperation),
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
})
const CreateDirectoryArgsSchema = z.object({
path: z.string()
})
const ListDirectoryArgsSchema = z.object({
path: z.string()
})
const DirectoryTreeArgsSchema = z.object({
path: z.string()
})
const MoveFileArgsSchema = z.object({
source: z.string(),
destination: z.string()
})
const SearchFilesArgsSchema = z.object({
path: z.string(),
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
})
const GetFileInfoArgsSchema = z.object({
path: z.string()
})
interface FileInfo {
size: number
created: Date
modified: Date
accessed: Date
isDirectory: boolean
isFile: boolean
permissions: string
}
// Tool implementations
async function getFileStats(filePath: string): Promise<FileInfo> {
const stats = await fs.stat(filePath)
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3)
}
}
async function searchFiles(
allowedDirectories: string[],
rootPath: string,
pattern: string,
excludePatterns: string[] = []
): Promise<string[]> {
const results: string[] = []
async function search(currentPath: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name)
try {
// Validate each path before processing
await validatePath(allowedDirectories, fullPath)
// Check if path matches any exclude pattern
const relativePath = path.relative(rootPath, fullPath)
const shouldExclude = excludePatterns.some((pattern) => {
const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`
return minimatch(relativePath, globPattern, { dot: true })
})
if (shouldExclude) {
continue
}
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
results.push(fullPath)
}
if (entry.isDirectory()) {
await search(fullPath)
}
} catch (error) {
// Skip invalid paths during search
}
}
}
await search(rootPath)
return results
}
// file editing and diffing utilities
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n')
}
function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
// Ensure consistent line endings for diff
const normalizedOriginal = normalizeLineEndings(originalContent)
const normalizedNew = normalizeLineEndings(newContent)
return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified')
}
async function applyFileEdits(
filePath: string,
edits: Array<{ oldText: string; newText: string }>,
dryRun = false
): Promise<string> {
// Read file content and normalize line endings
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'))
// Apply edits sequentially
let modifiedContent = content
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText)
const normalizedNew = normalizeLineEndings(edit.newText)
// If exact match exists, use it
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew)
continue
}
// Otherwise, try line-by-line matching with flexibility for whitespace
const oldLines = normalizedOld.split('\n')
const contentLines = modifiedContent.split('\n')
let matchFound = false
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length)
// Compare lines with normalized whitespace
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j]
return oldLine.trim() === contentLine.trim()
})
if (isMatch) {
// Preserve original indentation of first line
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return originalIndent + line.trimStart()
// For subsequent lines, try to preserve relative indentation
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''
const newIndent = line.match(/^\s*/)?.[0] || ''
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart()
}
return line
})
contentLines.splice(i, oldLines.length, ...newLines)
modifiedContent = contentLines.join('\n')
matchFound = true
break
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`)
}
}
// Create unified diff
const diff = createUnifiedDiff(content, modifiedContent, filePath)
// Format diff with appropriate number of backticks
let numBackticks = 3
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`
if (!dryRun) {
await fs.writeFile(filePath, modifiedContent, 'utf-8')
}
return formattedDiff
}
class FileSystemServer {
public server: Server
private allowedDirectories: string[]
constructor(allowedDirs: string[]) {
if (!Array.isArray(allowedDirs) || allowedDirs.length === 0) {
throw new Error('No allowed directories provided, please specify at least one directory in args')
}
this.allowedDirectories = allowedDirs.map((dir) => normalizePath(path.resolve(expandHome(dir))))
// Validate that all directories exist and are accessible
this.validateDirs().catch((error) => {
logger.error('Error validating allowed directories:', error)
throw new Error(`Error validating allowed directories: ${error}`)
})
this.server = new Server(
{
name: 'secure-filesystem-server',
version: '0.2.0'
},
{
capabilities: {
tools: {}
}
}
)
this.initialize()
}
async validateDirs() {
// Validate that all directories exist and are accessible
await Promise.all(
this.allowedDirectories.map(async (dir) => {
try {
const stats = await fs.stat(expandHome(dir))
if (!stats.isDirectory()) {
logger.error(`Error: ${dir} is not a directory`)
throw new Error(`Error: ${dir} is not a directory`)
}
} catch (error: any) {
logger.error(`Error accessing directory ${dir}:`, error)
throw new Error(`Error accessing directory ${dir}:`, error)
}
})
)
}
initialize() {
// Tool handlers
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'read_file',
description:
'Read the complete contents of a file from the file system. ' +
'Handles various text encodings and provides detailed error messages ' +
'if the file cannot be read. Use this tool when you need to examine ' +
'the contents of a single file. Only works within allowed directories.',
inputSchema: z.toJSONSchema(ReadFileArgsSchema)
},
{
name: 'read_multiple_files',
description:
'Read the contents of multiple files simultaneously. This is more ' +
'efficient than reading files one by one when you need to analyze ' +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
'the entire operation. Only works within allowed directories.',
inputSchema: z.toJSONSchema(ReadMultipleFilesArgsSchema)
},
{
name: 'write_file',
description:
'Create a new file or completely overwrite an existing file with new content. ' +
'Use with caution as it will overwrite existing files without warning. ' +
'Handles text content with proper encoding. Only works within allowed directories.',
inputSchema: z.toJSONSchema(WriteFileArgsSchema)
},
{
name: 'edit_file',
description:
'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
'with new content. Returns a git-style diff showing the changes made. ' +
'Only works within allowed directories.',
inputSchema: z.toJSONSchema(EditFileArgsSchema)
},
{
name: 'create_directory',
description:
'Create a new directory or ensure a directory exists. Can create multiple ' +
'nested directories in one operation. If the directory already exists, ' +
'this operation will succeed silently. Perfect for setting up directory ' +
'structures for projects or ensuring required paths exist. Only works within allowed directories.',
inputSchema: z.toJSONSchema(CreateDirectoryArgsSchema)
},
{
name: 'list_directory',
description:
'Get a detailed listing of all files and directories in a specified path. ' +
'Results clearly distinguish between files and directories with [FILE] and [DIR] ' +
'prefixes. This tool is essential for understanding directory structure and ' +
'finding specific files within a directory. Only works within allowed directories.',
inputSchema: z.toJSONSchema(ListDirectoryArgsSchema)
},
{
name: 'directory_tree',
description:
'Get a recursive tree view of files and directories as a JSON structure. ' +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
'Files have no children array, while directories always have a children array (which may be empty). ' +
'The output is formatted with 2-space indentation for readability. Only works within allowed directories.',
inputSchema: z.toJSONSchema(DirectoryTreeArgsSchema)
},
{
name: 'move_file',
description:
'Move or rename files and directories. Can move files between directories ' +
'and rename them in a single operation. If the destination exists, the ' +
'operation will fail. Works across different directories and can be used ' +
'for simple renaming within the same directory. Both source and destination must be within allowed directories.',
inputSchema: z.toJSONSchema(MoveFileArgsSchema)
},
{
name: 'search_files',
description:
'Recursively search for files and directories matching a pattern. ' +
'Searches through all subdirectories from the starting path. The search ' +
'is case-insensitive and matches partial names. Returns full paths to all ' +
"matching items. Great for finding files when you don't know their exact location. " +
'Only searches within allowed directories.',
inputSchema: z.toJSONSchema(SearchFilesArgsSchema)
},
{
name: 'get_file_info',
description:
'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
'information including size, creation time, last modified time, permissions, ' +
'and type. This tool is perfect for understanding file characteristics ' +
'without reading the actual content. Only works within allowed directories.',
inputSchema: z.toJSONSchema(GetFileInfoArgsSchema)
},
{
name: 'list_allowed_directories',
description:
'Returns the list of directories that this server is allowed to access. ' +
'Use this to understand which directories are available before trying to access files.',
inputSchema: {
type: 'object',
properties: {},
required: []
}
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params
switch (name) {
case 'read_file': {
const parsed = ReadFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for read_file: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const content = await fs.readFile(validPath, 'utf-8')
return {
content: [{ type: 'text', text: content }]
}
}
case 'read_multiple_files': {
const parsed = ReadMultipleFilesArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`)
}
const results = await Promise.all(
parsed.data.paths.map(async (filePath: string) => {
try {
const validPath = await validatePath(this.allowedDirectories, filePath)
const content = await fs.readFile(validPath, 'utf-8')
return `${filePath}:\n${content}\n`
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return `${filePath}: Error - ${errorMessage}`
}
})
)
return {
content: [{ type: 'text', text: results.join('\n---\n') }]
}
}
case 'write_file': {
const parsed = WriteFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for write_file: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
await fs.writeFile(validPath, parsed.data.content, 'utf-8')
return {
content: [{ type: 'text', text: `Successfully wrote to ${parsed.data.path}` }]
}
}
case 'edit_file': {
const parsed = EditFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun)
return {
content: [{ type: 'text', text: result }]
}
}
case 'create_directory': {
const parsed = CreateDirectoryArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
await fs.mkdir(validPath, { recursive: true })
return {
content: [{ type: 'text', text: `Successfully created directory ${parsed.data.path}` }]
}
}
case 'list_directory': {
const parsed = ListDirectoryArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const entries = await fs.readdir(validPath, { withFileTypes: true })
const formatted = entries
.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
.join('\n')
return {
content: [{ type: 'text', text: formatted }]
}
}
case 'directory_tree': {
const parsed = DirectoryTreeArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`)
}
interface TreeEntry {
name: string
type: 'file' | 'directory'
children?: TreeEntry[]
}
async function buildTree(allowedDirectories: string[], currentPath: string): Promise<TreeEntry[]> {
const validPath = await validatePath(allowedDirectories, currentPath)
const entries = await fs.readdir(validPath, { withFileTypes: true })
const result: TreeEntry[] = []
for (const entry of entries) {
const entryData: TreeEntry = {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file'
}
if (entry.isDirectory()) {
const subPath = path.join(currentPath, entry.name)
entryData.children = await buildTree(allowedDirectories, subPath)
}
result.push(entryData)
}
return result
}
const treeData = await buildTree(this.allowedDirectories, parsed.data.path)
return {
content: [
{
type: 'text',
text: JSON.stringify(treeData, null, 2)
}
]
}
}
case 'move_file': {
const parsed = MoveFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for move_file: ${parsed.error}`)
}
const validSourcePath = await validatePath(this.allowedDirectories, parsed.data.source)
const validDestPath = await validatePath(this.allowedDirectories, parsed.data.destination)
await fs.rename(validSourcePath, validDestPath)
return {
content: [
{ type: 'text', text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }
]
}
}
case 'search_files': {
const parsed = SearchFilesArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const results = await searchFiles(
this.allowedDirectories,
validPath,
parsed.data.pattern,
parsed.data.excludePatterns
)
return {
content: [{ type: 'text', text: results.length > 0 ? results.join('\n') : 'No matches found' }]
}
}
case 'get_file_info': {
const parsed = GetFileInfoArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const info = await getFileStats(validPath)
return {
content: [
{
type: 'text',
text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
}
]
}
}
case 'list_allowed_directories': {
return {
content: [
{
type: 'text',
text: `Allowed directories:\n${this.allowedDirectories.join('\n')}`
}
]
}
}
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
isError: true
}
}
})
}
}
export default FileSystemServer

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