Compare commits

...

56 Commits
v1.7.9 ... main

Author SHA1 Message Date
SuYao
9414f13f6d
fix: 修改请求体字段名 (#12430) 2026-01-12 16:38:55 +08:00
flt6
cbeda03acb
use cumsum in anthropic cache (#12419)
* use cumsum in anthropic cache

* fix types and refactor addCache in anthropicCacheMiddleware
2026-01-12 15:49:33 +08:00
George·Dong
cea36d170b
fix(qwen-code): format baseUrl with /v1 for OpenAI-compatible tools (#12418)
The Qwen Code tool was failing with 'Model stream ended without a finish reason'
because the OPENAI_BASE_URL environment variable was not properly formatted.

This fix adds /v1 suffix to the baseUrl when it's missing for OpenAI-compatible
tools (qwenCode, openaiCodex, iFlowCli).

Changes:
- Import formatApiHost from @renderer/utils/api
- Use formatApiHost to format baseUrl before passing to environment variables
- Add unit tests for the URL formatting behavior
2026-01-12 13:41:27 +08:00
Nicolae Fericitu
d84b84eb2f
i18n: Major improvements to Romanian (ro-RO) localization (#12428)
* fix(i18n): update and refine Romanian translation

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.

* i18n: Update and fix Romanian localization (ro-RO)

The Romanian localization file has been updated. 

Necessary corrections have been applied to address issues identified during an interface review, ensuring consistent terminology and improved message clarity.

* i18n: Capitalize "Users" label for UI consistency

Updated the "users" key in ro-ro.json to use an uppercase initial. This ensures visual consistency with other menu items in the settings section (User Management).
2026-01-12 10:58:38 +08:00
SuYao
c7c380d706
fix: disable strict JSON schema for OpenRouter to support MCP tools (#12415)
* fix: update dependencies and patch files for strict JSON schema compliance

- Updated `@ai-sdk/openai-compatible` to include version 1.0.30 and adjusted related patch files.
- Removed obsolete patch for `@ai-sdk/openai-compatible@1.0.28`.
- Added new patch for `@openrouter/ai-sdk-provider` to support strict JSON schema options.
- Modified `options.ts` to set `strictJsonSchema` to false for OpenAI models.
- Enhanced OpenAI compatible provider options to include `sendReasoning` and `strictJsonSchema`.
- Updated lockfile to reflect changes in patched dependencies and their hashes.

* fix: filter strictJsonSchema from request body in OpenRouter patch

- Destructure and remove strictJsonSchema from openrouterOptions before spreading into request body
- This prevents sending the internal option to the OpenRouter API

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:55:09 +08:00
Copilot
622e3f0db6
feat: Add year to topic timestamp and improve unpin UX (#12408)
* Initial plan

* feat: Add year to topic time display format

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

* fix: improve topic unpin UX by moving to top of unpinned list

- Unpinned topics now move to the top of the unpinned section
- List automatically scrolls to the unpinned topic position
- Keeps the requirement to unpin before deleting pinned topics

Closes #12398

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: GeorgeDong32 <98630204+GeorgeDong32@users.noreply.github.com>
Co-authored-by: George·Dong <GeorgeDong32@qq.com>
2026-01-10 21:37:37 +08:00
fullex
e5a2980da8
fix(logger): allow logging with unknown window source (#12406)
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
* fix(logger): allow logging with unknown window source

Previously, LoggerService would block all logging when window source
was not initialized, which could swallow important business errors.
Now it uses 'UNKNOWN' as the window source and continues logging.

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

* chore(logger): update documentation links for LoggerService eslint

Updated the ESLint configuration to point to the correct documentation for the unified LoggerService, ensuring users have access to the latest guides in both English and Chinese.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:12:00 +08:00
George·Dong
5b5e190132
feat(models): add Qwen text-embedding models to defaults (#12410)
* feat(models): add Qwen text-embedding models to defaults

Add four Qwen text-embedding models (v4, v3, v2, v1) to the
default model list in the renderer config. Group them under
"Qwen-text-embedding" and set the provider to "dashscope".

This ensures embedding models are available by default for
features that require text embeddings.

* fix(models): normalize qwen embedding group name

Change group value for Dashscope Qwen embedding models from
'Qwen-text-embedding' to 'qwen-text-embedding' in the default models
configuration. This makes the group naming consistent with other Qwen
model groups (lowercase) and avoids potential mismatches or lookup
errors caused by case differences.

* feat(dashscope): add qwen3-rerank model

Add qwen3-rerank model for Alibaba Cloud Bailian rerank API.
2026-01-10 15:11:08 +08:00
kangfenmao
e8e8f028f3 chore(release): v1.7.13
Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 21:03:45 +08:00
kangfenmao
8ab082ceb5 feat(i18n): add careers section to AboutSettings and update translations
- Updated package.json to remove redundant i18n:check command.
- Added a "careers" section in the AboutSettings component with a button linking to the careers page.
- Introduced translations for the "careers" section in multiple languages including English, Chinese, German, Spanish, French, Japanese, Portuguese, Romanian, and Russian.
- Updated cache-related translations across various languages to provide localized support.
2026-01-09 20:57:59 +08:00
Phantom
864eda68fb
ci(workflows): fix pnpm installation and improve issue tracker (#12388)
* ci(workflows): add pnpm caching to improve build performance

Cache pnpm dependencies to reduce installation time in CI workflows

* ci(github-actions): change claude-code-action version to v1

* ci(workflows): improve issue tracker formatting

Format issue body as markdown code block for better readability in workflow output

* ci(workflows): unify claude-code-action version to v1 for both jobs

Changed process-pending-issues job from @main to @v1 to maintain consistency across all jobs and avoid potential version conflicts.

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

* fix(workflows): remove --help from allowed-tools to avoid upstream parsing bug

The `--help` flags in claude_args were being incorrectly parsed as actual
command-line arguments by claude-code-action, causing JSON parsing errors
("Unexpected identifier 'Usage'").

Switched to wildcard pattern `pnpm tsx scripts/feishu-notify.ts*` which:
- Allows all feishu-notify.ts commands including --help
- Avoids triggering the upstream argument parsing bug
- Simplifies the allowed-tools configuration

This addresses the root cause identified by Anthropic engineers.

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

* fix(workflows): remove --help references from prompts

Remove `--help` references from prompts to avoid potential parsing issues.
The example commands are already comprehensive enough without needing to
mention the help flag.

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

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 20:47:39 +08:00
亢奋猫
c5ea42ca3a
fix(security): prevent path traversal vulnerability in DXT plugin system (#12377)
* fix(security): prevent path traversal vulnerability in DXT plugin system

Add input validation to prevent path traversal attacks in DXT plugin handling:

- Add sanitizeName() to filter dangerous characters from manifest.name
- Add validateCommand() to reject commands with path traversal sequences
- Add validateArgs() to validate command arguments
- Remove unsafe fallback logic in cleanupDxtServer()

The vulnerability allowed attackers to write files to arbitrary locations
on Windows by crafting malicious DXT packages with path traversal sequences
(e.g., "..\\..\\Windows\\System32\\") in manifest.name or command fields.

* refactor: use path validation instead of input sanitization

---------

Co-authored-by: defi-failure <159208748+defi-failure@users.noreply.github.com>
2026-01-09 20:47:14 +08:00
Sun
bdf8f103c8
fix(mcp): 修复 MCP 配置 timeout 字段不支持字符串类型的问题 (#12384)
fix(mcp): allow string input for timeout in mcp config

Co-authored-by: Sun <10309831+x_taiyang@user.noreply.gitee.com>
2026-01-09 17:24:08 +08:00
SuYao
7a7089e315
fix: normalize topics in useAssistant and assistants slice to prevent errors (#12319) 2026-01-09 17:21:20 +08:00
defi-failure
9b8420f9b9
fix: restore patch for claude-agent-sdk (#12391)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:26:39 +08:00
SuYao
29d8c4a7ed
fix(aiCore): only apply sendReasoning for openai-compatible SDK providers (#12387)
sendReasoning is a patch specific to @ai-sdk/openai-compatible package.
Previously it was incorrectly applied to all providers in buildGenericProviderOptions,
including those with dedicated SDK packages (e.g., cerebras, deepseek, openrouter).

Now it only applies when the provider will actually use openai-compatible SDK:
- No dedicated SDK registered (!hasProviderConfig(providerId))
- OR explicitly openai-compatible (providerId === 'openai-compatible')

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:00:31 +08:00
Phantom
76cc196667
ci(workflows): add Feishu notification for workflow failures (#12375)
* ci(workflows): add feishu notification for failed sync jobs

Add Feishu webhook notification when sync-to-gitcode workflow fails or is cancelled. The notification includes tag name, status and run URL for quick debugging.

* ci(workflow): add feishu notification for failed or cancelled jobs
2026-01-09 11:35:17 +08:00
SuYao
61aae7376a
fix: add dispose method to prevent abort listener leak (#12269)
* fix: add dispose method to prevent abort listener leak

Add dispose() method to StreamAbortController that explicitly removes
the abort event listener when stream ends normally. Previously, the
listener would only be removed when abort was triggered ({ once: true }),
but if the stream completed normally without abort, the listener would
remain attached until garbage collection.

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

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

* chore: format code

🤖 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-08 17:55:15 +08:00
kangfenmao
74e1d0887d chore: release v1.7.12
Updated version number to 1.7.12 in package.json and electron-builder.yml. Added release notes detailing the introduction of the MCP Hub with Auto mode and new cache control options for the Anthropic provider, along with various bug fixes.
2026-01-08 17:47:05 +08:00
Phantom
2a1722bb52
fix(workflows): add pnpm installing and caching (#12374) 2026-01-08 17:42:59 +08:00
fullex
7ff6955870
fix(SelectionService): add macOS key code support for modifier key detection (#12355) 2026-01-08 17:42:17 +08:00
pippobj
008df2d4b7
feat(baichuan):add baichuan models (#12364)
Co-authored-by: roberto <roberto@baichuan-inc.com>
2026-01-08 17:10:07 +08:00
Chen Yichi
8223c9fbfd
fix(Tray): set X11 window class and name to cherry-studio (#12348)
fix: set X11 window class and name to cherry-studio

Set window class and name for Linux X11 to ensure system tray and
window manager identify the app correctly instead of using default
'electron' identifier.
2026-01-08 17:08:51 +08:00
beyondkmp
153c1024f6
refactor: use pnpm install instead of manual download for prebuild packages (#12358)
* refactor: use pnpm install instead of manual download for prebuild packages

Replace manual tgz download with pnpm install for architecture-specific
prebuild binaries, simplifying the build process.

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

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

* delete utils

* update after pack

* udpate before pack

* use optional deps

* refactor: use js-yaml to modify pnpm-workspace.yaml for cross-platform builds

- Add all prebuild packages to optionalDependencies in package.json
- Use js-yaml to parse and modify pnpm-workspace.yaml
- Add target platform to supportedArchitectures.os and cpu
- Restore original config after pnpm install

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

* fix version

* refactor: streamline package management and filtering logic in before… (#12370)

refactor: streamline package management and filtering logic in before-pack.js

- Consolidated architecture-specific package definitions into a single array for better maintainability.
- Simplified the logic for determining target platform and architecture.
- Enhanced the filtering process for excluding and including packages based on architecture and platform.
- Improved console logging for clarity during package installation.

This refactor aims to improve the readability and efficiency of the prebuild package handling process.

* refactor: update package filtering logic in before-pack.js to read from electron-builder.yml

- Modified the package filtering process to load configuration directly from electron-builder.yml, reducing potential errors from multiple overrides.
- Enhanced maintainability by centralizing the file configuration management.

This change aims to streamline the prebuild package handling and improve configuration clarity.

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2026-01-08 17:07:41 +08:00
Phantom
43a48a4a38
feat(scripts): migrate feishu-notify to TypeScript CLI tool with subcommands (#12371)
* build: add commander package as dependency

* refactor(scripts): migrate feishu-notify to TypeScript with CLI interface

- Convert JavaScript implementation to TypeScript with proper type definitions
- Add CLI interface using commander for better usability
- Improve error handling and input validation
- Add version management and subcommand support

* ci(workflows): update feishu notification command and add pnpm install step

Update the feishu notification command to use CLI tool with proper arguments instead of direct node script execution
Add pnpm install step to ensure dependencies are available before running the workflow

* docs: add feishu notification script documentation

Add Chinese and English documentation for the feishu-notify.ts CLI tool

* feat(notify): add generic send command to feishu-notify

Add a new 'send' subcommand to send simple notifications to Feishu with customizable title, description and header color. This provides a more flexible way to send notifications without being tied to specific business logic like the existing 'issue' command.

The implementation includes:
- New send command handler and options interface
- Simple card creation function
- Zod schema for header color validation
- Documentation updates in both Chinese and English
2026-01-08 16:55:46 +08:00
kangfenmao
0cb3bd8311 fix: add claude code sdk support for arm version windows
- Enhanced the logic to determine included Claude code vendors based on architecture and platform.
- Adjusted filters for excluding and including Claude code vendors to improve compatibility, particularly for Windows ARM64.
- Removed unnecessary variables and streamlined the filter application process.
2026-01-08 00:05:35 +08:00
kangfenmao
2f67b63057 chore: update package.json and pnpm-lock.yaml for dependency management
- Removed outdated dependencies: js-yaml, bonjour-service, and emoji-picker-element-data from devDependencies.
- Added js-yaml, bonjour-service, and emoji-picker-element-data back to their respective locations in dependencies.
- Introduced optionalDependencies for @strongtz/win32-arm64-msvc with version ^0.4.7.
- Updated pnpm-lock.yaml to reflect the changes in package.json and added new package versions.
2026-01-07 23:12:37 +08:00
花月喵梦
81ea847989
Add Anthropic Cache (#12333)
* add anthropic cache

* i18n: sync

* fix: condition judgment

* lg

* ag

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2026-01-07 23:05:30 +08:00
defi-failure
1d07e89e38
fix: remove blockmap handling after differentialPackage disabled (#12351) 2026-01-07 22:48:31 +08:00
kangfenmao
90cd06d23d chore: release v1.7.11
Updated version number to 1.7.11 in package.json and electron-builder.yml. Added release notes highlighting the introduction of the MCP Hub with Auto mode and various bug fixes, including improvements to the Chat and Editor components.
2026-01-07 18:50:28 +08:00
kangfenmao
8d56bf80dd chore: update GitHub Actions workflow to enable corepack for pnpm installation
Replaced the pnpm action setup with a corepack enable command to streamline dependency management in the workflow.
2026-01-07 18:46:07 +08:00
kangfenmao
7766438853 Revert "fix(SearchService): Fix inability to retrieve search results from Bing, Baidu, and Google"
This reverts commit b83fbc0ace.
2026-01-07 18:40:39 +08:00
kangfenmao
3ec6e1167f chore: release v1.7.10 2026-01-07 17:31:35 +08:00
Le Bao
b83fbc0ace
fix(SearchService): Fix inability to retrieve search results from Bing, Baidu, and Google
This commit fixes a bug where search results could not be retrieved from Bing, Baidu, and Google.
The root cause of this issue was a discrepancy in page content when the Electron window was hidden versus when it was visible. Additionally, the previous use of `did-finish-load` caused page jitter within the window, leading to sporadic failures in fetching search content.
To resolve this, I've enabled offscreen rendering, ensuring consistent page content regardless of window visibility. Furthermore, I've switched to using the `ready-to-show` event to ensure the complete page DOM is available before attempting to retrieve content, thereby eliminating the search bug.
* feat(fetch): add request throttling (already present in the original, keeping it)
Co-authored-by: suyao <sy20010504@gmail.com>
2026-01-07 17:23:17 +08:00
SuYao
040f4daa98
fix: enable reasoning cot bug (#12342) 2026-01-07 17:11:41 +08:00
Phantom
d0a1512f23
fix: optimize action component state management to prevent duplicate loading spinners (#12318)
* refactor: separate message extraction from rendering

Extract `lastAssistantMessage` memoization separately from rendering
`MessageContent` component, improving code clarity and separation of
concerns.

* feat: Replace manual loading state with AssistantMessageStatus tracking

* refactor: Replace loading state with status enum in translation action

- Add LoadingOutlined icon for preparing state
- Remove AssistantMessageStatus dependency
- Simplify streaming detection using local status state

* feat: Add logging and status sync for translation action

* feat: Refactor action component state management to be consistent with
translate action

Replace separate `isContented` and `isLoading` states with a single
`status` state that tracks 'preparing', 'streaming', and 'finished'
phases. Sync status with assistant message status and update footer
loading prop accordingly.

* fix: Add missing pauseTrace import to ActionTranslate component

* fix: Add missing break statements in assistant message status handling

* fix: Move pauseTrace call inside abort completion condition
2026-01-07 16:51:25 +08:00
Zhaolin Liang
2777af77d8
fix: paragraph handle and plus button not selectable (#12320) 2026-01-07 16:45:15 +08:00
LiuVaayne
6d15b0dfd1
feat(mcp): add MCP Hub server for multi-server tool orchestration (#12192)
* feat(mcp): add hub server type definitions

- Add 'hub' to BuiltinMCPServerNames enum as '@cherry/hub'
- Create GeneratedTool, SearchQuery, ExecInput, ExecOutput types
- Add ExecutionContext and ConsoleMethods interfaces

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* feat(mcp): implement hub server core components

- generator.ts: Convert MCP tools to JS functions with JSDoc
- tool-registry.ts: In-memory cache with 10-min TTL
- search.ts: Comma-separated keyword search with ranking
- runtime.ts: Code execution with parallel/settle/console helpers

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* feat(mcp): integrate hub server with MCP infrastructure

- Create HubServer class with search/exec tools
- Implement mcp-bridge for calling tools via MCPService
- Register hub server in factory with dependency injection
- Initialize hub dependencies in MCPService constructor
- Add hub server description label for i18n

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* test(mcp): add unit tests for hub server

- generator.test.ts: Test schema conversion and JSDoc generation
- search.test.ts: Test keyword matching, ranking, and limits
- runtime.test.ts: Test code execution, helpers, and error handling

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* docs(mcp): add hub server documentation

- Document search/exec tool usage and parameters
- Explain configuration and caching behavior
- Include architecture diagram and file structure

Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e
Co-authored-by: Amp <amp@ampcode.com>

* ♻️ refactor(hub): simplify dependency injection for HubServer

- Remove HubServerDependencies interface and setHubServerDependencies from factory
- Add initHubBridge() to mcp-bridge for direct initialization
- Make HubServer constructor parameterless (uses pre-initialized bridge)
- MCPService now calls initHubBridge() directly instead of factory setter
- Add integration tests for full search → exec flow

* 📝 docs(hub): add comments explaining why hub is not in builtin list

- Add JSDoc to HubServer class explaining its purpose and design
- Add comment to builtinMCPServers explaining hub exclusion
- Hub is a meta-server for LLM code mode, auto-enabled internally

*  feat: add available tools section to HUB_MODE_SYSTEM_PROMPT

- Add shared utility for generating MCP tool function names (serverName_toolName format)
- Update hub server to use consistent function naming across search, exec and prompt
- Add fetchAllActiveServerTools to ApiService for renderer process
- Update parameterBuilder to include available tools in auto/hub mode prompt
- Use CacheService for 1-minute tools caching in hub server
- Remove ToolRegistry in favor of direct fetching with caching
- Update search ranking to include server name matching
- Fix tests to use new naming format

Amp-Thread-ID: https://ampcode.com/threads/T-019b6971-d5c9-7719-9245-a89390078647
Co-authored-by: Amp <amp@ampcode.com>

* ♻️ refactor: consolidate MCP tool name utilities into shared module

- Merge buildFunctionCallToolName from src/main/utils/mcp.ts into packages/shared/mcp.ts
- Create unified buildMcpToolName base function with options for prefix, delimiter, maxLength, existingNames
- Fix toCamelCase to normalize uppercase snake case (MY_SERVER → myServer)
- Fix maxLength + existingNames interaction to respect length limit when adding collision suffix
- Add comprehensive JSDoc documentation
- Update tests and hub.test.ts for new lowercase normalization behavior

*  feat: isolate hub exec worker and filter disabled tools

* 🐛 fix: inline hub worker source

* 🐛 fix: sync hub tool cache and map

* Update import path for buildFunctionCallToolName in BaseService

*  feat: refine hub mode system prompt

* 🐛 fix: propagate hub tool errors

* 📝 docs: clarify hub exec return

*  feat(hub): improve prompts and tool descriptions for better LLM success rate

- Rewrite HUB_MODE_SYSTEM_PROMPT_BASE with Critical Rules section
- Add Common Mistakes to Avoid section with examples
- Update exec tool description with IMPORTANT return requirement
- Improve search tool description clarity
- Simplify generator output with return reminder in header
- Add per-field @param JSDoc with required/optional markers

Fixes issue where LLMs forgot to return values from exec code

* ♻️ refactor(hub): return empty string when no tools available

*  feat(hub): add dedicated AUTO_MODE_SYSTEM_PROMPT for auto mode

- Create self-contained prompt teaching XML tool_use format
- Only shows search/exec tools (no generic examples)
- Add complete workflow example with common mistakes
- Update parameterBuilder to use getAutoModeSystemPrompt()
- User prompt comes first, then auto mode instructions
- Skip hub prompt when no tools available

* ♻️ refactor: move hub prompts to dedicated prompts-code-mode.ts

- Create src/renderer/src/config/prompts-code-mode.ts
- Move HUB_MODE_SYSTEM_PROMPT_BASE and AUTO_MODE_SYSTEM_PROMPT_BASE
- Move getHubModeSystemPrompt() and getAutoModeSystemPrompt()
- Extract shared buildToolsSection() helper
- Update parameterBuilder.ts import

* ♻️ refactor: add mcpMode support to promptToolUsePlugin

- Add mcpMode parameter to PromptToolUseConfig and defaultBuildSystemPrompt
- Pass mcpMode through middleware config to plugin builder
- Consolidate getAutoModeSystemPrompt into getHubModeSystemPrompt
- Update parameterBuilder to use getHubModeSystemPrompt

* ♻️ refactor: move getHubModeSystemPrompt to shared package

- Create @cherrystudio/shared workspace package with exports
- Move getHubModeSystemPrompt and ToolInfo to packages/shared/prompts
- Add @cherrystudio/shared dependency to @cherrystudio/ai-core
- Update promptToolUsePlugin to import from shared package
- Update renderer prompts-code-mode.ts to re-export from shared
- Add toolSetToToolInfoArray converter for type compatibility

* Revert "♻️ refactor: move getHubModeSystemPrompt to shared package"

This reverts commit 894b2fd487.

* Remove duplicate Tool Use Examples header from system prompt

* fix: add handleModeChange call in MCPToolsButton for manual mode activation

* style: update AssistantMCPSettings to use min-height instead of overflow for better layout control

* feat(i18n): add MCP server modes and truncate messages in multiple languages

- Introduced new "mode" options for MCP servers: auto, disabled, and manual with corresponding descriptions and labels.
- Added translations for "base64DataTruncated" and "truncated" messages across various language files.
- Enhanced user experience by providing clearer feedback on data truncation.

* Normalize tool names for search and exec in parser

* Clarify tool usage rules in code mode prompts and examples

* Clarify code execution instructions and update example usage

* refactor: simplify JSDoc description handling by removing unnecessary truncation

* refactor: optimize listAllActiveServerTools method to use Promise.allSettled for improved error handling and performance

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
2026-01-07 16:35:51 +08:00
beyondkmp
334b9bbe04
fix: disable differential package for nsis and dmg (#12335)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 16:22:40 +08:00
LiuVaayne
ed3401a016
⬆️ chore(deps): upgrade @anthropic-ai/claude-agent-sdk to 0.1.76 (#12317)
- Upgrade from 0.1.62 to 0.1.76 (latest stable)
- Remove version-specific patch (no longer needed)
2026-01-07 14:48:27 +08:00
Phantom
91b6ed81cc
fix(ProviderSettings): allow embedding model API check and optimize hooks (#12334) 2026-01-07 14:13:05 +08:00
Little White Dog
c940b5613f
fix: resolve ActionTranslate stalling after initialization (#12329)
* fix: resolve ActionTranslate stalling after initialization

Issue: When invoking translate from the selection assistant, the fetchResult function does not react to the completion of initialize, causing the ActionTranslate component to enter an infinite loading state.

Cause: In commit 680bda3993, the initialize effect hook was refactored into a callback function. This refactor omitted the notification that fetchResult should run after initialization, so fetchResult never executes post‑initialization.

Fix: Change the initialized flag from a ref to a state variable and have fetchResult listen to this state. This modification ensures the effect hook triggers fetchResult exactly once after initialization is complete.

* fix(ActionTranslate): fix missing dependency in useEffect hook

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2026-01-07 11:53:10 +08:00
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
165 changed files with 32166 additions and 28295 deletions

View File

@ -32,38 +32,37 @@ jobs:
with:
node-version: 22
- name: 📦 Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: 📦 Install pnpm
uses: pnpm/action-setup@v4
- name: 📂 Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: 📂 Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: 💾 Cache yarn dependencies
- name: 💾 Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: 📦 Install dependencies
run: |
yarn install
pnpm install
- name: 🏃‍♀️ Translate
run: yarn i18n:sync && yarn i18n:translate
run: pnpm i18n:sync && pnpm i18n:translate
- name: 🔍 Format
run: yarn format
run: pnpm format
- name: 🔍 Check for changes
id: git_status
run: |
# 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 status --porcelain
@ -91,3 +90,30 @@ jobs:
- name: 📢 Notify if no changes
if: steps.git_status.outputs.has_changes != 'true'
run: echo "Bot script ran, but no changes were detected. No PR created."
- name: Send failure notification to Feishu
if: always() && (failure() || cancelled())
shell: bash
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
# Determine status and color
if [ "$JOB_STATUS" = "cancelled" ]; then
STATUS_TEXT="已取消"
COLOR="orange"
else
STATUS_TEXT="失败"
COLOR="red"
fi
# Build description using printf
DESCRIPTION=$(printf "**状态:** %s\n\n**工作流:** [查看详情](%s)" "$STATUS_TEXT" "$RUN_URL")
# Send notification
pnpm tsx scripts/feishu-notify.ts send \
-t "自动国际化${STATUS_TEXT}" \
-d "$DESCRIPTION" \
-c "${COLOR}"

View File

@ -58,14 +58,34 @@ jobs:
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
if: steps.check_time.outputs.should_delay == 'false'
run: pnpm install
- name: Process issue with Claude
if: steps.check_time.outputs.should_delay == 'false'
uses: anthropics/claude-code-action@main
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: "*"
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(node scripts/feishu-notify.js)"
claude_args: "--allowed-tools Bash(gh issue:*),Bash(pnpm tsx scripts/feishu-notify.ts*)"
prompt: |
你是一个GitHub Issue自动化处理助手。请完成以下任务
@ -74,9 +94,14 @@ jobs:
- 标题:${{ github.event.issue.title }}
- 作者:${{ github.event.issue.user.login }}
- URL${{ github.event.issue.html_url }}
- 内容:${{ github.event.issue.body }}
- 标签:${{ join(github.event.issue.labels.*.name, ', ') }}
### Issue body
`````md
${{ github.event.issue.body }}
`````
## 任务步骤
1. **分析并总结issue**
@ -86,20 +111,20 @@ jobs:
- 重要的技术细节
2. **发送飞书通知**
使用以下命令发送飞书通知注意ISSUE_SUMMARY需要用引号包裹
使用CLI工具发送飞书通知参考以下示例
```bash
ISSUE_URL="${{ github.event.issue.html_url }}" \
ISSUE_NUMBER="${{ github.event.issue.number }}" \
ISSUE_TITLE="${{ github.event.issue.title }}" \
ISSUE_AUTHOR="${{ github.event.issue.user.login }}" \
ISSUE_LABELS="${{ join(github.event.issue.labels.*.name, ',') }}" \
ISSUE_SUMMARY="<你生成的中文总结>" \
node scripts/feishu-notify.js
pnpm tsx scripts/feishu-notify.ts issue \
-u "${{ github.event.issue.html_url }}" \
-n "${{ github.event.issue.number }}" \
-t "${{ github.event.issue.title }}" \
-a "${{ github.event.issue.user.login }}" \
-l "${{ join(github.event.issue.labels.*.name, ',') }}" \
-m "<你生成的中文总结>"
```
## 注意事项
- 总结必须使用简体中文
- ISSUE_SUMMARY 在传递给 node 命令时需要正确转义特殊字符
- 命令行参数需要正确转义特殊字符
- 如果issue内容为空也要提供一个简短的说明
请开始执行任务!
@ -125,13 +150,32 @@ jobs:
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install
- name: Process pending issues with Claude
uses: anthropics/claude-code-action@main
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
allowed_non_write_users: "*"
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(node scripts/feishu-notify.js)"
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:*),Bash(pnpm tsx scripts/feishu-notify.ts*)"
prompt: |
你是一个GitHub Issue自动化处理助手。请完成以下任务
@ -153,15 +197,15 @@ jobs:
- 重要的技术细节
3. **发送飞书通知**
对于每个issue使用以下命令发送飞书通知
使用CLI工具发送飞书通知参考以下示例
```bash
ISSUE_URL="<issue的html_url>" \
ISSUE_NUMBER="<issue编号>" \
ISSUE_TITLE="<issue标题>" \
ISSUE_AUTHOR="<issue作者>" \
ISSUE_LABELS="<逗号分隔的标签列表排除pending-feishu-notification>" \
ISSUE_SUMMARY="<你生成的中文总结>" \
node scripts/feishu-notify.js
pnpm tsx scripts/feishu-notify.ts issue \
-u "<issue的html_url>" \
-n "<issue编号>" \
-t "<issue标题>" \
-a "<issue作者>" \
-l "<逗号分隔的标签列表排除pending-feishu-notification>" \
-m "<你生成的中文总结>"
```
4. **移除标签**

View File

@ -65,25 +65,24 @@ jobs:
run: |
brew install python-setuptools
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: Install Dependencies
run: yarn install
run: pnpm install
- name: Generate date tag
id: date
@ -94,7 +93,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y rpm
yarn build:linux
pnpm build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
@ -106,7 +105,7 @@ jobs:
- name: Build Mac
if: matrix.os == 'macos-latest'
run: |
yarn build:mac
pnpm build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@ -123,7 +122,7 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:win
pnpm build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192

View File

@ -28,37 +28,36 @@ jobs:
with:
node-version: 22
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: Install Dependencies
run: yarn install
run: pnpm install
- name: Lint Check
run: yarn test:lint
run: pnpm test:lint
- name: Format Check
run: yarn format:check
run: pnpm format:check
- name: Type Check
run: yarn typecheck
run: pnpm typecheck
- name: i18n Check
run: yarn i18n:check
run: pnpm i18n:check
- name: Test
run: yarn test
run: pnpm test

View File

@ -56,31 +56,30 @@ jobs:
run: |
brew install python-setuptools
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: Install Dependencies
run: yarn install
run: pnpm install
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y rpm
yarn build:linux
pnpm build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -94,7 +93,7 @@ jobs:
if: matrix.os == 'macos-latest'
run: |
sudo -H pip install setuptools
yarn build:mac
pnpm build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@ -111,7 +110,7 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:win
pnpm build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192

View File

@ -48,9 +48,8 @@ jobs:
with:
node-version: 22
- name: Install corepack
shell: bash
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Clean node_modules
if: ${{ github.event.inputs.clean == 'true' }}
@ -59,11 +58,11 @@ jobs:
- name: Install Dependencies
shell: bash
run: yarn install
run: pnpm install
- name: Build Windows with code signing
shell: bash
run: yarn build:win
run: pnpm build:win
env:
WIN_SIGN: true
CHERRY_CERT_PATH: ${{ secrets.CHERRY_CERT_PATH }}
@ -80,7 +79,7 @@ jobs:
shell: bash
run: |
echo "Built Windows artifacts:"
ls -la dist/*.exe dist/*.blockmap dist/latest*.yml
ls -la dist/*.exe dist/latest*.yml
- name: Download GitHub release assets
shell: bash
@ -113,12 +112,10 @@ jobs:
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
rm -f release-assets/*.exe 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:"
@ -303,3 +300,31 @@ jobs:
run: |
rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt
rm -rf release-assets/
- name: Send failure notification to Feishu
if: always() && (failure() || cancelled())
shell: bash
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
TAG_NAME: ${{ steps.get-tag.outputs.tag }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
# Determine status and color
if [ "$JOB_STATUS" = "cancelled" ]; then
STATUS_TEXT="已取消"
COLOR="orange"
else
STATUS_TEXT="失败"
COLOR="red"
fi
# Build description using printf
DESCRIPTION=$(printf "**标签:** %s\n\n**状态:** %s\n\n**工作流:** [查看详情](%s)" "$TAG_NAME" "$STATUS_TEXT" "$RUN_URL")
# Send notification
pnpm tsx scripts/feishu-notify.ts send \
-t "GitCode 同步${STATUS_TEXT}" \
-d "$DESCRIPTION" \
-c "${COLOR}"

View File

@ -154,14 +154,15 @@ jobs:
with:
node-version: 22
- name: Enable Corepack
- name: Enable corepack
if: steps.check.outputs.should_run == 'true'
run: corepack enable && corepack prepare yarn@4.9.1 --activate
working-directory: main
run: corepack enable pnpm
- name: Install dependencies
if: steps.check.outputs.should_run == 'true'
working-directory: main
run: yarn install --immutable
run: pnpm install --frozen-lockfile
- name: Update upgrade config
if: steps.check.outputs.should_run == 'true'
@ -170,7 +171,7 @@ jobs:
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }}
run: |
yarn tsx scripts/update-app-upgrade-config.ts \
pnpm tsx scripts/update-app-upgrade-config.ts \
--tag "$RELEASE_TAG" \
--config ../cs/app-upgrade-config.json \
--is-prerelease "$IS_PRERELEASE"

View File

@ -1 +1 @@
yarn lint-staged
pnpm lint-staged

View File

@ -1,35 +0,0 @@
diff --git a/sdk.mjs b/sdk.mjs
index dea7766a3432a1e809f12d6daba4f2834a219689..e0b02ef73da177ba32b903887d7bbbeaa08cc6d3 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6250,7 +6250,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// ../src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -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?`;
throw new ReferenceError(errorMessage);
}
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- const spawnMessage = isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`;
- logForSdkDebugging(spawnMessage);
- if (stderr) {
- stderr(spawnMessage);
- }
+ logForSdkDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
const stderrMode = env.DEBUG_CLAUDE_AGENT_SDK || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {
+ this.child = fork(pathToClaudeCodeExecutable, args, {
cwd,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal: this.abortController.signal,
env
});

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,7 +10,7 @@ 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`.
- **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.
- **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:`).
## Pull Request Workflow (CRITICAL)
@ -24,18 +24,18 @@ When creating a Pull Request, you MUST:
## Development Commands
- **Install**: `yarn install` - Install all project dependencies
- **Development**: `yarn dev` - Runs Electron app in development mode with hot reload
- **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
- **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck)
- If having i18n sort issues, run `yarn i18n:sync` first to sync template
- If having formatting issues, run `yarn format` first
- **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes
- **Install**: `pnpm install` - Install all project dependencies
- **Development**: `pnpm dev` - Runs Electron app in development mode with hot reload
- **Debug**: `pnpm debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
- **Build Check**: `pnpm build:check` - **REQUIRED** before commits (lint + test + typecheck)
- If having i18n sort issues, run `pnpm i18n:sync` first to sync template
- If having formatting issues, run `pnpm format` first
- **Test**: `pnpm test` - Run all tests (Vitest) across main and renderer processes
- **Single Test**:
- `yarn test:main` - Run tests for main process only
- `yarn test:renderer` - Run tests for renderer process only
- **Lint**: `yarn lint` - Fix linting issues and run TypeScript type checking
- **Format**: `yarn format` - Auto-format code using Biome
- `pnpm test:main` - Run tests for main process only
- `pnpm test:renderer` - Run tests for renderer process only
- **Lint**: `pnpm lint` - Fix linting issues and run TypeScript type checking
- **Format**: `pnpm format` - Auto-format code using Biome
## Project Architecture
@ -49,7 +49,7 @@ When creating a Pull Request, you MUST:
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
- **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.
### Logging

View File

@ -50,7 +50,8 @@
"!*.json",
"!src/main/integration/**",
"!**/tailwind.css",
"!**/package.json"
"!**/package.json",
"!.zed/**"
],
"indentStyle": "space",
"indentWidth": 2,

View File

@ -11,7 +11,7 @@
### Install
```bash
yarn
pnpm install
```
### Development
@ -20,17 +20,17 @@ yarn
Download and install [Node.js v22.x.x](https://nodejs.org/en/download)
### Setup Yarn
### Setup pnpm
```bash
corepack enable
corepack prepare yarn@4.9.1 --activate
corepack prepare pnpm@10.27.0 --activate
```
### Install Dependencies
```bash
yarn install
pnpm install
```
### ENV
@ -42,13 +42,13 @@ cp .env.example .env
### Start
```bash
yarn dev
pnpm dev
```
### Debug
```bash
yarn debug
pnpm debug
```
Then input chrome://inspect in browser
@ -56,18 +56,18 @@ Then input chrome://inspect in browser
### Test
```bash
yarn test
pnpm test
```
### Build
```bash
# For windows
$ yarn build:win
$ pnpm build:win
# For macOS
$ yarn build:mac
$ pnpm build:mac
# For Linux
$ yarn build:linux
$ pnpm build:linux
```

View File

@ -116,7 +116,7 @@ This script checks:
- Whether keys are properly sorted
```bash
yarn i18n:check
pnpm i18n:check
```
### `i18n:sync` - Synchronize JSON Structure and Sort Order
@ -128,7 +128,7 @@ This script uses `zh-cn.json` as the source of truth to sync structure across al
3. Sorting keys automatically
```bash
yarn i18n:sync
pnpm i18n:sync
```
### `i18n:translate` - Automatically Translate Pending Texts
@ -148,20 +148,20 @@ MODEL="qwen-plus-latest"
Alternatively, add these variables directly to your `.env` file.
```bash
yarn i18n:translate
pnpm i18n:translate
```
### Workflow
1. During development, first add the required text in `zh-cn.json`
2. Confirm it displays correctly in the Chinese environment
3. Run `yarn i18n:sync` to propagate the keys to other language files
4. Run `yarn i18n:translate` to perform machine translation
3. Run `pnpm i18n:sync` to propagate the keys to other language files
4. Run `pnpm i18n:translate` to perform machine translation
5. Grab a coffee and let the magic happen!
## Best Practices
1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages.
2. **Run Check Script Before Commit**: Use `yarn i18n:check` 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.
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.
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/`.
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.
3. **Install toolchain** Node.js 22, Corepack, and frozen pnpm dependencies are installed inside `main/`.
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`.
- 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.
@ -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:
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`.
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

View File

@ -0,0 +1,155 @@
# Feishu Notification Script
`scripts/feishu-notify.ts` is a CLI tool for sending notifications to Feishu (Lark) Webhook. This script is primarily used in GitHub Actions workflows to enable automatic notifications.
## Features
- Subcommand-based CLI structure for different notification types
- HMAC-SHA256 signature verification
- Sends Feishu interactive card messages
- Full TypeScript type support
- Credentials via environment variables for security
## Usage
### Prerequisites
```bash
pnpm install
```
### CLI Structure
```bash
pnpm tsx scripts/feishu-notify.ts [command] [options]
```
### Environment Variables (Required)
| Variable | Description |
|----------|-------------|
| `FEISHU_WEBHOOK_URL` | Feishu Webhook URL |
| `FEISHU_WEBHOOK_SECRET` | Feishu Webhook signing secret |
## Commands
### `send` - Send Simple Notification
Send a generic notification without business-specific logic.
```bash
pnpm tsx scripts/feishu-notify.ts send [options]
```
| Option | Short | Description | Required |
|--------|-------|-------------|----------|
| `--title` | `-t` | Card title | Yes |
| `--description` | `-d` | Card description (supports markdown) | Yes |
| `--color` | `-c` | Header color template | No (default: turquoise) |
**Available colors:** `blue`, `wathet`, `turquoise`, `green`, `yellow`, `orange`, `red`, `carmine`, `violet`, `purple`, `indigo`, `grey`, `default`
#### Example
```bash
# Use $'...' syntax for proper newlines
pnpm tsx scripts/feishu-notify.ts send \
-t "Deployment Completed" \
-d $'**Status:** Success\n\n**Environment:** Production\n\n**Version:** v1.2.3' \
-c green
```
```bash
# Send an error alert (red color)
pnpm tsx scripts/feishu-notify.ts send \
-t "Error Alert" \
-d $'**Error Type:** Connection failed\n\n**Severity:** High\n\nPlease check the system status' \
-c red
```
**Note:** For proper newlines in the description, use bash's `$'...'` syntax. Do not use literal `\n` in double quotes, as it will be displayed as-is in the Feishu card.
### `issue` - Send GitHub Issue Notification
```bash
pnpm tsx scripts/feishu-notify.ts issue [options]
```
| Option | Short | Description | Required |
|--------|-------|-------------|----------|
| `--url` | `-u` | GitHub issue URL | Yes |
| `--number` | `-n` | Issue number | Yes |
| `--title` | `-t` | Issue title | Yes |
| `--summary` | `-m` | Issue summary | Yes |
| `--author` | `-a` | Issue author | No (default: "Unknown") |
| `--labels` | `-l` | Issue labels (comma-separated) | No |
#### Example
```bash
pnpm tsx scripts/feishu-notify.ts issue \
-u "https://github.com/owner/repo/issues/123" \
-n "123" \
-t "Bug: Something is broken" \
-m "This is a bug report about a feature" \
-a "username" \
-l "bug,high-priority"
```
## Usage in GitHub Actions
This script is primarily used in `.github/workflows/github-issue-tracker.yml`:
```yaml
- name: Install dependencies
run: pnpm install
- name: Send notification
run: |
pnpm tsx scripts/feishu-notify.ts issue \
-u "${{ github.event.issue.html_url }}" \
-n "${{ github.event.issue.number }}" \
-t "${{ github.event.issue.title }}" \
-a "${{ github.event.issue.user.login }}" \
-l "${{ join(github.event.issue.labels.*.name, ',') }}" \
-m "Issue summary content"
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
```
## Feishu Card Message Format
The `issue` command sends an interactive card containing:
- **Header**: `#<issue_number> - <issue_title>`
- **Author**: Issue creator
- **Labels**: Issue labels (if any)
- **Summary**: Issue content summary
- **Action Button**: "View Issue" button linking to the GitHub Issue page
## Configuring Feishu Webhook
1. Add a custom bot to your Feishu group
2. Obtain the Webhook URL and signing secret
3. Configure them in GitHub Secrets:
- `FEISHU_WEBHOOK_URL`: Webhook address
- `FEISHU_WEBHOOK_SECRET`: Signing secret
## Error Handling
The script exits with a non-zero code when:
- Required environment variables are missing (`FEISHU_WEBHOOK_URL`, `FEISHU_WEBHOOK_SECRET`)
- Required command options are missing
- Feishu API returns a non-2xx status code
- Network request fails
## Extending with New Commands
The CLI is designed to support multiple notification types. To add a new command:
1. Define the command options interface
2. Create a card builder function
3. Add a new command handler
4. Register the command with `program.command()`

View File

@ -11,7 +11,7 @@
### Install
```bash
yarn
pnpm install
```
### Development
@ -20,17 +20,17 @@ yarn
Download and install [Node.js v22.x.x](https://nodejs.org/en/download)
### Setup Yarn
### Setup pnpm
```bash
corepack enable
corepack prepare yarn@4.9.1 --activate
corepack prepare pnpm@10.27.0 --activate
```
### Install Dependencies
```bash
yarn install
pnpm install
```
### ENV
@ -42,13 +42,13 @@ cp .env.example .env
### Start
```bash
yarn dev
pnpm dev
```
### Debug
```bash
yarn debug
pnpm debug
```
Then input chrome://inspect in browser
@ -56,18 +56,18 @@ Then input chrome://inspect in browser
### Test
```bash
yarn test
pnpm test
```
### Build
```bash
# For windows
$ yarn build:win
$ pnpm build:win
# For macOS
$ yarn build:mac
$ pnpm build:mac
# For Linux
$ yarn build:linux
$ pnpm build:linux
```

View File

@ -111,7 +111,7 @@ export const getThemeModeLabel = (key: string): string => {
- 是否已经有序
```bash
yarn i18n:check
pnpm i18n:check
```
### `i18n:sync` - 同步 json 结构与排序
@ -123,7 +123,7 @@ yarn i18n:check
3. 自动排序
```bash
yarn i18n:sync
pnpm i18n:sync
```
### `i18n:translate` - 自动翻译待翻译文本
@ -143,19 +143,19 @@ MODEL="qwen-plus-latest"
你也可以通过直接编辑`.env`文件来添加环境变量。
```bash
yarn i18n:translate
pnpm i18n:translate
```
### 工作流
1. 开发阶段,先在`zh-cn.json`中添加所需文案
2. 确认在中文环境下显示无误后,使用`yarn i18n:sync`将文案同步到其他语言文件
3. 使用`yarn i18n:translate`进行自动翻译
2. 确认在中文环境下显示无误后,使用`pnpm i18n:sync`将文案同步到其他语言文件
3. 使用`pnpm i18n:translate`进行自动翻译
4. 喝杯咖啡,等翻译完成吧!
## 最佳实践
1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言
2. **提交前运行检查脚本**:使用`yarn i18n:check`检查 i18n 是否有问题
2. **提交前运行检查脚本**:使用`pnpm i18n:check`检查 i18n 是否有问题
3. **小步提交翻译**:避免积累大量未翻译文本
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`。若任意校验失败,工作流立即退出。
2. **检出分支**:默认分支被检出到 `main/`,长期维护的 `x-files/app-upgrade-config` 分支则在 `cs/` 中,所有改动都发生在 `cs/`
3. **安装工具链**:安装 Node.js 22、启用 Corepack并在 `main/` 目录执行 `yarn install --immutable`。
4. **运行更新脚本**:执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>`。
3. **安装工具链**:安装 Node.js 22、启用 Corepack并在 `main/` 目录执行 `pnpm install --frozen-lockfile`。
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` 中的分段规则。
- 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。
- 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON并刷新 `lastUpdated`
@ -223,10 +223,10 @@ interface ChannelConfig {
`.github/workflows/update-app-upgrade-config.yml` 会在 GitHub Release包含正常发布与 Pre Release触发
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 仅包含该文件。
如需本地调试,可执行 `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,155 @@
# 飞书通知脚本
`scripts/feishu-notify.ts` 是一个 CLI 工具,用于向飞书 Webhook 发送通知。该脚本主要在 GitHub Actions 工作流中使用,实现自动通知功能。
## 功能特性
- 基于子命令的 CLI 结构,支持不同类型的通知
- 使用 HMAC-SHA256 签名验证
- 发送飞书交互式卡片消息
- 完整的 TypeScript 类型支持
- 通过环境变量传递凭证,确保安全性
## 使用方式
### 前置依赖
```bash
pnpm install
```
### CLI 结构
```bash
pnpm tsx scripts/feishu-notify.ts [command] [options]
```
### 环境变量(必需)
| 变量 | 说明 |
|------|------|
| `FEISHU_WEBHOOK_URL` | 飞书 Webhook URL |
| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 签名密钥 |
## 命令
### `send` - 发送简单通知
发送通用通知,不涉及具体业务逻辑。
```bash
pnpm tsx scripts/feishu-notify.ts send [options]
```
| 参数 | 短选项 | 说明 | 必需 |
|------|--------|------|------|
| `--title` | `-t` | 卡片标题 | 是 |
| `--description` | `-d` | 卡片描述(支持 markdown | 是 |
| `--color` | `-c` | 标题栏颜色模板 | 否默认turquoise |
**可用颜色:** `blue`(蓝色), `wathet`(浅蓝), `turquoise`(青绿), `green`(绿色), `yellow`(黄色), `orange`(橙色), `red`(红色), `carmine`(深红), `violet`(紫罗兰), `purple`(紫色), `indigo`(靛蓝), `grey`(灰色), `default`(默认)
#### 示例
```bash
# 使用 $'...' 语法实现正确的换行
pnpm tsx scripts/feishu-notify.ts send \
-t "部署完成" \
-d $'**状态:** 成功\n\n**环境:** 生产环境\n\n**版本:** v1.2.3' \
-c green
```
```bash
# 发送错误警报(红色)
pnpm tsx scripts/feishu-notify.ts send \
-t "错误警报" \
-d $'**错误类型:** 连接失败\n\n**严重程度:** 高\n\n请及时检查系统状态' \
-c red
```
**注意:** 如需在描述中换行,请使用 bash 的 `$'...'` 语法。不要在双引号中使用字面量 `\n`,否则会原样显示在飞书卡片中。
### `issue` - 发送 GitHub Issue 通知
```bash
pnpm tsx scripts/feishu-notify.ts issue [options]
```
| 参数 | 短选项 | 说明 | 必需 |
|------|--------|------|------|
| `--url` | `-u` | GitHub Issue URL | 是 |
| `--number` | `-n` | Issue 编号 | 是 |
| `--title` | `-t` | Issue 标题 | 是 |
| `--summary` | `-m` | Issue 摘要 | 是 |
| `--author` | `-a` | Issue 作者 | 否(默认:"Unknown" |
| `--labels` | `-l` | Issue 标签(逗号分隔) | 否 |
#### 示例
```bash
pnpm tsx scripts/feishu-notify.ts issue \
-u "https://github.com/owner/repo/issues/123" \
-n "123" \
-t "Bug: Something is broken" \
-m "这是一个关于某功能的 bug 报告" \
-a "username" \
-l "bug,high-priority"
```
## 在 GitHub Actions 中使用
该脚本主要在 `.github/workflows/github-issue-tracker.yml` 工作流中使用:
```yaml
- name: Install dependencies
run: pnpm install
- name: Send notification
run: |
pnpm tsx scripts/feishu-notify.ts issue \
-u "${{ github.event.issue.html_url }}" \
-n "${{ github.event.issue.number }}" \
-t "${{ github.event.issue.title }}" \
-a "${{ github.event.issue.user.login }}" \
-l "${{ join(github.event.issue.labels.*.name, ',') }}" \
-m "Issue 摘要内容"
env:
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }}
```
## 飞书卡片消息格式
`issue` 命令发送的交互式卡片包含以下内容:
- **标题**: `#<issue编号> - <issue标题>`
- **作者**: Issue 创建者
- **标签**: Issue 标签列表(如有)
- **摘要**: Issue 内容摘要
- **操作按钮**: "View Issue" 按钮,点击跳转到 GitHub Issue 页面
## 配置飞书 Webhook
1. 在飞书群组中添加自定义机器人
2. 获取 Webhook URL 和签名密钥
3. 将 URL 和密钥配置到 GitHub Secrets
- `FEISHU_WEBHOOK_URL`: Webhook 地址
- `FEISHU_WEBHOOK_SECRET`: 签名密钥
## 错误处理
脚本在以下情况会返回非零退出码:
- 缺少必需的环境变量(`FEISHU_WEBHOOK_URL`、`FEISHU_WEBHOOK_SECRET`
- 缺少必需的命令参数
- 飞书 API 返回非 2xx 状态码
- 网络请求失败
## 扩展新命令
CLI 设计支持多种通知类型。添加新命令的步骤:
1. 定义命令选项接口
2. 创建卡片构建函数
3. 添加新的命令处理函数
4. 使用 `program.command()` 注册命令

View File

@ -28,6 +28,12 @@ files:
- "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}"
- "!**/{.editorconfig,.jekyll-metadata}"
- "!src"
- "!config"
- "!patches"
- "!app-upgrade-config.json"
- "!**/node_modules/**/*.cpp"
- "!**/node_modules/node-addon-api/**"
- "!**/node_modules/prebuild-install/**"
- "!scripts"
- "!local"
- "!docs"
@ -90,6 +96,7 @@ nsis:
oneClick: false
include: build/nsis-installer.nsh
buildUniversalInstaller: false
differentialPackage: false
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
buildUniversalInstaller: false
@ -105,6 +112,8 @@ mac:
target:
- target: dmg
- target: zip
dmg:
writeUpdateInfo: false
linux:
artifactName: ${productName}-${version}-${arch}.${ext}
target:
@ -134,44 +143,30 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
Cherry Studio 1.7.9 - New Features & Bug Fixes
Cherry Studio 1.7.13 - Security & Bug Fixes
✨ 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
🔒 Security
- [Plugin] Fix security vulnerability in DXT plugin system on Windows
🐛 Bug Fixes
- [Mac] Fix mini window unexpected closing issue
- [Preview] Fix HTML preview controls not working in fullscreen
- [Translate] Fix translation duplicate execution issue
- [Zoom] Fix page zoom reset issue during navigation
- [Agent] Fix crash when switching between agent and assistant
- [Agent] Fix navigation in agent mode
- [Copy] Fix markdown copy button issue
- [Windows] Fix compatibility issues on non-Windows systems
- [Agent] Fix Agent not working when Node.js is not installed on system
- [Chat] Fix app crash when opening certain agents
- [Chat] Fix reasoning process not displaying correctly for some providers
- [Chat] Fix memory leak issue during streaming conversations
- [MCP] Fix timeout field not accepting string format in MCP configuration
- [Settings] Add careers section in About page
<!--LANG:zh-CN-->
Cherry Studio 1.7.9 - 新功能与问题修复
Cherry Studio 1.7.13 - 安全与问题修复
✨ 新功能
- [Agent] 新增 302.AI 服务商支持
- [浏览器] 浏览器数据现在可以保存,支持多标签页
- [语言] 新增罗马尼亚语支持
- [搜索] 文件列表新增模糊搜索功能
- [模型] 新增最新智谱模型
- [图片] 优化文生图功能
🔒 安全修复
- [插件] 修复 Windows 系统 DXT 插件的安全漏洞
🐛 问题修复
- [Mac] 修复迷你窗口意外关闭的问题
- [预览] 修复全屏模式下 HTML 预览控件无法使用的问题
- [翻译] 修复翻译重复执行的问题
- [缩放] 修复页面导航时缩放被重置的问题
- [智能体] 修复在智能体和助手间切换时崩溃的问题
- [智能体] 修复智能体模式下的导航问题
- [复制] 修复 Markdown 复制按钮问题
- [兼容性] 修复非 Windows 系统的兼容性问题
- [Agent] 修复系统未安装 Node.js 时 Agent 功能无法使用的问题
- [对话] 修复打开某些智能体时应用崩溃的问题
- [对话] 修复部分服务商推理过程无法正确显示的问题
- [对话] 修复流式对话时的内存泄漏问题
- [MCP] 修复 MCP 配置的 timeout 字段不支持字符串格式的问题
- [设置] 关于页面新增招聘入口
<!--LANG:END-->

View File

@ -67,18 +67,7 @@ export default defineConfig({
plugins: [
(async () => (await import('@tailwindcss/vite')).default())(),
react({
tsDecorators: true,
plugins: [
[
'@swc/plugin-styled-components',
{
displayName: true, // 开发环境下启用组件名称
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
]
]
tsDecorators: true
}),
...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin
...visualizerPlugin('renderer')

View File

@ -84,7 +84,7 @@ export default defineConfig([
{
selector: 'CallExpression[callee.object.name="console"]',
message:
'❗CherryStudio uses unified LoggerService: 📖 docs/technical/how-to-use-logger-en.md\n❗CherryStudio 使用统一的日志服务:📖 docs/technical/how-to-use-logger-zh.md\n\n'
'❗CherryStudio uses unified LoggerService: 📖 docs/en/guides/logging.md\n❗CherryStudio 使用统一的日志服务:📖 docs/zh/guides/logging.md\n\n'
}
]
}

View File

@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.9",
"version": "1.7.13",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@ -9,28 +9,13 @@
"engines": {
"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": {
"start": "electron-vite preview",
"dev": "dotenv electron-vite dev",
"dev:watch": "dotenv electron-vite dev -- -w",
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"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:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
@ -42,68 +27,63 @@
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
"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 -",
"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: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",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"analyze:renderer": "VISUALIZER_RENDERER=true pnpm 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:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts",
"i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
"i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"i18n:all": "yarn i18n:check && yarn i18n:sync && yarn i18n:translate",
"i18n:all": "pnpm i18n:sync && pnpm i18n:translate",
"update:languages": "tsx scripts/update-languages.ts",
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
"test": "vitest run --silent",
"test:main": "vitest run --project main",
"test:renderer": "vitest run --project renderer",
"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:ui": "vitest --ui",
"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:scripts": "vitest scripts",
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn i18n:check && 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:check": "biome format && biome lint",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
"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: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": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm 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: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": "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": "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": "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": {
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.62#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch",
"@anthropic-ai/claude-agent-sdk": "0.1.76",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@paymoapp/electron-shutdown-handler": "^1.1.2",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"bonjour-service": "^1.3.0",
"emoji-picker-element-data": "^1",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"@napi-rs/system-ocr": "1.0.2",
"@paymoapp/electron-shutdown-handler": "1.1.2",
"express": "5.1.0",
"font-list": "2.0.0",
"graceful-fs": "4.2.11",
"gray-matter": "4.0.3",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"swagger-jsdoc": "^6.2.8",
"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",
"node-stream-zip": "1.15.0",
"officeparser": "4.2.0",
"os-proxy-config": "1.1.2",
"selection-hook": "1.0.12",
"sharp": "0.34.3",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"tesseract.js": "6.0.1",
"turndown": "7.2.0"
},
"devDependencies": {
@ -112,37 +92,48 @@
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.61",
"@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/azure": "2.0.87",
"@ai-sdk/cerebras": "^1.0.31",
"@ai-sdk/gateway": "^2.0.15",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/google": "2.0.49",
"@ai-sdk/google-vertex": "^3.0.94",
"@ai-sdk/huggingface": "^0.0.10",
"@ai-sdk/mistral": "^2.0.24",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
"@ai-sdk/openai": "2.0.85",
"@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/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",
"@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-runtime": "^3.910.0",
"@aws-sdk/client-s3": "^3.910.0",
"@biomejs/biome": "2.2.4",
"@cherrystudio/ai-core": "workspace:^1.0.9",
"@cherrystudio/embedjs": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
"@cherrystudio/embedjs-loader-image": "^0.1.31",
"@cherrystudio/embedjs-loader-markdown": "^0.1.31",
"@cherrystudio/embedjs-loader-msoffice": "^0.1.31",
"@cherrystudio/embedjs-loader-pdf": "^0.1.31",
"@cherrystudio/embedjs-loader-sitemap": "^0.1.31",
"@cherrystudio/embedjs-loader-web": "^0.1.31",
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/embedjs": "0.1.31",
"@cherrystudio/embedjs-interfaces": "0.1.31",
"@cherrystudio/embedjs-libsql": "0.1.31",
"@cherrystudio/embedjs-loader-csv": "0.1.31",
"@cherrystudio/embedjs-loader-image": "0.1.31",
"@cherrystudio/embedjs-loader-markdown": "0.1.31",
"@cherrystudio/embedjs-loader-msoffice": "0.1.31",
"@cherrystudio/embedjs-loader-pdf": "0.1.31",
"@cherrystudio/embedjs-loader-sitemap": "0.1.31",
"@cherrystudio/embedjs-loader-web": "0.1.31",
"@cherrystudio/embedjs-loader-xml": "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/openai": "^6.12.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/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -155,18 +146,21 @@
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@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",
"@kangfenmao/keyv-storage": "^0.1.0",
"@kangfenmao/keyv-storage": "^0.1.3",
"@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/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/core": "1.0.2",
"@langchain/openai": "1.0.0",
"@langchain/textsplitters": "0.1.0",
"@mistralai/mistralai": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"@modelcontextprotocol/sdk": "1.23.0",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@openrouter/ai-sdk-provider": "^1.2.8",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "2.0.1",
"@opentelemetry/core": "2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
@ -177,6 +171,7 @@
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.12.0",
"@swc/core": "^1.15.8",
"@swc/plugin-styled-components": "^8.0.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.85.5",
@ -185,21 +180,25 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tiptap/extension-collaboration": "^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-drag-handle-react": "^3.2.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-list": "^3.2.0",
"@tiptap/extension-mathematics": "^3.2.0",
"@tiptap/extension-mention": "^3.2.0",
"@tiptap/extension-node-range": "^3.2.0",
"@tiptap/extension-table-of-contents": "^3.2.0",
"@tiptap/extension-typography": "^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/core": "3.2.0",
"@tiptap/extension-code-block": "3.2.0",
"@tiptap/extension-collaboration": "3.2.0",
"@tiptap/extension-drag-handle": "3.2.0",
"@tiptap/extension-drag-handle-react": "3.2.0",
"@tiptap/extension-heading": "3.2.0",
"@tiptap/extension-image": "3.2.0",
"@tiptap/extension-link": "3.2.0",
"@tiptap/extension-list": "3.2.0",
"@tiptap/extension-mathematics": "3.2.0",
"@tiptap/extension-mention": "3.2.0",
"@tiptap/extension-node-range": "3.2.0",
"@tiptap/extension-table-of-contents": "3.2.0",
"@tiptap/extension-typography": "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",
"@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4",
@ -210,14 +209,17 @@
"@types/dotenv": "^8.2.3",
"@types/express": "^5",
"@types/fs-extra": "^11",
"@types/hast": "^3.0.4",
"@types/he": "^1",
"@types/html-to-text": "^9",
"@types/js-yaml": "^4.0.9",
"@types/json-schema": "7.0.15",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
"@types/mdast": "4.0.4",
"@types/mime-types": "^3",
"@types/node": "^22.17.1",
"@types/node": "22.17.2",
"@types/pako": "^1.0.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -228,9 +230,10 @@
"@types/swagger-ui-express": "^4.1.8",
"@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5",
"@types/unist": "3.0.3",
"@types/uuid": "^10.0.0",
"@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-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1",
@ -242,12 +245,16 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "0.4.16",
"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",
"async-mutex": "^0.5.0",
"axios": "^1.7.3",
"bonjour-service": "1.3.0",
"browser-image-compression": "^2.0.2",
"builder-util-runtime": "9.5.0",
"chalk": "4.1.2",
"chardet": "^2.1.0",
"check-disk-space": "3.4.0",
"cheerio": "^1.1.2",
@ -256,8 +263,11 @@
"cli-progress": "^3.12.0",
"clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",
"codemirror-lang-mermaid": "0.5.0",
"color": "^5.0.0",
"commander": "^14.0.2",
"concurrently": "^9.2.1",
"cors": "2.8.5",
"country-flag-emoji-polyfill": "0.1.8",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
@ -265,6 +275,7 @@
"diff": "^8.0.2",
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv": "16.6.1",
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
@ -273,12 +284,13 @@
"electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1",
"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": "5.0.0",
"electron-window-state": "^5.0.3",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"emoji-picker-element-data": "1",
"epub": "1.3.0",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
@ -289,6 +301,7 @@
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
"fetch-socks": "1.3.2",
"form-data": "4.0.4",
"framer-motion": "^12.23.12",
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
@ -305,6 +318,11 @@
"isbinaryfile": "5.0.4",
"jaison": "^2.0.2",
"jest-styled-components": "^7.2.0",
"js-base64": "3.7.7",
"js-yaml": "4.1.0",
"json-schema": "0.4.0",
"katex": "0.16.22",
"ky": "1.8.1",
"linguist-languages": "^8.1.0",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
@ -312,19 +330,27 @@
"lucide-react": "^0.525.0",
"macos-release": "^3.4.0",
"markdown-it": "^14.1.0",
"md5": "2.3.0",
"mermaid": "^11.10.1",
"mime": "^4.0.4",
"mime-types": "^3.0.1",
"motion": "^12.10.5",
"nanoid": "3.3.11",
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"ollama-ai-provider-v2": "patch:ollama-ai-provider-v2@npm%3A1.5.5#~/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch",
"ollama-ai-provider-v2": "1.5.5",
"open": "^8.4.2",
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pako": "1.0.11",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"prosemirror-model": "1.25.2",
"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-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
@ -351,8 +377,11 @@
"remark-gfm": "^4.0.1",
"remark-github-blockquote-alert": "^2.0.0",
"remark-math": "^6.0.0",
"remark-parse": "11.0.0",
"remark-stringify": "11.0.0",
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",
"semver": "7.7.1",
"shiki": "^3.12.0",
"strict-url-sanitise": "^0.0.1",
"string-width": "^7.2.0",
@ -367,9 +396,10 @@
"tsx": "^4.20.3",
"turndown-plugin-gfm": "^1.0.2",
"tw-animate-css": "^1.3.8",
"typescript": "~5.8.2",
"typescript": "~5.8.3",
"undici": "6.21.2",
"unified": "^11.0.5",
"unist-util-visit": "5.0.0",
"uuid": "^13.0.0",
"vite": "npm:rolldown-vite@7.3.0",
"vitest": "^3.2.4",
@ -384,44 +414,68 @@
"zipread": "^1.3.3",
"zod": "^4.1.5"
},
"resolutions": {
"pnpm": {
"overrides": {
"@smithy/types": "4.7.1",
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
"@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",
"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",
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.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",
"openai": "npm:@cherrystudio/openai@6.15.0",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"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-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0",
"@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",
"@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.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.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",
"@ai-sdk/google@npm:2.0.49": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/openai-compatible@npm:1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/openai-compatible@npm:^1.0.19": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
"@langchain/core": "1.0.2",
"@ai-sdk/openai-compatible@1.0.27": "1.0.28",
"@ai-sdk/openai-compatible@1.0.30": "1.0.28"
},
"packageManager": "yarn@4.9.1",
"patchedDependencies": {
"@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@1.0.28.patch",
"@anthropic-ai/claude-agent-sdk@0.1.76": "patches/@anthropic-ai__claude-agent-sdk@0.1.76.patch",
"@openrouter/ai-sdk-provider": "patches/@openrouter__ai-sdk-provider.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": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"biome format --write --no-errors-on-unmatched",
@ -430,5 +484,27 @@
"*.{json,yml,yaml,css,html}": [
"biome format --write --no-errors-on-unmatched"
]
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-libvips-darwin-arm64": "1.2.0",
"@img/sharp-libvips-darwin-x64": "1.2.0",
"@img/sharp-libvips-linux-arm64": "1.2.0",
"@img/sharp-libvips-linux-x64": "1.2.0",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-arm64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"@libsql/darwin-arm64": "0.4.7",
"@libsql/darwin-x64": "0.4.7",
"@libsql/linux-arm64-gnu": "0.4.7",
"@libsql/linux-x64-gnu": "0.4.7",
"@libsql/win32-x64-msvc": "0.4.7",
"@napi-rs/system-ocr-darwin-arm64": "1.0.2",
"@napi-rs/system-ocr-darwin-x64": "1.0.2",
"@napi-rs/system-ocr-win32-arm64-msvc": "1.0.2",
"@napi-rs/system-ocr-win32-x64-msvc": "1.0.2",
"@strongtz/win32-arm64-msvc": "0.4.7"
}
}

View File

@ -8,7 +8,7 @@ It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Ant
```bash
npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
# 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.

View File

@ -41,7 +41,7 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/openai-compatible": "1.0.28",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17"
},

View File

@ -42,7 +42,7 @@
"@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/azure": "^2.0.87",
"@ai-sdk/deepseek": "^1.0.31",
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/openai-compatible": "1.0.28",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17",
"@ai-sdk/xai": "^2.0.36",

View File

@ -21,9 +21,6 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
separator: '\n'
}
/**
*
*/
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 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.
@ -38,10 +35,16 @@ Tool use is formatted using XML-style tags. The tool name is enclosed in opening
The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example:
<tool_use>
<name>python_interpreter</name>
<arguments>{"code": "5 + 3 + 1294.678"}</arguments>
<name>search</name>
<arguments>{ "query": "browser,fetch" }</arguments>
</tool_use>
<tool_use>
<name>exec</name>
<arguments>{ "code": "const page = await CherryBrowser_fetch({ url: "https://example.com" })\nreturn page" }</arguments>
</tool_use>
The user will respond with the result of the tool use, which should be formatted as follows:
<tool_use_result>
@ -59,13 +62,6 @@ For example, if the result of the tool use is an image file, you can use it in t
Always adhere to this format for the tool use to ensure proper parsing and execution.
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}
## Tool Use Rules
Here are the rules you should always follow to solve your task:
1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead.
@ -74,6 +70,8 @@ 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.
5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format.
{{ TOOLS_INFO }}
## Response rules
Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used.
@ -154,7 +152,8 @@ User: <tool_use_result>
<name>search</name>
<result>26 million (2019)</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
@ -184,13 +183,30 @@ ${result}
/**
* Cherry Studio
*/
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet): string {
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet, mcpMode?: string): string {
const availableTools = buildAvailableTools(tools)
if (availableTools === null) return userSystemPrompt
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
if (mcpMode == 'auto') {
return DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', '').replace(
'{{ USER_SYSTEM_PROMPT }}',
userSystemPrompt || ''
)
}
const toolsInfo = `
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}`
.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
.replace('{{ AVAILABLE_TOOLS }}', availableTools)
.replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt || '')
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', toolsInfo).replace(
'{{ USER_SYSTEM_PROMPT }}',
userSystemPrompt || ''
)
return fullPrompt
}
@ -223,7 +239,17 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs
// Find all tool use blocks
while ((match = toolUsePattern.exec(contentToProcess)) !== null) {
const fullMatch = match[0]
const toolName = match[2].trim()
let toolName = match[2].trim()
switch (toolName.toLowerCase()) {
case 'search':
toolName = 'mcp__CherryHub__search'
break
case 'exec':
toolName = 'mcp__CherryHub__exec'
break
default:
break
}
const toolArgs = match[4].trim()
// Try to parse the arguments as JSON
@ -255,7 +281,12 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs
}
export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
const { enabled = true, buildSystemPrompt = defaultBuildSystemPrompt, parseToolUse = defaultParseToolUse } = config
const {
enabled = true,
buildSystemPrompt = defaultBuildSystemPrompt,
parseToolUse = defaultParseToolUse,
mcpMode
} = config
return definePlugin({
name: 'built-in:prompt-tool-use',
@ -285,7 +316,7 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
// 构建系统提示符(只包含非 provider-defined 工具)
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools, mcpMode)
let systemMessage: string | null = systemPrompt
if (config.createSystemMessage) {
// 🎯 如果用户提供了自定义处理函数,使用它

View File

@ -23,6 +23,7 @@ export interface PromptToolUseConfig extends BaseToolUsePluginConfig {
// 自定义工具解析函数(可选,有默认实现)
parseToolUse?: (content: string, tools: ToolSet) => { results: ToolUseResult[]; content: string }
createSystemMessage?: (systemPrompt: string, originalParams: any, context: AiRequestContext) => string | null
mcpMode?: string
}
/**

View File

@ -68,8 +68,8 @@
],
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@tiptap/core": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/core": "3.2.0",
"@tiptap/pm": "3.2.0",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
@ -89,5 +89,5 @@
"build": "tsdown",
"lint": "biome format ./src/ --write && eslint --fix ./src/"
},
"packageManager": "yarn@4.9.1"
"packageManager": "pnpm@10.27.0"
}

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

@ -4,7 +4,7 @@
*
*
* 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.
*
*
*/

116
packages/shared/mcp.ts Normal file
View File

@ -0,0 +1,116 @@
/**
* Convert a string to camelCase, ensuring it's a valid JavaScript identifier.
*
* - Normalizes to lowercase first, then capitalizes word boundaries
* - Non-alphanumeric characters are treated as word separators
* - Non-ASCII characters are dropped (ASCII-only output)
* - If result starts with a digit, prefixes with underscore
*
* @example
* toCamelCase('my-server') // 'myServer'
* toCamelCase('MY_SERVER') // 'myServer'
* toCamelCase('123tool') // '_123tool'
*/
export function toCamelCase(str: string): string {
let result = str
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase())
.replace(/[^a-zA-Z0-9]/g, '')
if (result && !/^[a-zA-Z_]/.test(result)) {
result = '_' + result
}
return result
}
export type McpToolNameOptions = {
/** Prefix added before the name (e.g., 'mcp__'). Must be JS-identifier-safe. */
prefix?: string
/** Delimiter between server and tool parts (e.g., '_' or '__'). Must be JS-identifier-safe. */
delimiter?: string
/** Maximum length of the final name. Suffix numbers for uniqueness are included in this limit. */
maxLength?: number
/** Mutable Set for collision detection. The final name will be added to this Set. */
existingNames?: Set<string>
}
/**
* Build a valid JavaScript function name from server and tool names.
* Uses camelCase for both parts.
*
* @param serverName - The MCP server name (optional)
* @param toolName - The tool name
* @param options - Configuration options
* @returns A valid JS identifier
*/
export function buildMcpToolName(
serverName: string | undefined,
toolName: string,
options: McpToolNameOptions = {}
): string {
const { prefix = '', delimiter = '_', maxLength, existingNames } = options
const serverPart = serverName ? toCamelCase(serverName) : ''
const toolPart = toCamelCase(toolName)
const baseName = serverPart ? `${prefix}${serverPart}${delimiter}${toolPart}` : `${prefix}${toolPart}`
if (!existingNames) {
return maxLength ? truncateToLength(baseName, maxLength) : baseName
}
let name = maxLength ? truncateToLength(baseName, maxLength) : baseName
let counter = 1
while (existingNames.has(name)) {
const suffix = String(counter)
const truncatedBase = maxLength ? truncateToLength(baseName, maxLength - suffix.length) : baseName
name = `${truncatedBase}${suffix}`
counter++
}
existingNames.add(name)
return name
}
function truncateToLength(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str
}
return str.slice(0, maxLength).replace(/_+$/, '')
}
/**
* Generate a unique function name from server name and tool name.
* Format: serverName_toolName (camelCase)
*
* @example
* generateMcpToolFunctionName('github', 'search_issues') // 'github_searchIssues'
*/
export function generateMcpToolFunctionName(
serverName: string | undefined,
toolName: string,
existingNames?: Set<string>
): string {
return buildMcpToolName(serverName, toolName, { existingNames })
}
/**
* Builds a valid JavaScript function name for MCP tool calls.
* Format: mcp__{serverName}__{toolName}
*
* @param serverName - The MCP server name
* @param toolName - The tool name from the server
* @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars
*
* @example
* buildFunctionCallToolName('github', 'search_issues') // 'mcp__github__searchIssues'
*/
export function buildFunctionCallToolName(serverName: string, toolName: string): string {
return buildMcpToolName(serverName, toolName, {
prefix: 'mcp__',
delimiter: '__',
maxLength: 63
})
}

View File

@ -88,3 +88,81 @@ const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
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

@ -11,7 +11,7 @@ index 48e2f6263c6ee4c75d7e5c28733e64f6ebe92200..00d0729c4a3cbf9a48e8e1e962c7e2b2
type OpenAICompatibleProviderOptions = z.infer<typeof openaiCompatibleProviderOptions>;
diff --git a/dist/index.js b/dist/index.js
index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e5bfe0f9a 100644
index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..88349c614a69a268a2e4f3b157cb5e328ca1d347 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -41,7 +41,7 @@ function getOpenAIMetadata(message) {
@ -48,21 +48,42 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text ?? undefined,
+ reasoning_content: reasoning_text || undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@@ -200,7 +208,8 @@ var openaiCompatibleProviderOptions = import_v4.z.object({
@@ -200,7 +208,9 @@ 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()
+ sendReasoning: import_v4.z.boolean().optional(),
+ strictJsonSchema: z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -378,7 +387,7 @@ var OpenAICompatibleChatLanguageModel = class {
@@ -225,7 +235,8 @@ var defaultOpenAICompatibleErrorStructure = {
var import_provider2 = require("@ai-sdk/provider");
function prepareTools({
tools,
- toolChoice
+ toolChoice,
+ strictJsonSchema
}) {
tools = (tools == null ? void 0 : tools.length) ? tools : void 0;
const toolWarnings = [];
@@ -242,7 +253,8 @@ function prepareTools({
function: {
name: tool.name,
description: tool.description,
- parameters: tool.inputSchema
+ parameters: tool.inputSchema,
+ strict: strictJsonSchema
}
});
}
@@ -378,7 +390,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,
verbosity: compatibleOptions.textVerbosity,
// messages:
@ -71,7 +92,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
// tools:
tools: openaiTools,
tool_choice: openaiToolChoice
@@ -421,6 +430,17 @@ var OpenAICompatibleChatLanguageModel = class {
@@ -421,6 +433,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
@ -89,7 +110,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -598,6 +618,17 @@ var OpenAICompatibleChatLanguageModel = class {
@@ -598,6 +621,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
@ -107,7 +128,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
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({
@@ -765,6 +799,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({
arguments: import_v43.z.string()
})
})
@ -122,7 +143,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
).nullish()
}),
finish_reason: import_v43.z.string().nullish()
@@ -795,6 +834,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union(
@@ -795,6 +837,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union(
arguments: import_v43.z.string().nullish()
})
})
@ -138,7 +159,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
}).nullish(),
finish_reason: import_v43.z.string().nullish()
diff --git a/dist/index.mjs b/dist/index.mjs
index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5700264de 100644
index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..fca65c04000ce4c01fb90e93326ac179c2378055 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -23,7 +23,7 @@ function getOpenAIMetadata(message) {
@ -175,21 +196,42 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text ?? undefined,
+ reasoning_content: reasoning_text || undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@@ -182,7 +190,8 @@ var openaiCompatibleProviderOptions = z.object({
@@ -182,7 +190,9 @@ 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()
+ sendReasoning: z.boolean().optional(),
+ strictJsonSchema: z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -362,7 +371,7 @@ var OpenAICompatibleChatLanguageModel = class {
@@ -209,7 +219,8 @@ import {
} from "@ai-sdk/provider";
function prepareTools({
tools,
- toolChoice
+ toolChoice,
+ strictJsonSchema
}) {
tools = (tools == null ? void 0 : tools.length) ? tools : void 0;
const toolWarnings = [];
@@ -226,7 +237,8 @@ function prepareTools({
function: {
name: tool.name,
description: tool.description,
- parameters: tool.inputSchema
+ parameters: tool.inputSchema,
+ strict: strictJsonSchema
}
});
}
@@ -362,7 +374,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,
verbosity: compatibleOptions.textVerbosity,
// messages:
@ -198,7 +240,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
// tools:
tools: openaiTools,
tool_choice: openaiToolChoice
@@ -405,6 +414,17 @@ var OpenAICompatibleChatLanguageModel = class {
@@ -405,6 +417,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
@ -216,7 +258,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -582,6 +602,17 @@ var OpenAICompatibleChatLanguageModel = class {
@@ -582,6 +605,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
@ -234,7 +276,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -749,6 +780,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({
@@ -749,6 +783,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({
arguments: z3.string()
})
})
@ -249,7 +291,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
).nullish()
}),
finish_reason: z3.string().nullish()
@@ -779,6 +818,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([
@@ -779,6 +821,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([
arguments: z3.string().nullish()
})
})

View File

@ -0,0 +1,33 @@
diff --git a/sdk.mjs b/sdk.mjs
index 1e1c3e4e3f81db622fb2789d17f3d421f212306e..5d193cdb6a43c7799fd5eff2d8af80827bfbdf1e 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -11985,7 +11985,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// ../src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -12999,14 +12999,14 @@ class ProcessTransport {
return isRunningWithBun() ? "bun" : "node";
}
spawnLocalProcess(spawnOptions) {
- const { command, args, cwd: cwd2, env, signal } = spawnOptions;
+ const { args, cwd: cwd2, env, signal } = spawnOptions;
const stderrMode = env.DEBUG_CLAUDE_AGENT_SDK || this.options.stderr ? "pipe" : "ignore";
- const childProcess = spawn(command, args, {
+ logForSdkDebugging(`Forking Claude Code Node.js process: ${args[0]} ${args.slice(1).join(" ")}`);
+ const childProcess = fork(args[0], args.slice(1), {
cwd: cwd2,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal,
- env,
- windowsHide: true
+ env
});
if (env.DEBUG_CLAUDE_AGENT_SDK || this.options.stderr) {
childProcess.stderr.on("data", (data) => {

View File

@ -0,0 +1,140 @@
diff --git a/dist/index.js b/dist/index.js
index f33510a50d11a2cb92a90ea70cc0ac84c89f29b9..db0af7e2cc05c47baeb29c0a3974a155316fbd05 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1050,7 +1050,8 @@ var OpenRouterProviderMetadataSchema = import_v43.z.object({
var OpenRouterProviderOptionsSchema = import_v43.z.object({
openrouter: import_v43.z.object({
reasoning_details: import_v43.z.array(ReasoningDetailUnionSchema).optional(),
- annotations: import_v43.z.array(FileAnnotationSchema).optional()
+ annotations: import_v43.z.array(FileAnnotationSchema).optional(),
+ strictJsonSchema: import_v43.z.boolean().optional()
}).optional()
}).optional();
@@ -1658,7 +1659,8 @@ var OpenRouterChatLanguageModel = class {
responseFormat,
topK,
tools,
- toolChoice
+ toolChoice,
+ providerOptions
}) {
var _a15;
const baseArgs = __spreadValues(__spreadValues({
@@ -1712,7 +1714,8 @@ var OpenRouterChatLanguageModel = class {
function: {
name: tool.name,
description: tool.description,
- parameters: tool.inputSchema
+ parameters: tool.inputSchema,
+ strict: providerOptions?.openrouter?.strictJsonSchema
}
}));
return __spreadProps(__spreadValues({}, baseArgs), {
@@ -1725,7 +1728,7 @@ var OpenRouterChatLanguageModel = class {
async doGenerate(options) {
var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w;
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: responseValue, responseHeaders } = await postJsonToApi({
url: this.config.url({
@@ -1931,7 +1934,7 @@ var OpenRouterChatLanguageModel = class {
async doStream(options) {
var _a15;
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: response, responseHeaders } = await postJsonToApi({
url: this.config.url({
@@ -2564,7 +2567,7 @@ var OpenRouterCompletionLanguageModel = class {
async doGenerate(options) {
var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o;
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: response, responseHeaders } = await postJsonToApi({
url: this.config.url({
@@ -2623,7 +2626,7 @@ var OpenRouterCompletionLanguageModel = class {
}
async doStream(options) {
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: response, responseHeaders } = await postJsonToApi({
url: this.config.url({
diff --git a/dist/index.mjs b/dist/index.mjs
index 8a688331b88b4af738ee4ca8062b5f24124d3d81..a2aa299a44352addc26f8891d839ea31a2150ee2 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1015,7 +1015,8 @@ var OpenRouterProviderMetadataSchema = z3.object({
var OpenRouterProviderOptionsSchema = z3.object({
openrouter: z3.object({
reasoning_details: z3.array(ReasoningDetailUnionSchema).optional(),
- annotations: z3.array(FileAnnotationSchema).optional()
+ annotations: z3.array(FileAnnotationSchema).optional(),
+ strictJsonSchema: z3.boolean().optional()
}).optional()
}).optional();
@@ -1623,7 +1624,8 @@ var OpenRouterChatLanguageModel = class {
responseFormat,
topK,
tools,
- toolChoice
+ toolChoice,
+ providerOptions
}) {
var _a15;
const baseArgs = __spreadValues(__spreadValues({
@@ -1677,7 +1679,8 @@ var OpenRouterChatLanguageModel = class {
function: {
name: tool.name,
description: tool.description,
- parameters: tool.inputSchema
+ parameters: tool.inputSchema,
+ strict: providerOptions?.openrouter?.strictJsonSchema
}
}));
return __spreadProps(__spreadValues({}, baseArgs), {
@@ -1690,7 +1693,7 @@ var OpenRouterChatLanguageModel = class {
async doGenerate(options) {
var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w;
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: responseValue, responseHeaders } = await postJsonToApi({
url: this.config.url({
@@ -1896,7 +1899,7 @@ var OpenRouterChatLanguageModel = class {
async doStream(options) {
var _a15;
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: response, responseHeaders } = await postJsonToApi({
url: this.config.url({
@@ -2529,7 +2532,7 @@ var OpenRouterCompletionLanguageModel = class {
async doGenerate(options) {
var _a15, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o;
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: response, responseHeaders } = await postJsonToApi({
url: this.config.url({
@@ -2588,7 +2591,7 @@ var OpenRouterCompletionLanguageModel = class {
}
async doStream(options) {
const providerOptions = options.providerOptions || {};
- const openrouterOptions = providerOptions.openrouter || {};
+ const { strictJsonSchema: _strictJsonSchema, ...openrouterOptions } = providerOptions.openrouter || {};
const args = __spreadValues(__spreadValues({}, this.getArgs(options)), openrouterOptions);
const { value: response, responseHeaders } = await postJsonToApi({
url: this.config.url({

25639
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

8
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,8 @@
packages:
- 'packages/*'
supportedArchitectures:
os:
- current
cpu:
- current

View File

@ -50,7 +50,7 @@ Usage Instructions:
- pt-pt (Portuguese)
Run Command:
yarn i18n:translate
pnpm i18n:translate
Performance Optimization Recommendations:
- For stable API services: MAX_CONCURRENT_TRANSLATIONS=8, TRANSLATION_DELAY_MS=50

View File

@ -1,42 +1,35 @@
const { Arch } = require('electron-builder')
const { downloadNpmPackage } = require('./utils')
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const workspaceConfigPath = path.join(__dirname, '..', 'pnpm-workspace.yaml')
// 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
const allArm64 = {
'@img/sharp-darwin-arm64': '0.34.3',
'@img/sharp-win32-arm64': '0.34.3',
'@img/sharp-linux-arm64': '0.34.3',
'@img/sharp-libvips-darwin-arm64': '1.2.0',
'@img/sharp-libvips-linux-arm64': '1.2.0',
'@libsql/darwin-arm64': '0.4.7',
'@libsql/linux-arm64-gnu': '0.4.7',
'@strongtz/win32-arm64-msvc': '0.4.7',
'@napi-rs/system-ocr-darwin-arm64': '1.0.2',
'@napi-rs/system-ocr-win32-arm64-msvc': '1.0.2'
}
const allX64 = {
'@img/sharp-darwin-x64': '0.34.3',
'@img/sharp-linux-x64': '0.34.3',
'@img/sharp-win32-x64': '0.34.3',
'@img/sharp-libvips-darwin-x64': '1.2.0',
'@img/sharp-libvips-linux-x64': '1.2.0',
'@libsql/darwin-x64': '0.4.7',
'@libsql/linux-x64-gnu': '0.4.7',
'@libsql/win32-x64-msvc': '0.4.7',
'@napi-rs/system-ocr-darwin-x64': '1.0.2',
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
}
const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor'
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
// please add to allX64 and allArm64 from pnpm-lock.yaml
const packages = [
'@img/sharp-darwin-arm64',
'@img/sharp-darwin-x64',
'@img/sharp-linux-arm64',
'@img/sharp-linux-x64',
'@img/sharp-win32-arm64',
'@img/sharp-win32-x64',
'@img/sharp-libvips-darwin-arm64',
'@img/sharp-libvips-darwin-x64',
'@img/sharp-libvips-linux-arm64',
'@img/sharp-libvips-linux-x64',
'@libsql/darwin-arm64',
'@libsql/darwin-x64',
'@libsql/linux-arm64-gnu',
'@libsql/linux-x64-gnu',
'@libsql/win32-x64-msvc',
'@napi-rs/system-ocr-darwin-arm64',
'@napi-rs/system-ocr-darwin-x64',
'@napi-rs/system-ocr-win32-arm64-msvc',
'@napi-rs/system-ocr-win32-x64-msvc',
'@strongtz/win32-arm64-msvc'
]
const platformToArch = {
mac: 'darwin',
@ -45,61 +38,82 @@ const platformToArch = {
}
exports.default = async function (context) {
const arch = context.arch
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
const platform = context.packager.platform.name
const arch = context.arch === Arch.arm64 ? 'arm64' : 'x64'
const platformName = context.packager.platform.name
const platform = platformToArch[platformName]
const downloadPackages = async (packages) => {
console.log('downloading packages ......')
const downloadPromises = []
const downloadPackages = async () => {
// Skip if target platform and architecture match current system
if (platform === process.platform && arch === process.arch) {
console.log(`Skipping install: target (${platform}/${arch}) matches current system`)
return
}
for (const name of Object.keys(packages)) {
if (name.includes(`${platformToArch[platform]}`) && name.includes(`-${archType}`)) {
downloadPromises.push(
downloadNpmPackage(
name,
`https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${packages[name]}.tgz`
)
)
console.log(`Installing packages for target platform=${platform} arch=${arch}...`)
// Backup and modify pnpm-workspace.yaml to add target platform support
const originalWorkspaceConfig = fs.readFileSync(workspaceConfigPath, 'utf-8')
const workspaceConfig = yaml.load(originalWorkspaceConfig)
// Add target platform to supportedArchitectures.os
if (!workspaceConfig.supportedArchitectures.os.includes(platform)) {
workspaceConfig.supportedArchitectures.os.push(platform)
}
// Add target architecture to supportedArchitectures.cpu
if (!workspaceConfig.supportedArchitectures.cpu.includes(arch)) {
workspaceConfig.supportedArchitectures.cpu.push(arch)
}
const modifiedWorkspaceConfig = yaml.dump(workspaceConfig)
console.log('Modified workspace config:', modifiedWorkspaceConfig)
fs.writeFileSync(workspaceConfigPath, modifiedWorkspaceConfig)
try {
execSync(`pnpm install`, { stdio: 'inherit' })
} finally {
// Restore original pnpm-workspace.yaml
fs.writeFileSync(workspaceConfigPath, originalWorkspaceConfig)
}
}
await Promise.all(downloadPromises)
}
await downloadPackages()
const changeFilters = async (filtersToExclude, filtersToInclude) => {
// remove filters for the target architecture (allow inclusion)
let filters = context.packager.config.files[0].filter
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
const excludePackages = async (packagesToExclude) => {
// 从项目根目录的 electron-builder.yml 读取 files 配置,避免多次覆盖配置导致出错
const electronBuilderConfigPath = path.join(__dirname, '..', 'electron-builder.yml')
const electronBuilderConfig = yaml.load(fs.readFileSync(electronBuilderConfigPath, 'utf-8'))
let filters = electronBuilderConfig.files
// add filters for other architectures (exclude them)
filters.push(...filtersToExclude)
filters.push(...packagesToExclude)
context.packager.config.files[0].filter = filters
}
await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64)
const arm64KeepPackages = packages.filter((p) => p.includes('arm64') && p.includes(platform))
const arm64ExcludePackages = packages
.filter((p) => !arm64KeepPackages.includes(p))
.map((p) => '!node_modules/' + p + '/**')
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
const excludeClaudeCodeRipgrepFilters = claudeCodeVenders
.filter((f) => f !== `${archType}-${platformToArch[platform]}`)
.map((f) => '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + f + '/**')
const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin']
const x64KeepPackages = packages.filter((p) => p.includes('x64') && p.includes(platform))
const x64ExcludePackages = packages
.filter((p) => !x64KeepPackages.includes(p))
.map((p) => '!node_modules/' + p + '/**')
const includeClaudeCodeFilters = [
'!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + `${archType}-${platformToArch[platform]}/**`
]
const excludeRipgrepFilters = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
.filter((f) => {
// On Windows ARM64, also keep x64-win32 for emulation compatibility
if (platform === 'win32' && context.arch === Arch.arm64 && f === 'x64-win32') {
return false
}
return f !== `${arch}-${platform}`
})
.map((f) => '!node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep/' + f + '/**')
if (arch === Arch.arm64) {
await changeFilters(
[...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
[...arm64Filters, ...includeClaudeCodeFilters]
)
if (context.arch === Arch.arm64) {
await excludePackages([...arm64ExcludePackages, ...excludeRipgrepFilters])
} else {
await changeFilters(
[...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
[...x64Filters, ...includeClaudeCodeFilters]
)
await excludePackages([...x64ExcludePackages, ...excludeRipgrepFilters])
}
}

View File

@ -145,7 +145,7 @@ export function main() {
console.log('i18n 检查已通过')
} catch (e) {
console.error(e)
throw new Error(`检查未通过。尝试运行 yarn i18n:sync 以解决问题。`)
throw new Error(`检查未通过。尝试运行 pnpm i18n:sync 以解决问题。`)
}
}

View File

@ -1,211 +0,0 @@
/**
* Feishu (Lark) Webhook Notification Script
* Sends GitHub issue summaries to Feishu with signature verification
*/
const crypto = require('crypto')
const https = require('https')
/**
* Generate Feishu webhook signature
* @param {string} secret - Feishu webhook secret
* @param {number} timestamp - Unix timestamp in seconds
* @returns {string} Base64 encoded signature
*/
function generateSignature(secret, timestamp) {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', stringToSign)
return hmac.digest('base64')
}
/**
* Send message to Feishu webhook
* @param {string} webhookUrl - Feishu webhook URL
* @param {string} secret - Feishu webhook secret
* @param {object} content - Message content
* @returns {Promise<void>}
*/
function sendToFeishu(webhookUrl, secret, content) {
return new Promise((resolve, reject) => {
const timestamp = Math.floor(Date.now() / 1000)
const sign = generateSignature(secret, timestamp)
const payload = JSON.stringify({
timestamp: timestamp.toString(),
sign: sign,
msg_type: 'interactive',
card: content
})
const url = new URL(webhookUrl)
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log('✅ Successfully sent to Feishu:', data)
resolve()
} else {
reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`))
}
})
})
req.on('error', (error) => {
reject(error)
})
req.write(payload)
req.end()
})
}
/**
* Create Feishu card message from issue data
* @param {object} issueData - GitHub issue data
* @returns {object} Feishu card content
*/
function createIssueCard(issueData) {
const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData
// Build labels section if labels exist
const labelElements =
labels && labels.length > 0
? labels.map((label) => ({
tag: 'markdown',
content: `\`${label}\``
}))
: []
return {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**👤 Author:** ${issueAuthor}`
}
},
...(labelElements.length > 0
? [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**🏷️ Labels:** ${labels.join(', ')}`
}
}
]
: []),
{
tag: 'hr'
},
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**📋 Summary:**\n${issueSummary}`
}
},
{
tag: 'hr'
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: '🔗 View Issue'
},
type: 'primary',
url: issueUrl
}
]
}
],
header: {
template: 'blue',
title: {
tag: 'plain_text',
content: `#${issueNumber} - ${issueTitle}`
}
}
}
}
/**
* Main function
*/
async function main() {
try {
// Get environment variables
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
const secret = process.env.FEISHU_WEBHOOK_SECRET
const issueUrl = process.env.ISSUE_URL
const issueNumber = process.env.ISSUE_NUMBER
const issueTitle = process.env.ISSUE_TITLE
const issueSummary = process.env.ISSUE_SUMMARY
const issueAuthor = process.env.ISSUE_AUTHOR
const labelsStr = process.env.ISSUE_LABELS || ''
// Validate required environment variables
if (!webhookUrl) {
throw new Error('FEISHU_WEBHOOK_URL environment variable is required')
}
if (!secret) {
throw new Error('FEISHU_WEBHOOK_SECRET environment variable is required')
}
if (!issueUrl || !issueNumber || !issueTitle || !issueSummary) {
throw new Error('Issue data environment variables are required')
}
// Parse labels
const labels = labelsStr
? labelsStr
.split(',')
.map((l) => l.trim())
.filter(Boolean)
: []
// Create issue data object
const issueData = {
issueUrl,
issueNumber,
issueTitle,
issueSummary,
issueAuthor: issueAuthor || 'Unknown',
labels
}
// Create card content
const card = createIssueCard(issueData)
console.log('📤 Sending notification to Feishu...')
console.log(`Issue #${issueNumber}: ${issueTitle}`)
// Send to Feishu
await sendToFeishu(webhookUrl, secret, card)
console.log('✅ Notification sent successfully!')
} catch (error) {
console.error('❌ Error:', error.message)
process.exit(1)
}
}
// Run main function
main()

421
scripts/feishu-notify.ts Normal file
View File

@ -0,0 +1,421 @@
#!/usr/bin/env npx tsx
/**
* @fileoverview Feishu (Lark) Webhook Notification CLI Tool
* @description Sends notifications to Feishu with signature verification.
* Supports subcommands for different notification types.
* @module feishu-notify
* @example
* // Send GitHub issue notification
* pnpm tsx feishu-notify.ts issue -u "https://..." -n "123" -t "Title" -m "Summary"
*
* // Using environment variables for credentials
* FEISHU_WEBHOOK_URL="..." FEISHU_WEBHOOK_SECRET="..." pnpm tsx feishu-notify.ts issue ...
*/
import { Command } from 'commander'
import crypto from 'crypto'
import dotenv from 'dotenv'
import https from 'https'
import * as z from 'zod'
// Load environment variables from .env file
dotenv.config()
/** CLI tool version */
const VERSION = '1.0.0'
/** GitHub issue data structure */
interface IssueData {
/** GitHub issue URL */
issueUrl: string
/** Issue number */
issueNumber: string
/** Issue title */
issueTitle: string
/** Issue summary/description */
issueSummary: string
/** Issue author username */
issueAuthor: string
/** Issue labels */
labels: string[]
}
/** Feishu card text element */
interface FeishuTextElement {
tag: 'div'
text: {
tag: 'lark_md'
content: string
}
}
/** Feishu card horizontal rule element */
interface FeishuHrElement {
tag: 'hr'
}
/** Feishu card action button */
interface FeishuActionElement {
tag: 'action'
actions: Array<{
tag: 'button'
text: {
tag: 'plain_text'
content: string
}
type: 'primary' | 'default'
url: string
}>
}
/** Feishu card element union type */
type FeishuCardElement = FeishuTextElement | FeishuHrElement | FeishuActionElement
/** Zod schema for Feishu header color template */
const FeishuHeaderTemplateSchema = z.enum([
'blue',
'wathet',
'turquoise',
'green',
'yellow',
'orange',
'red',
'carmine',
'violet',
'purple',
'indigo',
'grey',
'default'
])
/** Feishu card header color template (inferred from schema) */
type FeishuHeaderTemplate = z.infer<typeof FeishuHeaderTemplateSchema>
/** Feishu interactive card structure */
interface FeishuCard {
elements: FeishuCardElement[]
header: {
template: FeishuHeaderTemplate
title: {
tag: 'plain_text'
content: string
}
}
}
/** Feishu webhook request payload */
interface FeishuPayload {
timestamp: string
sign: string
msg_type: 'interactive'
card: FeishuCard
}
/** Issue subcommand options */
interface IssueOptions {
url: string
number: string
title: string
summary: string
author?: string
labels?: string
}
/** Send subcommand options */
interface SendOptions {
title: string
description: string
color?: string
}
/**
* Generate Feishu webhook signature using HMAC-SHA256
* @param secret - Feishu webhook secret
* @param timestamp - Unix timestamp in seconds
* @returns Base64 encoded signature
*/
function generateSignature(secret: string, timestamp: number): string {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', stringToSign)
return hmac.digest('base64')
}
/**
* Send message to Feishu webhook
* @param webhookUrl - Feishu webhook URL
* @param secret - Feishu webhook secret
* @param content - Feishu card message content
* @returns Resolves when message is sent successfully
* @throws When Feishu API returns non-2xx status code or network error occurs
*/
function sendToFeishu(webhookUrl: string, secret: string, content: FeishuCard): Promise<void> {
return new Promise((resolve, reject) => {
const timestamp = Math.floor(Date.now() / 1000)
const sign = generateSignature(secret, timestamp)
const payload: FeishuPayload = {
timestamp: timestamp.toString(),
sign,
msg_type: 'interactive',
card: content
}
const payloadStr = JSON.stringify(payload)
const url = new URL(webhookUrl)
const options: https.RequestOptions = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payloadStr)
}
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk: Buffer) => {
data += chunk.toString()
})
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Successfully sent to Feishu:', data)
resolve()
} else {
reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`))
}
})
})
req.on('error', (error: Error) => {
reject(error)
})
req.write(payloadStr)
req.end()
})
}
/**
* Create Feishu card message from issue data
* @param issueData - GitHub issue data
* @returns Feishu card content
*/
function createIssueCard(issueData: IssueData): FeishuCard {
const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData
const elements: FeishuCardElement[] = [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**Author:** ${issueAuthor}`
}
}
]
if (labels.length > 0) {
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**Labels:** ${labels.join(', ')}`
}
})
}
elements.push(
{ tag: 'hr' },
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**Summary:**\n${issueSummary}`
}
},
{ tag: 'hr' },
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: 'View Issue'
},
type: 'primary',
url: issueUrl
}
]
}
)
return {
elements,
header: {
template: 'blue',
title: {
tag: 'plain_text',
content: `#${issueNumber} - ${issueTitle}`
}
}
}
}
/**
* Create a simple Feishu card message
* @param title - Card title
* @param description - Card description content
* @param color - Header color template (default: 'turquoise')
* @returns Feishu card content
*/
function createSimpleCard(title: string, description: string, color: FeishuHeaderTemplate = 'turquoise'): FeishuCard {
return {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: description
}
}
],
header: {
template: color,
title: {
tag: 'plain_text',
content: title
}
}
}
}
/**
* Get Feishu credentials from environment variables
*/
function getCredentials(): { webhookUrl: string; secret: string } {
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
const secret = process.env.FEISHU_WEBHOOK_SECRET
if (!webhookUrl) {
console.error('Error: FEISHU_WEBHOOK_URL environment variable is required')
process.exit(1)
}
if (!secret) {
console.error('Error: FEISHU_WEBHOOK_SECRET environment variable is required')
process.exit(1)
}
return { webhookUrl, secret }
}
/**
* Handle send subcommand
*/
async function handleSendCommand(options: SendOptions): Promise<void> {
const { webhookUrl, secret } = getCredentials()
const { title, description, color = 'turquoise' } = options
// Validate color parameter
const colorValidation = FeishuHeaderTemplateSchema.safeParse(color)
if (!colorValidation.success) {
console.error(`Error: Invalid color "${color}". Valid colors: ${FeishuHeaderTemplateSchema.options.join(', ')}`)
process.exit(1)
}
const card = createSimpleCard(title, description, colorValidation.data)
console.log('Sending notification to Feishu...')
console.log(`Title: ${title}`)
await sendToFeishu(webhookUrl, secret, card)
console.log('Notification sent successfully!')
}
/**
* Handle issue subcommand
*/
async function handleIssueCommand(options: IssueOptions): Promise<void> {
const { webhookUrl, secret } = getCredentials()
const { url, number, title, summary, author = 'Unknown', labels: labelsStr = '' } = options
if (!url || !number || !title || !summary) {
console.error('Error: --url, --number, --title, and --summary are required')
process.exit(1)
}
const labels = labelsStr
? labelsStr
.split(',')
.map((l) => l.trim())
.filter(Boolean)
: []
const issueData: IssueData = {
issueUrl: url,
issueNumber: number,
issueTitle: title,
issueSummary: summary,
issueAuthor: author,
labels
}
const card = createIssueCard(issueData)
console.log('Sending notification to Feishu...')
console.log(`Issue #${number}: ${title}`)
await sendToFeishu(webhookUrl, secret, card)
console.log('Notification sent successfully!')
}
// Configure CLI
const program = new Command()
program.name('feishu-notify').description('Send notifications to Feishu webhook').version(VERSION)
// Send subcommand (generic)
program
.command('send')
.description('Send a simple notification to Feishu')
.requiredOption('-t, --title <title>', 'Card title')
.requiredOption('-d, --description <description>', 'Card description (supports markdown)')
.option(
'-c, --color <color>',
`Header color template (default: turquoise). Options: ${FeishuHeaderTemplateSchema.options.join(', ')}`,
'turquoise'
)
.action(async (options: SendOptions) => {
try {
await handleSendCommand(options)
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error)
process.exit(1)
}
})
// Issue subcommand
program
.command('issue')
.description('Send GitHub issue notification to Feishu')
.requiredOption('-u, --url <url>', 'GitHub issue URL')
.requiredOption('-n, --number <number>', 'Issue number')
.requiredOption('-t, --title <title>', 'Issue title')
.requiredOption('-m, --summary <summary>', 'Issue summary')
.option('-a, --author <author>', 'Issue author', 'Unknown')
.option('-l, --labels <labels>', 'Issue labels, comma-separated')
.action(async (options: IssueOptions) => {
try {
await handleIssueCommand(options)
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error)
process.exit(1)
}
})
program.parse()

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!
* 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> {
console.log('🎨 Formatting file with Biome...')
try {
await execAsync(`yarn biome format --write ${filePath}`)
await execAsync(`pnpm biome format --write ${filePath}`)
console.log('✅ Biome formatting complete.')
} catch (e: any) {
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> {
console.log('🧐 Checking file with TypeScript compiler...')
try {
await execAsync(`yarn tsc --noEmit --skipLibCheck ${filePath}`)
await execAsync(`pnpm tsc --noEmit --skipLibCheck ${filePath}`)
console.log('✅ TypeScript check passed.')
} catch (e: any) {
console.error('❌ TypeScript check failed:', e.stdout || e.stderr)

View File

@ -1,64 +0,0 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const zlib = require('zlib')
const tar = require('tar')
const { pipeline } = require('stream/promises')
async function downloadNpmPackage(packageName, url) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-'))
const targetDir = path.join('./node_modules/', packageName)
const filename = path.join(tempDir, packageName.replace('/', '-') + '.tgz')
const extractDir = path.join(tempDir, 'extract')
// Skip if directory already exists
if (fs.existsSync(targetDir)) {
console.log(`${targetDir} already exists, skipping download...`)
return
}
try {
console.log(`Downloading ${packageName}...`, url)
// Download file using fetch API
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const fileStream = fs.createWriteStream(filename)
await pipeline(response.body, fileStream)
console.log(`Extracting ${filename}...`)
// Create extraction directory
fs.mkdirSync(extractDir, { recursive: true })
// Extract tar.gz file using Node.js streams
await pipeline(fs.createReadStream(filename), zlib.createGunzip(), tar.extract({ cwd: extractDir }))
// Remove the downloaded file
fs.rmSync(filename, { force: true })
// Create target directory
fs.mkdirSync(targetDir, { recursive: true })
// Move extracted package contents to target directory
const packageDir = path.join(extractDir, 'package')
if (fs.existsSync(packageDir)) {
fs.cpSync(packageDir, targetDir, { recursive: true })
}
} catch (error) {
console.error(`Error processing ${packageName}: ${error.message}`)
throw error
} finally {
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
}
}
module.exports = {
downloadNpmPackage
}

View File

@ -18,7 +18,7 @@ if (!['patch', 'minor', 'major'].includes(versionType)) {
}
// 更新版本
exec(`yarn version ${versionType} --immediate`)
exec(`pnpm version ${versionType}`)
// 读取更新后的 package.json 获取新版本号
const updatedPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'))

View File

@ -0,0 +1,240 @@
import { buildFunctionCallToolName, buildMcpToolName, generateMcpToolFunctionName, toCamelCase } from '@shared/mcp'
import { describe, expect, it } from 'vitest'
describe('toCamelCase', () => {
it('should convert hyphenated strings', () => {
expect(toCamelCase('my-server')).toBe('myServer')
expect(toCamelCase('my-tool-name')).toBe('myToolName')
})
it('should convert underscored strings', () => {
expect(toCamelCase('my_server')).toBe('myServer')
expect(toCamelCase('search_issues')).toBe('searchIssues')
})
it('should handle mixed delimiters', () => {
expect(toCamelCase('my-server_name')).toBe('myServerName')
})
it('should handle leading numbers by prefixing underscore', () => {
expect(toCamelCase('123server')).toBe('_123server')
})
it('should handle special characters', () => {
expect(toCamelCase('test@server!')).toBe('testServer')
expect(toCamelCase('tool#name$')).toBe('toolName')
})
it('should trim whitespace', () => {
expect(toCamelCase(' server ')).toBe('server')
})
it('should handle empty string', () => {
expect(toCamelCase('')).toBe('')
})
it('should handle uppercase snake case', () => {
expect(toCamelCase('MY_SERVER')).toBe('myServer')
expect(toCamelCase('SEARCH_ISSUES')).toBe('searchIssues')
})
it('should handle mixed case', () => {
expect(toCamelCase('MyServer')).toBe('myserver')
expect(toCamelCase('myTOOL')).toBe('mytool')
})
})
describe('buildMcpToolName', () => {
it('should build basic name with defaults', () => {
expect(buildMcpToolName('github', 'search_issues')).toBe('github_searchIssues')
})
it('should handle undefined server name', () => {
expect(buildMcpToolName(undefined, 'search_issues')).toBe('searchIssues')
})
it('should apply custom prefix and delimiter', () => {
expect(buildMcpToolName('github', 'search', { prefix: 'mcp__', delimiter: '__' })).toBe('mcp__github__search')
})
it('should respect maxLength', () => {
const result = buildMcpToolName('veryLongServerName', 'veryLongToolName', { maxLength: 20 })
expect(result.length).toBeLessThanOrEqual(20)
})
it('should handle collision with existingNames', () => {
const existingNames = new Set(['github_search'])
const result = buildMcpToolName('github', 'search', { existingNames })
expect(result).toBe('github_search1')
expect(existingNames.has('github_search1')).toBe(true)
})
it('should respect maxLength when adding collision suffix', () => {
const existingNames = new Set(['a'.repeat(20)])
const result = buildMcpToolName('a'.repeat(20), '', { maxLength: 20, existingNames })
expect(result.length).toBeLessThanOrEqual(20)
expect(existingNames.has(result)).toBe(true)
})
it('should handle multiple collisions with maxLength', () => {
const existingNames = new Set(['abcd', 'abc1', 'abc2'])
const result = buildMcpToolName('abcd', '', { maxLength: 4, existingNames })
expect(result).toBe('abc3')
expect(result.length).toBeLessThanOrEqual(4)
})
})
describe('generateMcpToolFunctionName', () => {
it('should return format serverName_toolName in camelCase', () => {
expect(generateMcpToolFunctionName('github', 'search_issues')).toBe('github_searchIssues')
})
it('should handle hyphenated names', () => {
expect(generateMcpToolFunctionName('my-server', 'my-tool')).toBe('myServer_myTool')
})
it('should handle undefined server name', () => {
expect(generateMcpToolFunctionName(undefined, 'search_issues')).toBe('searchIssues')
})
it('should handle collision detection', () => {
const existingNames = new Set<string>()
const first = generateMcpToolFunctionName('github', 'search', existingNames)
const second = generateMcpToolFunctionName('github', 'search', existingNames)
expect(first).toBe('github_search')
expect(second).toBe('github_search1')
})
})
describe('buildFunctionCallToolName', () => {
describe('basic format', () => {
it('should return format mcp__{server}__{tool} in camelCase', () => {
const result = buildFunctionCallToolName('github', 'search_issues')
expect(result).toBe('mcp__github__searchIssues')
})
it('should handle simple server and tool names', () => {
expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__getPage')
expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query')
})
})
describe('valid JavaScript identifier', () => {
it('should always start with mcp__ prefix (valid JS identifier start)', () => {
const result = buildFunctionCallToolName('123server', '456tool')
expect(result).toMatch(/^mcp__/)
})
it('should handle hyphenated names with camelCase', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool')
expect(result).toBe('mcp__myServer__myTool')
})
it('should be a valid JavaScript identifier', () => {
const testCases = [
['github', 'create_issue'],
['my-server', 'fetch-data'],
['test@server', 'tool#name'],
['server.name', 'tool.action']
]
for (const [server, tool] of testCases) {
const result = buildFunctionCallToolName(server, tool)
expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
}
})
})
describe('character sanitization', () => {
it('should convert special characters to camelCase boundaries', () => {
expect(buildFunctionCallToolName('my-server', 'my-tool-name')).toBe('mcp__myServer__myToolName')
expect(buildFunctionCallToolName('test@server!', 'tool#name$')).toBe('mcp__testServer__toolName')
expect(buildFunctionCallToolName('server.name', 'tool.action')).toBe('mcp__serverName__toolAction')
})
it('should handle spaces', () => {
const result = buildFunctionCallToolName('my server', 'my tool')
expect(result).toBe('mcp__myServer__myTool')
})
})
describe('length constraints', () => {
it('should not exceed 63 characters', () => {
const longServerName = 'a'.repeat(50)
const longToolName = 'b'.repeat(50)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should not end with underscores after truncation', () => {
const longServerName = 'a'.repeat(30)
const longToolName = 'b'.repeat(30)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result).not.toMatch(/_+$/)
expect(result.length).toBeLessThanOrEqual(63)
})
})
describe('edge cases', () => {
it('should handle empty server name', () => {
const result = buildFunctionCallToolName('', 'tool')
expect(result).toBe('mcp__tool')
})
it('should handle empty tool name', () => {
const result = buildFunctionCallToolName('server', '')
expect(result).toBe('mcp__server__')
})
it('should trim whitespace from names', () => {
const result = buildFunctionCallToolName(' server ', ' tool ')
expect(result).toBe('mcp__server__tool')
})
it('should handle mixed case by normalizing to lowercase first', () => {
const result = buildFunctionCallToolName('MyServer', 'MyTool')
expect(result).toBe('mcp__myserver__mytool')
})
it('should handle uppercase snake case', () => {
const result = buildFunctionCallToolName('MY_SERVER', 'SEARCH_ISSUES')
expect(result).toBe('mcp__myServer__searchIssues')
})
})
describe('deterministic output', () => {
it('should produce consistent results for same input', () => {
const result1 = buildFunctionCallToolName('github', 'search_repos')
const result2 = buildFunctionCallToolName('github', 'search_repos')
expect(result1).toBe(result2)
})
it('should produce different results for different inputs', () => {
const result1 = buildFunctionCallToolName('server1', 'tool')
const result2 = buildFunctionCallToolName('server2', 'tool')
expect(result1).not.toBe(result2)
})
})
describe('real-world scenarios', () => {
it('should handle GitHub MCP server', () => {
expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__createIssue')
expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__searchRepositories')
})
it('should handle filesystem MCP server', () => {
expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__readFile')
expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__writeFile')
})
it('should handle hyphenated server names (common in npm packages)', () => {
expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherryFetch__getPage')
expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcpServerGithub__search')
})
it('should handle scoped npm package style names', () => {
const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat')
expect(result).toBe('mcp__AnthropicMcpServer__chat')
})
})
})

View File

@ -1,6 +1,10 @@
import { loggerService } from '@logger'
import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts'
import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController'
import {
createStreamAbortController,
STREAM_TIMEOUT_REASON,
type StreamAbortController
} from '@main/apiServer/utils/createStreamAbortController'
import { agentService, sessionMessageService, sessionService } from '@main/services/agents'
import type { Request, Response } from 'express'
@ -26,7 +30,7 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
}
export const createMessage = async (req: Request, res: Response): Promise<void> => {
let clearAbortTimeout: (() => void) | undefined
let streamController: StreamAbortController | undefined
try {
const { agentId, sessionId } = req.params
@ -45,14 +49,10 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
const {
abortController,
registerAbortHandler,
clearAbortTimeout: helperClearAbortTimeout
} = createStreamAbortController({
streamController = createStreamAbortController({
timeoutMs: MESSAGE_STREAM_TIMEOUT_MS
})
clearAbortTimeout = helperClearAbortTimeout
const { abortController, registerAbortHandler, dispose } = streamController
const { stream, completion } = await sessionMessageService.createSessionMessage(
session,
messageData,
@ -64,8 +64,8 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
let responseEnded = false
let streamFinished = false
const cleanupAbortTimeout = () => {
clearAbortTimeout?.()
const cleanup = () => {
dispose()
}
const finalizeResponse = () => {
@ -78,7 +78,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
}
responseEnded = true
cleanupAbortTimeout()
cleanup()
try {
// res.write('data: {"type":"finish"}\n\n')
res.write('data: [DONE]\n\n')
@ -108,7 +108,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
* - Mark the response as ended to prevent further writes
*/
registerAbortHandler((abortReason) => {
cleanupAbortTimeout()
cleanup()
if (responseEnded) return
@ -189,7 +189,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing stream error to SSE', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
cleanup()
res.end()
}
}
@ -221,14 +221,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing completion error to SSE stream', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
cleanup()
res.end()
})
// Clear timeout when response ends
res.on('close', cleanupAbortTimeout)
res.on('finish', cleanupAbortTimeout)
res.on('close', cleanup)
res.on('finish', cleanup)
} catch (error: any) {
clearAbortTimeout?.()
streamController?.dispose()
logger.error('Error in streaming message handler', {
error,
agentId: req.params.agentId,

View File

@ -4,6 +4,7 @@ export interface StreamAbortController {
abortController: AbortController
registerAbortHandler: (handler: StreamAbortHandler) => void
clearAbortTimeout: () => void
dispose: () => void
}
export const STREAM_TIMEOUT_REASON = 'stream timeout'
@ -40,6 +41,15 @@ export const createStreamAbortController = (options: CreateStreamAbortController
signal.addEventListener('abort', handleAbort, { once: true })
let disposed = false
const dispose = () => {
if (disposed) return
disposed = true
clearAbortTimeout()
signal.removeEventListener('abort', handleAbort)
}
const registerAbortHandler = (handler: StreamAbortHandler) => {
abortHandler = handler
@ -59,6 +69,7 @@ export const createStreamAbortController = (options: CreateStreamAbortController
return {
abortController,
registerAbortHandler,
clearAbortTimeout
clearAbortTimeout,
dispose
}
}

View File

@ -75,6 +75,15 @@ if (isLinux && process.env.XDG_SESSION_TYPE === 'wayland') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal')
}
/**
* Set window class and name for X11
* This ensures the system tray and window manager identify the app correctly
*/
if (isLinux) {
app.commandLine.appendSwitch('class', 'cherry-studio')
app.commandLine.appendSwitch('name', 'cherry-studio')
}
// DocumentPolicyIncludeJSCallStacksInCrashReports: Enable features for unresponsive renderer js call stacks
// EarlyEstablishGpuChannel,EstablishGpuChannelAsync: Enable features for early establish gpu channel
// speed up the startup time

View File

@ -9,6 +9,7 @@ import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import HubServer from './hub'
import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking'
@ -52,6 +53,9 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.browser: {
return new BrowserServer().server
}
case BuiltinMCPServerNames.hub: {
return new HubServer().server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@ -0,0 +1,213 @@
# Hub MCP Server
A built-in MCP server that aggregates all active MCP servers in Cherry Studio and exposes them through `search` and `exec` tools.
## Overview
The Hub server enables LLMs to discover and call tools from all active MCP servers without needing to know the specific server names or tool signatures upfront.
## Auto Mode Integration
The Hub server is the core component of Cherry Studio's **Auto MCP Mode**. When an assistant is set to Auto mode:
1. **Automatic Injection**: The Hub server is automatically injected as the only MCP server for the assistant
2. **System Prompt**: A specialized system prompt (`HUB_MODE_SYSTEM_PROMPT`) is appended to guide the LLM on how to use the `search` and `exec` tools
3. **Dynamic Discovery**: The LLM can discover and use any tools from all active MCP servers without manual configuration
### MCP Modes
Cherry Studio supports three MCP modes per assistant:
| Mode | Description | Tools Available |
|------|-------------|-----------------|
| **Disabled** | No MCP tools | None |
| **Auto** | Hub server only | `search`, `exec` |
| **Manual** | User selects servers | Selected server tools |
### How Auto Mode Works
```
User Message
┌─────────────────────────────────────────┐
│ Assistant (mcpMode: 'auto') │
│ │
│ System Prompt + HUB_MODE_SYSTEM_PROMPT │
│ Tools: [hub.search, hub.exec] │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ LLM decides to use MCP tools │
│ │
│ 1. search({ query: "github,repo" }) │
│ 2. exec({ code: "await searchRepos()" })│
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Hub Server │
│ │
│ Aggregates all active MCP servers │
│ Routes tool calls to appropriate server │
└─────────────────────────────────────────┘
```
### Relevant Code
- **Type Definition**: `src/renderer/src/types/index.ts` - `McpMode` type and `getEffectiveMcpMode()`
- **Hub Server Constant**: `src/renderer/src/store/mcp.ts` - `hubMCPServer`
- **Server Selection**: `src/renderer/src/services/ApiService.ts` - `getMcpServersForAssistant()`
- **System Prompt**: `src/renderer/src/config/prompts.ts` - `HUB_MODE_SYSTEM_PROMPT`
- **Prompt Injection**: `src/renderer/src/aiCore/prepareParams/parameterBuilder.ts`
## Tools
### `search`
Search for available MCP tools by keywords.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `query` | string | Yes | Search keywords, comma-separated for OR matching |
| `limit` | number | No | Maximum results to return (default: 10, max: 50) |
**Example:**
```json
{
"query": "browser,chrome",
"limit": 5
}
```
**Returns:** JavaScript function declarations with JSDoc comments that can be used in the `exec` tool.
```javascript
// Found 2 tool(s):
/**
* Launch a browser instance
*
* @param {{ browser?: "chromium" | "firefox" | "webkit", headless?: boolean }} params
* @returns {Promise<unknown>}
*/
async function launchBrowser(params) {
return await __callTool("browser__launch_browser", params);
}
```
### `exec`
Execute JavaScript code that calls MCP tools.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `code` | string | Yes | JavaScript code to execute |
**Built-in Helpers:**
- `parallel(...promises)` - Run multiple tool calls concurrently (Promise.all)
- `settle(...promises)` - Run multiple tool calls and get all results (Promise.allSettled)
- `console.log/warn/error/info/debug` - Captured in output logs
**Example:**
```javascript
// Call a single tool
const result = await searchRepos({ query: "react" });
return result;
// Call multiple tools in parallel
const [users, repos] = await parallel(
getUsers({ limit: 10 }),
searchRepos({ query: "typescript" })
);
return { users, repos };
```
**Returns:**
```json
{
"result": { "users": [...], "repos": [...] },
"logs": ["[log] Processing..."],
"error": null
}
```
## Usage Flow
1. **Search** for tools using keywords:
```
search({ query: "github,repository" })
```
2. **Review** the returned function signatures and JSDoc
3. **Execute** code using the discovered tools:
```
exec({ code: 'return await searchRepos({ query: "react" })' })
```
## Configuration
The Hub server is a built-in server identified as `@cherry/hub`.
### Using Auto Mode (Recommended)
The easiest way to use the Hub server is through Auto mode:
1. Click the **MCP Tools** button (hammer icon) in the input bar
2. Select **Auto** mode
3. The Hub server is automatically enabled for the assistant
### Manual Configuration
Alternatively, you can enable the Hub server manually:
1. Go to **Settings** → **MCP Servers**
2. Find **Hub** in the built-in servers list
3. Toggle it on
4. In the assistant's MCP settings, select the Hub server
## Caching
- Tool definitions are cached for **10 minutes**
- Cache is automatically refreshed when expired
- Cache is invalidated when MCP servers connect/disconnect
## Limitations
- Code execution has a **60-second timeout**
- Console logs are limited to **1000 entries**
- Search results are limited to **50 tools** maximum
- The Hub server excludes itself from the aggregated server list
## Architecture
```
LLM
HubServer
├── search → ToolRegistry → SearchIndex
└── exec → Runtime → callMcpTool()
MCPService.callTool()
External MCP Servers
```
## Files
| File | Description |
|------|-------------|
| `index.ts` | Main HubServer class |
| `types.ts` | TypeScript type definitions |
| `generator.ts` | Converts MCP tools to JS functions with JSDoc |
| `tool-registry.ts` | In-memory tool cache with TTL |
| `search.ts` | Keyword-based tool search |
| `runtime.ts` | JavaScript code execution engine |
| `mcp-bridge.ts` | Bridge to Cherry Studio's MCPService |

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { generateToolFunction, generateToolsCode } from '../generator'
import type { GeneratedTool } from '../types'
describe('generator', () => {
describe('generateToolFunction', () => {
it('generates a simple tool function', () => {
const tool = {
id: 'test-id',
name: 'search_repos',
description: 'Search for GitHub repositories',
serverId: 'github',
serverName: 'github-server',
inputSchema: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results' }
},
required: ['query']
},
type: 'mcp' as const
}
const existingNames = new Set<string>()
const callTool = async () => ({ success: true })
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.functionName).toBe('githubServer_searchRepos')
expect(result.jsCode).toContain('async function githubServer_searchRepos')
expect(result.jsCode).toContain('Search for GitHub repositories')
expect(result.jsCode).toContain('__callTool')
})
it('handles unique function names', () => {
const tool = {
id: 'test-id',
name: 'search',
serverId: 'server1',
serverName: 'server1',
inputSchema: { type: 'object' as const, properties: {} },
type: 'mcp' as const
}
const existingNames = new Set<string>(['server1_search'])
const callTool = async () => ({})
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.functionName).toBe('server1_search1')
})
it('handles enum types in schema', () => {
const tool = {
id: 'test-id',
name: 'launch_browser',
serverId: 'browser',
serverName: 'browser',
inputSchema: {
type: 'object' as const,
properties: {
browser: {
type: 'string',
enum: ['chromium', 'firefox', 'webkit']
}
}
},
type: 'mcp' as const
}
const existingNames = new Set<string>()
const callTool = async () => ({})
const result = generateToolFunction(tool, existingNames, callTool)
expect(result.jsCode).toContain('"chromium" | "firefox" | "webkit"')
})
})
describe('generateToolsCode', () => {
it('generates code for multiple tools', () => {
const tools: GeneratedTool[] = [
{
serverId: 's1',
serverName: 'server1',
toolName: 'tool1',
functionName: 'server1_tool1',
jsCode: 'async function server1_tool1() {}',
fn: async () => ({}),
signature: '{}',
returns: 'unknown'
},
{
serverId: 's2',
serverName: 'server2',
toolName: 'tool2',
functionName: 'server2_tool2',
jsCode: 'async function server2_tool2() {}',
fn: async () => ({}),
signature: '{}',
returns: 'unknown'
}
]
const result = generateToolsCode(tools)
expect(result).toContain('2 tool(s)')
expect(result).toContain('async function server1_tool1')
expect(result).toContain('async function server2_tool2')
})
it('returns message for empty tools', () => {
const result = generateToolsCode([])
expect(result).toBe('// No tools available')
})
})
})

View File

@ -0,0 +1,229 @@
import type { MCPTool } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HubServer } from '../index'
const mockTools: MCPTool[] = [
{
id: 'github__search_repos',
name: 'search_repos',
description: 'Search for GitHub repositories',
serverId: 'github',
serverName: 'GitHub',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results' }
},
required: ['query']
},
type: 'mcp'
},
{
id: 'github__get_user',
name: 'get_user',
description: 'Get GitHub user profile',
serverId: 'github',
serverName: 'GitHub',
inputSchema: {
type: 'object',
properties: {
username: { type: 'string', description: 'GitHub username' }
},
required: ['username']
},
type: 'mcp'
},
{
id: 'database__query',
name: 'query',
description: 'Execute a database query',
serverId: 'database',
serverName: 'Database',
inputSchema: {
type: 'object',
properties: {
sql: { type: 'string', description: 'SQL query to execute' }
},
required: ['sql']
},
type: 'mcp'
}
]
vi.mock('@main/services/MCPService', () => ({
default: {
listAllActiveServerTools: vi.fn(async () => mockTools),
callToolById: vi.fn(async (toolId: string, args: unknown) => {
if (toolId === 'github__search_repos') {
return {
content: [{ type: 'text', text: JSON.stringify({ repos: ['repo1', 'repo2'], query: args }) }]
}
}
if (toolId === 'github__get_user') {
return {
content: [{ type: 'text', text: JSON.stringify({ username: (args as any).username, id: 123 }) }]
}
}
if (toolId === 'database__query') {
return {
content: [{ type: 'text', text: JSON.stringify({ rows: [{ id: 1 }, { id: 2 }] }) }]
}
}
return { content: [{ type: 'text', text: '{}' }] }
}),
abortTool: vi.fn(async () => true)
}
}))
import mcpService from '@main/services/MCPService'
describe('HubServer Integration', () => {
let hubServer: HubServer
beforeEach(() => {
vi.clearAllMocks()
hubServer = new HubServer()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('full search → exec flow', () => {
it('searches for tools and executes them', async () => {
const searchResult = await (hubServer as any).handleSearch({ query: 'github,repos' })
expect(searchResult.content).toBeDefined()
const searchText = JSON.parse(searchResult.content[0].text)
expect(searchText.total).toBeGreaterThan(0)
expect(searchText.tools).toContain('github_searchRepos')
const execResult = await (hubServer as any).handleExec({
code: 'return await github_searchRepos({ query: "test" })'
})
expect(execResult.content).toBeDefined()
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.result).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'test' } })
})
it('handles multiple tool calls in parallel', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const execResult = await (hubServer as any).handleExec({
code: `
const results = await parallel(
github_searchRepos({ query: "react" }),
github_getUser({ username: "octocat" })
);
return results
`
})
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.result).toHaveLength(2)
expect(execOutput.result[0]).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'react' } })
expect(execOutput.result[1]).toEqual({ username: 'octocat', id: 123 })
})
it('searches across multiple servers', async () => {
const searchResult = await (hubServer as any).handleSearch({ query: 'query' })
const searchText = JSON.parse(searchResult.content[0].text)
expect(searchText.tools).toContain('database_query')
})
})
describe('tools caching', () => {
it('uses cached tools within TTL', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
await (hubServer as any).handleSearch({ query: 'github' })
const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
expect(secondCallCount).toBe(firstCallCount) // Should use cache
})
it('refreshes tools after cache invalidation', async () => {
await (hubServer as any).handleSearch({ query: 'github' })
const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
hubServer.invalidateCache()
await (hubServer as any).handleSearch({ query: 'github' })
const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length
expect(secondCallCount).toBe(firstCallCount + 1)
})
})
describe('error handling', () => {
it('throws error for invalid search query', async () => {
await expect((hubServer as any).handleSearch({})).rejects.toThrow('query parameter is required')
})
it('throws error for invalid exec code', async () => {
await expect((hubServer as any).handleExec({})).rejects.toThrow('code parameter is required')
})
it('handles runtime errors in exec', async () => {
const execResult = await (hubServer as any).handleExec({
code: 'throw new Error("test error")'
})
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.error).toBe('test error')
expect(execOutput.isError).toBe(true)
})
})
describe('exec timeouts', () => {
afterEach(() => {
vi.useRealTimers()
})
it('aborts in-flight tool calls and returns logs on timeout', async () => {
vi.useFakeTimers()
let toolCallStarted: (() => void) | null = null
const toolCallStartedPromise = new Promise<void>((resolve) => {
toolCallStarted = resolve
})
vi.mocked(mcpService.callToolById).mockImplementationOnce(async () => {
toolCallStarted?.()
return await new Promise(() => {})
})
const execPromise = (hubServer as any).handleExec({
code: `
console.log("starting");
return await github_searchRepos({ query: "hang" });
`
})
await toolCallStartedPromise
await vi.advanceTimersByTimeAsync(60000)
await vi.runAllTimersAsync()
const execResult = await execPromise
const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.error).toBe('Execution timed out after 60000ms')
expect(execOutput.result).toBeUndefined()
expect(execOutput.isError).toBe(true)
expect(execOutput.logs).toContain('[log] starting')
expect(vi.mocked(mcpService.abortTool)).toHaveBeenCalled()
})
})
describe('server instance', () => {
it('creates a valid MCP server instance', () => {
expect(hubServer.server).toBeDefined()
expect(hubServer.server.setRequestHandler).toBeDefined()
})
})
})

View File

@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest'
import { Runtime } from '../runtime'
import type { GeneratedTool } from '../types'
vi.mock('../mcp-bridge', () => ({
callMcpTool: vi.fn(async (toolId: string, params: unknown) => {
if (toolId === 'server__failing_tool') {
throw new Error('Tool failed')
}
return { toolId, params, success: true }
})
}))
const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => ({
serverId: 'server1',
serverName: 'server1',
toolName: 'tool',
functionName: 'server1_mockTool',
jsCode: 'async function server1_mockTool() {}',
fn: async (params) => ({ result: params }),
signature: '{}',
returns: 'unknown',
...partial
})
describe('Runtime', () => {
describe('execute', () => {
it('executes simple code and returns result', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('return 1 + 1', tools)
expect(result.result).toBe(2)
expect(result.error).toBeUndefined()
})
it('executes async code', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('return await Promise.resolve(42)', tools)
expect(result.result).toBe(42)
})
it('calls tool functions', async () => {
const runtime = new Runtime()
const tools = [
createMockTool({
functionName: 'searchRepos',
fn: async (params) => ({ repos: ['repo1', 'repo2'], query: params })
})
]
const result = await runtime.execute('return await searchRepos({ query: "test" })', tools)
expect(result.result).toEqual({ toolId: 'searchRepos', params: { query: 'test' }, success: true })
})
it('captures console logs', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
console.log("hello");
console.warn("warning");
return "done"
`,
tools
)
expect(result.result).toBe('done')
expect(result.logs).toContain('[log] hello')
expect(result.logs).toContain('[warn] warning')
})
it('handles errors gracefully', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute('throw new Error("test error")', tools)
expect(result.result).toBeUndefined()
expect(result.error).toBe('test error')
expect(result.isError).toBe(true)
})
it('supports parallel helper', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const results = await parallel(
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3)
);
return results
`,
tools
)
expect(result.result).toEqual([1, 2, 3])
})
it('supports settle helper', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const results = await settle(
Promise.resolve(1),
Promise.reject(new Error("fail"))
);
return results.map(r => r.status)
`,
tools
)
expect(result.result).toEqual(['fulfilled', 'rejected'])
})
it('returns last expression when no explicit return', async () => {
const runtime = new Runtime()
const tools: GeneratedTool[] = []
const result = await runtime.execute(
`
const x = 10;
const y = 20;
return x + y
`,
tools
)
expect(result.result).toBe(30)
})
it('stops execution when a tool throws', async () => {
const runtime = new Runtime()
const tools = [
createMockTool({
functionName: 'server__failing_tool'
})
]
const result = await runtime.execute('return await server__failing_tool({})', tools)
expect(result.result).toBeUndefined()
expect(result.error).toBe('Tool failed')
expect(result.isError).toBe(true)
})
})
})

View File

@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { searchTools } from '../search'
import type { GeneratedTool } from '../types'
const createMockTool = (partial: Partial<GeneratedTool>): GeneratedTool => {
const functionName = partial.functionName || 'server1_tool'
return {
serverId: 'server1',
serverName: 'server1',
toolName: partial.toolName || 'tool',
functionName,
jsCode: `async function ${functionName}() {}`,
fn: async () => ({}),
signature: '{}',
returns: 'unknown',
...partial
}
}
describe('search', () => {
describe('searchTools', () => {
it('returns all tools when query is empty', () => {
const tools = [
createMockTool({ toolName: 'tool1', functionName: 'tool1' }),
createMockTool({ toolName: 'tool2', functionName: 'tool2' })
]
const result = searchTools(tools, { query: '' })
expect(result.total).toBe(2)
expect(result.tools).toContain('tool1')
expect(result.tools).toContain('tool2')
})
it('filters tools by single keyword', () => {
const tools = [
createMockTool({ toolName: 'search_repos', functionName: 'searchRepos' }),
createMockTool({ toolName: 'get_user', functionName: 'getUser' }),
createMockTool({ toolName: 'search_users', functionName: 'searchUsers' })
]
const result = searchTools(tools, { query: 'search' })
expect(result.total).toBe(2)
expect(result.tools).toContain('searchRepos')
expect(result.tools).toContain('searchUsers')
expect(result.tools).not.toContain('getUser')
})
it('supports OR matching with comma-separated keywords', () => {
const tools = [
createMockTool({ toolName: 'browser_open', functionName: 'browserOpen' }),
createMockTool({ toolName: 'chrome_launch', functionName: 'chromeLaunch' }),
createMockTool({ toolName: 'file_read', functionName: 'fileRead' })
]
const result = searchTools(tools, { query: 'browser,chrome' })
expect(result.total).toBe(2)
expect(result.tools).toContain('browserOpen')
expect(result.tools).toContain('chromeLaunch')
expect(result.tools).not.toContain('fileRead')
})
it('matches against description', () => {
const tools = [
createMockTool({
toolName: 'launch',
functionName: 'launch',
description: 'Launch a browser instance'
}),
createMockTool({
toolName: 'close',
functionName: 'close',
description: 'Close a window'
})
]
const result = searchTools(tools, { query: 'browser' })
expect(result.total).toBe(1)
expect(result.tools).toContain('launch')
})
it('respects limit parameter', () => {
const tools = Array.from({ length: 20 }, (_, i) =>
createMockTool({ toolName: `tool${i}`, functionName: `server1_tool${i}` })
)
const result = searchTools(tools, { query: 'tool', limit: 5 })
expect(result.total).toBe(20)
const matches = (result.tools.match(/async function server1_tool\d+/g) || []).length
expect(matches).toBe(5)
})
it('is case insensitive', () => {
const tools = [createMockTool({ toolName: 'SearchRepos', functionName: 'searchRepos' })]
const result = searchTools(tools, { query: 'SEARCH' })
expect(result.total).toBe(1)
})
it('ranks exact matches higher', () => {
const tools = [
createMockTool({ toolName: 'searching', functionName: 'searching' }),
createMockTool({ toolName: 'search', functionName: 'search' }),
createMockTool({ toolName: 'search_more', functionName: 'searchMore' })
]
const result = searchTools(tools, { query: 'search', limit: 1 })
expect(result.tools).toContain('function search(')
})
})
})

View File

@ -0,0 +1,152 @@
import { generateMcpToolFunctionName } from '@shared/mcp'
import type { MCPTool } from '@types'
import type { GeneratedTool } from './types'
type PropertySchema = Record<string, unknown>
type InputSchema = {
type?: string
properties?: Record<string, PropertySchema>
required?: string[]
}
function schemaTypeToTS(prop: Record<string, unknown>): string {
const type = prop.type as string | string[] | undefined
const enumValues = prop.enum as unknown[] | undefined
if (enumValues && Array.isArray(enumValues)) {
return enumValues.map((v) => (typeof v === 'string' ? `"${v}"` : String(v))).join(' | ')
}
if (Array.isArray(type)) {
return type.map((t) => primitiveTypeToTS(t)).join(' | ')
}
if (type === 'array') {
const items = prop.items as Record<string, unknown> | undefined
if (items) {
return `${schemaTypeToTS(items)}[]`
}
return 'unknown[]'
}
if (type === 'object') {
return 'object'
}
return primitiveTypeToTS(type)
}
function primitiveTypeToTS(type: string | undefined): string {
switch (type) {
case 'string':
return 'string'
case 'number':
case 'integer':
return 'number'
case 'boolean':
return 'boolean'
case 'null':
return 'null'
default:
return 'unknown'
}
}
function jsonSchemaToSignature(schema: Record<string, unknown> | undefined): string {
if (!schema || typeof schema !== 'object') {
return '{}'
}
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
if (!properties) {
return '{}'
}
const required = (schema.required as string[]) || []
const parts: string[] = []
for (const [key, prop] of Object.entries(properties)) {
const isRequired = required.includes(key)
const typeStr = schemaTypeToTS(prop)
parts.push(`${key}${isRequired ? '' : '?'}: ${typeStr}`)
}
return `{ ${parts.join(', ')} }`
}
function generateJSDoc(tool: MCPTool, inputSchema: InputSchema | undefined, returns: string): string {
const lines: string[] = ['/**']
if (tool.description) {
const desc = tool.description.split('\n')[0]
lines.push(` * ${desc}`)
}
const properties = inputSchema?.properties || {}
const required = inputSchema?.required || []
if (Object.keys(properties).length > 0) {
lines.push(` * @param {Object} params`)
for (const [name, prop] of Object.entries(properties)) {
const isReq = required.includes(name)
const type = schemaTypeToTS(prop)
const paramName = isReq ? `params.${name}` : `[params.${name}]`
const desc = (prop.description as string)?.split('\n')[0] || ''
lines.push(` * @param {${type}} ${paramName} ${desc}`)
}
}
lines.push(` * @returns {Promise<${returns}>}`)
lines.push(` */`)
return lines.join('\n')
}
export function generateToolFunction(
tool: MCPTool,
existingNames: Set<string>,
callToolFn: (functionName: string, params: unknown) => Promise<unknown>
): GeneratedTool {
const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames)
const inputSchema = tool.inputSchema as InputSchema | undefined
const outputSchema = tool.outputSchema as Record<string, unknown> | undefined
const signature = jsonSchemaToSignature(inputSchema)
const returns = outputSchema ? jsonSchemaToSignature(outputSchema) : 'unknown'
const jsDoc = generateJSDoc(tool, inputSchema, returns)
const jsCode = `${jsDoc}
async function ${functionName}(params) {
return await __callTool("${functionName}", params);
}`
const fn = async (params: unknown): Promise<unknown> => {
return await callToolFn(functionName, params)
}
return {
serverId: tool.serverId,
serverName: tool.serverName,
toolName: tool.name,
functionName,
jsCode,
fn,
signature,
returns,
description: tool.description
}
}
export function generateToolsCode(tools: GeneratedTool[]): string {
if (tools.length === 0) {
return '// No tools available'
}
const header = `// ${tools.length} tool(s). ALWAYS use: const r = await ToolName({...}); return r;`
const code = tools.map((t) => t.jsCode).join('\n\n')
return header + '\n\n' + code
}

View File

@ -0,0 +1,184 @@
import { loggerService } from '@logger'
import { CacheService } from '@main/services/CacheService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import { generateToolFunction } from './generator'
import { callMcpTool, clearToolMap, listAllTools, syncToolMapFromGeneratedTools } from './mcp-bridge'
import { Runtime } from './runtime'
import { searchTools } from './search'
import type { ExecInput, GeneratedTool, SearchQuery } from './types'
const logger = loggerService.withContext('MCPServer:Hub')
const TOOLS_CACHE_KEY = 'hub:tools'
const TOOLS_CACHE_TTL = 60 * 1000 // 1 minute
/**
* Hub MCP Server - A meta-server that aggregates all active MCP servers.
*
* This server is NOT included in builtinMCPServers because:
* 1. It aggregates tools from all other MCP servers, not a standalone tool provider
* 2. It's designed for LLM "code mode" - enabling AI to discover and call tools programmatically
* 3. It should be auto-enabled when code mode features are used, not manually installed by users
*
* The server exposes two tools:
* - `search`: Find available tools by keywords, returns JS function signatures
* - `exec`: Execute JavaScript code that calls discovered tools
*/
export class HubServer {
public server: Server
private runtime: Runtime
constructor() {
this.runtime = new Runtime()
this.server = new Server(
{
name: 'hub-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
)
this.setupRequestHandlers()
}
private setupRequestHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'search',
description:
'Search for available MCP tools by keywords. Use this FIRST to discover tools. Returns JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types for use in `exec`.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'Comma-separated search keywords. A tool matches if ANY keyword appears in its name, description, or server name. Example: "chrome,browser,tab" matches tools related to Chrome OR browser OR tabs.'
},
limit: {
type: 'number',
description: 'Maximum number of tools to return (default: 10, max: 50)'
}
},
required: ['query']
}
},
{
name: 'exec',
description:
'Execute JavaScript that calls MCP tools discovered via `search`. IMPORTANT: You MUST explicitly `return` the final value, or the result will be `undefined`.',
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description:
'JavaScript code to execute. The code runs inside an async context, so use `await` directly. Do NOT wrap your code in `(async () => { ... })()` - this causes double-wrapping and returns undefined. All discovered tools are async functions (call as `await ToolName(params)`). Helpers: `parallel(...promises)`, `settle(...promises)`, `console.*`. You MUST `return` the final value. Examples: `const r = await Tool({ id: "1" }); return r` or `return await Tool({ x: 1 })`'
}
},
required: ['code']
}
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (!args) {
throw new McpError(ErrorCode.InvalidParams, 'No arguments provided')
}
try {
switch (name) {
case 'search':
return await this.handleSearch(args as unknown as SearchQuery)
case 'exec':
return await this.handleExec(args as unknown as ExecInput)
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
}
} catch (error) {
if (error instanceof McpError) {
throw error
}
logger.error(`Error executing tool ${name}:`, error as Error)
throw new McpError(
ErrorCode.InternalError,
`Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`
)
}
})
}
private async fetchTools(): Promise<GeneratedTool[]> {
const cached = CacheService.get<GeneratedTool[]>(TOOLS_CACHE_KEY)
if (cached) {
logger.debug('Returning cached tools')
syncToolMapFromGeneratedTools(cached)
return cached
}
logger.debug('Fetching fresh tools')
const allTools = await listAllTools()
const existingNames = new Set<string>()
const tools = allTools.map((tool) => generateToolFunction(tool, existingNames, callMcpTool))
CacheService.set(TOOLS_CACHE_KEY, tools, TOOLS_CACHE_TTL)
syncToolMapFromGeneratedTools(tools)
return tools
}
invalidateCache(): void {
CacheService.remove(TOOLS_CACHE_KEY)
clearToolMap()
logger.debug('Tools cache invalidated')
}
private async handleSearch(query: SearchQuery) {
if (!query.query || typeof query.query !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'query parameter is required and must be a string')
}
const tools = await this.fetchTools()
const result = searchTools(tools, query)
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
]
}
}
private async handleExec(input: ExecInput) {
if (!input.code || typeof input.code !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'code parameter is required and must be a string')
}
const tools = await this.fetchTools()
const result = await this.runtime.execute(input.code, tools)
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2)
}
],
isError: result.isError
}
}
}
export default HubServer

View File

@ -0,0 +1,96 @@
/**
* Bridge module for Hub server to access MCPService.
* Re-exports the methods needed by tool-registry and runtime.
*/
import mcpService from '@main/services/MCPService'
import { generateMcpToolFunctionName } from '@shared/mcp'
import type { MCPCallToolResponse, MCPTool, MCPToolResultContent } from '@types'
import type { GeneratedTool } from './types'
export const listAllTools = () => mcpService.listAllActiveServerTools()
const toolFunctionNameToIdMap = new Map<string, { serverId: string; toolName: string }>()
export async function refreshToolMap(): Promise<void> {
const tools = await listAllTools()
syncToolMapFromTools(tools)
}
export function syncToolMapFromTools(tools: MCPTool[]): void {
toolFunctionNameToIdMap.clear()
const existingNames = new Set<string>()
for (const tool of tools) {
const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames)
toolFunctionNameToIdMap.set(functionName, { serverId: tool.serverId, toolName: tool.name })
}
}
export function syncToolMapFromGeneratedTools(tools: GeneratedTool[]): void {
toolFunctionNameToIdMap.clear()
for (const tool of tools) {
toolFunctionNameToIdMap.set(tool.functionName, { serverId: tool.serverId, toolName: tool.toolName })
}
}
export function clearToolMap(): void {
toolFunctionNameToIdMap.clear()
}
export const callMcpTool = async (functionName: string, params: unknown, callId?: string): Promise<unknown> => {
const toolInfo = toolFunctionNameToIdMap.get(functionName)
if (!toolInfo) {
await refreshToolMap()
const retryToolInfo = toolFunctionNameToIdMap.get(functionName)
if (!retryToolInfo) {
throw new Error(`Tool not found: ${functionName}`)
}
const toolId = `${retryToolInfo.serverId}__${retryToolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result)
}
const toolId = `${toolInfo.serverId}__${toolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result)
}
export const abortMcpTool = async (callId: string): Promise<boolean> => {
return mcpService.abortTool(null as unknown as Electron.IpcMainInvokeEvent, callId)
}
function extractToolResult(result: MCPCallToolResponse): unknown {
if (!result.content || result.content.length === 0) {
return null
}
const textContent = result.content.find((c) => c.type === 'text')
if (textContent?.text) {
try {
return JSON.parse(textContent.text)
} catch {
return textContent.text
}
}
return result.content
}
function throwIfToolError(result: MCPCallToolResponse): void {
if (!result.isError) {
return
}
const textContent = extractTextContent(result.content)
throw new Error(textContent ?? 'Tool execution failed')
}
function extractTextContent(content: MCPToolResultContent[] | undefined): string | undefined {
if (!content || content.length === 0) {
return undefined
}
const textBlock = content.find((item) => item.type === 'text' && item.text)
return textBlock?.text
}

View File

@ -0,0 +1,170 @@
import crypto from 'node:crypto'
import { Worker } from 'node:worker_threads'
import { loggerService } from '@logger'
import { abortMcpTool, callMcpTool } from './mcp-bridge'
import type {
ExecOutput,
GeneratedTool,
HubWorkerCallToolMessage,
HubWorkerExecMessage,
HubWorkerMessage,
HubWorkerResultMessage
} from './types'
import { hubWorkerSource } from './worker'
const logger = loggerService.withContext('MCPServer:Hub:Runtime')
const MAX_LOGS = 1000
const EXECUTION_TIMEOUT = 60000
export class Runtime {
async execute(code: string, tools: GeneratedTool[]): Promise<ExecOutput> {
return await new Promise<ExecOutput>((resolve) => {
const logs: string[] = []
const activeCallIds = new Map<string, string>()
let finished = false
let timedOut = false
let timeoutId: NodeJS.Timeout | null = null
const worker = new Worker(hubWorkerSource, { eval: true })
const addLog = (entry: string) => {
if (logs.length >= MAX_LOGS) {
return
}
logs.push(entry)
}
const finalize = async (output: ExecOutput, terminateWorker = true) => {
if (finished) {
return
}
finished = true
if (timeoutId) {
clearTimeout(timeoutId)
}
worker.removeAllListeners()
if (terminateWorker) {
try {
await worker.terminate()
} catch (error) {
logger.warn('Failed to terminate exec worker', error as Error)
}
}
resolve(output)
}
const abortActiveTools = async () => {
const callIds = Array.from(activeCallIds.values())
activeCallIds.clear()
if (callIds.length === 0) {
return
}
await Promise.allSettled(callIds.map((callId) => abortMcpTool(callId)))
}
const handleToolCall = async (message: HubWorkerCallToolMessage) => {
if (finished || timedOut) {
return
}
const callId = crypto.randomUUID()
activeCallIds.set(message.requestId, callId)
try {
const result = await callMcpTool(message.functionName, message.params, callId)
if (finished || timedOut) {
return
}
worker.postMessage({ type: 'toolResult', requestId: message.requestId, result })
} catch (error) {
if (finished || timedOut) {
return
}
const errorMessage = error instanceof Error ? error.message : String(error)
worker.postMessage({ type: 'toolError', requestId: message.requestId, error: errorMessage })
} finally {
activeCallIds.delete(message.requestId)
}
}
const handleResult = (message: HubWorkerResultMessage) => {
const resolvedLogs = message.logs && message.logs.length > 0 ? message.logs : logs
void finalize({
result: message.result,
logs: resolvedLogs.length > 0 ? resolvedLogs : undefined
})
}
const handleError = (errorMessage: string, messageLogs?: string[], terminateWorker = true) => {
const resolvedLogs = messageLogs && messageLogs.length > 0 ? messageLogs : logs
void finalize(
{
result: undefined,
logs: resolvedLogs.length > 0 ? resolvedLogs : undefined,
error: errorMessage,
isError: true
},
terminateWorker
)
}
const handleMessage = (message: HubWorkerMessage) => {
if (!message || typeof message !== 'object') {
return
}
switch (message.type) {
case 'log':
addLog(message.entry)
break
case 'callTool':
void handleToolCall(message)
break
case 'result':
handleResult(message)
break
case 'error':
handleError(message.error, message.logs)
break
default:
break
}
}
timeoutId = setTimeout(() => {
timedOut = true
void (async () => {
await abortActiveTools()
try {
await worker.terminate()
} catch (error) {
logger.warn('Failed to terminate exec worker after timeout', error as Error)
}
handleError(`Execution timed out after ${EXECUTION_TIMEOUT}ms`, undefined, false)
})()
}, EXECUTION_TIMEOUT)
worker.on('message', handleMessage)
worker.on('error', (error) => {
logger.error('Worker execution error', error)
handleError(error instanceof Error ? error.message : String(error))
})
worker.on('exit', (code) => {
if (finished || timedOut) {
return
}
const message = code === 0 ? 'Exec worker exited unexpectedly' : `Exec worker exited with code ${code}`
logger.error(message)
handleError(message, undefined, false)
})
const execMessage: HubWorkerExecMessage = {
type: 'exec',
code,
tools: tools.map((tool) => ({ functionName: tool.functionName }))
}
worker.postMessage(execMessage)
})
}
}

View File

@ -0,0 +1,109 @@
import { generateToolsCode } from './generator'
import type { GeneratedTool, SearchQuery, SearchResult } from './types'
const DEFAULT_LIMIT = 10
const MAX_LIMIT = 50
export function searchTools(tools: GeneratedTool[], query: SearchQuery): SearchResult {
const { query: queryStr, limit = DEFAULT_LIMIT } = query
const effectiveLimit = Math.min(Math.max(1, limit), MAX_LIMIT)
const keywords = queryStr
.toLowerCase()
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0)
if (keywords.length === 0) {
const sliced = tools.slice(0, effectiveLimit)
return {
tools: generateToolsCode(sliced),
total: tools.length
}
}
const matchedTools = tools.filter((tool) => {
const searchText = buildSearchText(tool).toLowerCase()
return keywords.some((keyword) => searchText.includes(keyword))
})
const rankedTools = rankTools(matchedTools, keywords)
const sliced = rankedTools.slice(0, effectiveLimit)
return {
tools: generateToolsCode(sliced),
total: matchedTools.length
}
}
function buildSearchText(tool: GeneratedTool): string {
const combinedName = tool.serverName ? `${tool.serverName}_${tool.toolName}` : tool.toolName
const parts = [
tool.toolName,
tool.functionName,
tool.serverName,
combinedName,
tool.description || '',
tool.signature
]
return parts.join(' ')
}
function rankTools(tools: GeneratedTool[], keywords: string[]): GeneratedTool[] {
const scored = tools.map((tool) => ({
tool,
score: calculateScore(tool, keywords)
}))
scored.sort((a, b) => b.score - a.score)
return scored.map((s) => s.tool)
}
function calculateScore(tool: GeneratedTool, keywords: string[]): number {
let score = 0
const toolName = tool.toolName.toLowerCase()
const serverName = (tool.serverName || '').toLowerCase()
const functionName = tool.functionName.toLowerCase()
const description = (tool.description || '').toLowerCase()
for (const keyword of keywords) {
// Match tool name
if (toolName === keyword) {
score += 10
} else if (toolName.startsWith(keyword)) {
score += 5
} else if (toolName.includes(keyword)) {
score += 3
}
// Match server name
if (serverName === keyword) {
score += 8
} else if (serverName.startsWith(keyword)) {
score += 4
} else if (serverName.includes(keyword)) {
score += 2
}
// Match function name (serverName_toolName format)
if (functionName === keyword) {
score += 10
} else if (functionName.startsWith(keyword)) {
score += 5
} else if (functionName.includes(keyword)) {
score += 3
}
if (description.includes(keyword)) {
const count = (description.match(new RegExp(escapeRegex(keyword), 'g')) || []).length
score += Math.min(count, 3)
}
}
return score
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View File

@ -0,0 +1,113 @@
import type { MCPServer, MCPTool } from '@types'
export interface GeneratedTool {
serverId: string
serverName: string
toolName: string
functionName: string
jsCode: string
fn: (params: unknown) => Promise<unknown>
signature: string
returns: string
description?: string
}
export interface SearchQuery {
query: string
limit?: number
}
export interface SearchResult {
tools: string
total: number
}
export interface ExecInput {
code: string
}
export type ExecOutput = {
result: unknown
logs?: string[]
error?: string
isError?: boolean
}
export interface ToolRegistryOptions {
ttl?: number
}
export interface MCPToolWithServer extends MCPTool {
server: MCPServer
}
export interface ExecutionContext {
__callTool: (functionName: string, params: unknown) => Promise<unknown>
parallel: <T>(...promises: Promise<T>[]) => Promise<T[]>
settle: <T>(...promises: Promise<T>[]) => Promise<PromiseSettledResult<T>[]>
console: ConsoleMethods
[functionName: string]: unknown
}
export interface ConsoleMethods {
log: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
info: (...args: unknown[]) => void
debug: (...args: unknown[]) => void
}
export type HubWorkerTool = {
functionName: string
}
export type HubWorkerExecMessage = {
type: 'exec'
code: string
tools: HubWorkerTool[]
}
export type HubWorkerCallToolMessage = {
type: 'callTool'
requestId: string
functionName: string
params: unknown
}
export type HubWorkerToolResultMessage = {
type: 'toolResult'
requestId: string
result: unknown
}
export type HubWorkerToolErrorMessage = {
type: 'toolError'
requestId: string
error: string
}
export type HubWorkerResultMessage = {
type: 'result'
result: unknown
logs?: string[]
}
export type HubWorkerErrorMessage = {
type: 'error'
error: string
logs?: string[]
}
export type HubWorkerLogMessage = {
type: 'log'
entry: string
}
export type HubWorkerMessage =
| HubWorkerExecMessage
| HubWorkerCallToolMessage
| HubWorkerToolResultMessage
| HubWorkerToolErrorMessage
| HubWorkerResultMessage
| HubWorkerErrorMessage
| HubWorkerLogMessage

View File

@ -0,0 +1,133 @@
export const hubWorkerSource = `
const crypto = require('node:crypto')
const { parentPort } = require('node:worker_threads')
const MAX_LOGS = 1000
const logs = []
const pendingCalls = new Map()
let isExecuting = false
const stringify = (value) => {
if (value === undefined) return 'undefined'
if (value === null) return 'null'
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (value instanceof Error) return value.message
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
const pushLog = (level, args) => {
if (logs.length >= MAX_LOGS) {
return
}
const message = args.map((arg) => stringify(arg)).join(' ')
const entry = \`[\${level}] \${message}\`
logs.push(entry)
parentPort?.postMessage({ type: 'log', entry })
}
const capturedConsole = {
log: (...args) => pushLog('log', args),
warn: (...args) => pushLog('warn', args),
error: (...args) => pushLog('error', args),
info: (...args) => pushLog('info', args),
debug: (...args) => pushLog('debug', args)
}
const callTool = (functionName, params) =>
new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
pendingCalls.set(requestId, { resolve, reject })
parentPort?.postMessage({ type: 'callTool', requestId, functionName, params })
})
const buildContext = (tools) => {
const context = {
__callTool: callTool,
parallel: (...promises) => Promise.all(promises),
settle: (...promises) => Promise.allSettled(promises),
console: capturedConsole
}
for (const tool of tools) {
context[tool.functionName] = (params) => callTool(tool.functionName, params)
}
return context
}
const runCode = async (code, context) => {
const contextKeys = Object.keys(context)
const contextValues = contextKeys.map((key) => context[key])
const wrappedCode = \`
return (async () => {
\${code}
})()
\`
const fn = new Function(...contextKeys, wrappedCode)
return await fn(...contextValues)
}
const handleExec = async (code, tools) => {
if (isExecuting) {
return
}
isExecuting = true
try {
const context = buildContext(tools)
const result = await runCode(code, context)
parentPort?.postMessage({ type: 'result', result, logs: logs.length > 0 ? logs : undefined })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
parentPort?.postMessage({ type: 'error', error: errorMessage, logs: logs.length > 0 ? logs : undefined })
} finally {
pendingCalls.clear()
}
}
const handleToolResult = (message) => {
const pending = pendingCalls.get(message.requestId)
if (!pending) {
return
}
pendingCalls.delete(message.requestId)
pending.resolve(message.result)
}
const handleToolError = (message) => {
const pending = pendingCalls.get(message.requestId)
if (!pending) {
return
}
pendingCalls.delete(message.requestId)
pending.reject(new Error(message.error))
}
parentPort?.on('message', (message) => {
if (!message || typeof message !== 'object') {
return
}
switch (message.type) {
case 'exec':
handleExec(message.code, message.tools ?? [])
break
case 'toolResult':
handleToolResult(message)
break
case 'toolError':
handleToolError(message)
break
default:
break
}
})
`

View File

@ -8,6 +8,27 @@ import { v4 as uuidv4 } from 'uuid'
const logger = loggerService.withContext('DxtService')
/**
* Ensure a target path is within the base directory to prevent path traversal attacks.
* This is the correct approach: validate the final resolved path rather than sanitizing input.
*
* @param basePath - The base directory that the target must be within
* @param targetPath - The target path to validate
* @returns The resolved target path if valid
* @throws Error if the target path escapes the base directory
*/
export function ensurePathWithin(basePath: string, targetPath: string): string {
const resolvedBase = path.resolve(basePath)
const resolvedTarget = path.resolve(path.normalize(targetPath))
// Must be direct child of base directory, no subdirectories allowed
if (path.dirname(resolvedTarget) !== resolvedBase) {
throw new Error('Path traversal detected: target path must be direct child of base directory')
}
return resolvedTarget
}
// Type definitions
export interface DxtManifest {
dxt_version: string
@ -68,6 +89,76 @@ export interface DxtUploadResult {
error?: string
}
/**
* Validate and sanitize a command to prevent path traversal attacks.
* Commands should be either:
* 1. Simple command names (e.g., "node", "python", "npx") - looked up in PATH
* 2. Absolute paths (e.g., "/usr/bin/node", "C:\\Program Files\\node\\node.exe")
* 3. Relative paths starting with ./ or .\ (relative to extractDir)
*
* Rejects commands containing path traversal sequences (..)
*
* @param command - The command to validate
* @returns The validated command
* @throws Error if command contains path traversal or is invalid
*/
export function validateCommand(command: string): string {
if (!command || typeof command !== 'string') {
throw new Error('Invalid command: command must be a non-empty string')
}
const trimmed = command.trim()
if (!trimmed) {
throw new Error('Invalid command: command cannot be empty')
}
// Check for path traversal sequences
// This catches: .., ../, ..\, /../, \..\, etc.
if (/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(trimmed) || trimmed === '..') {
throw new Error(`Invalid command: path traversal detected in "${command}"`)
}
// Check for null bytes
if (trimmed.includes('\0')) {
throw new Error('Invalid command: null byte detected')
}
return trimmed
}
/**
* Validate command arguments to prevent injection attacks.
* Rejects arguments containing path traversal sequences.
*
* @param args - The arguments array to validate
* @returns The validated arguments array
* @throws Error if any argument contains path traversal
*/
export function validateArgs(args: string[]): string[] {
if (!Array.isArray(args)) {
throw new Error('Invalid args: must be an array')
}
return args.map((arg, index) => {
if (typeof arg !== 'string') {
throw new Error(`Invalid args: argument at index ${index} must be a string`)
}
// Check for null bytes
if (arg.includes('\0')) {
throw new Error(`Invalid args: null byte detected in argument at index ${index}`)
}
// Check for path traversal in arguments that look like paths
// Only validate if the arg contains path separators (indicating it's meant to be a path)
if ((arg.includes('/') || arg.includes('\\')) && /(?:^|[/\\])\.\.(?:[/\\]|$)/.test(arg)) {
throw new Error(`Invalid args: path traversal detected in argument at index ${index}`)
}
return arg
})
}
export function performVariableSubstitution(
value: string,
extractDir: string,
@ -134,12 +225,16 @@ export function applyPlatformOverrides(mcpConfig: any, extractDir: string, userC
// Apply variable substitution to all string values
if (resolvedConfig.command) {
resolvedConfig.command = performVariableSubstitution(resolvedConfig.command, extractDir, userConfig)
// Validate command after substitution to prevent path traversal attacks
resolvedConfig.command = validateCommand(resolvedConfig.command)
}
if (resolvedConfig.args) {
resolvedConfig.args = resolvedConfig.args.map((arg: string) =>
performVariableSubstitution(arg, extractDir, userConfig)
)
// Validate args after substitution to prevent path traversal attacks
resolvedConfig.args = validateArgs(resolvedConfig.args)
}
if (resolvedConfig.env) {
@ -271,10 +366,8 @@ class DxtService {
}
// Use server name as the final extract directory for automatic version management
// Sanitize the name to prevent creating subdirectories
const sanitizedName = manifest.name.replace(/\//g, '-')
const serverDirName = `server-${sanitizedName}`
const finalExtractDir = path.join(this.mcpDir, serverDirName)
const serverDirName = `server-${manifest.name}`
const finalExtractDir = ensurePathWithin(this.mcpDir, path.join(this.mcpDir, serverDirName))
// Clean up any existing version of this server
if (fs.existsSync(finalExtractDir)) {
@ -354,27 +447,15 @@ class DxtService {
public cleanupDxtServer(serverName: string): boolean {
try {
// Handle server names that might contain slashes (e.g., "anthropic/sequential-thinking")
// by replacing slashes with the same separator used during installation
const sanitizedName = serverName.replace(/\//g, '-')
const serverDirName = `server-${sanitizedName}`
const serverDir = path.join(this.mcpDir, serverDirName)
const serverDirName = `server-${serverName}`
const serverDir = ensurePathWithin(this.mcpDir, path.join(this.mcpDir, serverDirName))
// First try the sanitized path
if (fs.existsSync(serverDir)) {
logger.debug(`Removing DXT server directory: ${serverDir}`)
fs.rmSync(serverDir, { recursive: true, force: true })
return true
}
// Fallback: try with original name in case it was stored differently
const originalServerDir = path.join(this.mcpDir, `server-${serverName}`)
if (fs.existsSync(originalServerDir)) {
logger.debug(`Removing DXT server directory: ${originalServerDir}`)
fs.rmSync(originalServerDir, { recursive: true, force: true })
return true
}
logger.warn(`Server directory not found: ${serverDir}`)
return false
} catch (error) {

View File

@ -10,6 +10,7 @@ import {
scanDir
} from '@main/utils/file'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { parseDataUrl } from '@shared/utils'
import type { FileMetadata, NotesTreeNode } from '@types'
import { FileTypes } from '@types'
import chardet from 'chardet'
@ -672,8 +673,8 @@ class FileStorage {
throw new Error('Base64 data is required')
}
// 移除 base64 头部信息(如果存在)
const base64String = base64Data.replace(/^data:.*;base64,/, '')
const parseResult = parseDataUrl(base64Data)
const base64String = parseResult?.data ?? base64Data
const buffer = Buffer.from(base64String, 'base64')
const uuid = uuidv4()
const ext = '.png'
@ -1464,8 +1465,8 @@ class FileStorage {
})
if (filePath) {
const base64Data = data.replace(/^data:image\/png;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const parseResult = parseDataUrl(data)
fs.writeFileSync(filePath, parseResult?.data ?? data, 'base64')
}
} catch (error) {
logger.error('[IPC - Error] An error occurred saving the image:', error as Error)

View File

@ -3,9 +3,9 @@ import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { findCommandInShellEnv, getBinaryName, getBinaryPath, isBinaryExists } from '@main/utils/process'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
@ -35,6 +35,7 @@ import { HOME_CHERRY_DIR } from '@shared/config/constant'
import type { MCPProgressEvent } from '@shared/config/types'
import type { MCPServerLogEntry } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { buildFunctionCallToolName } from '@shared/mcp'
import { defaultAppHeaders } from '@shared/utils'
import {
BuiltinMCPServerNames,
@ -165,6 +166,67 @@ class McpService {
this.getServerLogs = this.getServerLogs.bind(this)
}
/**
* List all tools from all active MCP servers (excluding hub).
* Used by Hub server's tool registry.
*/
public async listAllActiveServerTools(): Promise<MCPTool[]> {
const servers = await getMCPServersFromRedux()
const activeServers = servers.filter((server) => server.isActive)
const results = await Promise.allSettled(
activeServers.map(async (server) => {
const tools = await this.listToolsImpl(server)
const disabledTools = new Set(server.disabledTools ?? [])
return disabledTools.size > 0 ? tools.filter((tool) => !disabledTools.has(tool.name)) : tools
})
)
const allTools: MCPTool[] = []
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
allTools.push(...result.value)
} else {
logger.error(
`[listAllActiveServerTools] Failed to list tools from ${activeServers[index].name}:`,
result.reason as Error
)
}
})
return allTools
}
/**
* Call a tool by its full ID (serverId__toolName format).
* Used by Hub server's runtime.
*/
public async callToolById(toolId: string, params: unknown, callId?: string): Promise<MCPCallToolResponse> {
const parts = toolId.split('__')
if (parts.length < 2) {
throw new Error(`Invalid tool ID format: ${toolId}`)
}
const serverId = parts[0]
const toolName = parts.slice(1).join('__')
const servers = await getMCPServersFromRedux()
const server = servers.find((s) => s.id === serverId)
if (!server) {
throw new Error(`Server not found: ${serverId}`)
}
logger.debug(`[callToolById] Calling tool ${toolName} on server ${server.name}`)
return this.callTool(null as unknown as Electron.IpcMainInvokeEvent, {
server,
name: toolName,
args: params,
callId
})
}
private getServerKey(server: MCPServer): string {
return JSON.stringify({
baseUrl: server.baseUrl,

View File

@ -1083,18 +1083,33 @@ export class SelectionService {
this.lastCtrlkeyDownTime = -1
}
//check if the key is ctrl key
// Check if the key is ctrl key
// Windows: VK_LCONTROL(162), VK_RCONTROL(163)
// macOS: kVK_Control(59), kVK_RightControl(62)
private isCtrlkey(vkCode: number) {
if (isMac) {
return vkCode === 59 || vkCode === 62
}
return vkCode === 162 || vkCode === 163
}
//check if the key is shift key
// Check if the key is shift key
// Windows: VK_LSHIFT(160), VK_RSHIFT(161)
// macOS: kVK_Shift(56), kVK_RightShift(60)
private isShiftkey(vkCode: number) {
if (isMac) {
return vkCode === 56 || vkCode === 60
}
return vkCode === 160 || vkCode === 161
}
//check if the key is alt key
// Check if the key is alt/option key
// Windows: VK_LMENU(164), VK_RMENU(165)
// macOS: kVK_Option(58), kVK_RightOption(61)
private isAltkey(vkCode: number) {
if (isMac) {
return vkCode === 58 || vkCode === 61
}
return vkCode === 164 || vkCode === 165
}

View File

@ -0,0 +1,202 @@
import path from 'path'
import { describe, expect, it } from 'vitest'
import { ensurePathWithin, validateArgs, validateCommand } from '../DxtService'
describe('ensurePathWithin', () => {
const baseDir = '/home/user/mcp'
describe('valid paths', () => {
it('should accept direct child paths', () => {
expect(ensurePathWithin(baseDir, '/home/user/mcp/server-test')).toBe('/home/user/mcp/server-test')
expect(ensurePathWithin(baseDir, '/home/user/mcp/my-server')).toBe('/home/user/mcp/my-server')
})
it('should accept paths with unicode characters', () => {
expect(ensurePathWithin(baseDir, '/home/user/mcp/服务器')).toBe('/home/user/mcp/服务器')
expect(ensurePathWithin(baseDir, '/home/user/mcp/サーバー')).toBe('/home/user/mcp/サーバー')
})
})
describe('path traversal prevention', () => {
it('should reject paths that escape base directory', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/../../../etc')).toThrow('Path traversal detected')
expect(() => ensurePathWithin(baseDir, '/etc/passwd')).toThrow('Path traversal detected')
expect(() => ensurePathWithin(baseDir, '/home/user')).toThrow('Path traversal detected')
})
it('should reject subdirectories', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/sub/dir')).toThrow('Path traversal detected')
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/a/b/c')).toThrow('Path traversal detected')
})
it('should reject Windows-style path traversal', () => {
const winBase = 'C:\\Users\\user\\mcp'
expect(() => ensurePathWithin(winBase, 'C:\\Users\\user\\mcp\\..\\..\\Windows\\System32')).toThrow(
'Path traversal detected'
)
})
it('should reject null byte attacks', () => {
const maliciousPath = path.join(baseDir, 'server\x00/../../../etc/passwd')
expect(() => ensurePathWithin(baseDir, maliciousPath)).toThrow('Path traversal detected')
})
it('should handle encoded traversal attempts', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp/../escape')).toThrow('Path traversal detected')
})
})
describe('edge cases', () => {
it('should reject base directory itself', () => {
expect(() => ensurePathWithin(baseDir, '/home/user/mcp')).toThrow('Path traversal detected')
})
it('should handle relative path construction', () => {
const target = path.join(baseDir, 'server-name')
expect(ensurePathWithin(baseDir, target)).toBe('/home/user/mcp/server-name')
})
})
})
describe('validateCommand', () => {
describe('valid commands', () => {
it('should accept simple command names', () => {
expect(validateCommand('node')).toBe('node')
expect(validateCommand('python')).toBe('python')
expect(validateCommand('npx')).toBe('npx')
expect(validateCommand('uvx')).toBe('uvx')
})
it('should accept absolute paths', () => {
expect(validateCommand('/usr/bin/node')).toBe('/usr/bin/node')
expect(validateCommand('/usr/local/bin/python3')).toBe('/usr/local/bin/python3')
expect(validateCommand('C:\\Program Files\\nodejs\\node.exe')).toBe('C:\\Program Files\\nodejs\\node.exe')
})
it('should accept relative paths starting with ./', () => {
expect(validateCommand('./node_modules/.bin/tsc')).toBe('./node_modules/.bin/tsc')
expect(validateCommand('.\\scripts\\run.bat')).toBe('.\\scripts\\run.bat')
})
it('should trim whitespace', () => {
expect(validateCommand(' node ')).toBe('node')
expect(validateCommand('\tpython\n')).toBe('python')
})
})
describe('path traversal prevention', () => {
it('should reject commands with path traversal (Unix style)', () => {
expect(() => validateCommand('../../../bin/sh')).toThrow('path traversal detected')
expect(() => validateCommand('../../etc/passwd')).toThrow('path traversal detected')
expect(() => validateCommand('/usr/../../../bin/sh')).toThrow('path traversal detected')
})
it('should reject commands with path traversal (Windows style)', () => {
expect(() => validateCommand('..\\..\\..\\Windows\\System32\\cmd.exe')).toThrow('path traversal detected')
expect(() => validateCommand('..\\..\\Windows\\System32\\calc.exe')).toThrow('path traversal detected')
expect(() => validateCommand('C:\\..\\..\\Windows\\System32\\cmd.exe')).toThrow('path traversal detected')
})
it('should reject just ".."', () => {
expect(() => validateCommand('..')).toThrow('path traversal detected')
})
it('should reject mixed style path traversal', () => {
expect(() => validateCommand('../..\\mixed/..\\attack')).toThrow('path traversal detected')
})
})
describe('null byte injection', () => {
it('should reject commands with null bytes', () => {
expect(() => validateCommand('node\x00.exe')).toThrow('null byte detected')
expect(() => validateCommand('python\0')).toThrow('null byte detected')
})
})
describe('edge cases', () => {
it('should reject empty strings', () => {
expect(() => validateCommand('')).toThrow('command must be a non-empty string')
expect(() => validateCommand(' ')).toThrow('command cannot be empty')
})
it('should reject non-string input', () => {
// @ts-expect-error - testing runtime behavior
expect(() => validateCommand(null)).toThrow('command must be a non-empty string')
// @ts-expect-error - testing runtime behavior
expect(() => validateCommand(undefined)).toThrow('command must be a non-empty string')
// @ts-expect-error - testing runtime behavior
expect(() => validateCommand(123)).toThrow('command must be a non-empty string')
})
})
describe('real-world attack scenarios', () => {
it('should prevent Windows system32 command injection', () => {
expect(() => validateCommand('../../../../Windows/System32/cmd.exe')).toThrow('path traversal detected')
expect(() => validateCommand('..\\..\\..\\..\\Windows\\System32\\powershell.exe')).toThrow(
'path traversal detected'
)
})
it('should prevent Unix bin injection', () => {
expect(() => validateCommand('../../../../bin/bash')).toThrow('path traversal detected')
expect(() => validateCommand('../../../usr/bin/curl')).toThrow('path traversal detected')
})
})
})
describe('validateArgs', () => {
describe('valid arguments', () => {
it('should accept normal arguments', () => {
expect(validateArgs(['--version'])).toEqual(['--version'])
expect(validateArgs(['-y', '@anthropic/mcp-server'])).toEqual(['-y', '@anthropic/mcp-server'])
expect(validateArgs(['install', 'package-name'])).toEqual(['install', 'package-name'])
})
it('should accept arguments with safe paths', () => {
expect(validateArgs(['./src/index.ts'])).toEqual(['./src/index.ts'])
expect(validateArgs(['/absolute/path/file.js'])).toEqual(['/absolute/path/file.js'])
})
it('should accept empty array', () => {
expect(validateArgs([])).toEqual([])
})
})
describe('path traversal prevention', () => {
it('should reject arguments with path traversal', () => {
expect(() => validateArgs(['../../../etc/passwd'])).toThrow('path traversal detected')
expect(() => validateArgs(['--config', '../../secrets.json'])).toThrow('path traversal detected')
expect(() => validateArgs(['..\\..\\Windows\\System32\\config'])).toThrow('path traversal detected')
})
it('should only check path-like arguments', () => {
// Arguments without path separators should pass even with dots
expect(validateArgs(['..version'])).toEqual(['..version'])
expect(validateArgs(['test..name'])).toEqual(['test..name'])
})
})
describe('null byte injection', () => {
it('should reject arguments with null bytes', () => {
expect(() => validateArgs(['file\x00.txt'])).toThrow('null byte detected')
expect(() => validateArgs(['--config', 'path\0name'])).toThrow('null byte detected')
})
})
describe('edge cases', () => {
it('should reject non-array input', () => {
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs('not an array')).toThrow('must be an array')
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs(null)).toThrow('must be an array')
})
it('should reject non-string elements', () => {
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs([123])).toThrow('must be a string')
// @ts-expect-error - testing runtime behavior
expect(() => validateArgs(['valid', null])).toThrow('must be a string')
})
})
})

View File

@ -0,0 +1,75 @@
import type { MCPServer, MCPTool } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@main/apiServer/utils/mcp', () => ({
getMCPServersFromRedux: vi.fn()
}))
vi.mock('@main/services/WindowService', () => ({
windowService: {
getMainWindow: vi.fn(() => null)
}
}))
import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp'
import mcpService from '@main/services/MCPService'
const baseInputSchema: { type: 'object'; properties: Record<string, unknown>; required: string[] } = {
type: 'object',
properties: {},
required: []
}
const createTool = (overrides: Partial<MCPTool>): MCPTool => ({
id: `${overrides.serverId}__${overrides.name}`,
name: overrides.name ?? 'tool',
description: overrides.description,
serverId: overrides.serverId ?? 'server',
serverName: overrides.serverName ?? 'server',
inputSchema: baseInputSchema,
type: 'mcp',
...overrides
})
describe('MCPService.listAllActiveServerTools', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('filters disabled tools per server', async () => {
const servers: MCPServer[] = [
{
id: 'alpha',
name: 'Alpha',
isActive: true,
disabledTools: ['disabled_tool']
},
{
id: 'beta',
name: 'Beta',
isActive: true
}
]
vi.mocked(getMCPServersFromRedux).mockResolvedValue(servers)
const listToolsSpy = vi.spyOn(mcpService as any, 'listToolsImpl').mockImplementation(async (server: any) => {
if (server.id === 'alpha') {
return [
createTool({ name: 'enabled_tool', serverId: server.id, serverName: server.name }),
createTool({ name: 'disabled_tool', serverId: server.id, serverName: server.name })
]
}
return [createTool({ name: 'beta_tool', serverId: server.id, serverName: server.name })]
})
const tools = await mcpService.listAllActiveServerTools()
expect(listToolsSpy).toHaveBeenCalledTimes(2)
expect(tools.map((tool) => tool.name)).toEqual(['enabled_tool', 'beta_tool'])
})
})

View File

@ -2,7 +2,7 @@ import { loggerService } from '@logger'
import { mcpApiService } from '@main/apiServer/services/mcp'
import type { ModelValidationError } from '@main/apiServer/utils'
import { validateModelId } from '@main/apiServer/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { buildFunctionCallToolName } from '@shared/mcp'
import type { AgentType, MCPTool, SlashCommand, Tool } from '@types'
import { objectKeys } from '@types'
import fs from 'fs'

View File

@ -39,22 +39,22 @@ const agent = await agentService.createAgent({
```bash
# Apply schema changes
yarn agents:generate
pnpm agents:generate
# Quick development sync
yarn agents:push
pnpm agents:push
# Database tools
yarn agents:studio # Open Drizzle Studio
yarn agents:health # Health check
yarn agents:drop # Reset database
pnpm agents:studio # Open Drizzle Studio
pnpm agents:health # Health check
pnpm agents:drop # Reset database
```
## Workflow
1. **Edit schema** in `/database/schema/`
2. **Generate migration** with `yarn agents:generate`
3. **Test changes** with `yarn agents:health`
2. **Generate migration** with `pnpm agents:generate`
3. **Test changes** with `pnpm agents:health`
4. **Deploy** - migrations apply automatically
## Services
@ -69,13 +69,13 @@ yarn agents:drop # Reset database
```bash
# Check status
yarn agents:health
pnpm agents:health
# Apply migrations
yarn agents:migrate
pnpm agents:migrate
# Reset completely
yarn agents:reset --yes
pnpm agents:reset --yes
```
The simplified migration system reduced complexity from 463 to ~30 lines while maintaining all functionality through Drizzle's native migration system.

View File

@ -1,225 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildFunctionCallToolName } from '../mcp'
describe('buildFunctionCallToolName', () => {
describe('basic format', () => {
it('should return format mcp__{server}__{tool}', () => {
const result = buildFunctionCallToolName('github', 'search_issues')
expect(result).toBe('mcp__github__search_issues')
})
it('should handle simple server and tool names', () => {
expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__get_page')
expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query')
expect(buildFunctionCallToolName('cherry_studio', 'search')).toBe('mcp__cherry_studio__search')
})
})
describe('valid JavaScript identifier', () => {
it('should always start with mcp__ prefix (valid JS identifier start)', () => {
const result = buildFunctionCallToolName('123server', '456tool')
expect(result).toMatch(/^mcp__/)
expect(result).toBe('mcp__123server__456tool')
})
it('should only contain alphanumeric chars and underscores', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool')
expect(result).toBe('mcp__my_server__my_tool')
expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_]*$/)
})
it('should be a valid JavaScript identifier', () => {
const testCases = [
['github', 'create_issue'],
['my-server', 'fetch-data'],
['test@server', 'tool#name'],
['server.name', 'tool.action'],
['123abc', 'def456']
]
for (const [server, tool] of testCases) {
const result = buildFunctionCallToolName(server, tool)
// Valid JS identifiers match this pattern
expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
}
})
})
describe('character sanitization', () => {
it('should replace dashes with underscores', () => {
const result = buildFunctionCallToolName('my-server', 'my-tool-name')
expect(result).toBe('mcp__my_server__my_tool_name')
})
it('should replace special characters with underscores', () => {
const result = buildFunctionCallToolName('test@server!', 'tool#name$')
expect(result).toBe('mcp__test_server__tool_name')
})
it('should replace dots with underscores', () => {
const result = buildFunctionCallToolName('server.name', 'tool.action')
expect(result).toBe('mcp__server_name__tool_action')
})
it('should replace spaces with underscores', () => {
const result = buildFunctionCallToolName('my server', 'my tool')
expect(result).toBe('mcp__my_server__my_tool')
})
it('should collapse consecutive underscores', () => {
const result = buildFunctionCallToolName('my--server', 'my___tool')
expect(result).toBe('mcp__my_server__my_tool')
expect(result).not.toMatch(/_{3,}/)
})
it('should trim leading and trailing underscores from parts', () => {
const result = buildFunctionCallToolName('_server_', '_tool_')
expect(result).toBe('mcp__server__tool')
})
it('should handle names with only special characters', () => {
const result = buildFunctionCallToolName('---', '###')
expect(result).toBe('mcp____')
})
})
describe('length constraints', () => {
it('should not exceed 63 characters', () => {
const longServerName = 'a'.repeat(50)
const longToolName = 'b'.repeat(50)
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should truncate server name to max 20 chars', () => {
const longServerName = 'abcdefghijklmnopqrstuvwxyz' // 26 chars
const result = buildFunctionCallToolName(longServerName, 'tool')
expect(result).toBe('mcp__abcdefghijklmnopqrst__tool')
expect(result).toContain('abcdefghijklmnopqrst') // First 20 chars
expect(result).not.toContain('uvwxyz') // Truncated
})
it('should truncate tool name to max 35 chars', () => {
const longToolName = 'a'.repeat(40)
const result = buildFunctionCallToolName('server', longToolName)
const expectedTool = 'a'.repeat(35)
expect(result).toBe(`mcp__server__${expectedTool}`)
})
it('should not end with underscores after truncation', () => {
// Create a name that would end with underscores after truncation
const longServerName = 'a'.repeat(20)
const longToolName = 'b'.repeat(35) + '___extra'
const result = buildFunctionCallToolName(longServerName, longToolName)
expect(result).not.toMatch(/_+$/)
expect(result.length).toBeLessThanOrEqual(63)
})
it('should handle max length edge case exactly', () => {
// mcp__ (5) + server (20) + __ (2) + tool (35) = 62 chars
const server = 'a'.repeat(20)
const tool = 'b'.repeat(35)
const result = buildFunctionCallToolName(server, tool)
expect(result.length).toBe(62)
expect(result).toBe(`mcp__${'a'.repeat(20)}__${'b'.repeat(35)}`)
})
})
describe('edge cases', () => {
it('should handle empty server name', () => {
const result = buildFunctionCallToolName('', 'tool')
expect(result).toBe('mcp____tool')
})
it('should handle empty tool name', () => {
const result = buildFunctionCallToolName('server', '')
expect(result).toBe('mcp__server__')
})
it('should handle both empty names', () => {
const result = buildFunctionCallToolName('', '')
expect(result).toBe('mcp____')
})
it('should handle whitespace-only names', () => {
const result = buildFunctionCallToolName(' ', ' ')
expect(result).toBe('mcp____')
})
it('should trim whitespace from names', () => {
const result = buildFunctionCallToolName(' server ', ' tool ')
expect(result).toBe('mcp__server__tool')
})
it('should handle unicode characters', () => {
const result = buildFunctionCallToolName('服务器', '工具')
// Unicode chars are replaced with underscores, then collapsed
expect(result).toMatch(/^mcp__/)
})
it('should handle mixed case', () => {
const result = buildFunctionCallToolName('MyServer', 'MyTool')
expect(result).toBe('mcp__MyServer__MyTool')
})
})
describe('deterministic output', () => {
it('should produce consistent results for same input', () => {
const serverName = 'github'
const toolName = 'search_repos'
const result1 = buildFunctionCallToolName(serverName, toolName)
const result2 = buildFunctionCallToolName(serverName, toolName)
const result3 = buildFunctionCallToolName(serverName, toolName)
expect(result1).toBe(result2)
expect(result2).toBe(result3)
})
it('should produce different results for different inputs', () => {
const result1 = buildFunctionCallToolName('server1', 'tool')
const result2 = buildFunctionCallToolName('server2', 'tool')
const result3 = buildFunctionCallToolName('server', 'tool1')
const result4 = buildFunctionCallToolName('server', 'tool2')
expect(result1).not.toBe(result2)
expect(result3).not.toBe(result4)
})
})
describe('real-world scenarios', () => {
it('should handle GitHub MCP server', () => {
expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__create_issue')
expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__search_repositories')
expect(buildFunctionCallToolName('github', 'get_pull_request')).toBe('mcp__github__get_pull_request')
})
it('should handle filesystem MCP server', () => {
expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__read_file')
expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__write_file')
expect(buildFunctionCallToolName('filesystem', 'list_directory')).toBe('mcp__filesystem__list_directory')
})
it('should handle hyphenated server names (common in npm packages)', () => {
expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherry_fetch__get_page')
expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcp_server_github__search')
})
it('should handle scoped npm package style names', () => {
const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat')
expect(result).toBe('mcp__anthropic_mcp_server__chat')
})
it('should handle tools with long descriptive names', () => {
const result = buildFunctionCallToolName('github', 'search_repositories_by_language_and_stars')
expect(result.length).toBeLessThanOrEqual(63)
expect(result).toMatch(/^mcp__github__search_repositories_by_lan/)
})
})
})

View File

@ -13,18 +13,13 @@ export async function getIpCountry(): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await net.fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
const ipinfo = await net.fetch(`https://api.ipinfo.io/lite/me?token=2a42580355dae4`, {
signal: controller.signal
})
clearTimeout(timeoutId)
const data = await ipinfo.json()
const country = data.country || 'CN'
const country = data.country_code || 'CN'
logger.info(`Detected user IP address country: ${country}`)
return country
} catch (error) {

View File

@ -1,29 +0,0 @@
/**
* Builds a valid JavaScript function name for MCP tool calls.
* Format: mcp__{server_name}__{tool_name}
*
* @param serverName - The MCP server name
* @param toolName - The tool name from the server
* @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars
*/
export function buildFunctionCallToolName(serverName: string, toolName: string): string {
// Sanitize to valid JS identifier chars (alphanumeric + underscore only)
const sanitize = (str: string): string =>
str
.trim()
.replace(/[^a-zA-Z0-9]/g, '_') // Replace all non-alphanumeric with underscore
.replace(/_{2,}/g, '_') // Collapse multiple underscores
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
const server = sanitize(serverName).slice(0, 20) // Keep server name short
const tool = sanitize(toolName).slice(0, 35) // More room for tool name
let name = `mcp__${server}__${tool}`
// Ensure max 63 chars and clean trailing underscores
if (name.length > 63) {
name = name.slice(0, 63).replace(/_+$/, '')
}
return name
}

View File

@ -120,6 +120,21 @@ export class AiSdkToChunkAdapter {
}
}
/**
* THINKING_COMPLETE chunk
* @param final reasoningContent
* @returns THINKING_COMPLETE chunk
*/
private emitThinkingCompleteIfNeeded(final: { reasoningContent: string; [key: string]: any }) {
if (final.reasoningContent) {
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: final.reasoningContent
})
final.reasoningContent = ''
}
}
/**
* AI SDK chunk Cherry Studio chunk
* @param chunk AI SDK chunk
@ -145,6 +160,9 @@ export class AiSdkToChunkAdapter {
}
// === 文本相关事件 ===
case 'text-start':
// 如果有未完成的思考内容,先生成 THINKING_COMPLETE
// 这处理了某些提供商不发送 reasoning-end 事件的情况
this.emitThinkingCompleteIfNeeded(final)
this.onChunk({
type: ChunkType.TEXT_START
})
@ -215,11 +233,7 @@ export class AiSdkToChunkAdapter {
})
break
case 'reasoning-end':
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: final.reasoningContent || ''
})
final.reasoningContent = ''
this.emitThinkingCompleteIfNeeded(final)
break
// === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) ===

View File

@ -1,7 +1,7 @@
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import type { MCPTool } from '@renderer/types'
import { isAnthropicModel, isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import type { McpMode, MCPTool } from '@renderer/types'
import { type Assistant, type Message, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { isOllamaProvider, isSupportEnableThinkingProvider } from '@renderer/utils/provider'
@ -10,6 +10,7 @@ import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { getAiSdkProviderId } from '../provider/factory'
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
import { anthropicCacheMiddleware } from './anthropicCacheMiddleware'
import { noThinkMiddleware } from './noThinkMiddleware'
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
import { openrouterReasoningMiddleware } from './openrouterReasoningMiddleware'
@ -38,6 +39,7 @@ export interface AiSdkMiddlewareConfig {
enableWebSearch: boolean
enableGenerateImage: boolean
enableUrlContext: boolean
mcpMode?: McpMode
mcpTools?: MCPTool[]
uiMessages?: Message[]
// 内置搜索配置
@ -178,17 +180,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
// 根据不同provider添加特定中间件
switch (config.provider.type) {
case 'anthropic':
// Anthropic特定中间件
if (isAnthropicModel(config.model) && config.provider.anthropicCacheControl?.tokenThreshold) {
builder.add({
name: 'anthropic-cache',
middleware: anthropicCacheMiddleware(config.provider)
})
}
break
case 'openai':
case 'azure-openai': {
if (config.enableReasoning) {
// 就算这里不传参数也有可能调用推理
const tagName = getReasoningTagName(config.model?.id.toLowerCase())
builder.add({
name: 'thinking-tag-extraction',
middleware: extractReasoningMiddleware({ tagName })
})
}
break
}
case 'gemini':

View File

@ -0,0 +1,85 @@
/**
* Anthropic Prompt Caching Middleware
* @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control
*/
import type { LanguageModelV2Message } from '@ai-sdk/provider'
import { estimateTextTokens } from '@renderer/services/TokenService'
import type { Provider } from '@renderer/types'
import type { LanguageModelMiddleware } from 'ai'
const cacheProviderOptions = {
anthropic: { cacheControl: { type: 'ephemeral' } }
}
function estimateContentTokens(content: LanguageModelV2Message['content']): number {
if (typeof content === 'string') return estimateTextTokens(content)
if (Array.isArray(content)) {
return content.reduce((acc, part) => {
if (part.type === 'text') {
return acc + estimateTextTokens(part.text as string)
}
return acc
}, 0)
}
return 0
}
export function anthropicCacheMiddleware(provider: Provider): LanguageModelMiddleware {
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const settings = provider.anthropicCacheControl
if (!settings?.tokenThreshold || !Array.isArray(params.prompt) || params.prompt.length === 0) {
return params
}
const { tokenThreshold, cacheSystemMessage, cacheLastNMessages } = settings
const messages = [...params.prompt]
let cachedCount = 0
// Cache system message (providerOptions on message object)
if (cacheSystemMessage) {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i] as LanguageModelV2Message
if (msg.role === 'system' && estimateContentTokens(msg.content) >= tokenThreshold) {
messages[i] = { ...msg, providerOptions: cacheProviderOptions }
break
}
}
}
// Cache last N non-system messages (providerOptions on content parts)
if (cacheLastNMessages > 0) {
const cumsumTokens = [] as Array<number>
let tokenSum = 0 as number
for (let i = 0; i < messages.length; i++) {
const msg = messages[i] as LanguageModelV2Message
tokenSum += estimateContentTokens(msg.content)
cumsumTokens.push(tokenSum)
}
for (let i = messages.length - 1; i >= 0 && cachedCount < cacheLastNMessages; i--) {
const msg = messages[i] as LanguageModelV2Message
if (msg.role === 'system' || cumsumTokens[i] < tokenThreshold || msg.content.length === 0) {
continue
}
const newContent = [...msg.content]
const lastIndex = newContent.length - 1
newContent[lastIndex] = {
...newContent[lastIndex],
providerOptions: cacheProviderOptions
}
messages[i] = {
...msg,
content: newContent
} as LanguageModelV2Message
cachedCount++
}
}
return { ...params, prompt: messages }
}
}
}

View File

@ -47,6 +47,7 @@ export function buildPlugins(
plugins.push(
createPromptToolUsePlugin({
enabled: true,
mcpMode: middlewareConfig.mcpMode,
createSystemMessage: (systemPrompt, params, context) => {
const modelId = typeof context.model === 'string' ? context.model : context.model.modelId
if (modelId.includes('o1-mini') || modelId.includes('o1-preview')) {

View File

@ -8,13 +8,13 @@ import { loggerService } from '@logger'
import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models'
import type { Message, Model } from '@renderer/types'
import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
import { parseDataUrlMediaType } from '@renderer/utils/image'
import {
findFileBlocks,
findImageBlocks,
findThinkingBlocks,
getMainTextContent
} from '@renderer/utils/messageUtils/find'
import { parseDataUrl } from '@shared/utils'
import type {
AssistantModelMessage,
FilePart,
@ -69,18 +69,16 @@ async function convertImageBlockToImagePart(imageBlocks: ImageMessageBlock[]): P
}
} else if (imageBlock.url) {
const url = imageBlock.url
const isDataUrl = url.startsWith('data:')
if (isDataUrl) {
const { mediaType } = parseDataUrlMediaType(url)
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
logger.error('Malformed data URL detected (missing comma separator), image will be excluded:', {
const parseResult = parseDataUrl(url)
if (parseResult?.isBase64) {
const { mediaType, data } = parseResult
parts.push({ type: 'image', image: data, ...(mediaType ? { mediaType } : {}) })
} else if (url.startsWith('data:')) {
// Malformed data URL or non-base64 data URL
logger.error('Malformed or non-base64 data URL detected, image will be excluded:', {
urlPrefix: url.slice(0, 50) + '...'
})
continue
}
const base64Data = url.slice(commaIndex + 1)
parts.push({ type: 'image', image: base64Data, ...(mediaType ? { mediaType } : {}) })
} else {
// For remote URLs we keep payload minimal to match existing expectations.
parts.push({ type: 'image', image: url })

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