Compare commits

...

24 Commits

Author SHA1 Message Date
beyondkmp
03c8b6b5b4 format imports 2025-10-30 13:18:01 +08:00
beyondkmp
f5136a0adb format code 2025-10-30 13:09:58 +08:00
beyondkmp
99873a0767 📝 docs: refresh shortcut refactor design 2025-10-30 11:35:00 +08:00
beyondkmp
34affb4533 ♻️ refactor: migrate shortcuts to preferences 2025-10-30 11:28:41 +08:00
MyPrototypeWhat
cf008ca22e feat: update migration status documentation for Cherry Studio UI
- Expanded the migration status document to outline the comprehensive plan for transitioning from antd + styled-components to shadcn/ui + Tailwind CSS.
- Introduced detailed migration strategies, principles, and component classification guidelines.
- Added extraction criteria and migration steps to ensure a structured approach to component migration and optimization.
- Emphasized collaboration with UI designers for maintaining design consistency throughout the migration process.
2025-10-29 18:06:30 +08:00
MyPrototypeWhat
851ff8992f style: format color variables in todocss.css for improved readability
- Reformatted color variable definitions in todocss.css to enhance readability by breaking long lines into multiple lines.
- Ensured consistency in the formatting of HSLA values across the file.
2025-10-29 16:56:31 +08:00
MyPrototypeWhat
91f9088436 feat: add design system documentation and todocss.css file
- Introduced a comprehensive design system document outlining integration strategies for Tailwind CSS v4, usage guidelines, and UI library balance strategies.
- Added todocss.css file containing typography, spacing, sizing, and color variables for the design system.
- Established naming conventions and core transformation rules for design tokens to enhance consistency and usability across the UI components.
2025-10-29 16:52:05 +08:00
fullex
c971daf23c Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2 2025-10-29 16:43:40 +08:00
fullex
0c7cee2700 Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2 2025-10-29 16:37:54 +08:00
defi-failure
dfbfc2869c fix: use session model when sending messages (#11028) 2025-10-29 16:32:04 +08:00
Phantom
1575e97168 fix: approved tools (#11025)
* refactor(agent): move permission mode types and constants to config

Move PermissionModeCard type definition to types/agent.ts and relocate permissionModeCards constant from constants/permissionModes.ts to config/agent.ts for better organization and maintainability

* refactor(AgentSettings): simplify state management in ToolingSettings

remove redundant state for selectedMode and derive it from configuration
consolidate permission mode constants import path

* docs(AgentSettings): add jsdoc for computeModeDefaults function

* refactor(AgentSettings): simplify tooling state management with useMemo

remove redundant state for autoToolIds and compute it directly using useMemo

* refactor(AgentSettings): simplify tool approval state management

- Replace useState with useMemo for approvedToolIds to prevent unnecessary state updates
- Remove redundant state transitions and simplify toggle logic
- Ensure consistent tool filtering and merging with defaults

* refactor(AgentSettings): replace useState with useMemo for configuration state

Optimize performance by memoizing agent configuration state to prevent unnecessary re-renders

* perf(AgentSettings): optimize permission_mode computation with useMemo

Prevent unnecessary recalculations of permission_mode by memoizing the value

* refactor(AgentSettings): simplify MCP selection logic and remove unused imports

Remove useEffect for MCP state synchronization and directly use memoized value
Clean up unused imports and simplify toggle handler logic

* refactor: remove unused useAgentClient hook from ToolingSettings
2025-10-29 16:21:29 +08:00
SuYao
e0a2ed0481 Provider Config & anthropic-web-fetch (#10808)
* fix: update AI SDK dependencies to latest versions

* feat: Update provider configurations and API handling

- Refactor provider configuration to support new API types and enhance API host formatting.
- Introduce new utility functions for handling API versions and formatting Azure OpenAI hosts.
- Update system models to include new capabilities and adjust provider types for CherryIN and VertexAI.
- Enhance provider settings UI to accommodate new API types and improve user experience.
- Implement migration logic for provider type updates and default API host settings.
- Update translations for API host configuration tips across multiple languages.
- Fix various type checks and utility functions to ensure compatibility with new provider types.

* fix: update unsupported API version providers and add longcat to compatible provider IDs

* fix: 移除不再使用的 Azure OpenAI API 版本参数,优化 API 主机格式化逻辑
feat: 在选择器组件中添加样式属性,增强可定制性
feat: 更新提供者设置,支持动态选择 API 主机字段

* refactor: 优化测试用例

* 修复: 更新工具调用处理器以支持新的工具调用类型

* feat: 添加TODO注释以改进基于AI SDK的供应商内置工具展示和类型安全处理

* feat: 添加对Google SDK的支持,更新流式参数构建逻辑以包含Google工具的上下文

* feat: 更新web搜索模型判断逻辑,使用SystemProviderIds常量替代硬编码字符串

* feat: 添加对@renderer/store的mock以支持测试环境

* feat: 添加API主机地址验证功能,更新相关逻辑以支持端点提取

* fix: i18n

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

* Apply suggestion from @EurFelux

Co-authored-by: Phantom <eurfelux@gmail.com>

* Apply suggestion from @EurFelux

Co-authored-by: Phantom <eurfelux@gmail.com>

* Apply suggestion from @EurFelux

Co-authored-by: Phantom <eurfelux@gmail.com>

* refactor: Simplify provider type migration logic and enhance API version validation

* fix: Correct variable name from configedApiHost to configuredApiHost for consistency

* fix: Update package.json to remove deprecated @ai-sdk/google version and streamline @ai-sdk/openai versioning

* fix: 更新 hasAPIVersion 函数中的正则表达式以更准确地匹配 API 版本路径

* fix(api): 简化 validateApiHost 函数逻辑以始终返回 true
fix(yarn): 更新 @ai-sdk/openai 版本至 2.0.53 并添加依赖项

* fix(api): 修正 validateApiHost 函数在使用哈希后缀时的验证逻辑

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Phantom <eurfelux@gmail.com>
2025-10-29 14:47:21 +08:00
fullex
3e9d9f16d6 fix: test 2025-10-29 14:45:55 +08:00
fullex
f3a279d8de Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2 2025-10-29 14:28:03 +08:00
LiuVaayne
5790c12011 Feat: Enhanced Tool Permission System with Real-time Approvals #10738 (#10743)
* ⬆️ chore: upgrade claude agent sdk to 0.1.15

*  feat: add initial tool permission approval system

- Add promptForToolApproval function for real-time tool approval UI
- Integrate canUseTool callback into ClaudeCodeService
- Create tool-permissions.ts module for permission handling
- Set foundation for enhanced tool permission system #10738

This provides the basic infrastructure for displaying tool approval
prompts and getting user consent before agents execute potentially
dangerous operations.

* restore to main for

restore to main for

*  feat: implement agent tool permission request system

Add comprehensive tool permission management for Claude Code agents with:
- IPC channels for bidirectional permission requests/responses between main and renderer
- Permission request queue with timeout (30s), abort signal handling, and auto-cleanup
- Auto-approval system via CHERRY_AUTO_ALLOW_TOOLS env var and default allow-list (Read, Glob, Grep)
- New ToolPermissionRequestCard UI component for user approval with input preview
- Redux store (toolPermissions) for tracking pending/resolved permission requests
- User input stream architecture allowing dynamic user messages during agent execution

Key changes:
- packages/shared/IpcChannel.ts: Add AgentToolPermission_* channels
- src/main/services/agents/services/claudecode/: Refactor canUseTool with permission prompts
- src/renderer/src/store/toolPermissions.ts: New Redux slice for permission state
- src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx: Interactive approval UI

* refactor: simplify ToolPermissionRequestCard by removing unused imports and suggestion handling logic

* feat: add i18n

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

---------

Co-authored-by: dev <verc20.dev@proton.me>
Co-authored-by: GitHub Action <action@github.com>
2025-10-29 13:37:32 +08:00
LiuVaayne
352ecbc506 feat: add plugin management system for Claude Agent (agents, commands, skills) (#10854)
*  feat: add claude-code-templates via git submodule with build-time copy

- Add git submodule for davila7/claude-code-templates
- Create scripts/copy-templates.js to copy components at build time
- Update package.json build script to include template copying
- Add resources/data/components/ to .gitignore (generated files)

Templates are now automatically synced from external repo to resources/data/components/
during build process, avoiding manual file copying.

To update templates: git submodule update --remote --merge

* fix: update target directory for copying Claude Code templates

*  feat: merge Anthropics skills into template sync

* 📝 docs: add agent plugins management implementation spec

Add comprehensive implementation plan for plugin management feature:
- Security validation and transactional operations
- Plugin browsing, installation, and management UI
- IPC handlers and PluginService architecture
- Metadata caching and database integration

*  feat: add plugin management backend infrastructure

Backend implementation for Claude Code plugin management:

- Add PluginService with security validation and caching
- Create IPC handlers for plugin operations (list, install, uninstall)
- Add markdown parser with safe YAML frontmatter parsing
- Extend AgentConfiguration schema with installed_plugins field
- Update preload bridge to expose plugin API to renderer
- Add plugin types (PluginMetadata, PluginError, PluginResult)

Features:
- Transactional install/uninstall with rollback
- Path traversal prevention and file validation
- 5-minute plugin list caching for performance
- SHA-256 content hashing for integrity checks
- Duplicate plugin handling (auto-replace)

Dependencies added:
- gray-matter: Markdown frontmatter parsing
- js-yaml: Safe YAML parsing with FAILSAFE_SCHEMA

*  feat: add plugin management UI and integration

Complete frontend implementation for Claude Code plugin management:

**React Hooks:**
- useAvailablePlugins: Fetch and cache available plugins
- useInstalledPlugins: List installed plugins with refresh
- usePluginActions: Install/uninstall with loading states

**UI Components (HeroUI):**
- PluginCard: Display plugin with install/uninstall actions
- CategoryFilter: Multi-select chip-based category filter
- InstalledPluginsList: Table view with uninstall confirmation
- PluginBrowser: Search, filter, pagination, responsive grid
- PluginSettings: Main container with Available/Installed tabs

**Integration:**
- Added "Plugins" tab to AgentSettingsPopup
- Full i18n support (English, Simplified Chinese, Traditional Chinese)
- Toast notifications for success/error states
- Loading skeletons and empty states

**Features:**
- Search plugins by name/description
- Filter by category and type (agents/commands)
- Pagination (12 items per page)
- Install/uninstall with confirmation dialogs
- Real-time plugin list updates
- Responsive grid layout (1-3 columns)

All code formatted with Biome and follows existing patterns.

* 🐛 fix: add missing plugin i18n keys at root level

Add plugin translation keys at root 'plugins.*' level to match component usage:
- Search and filter UI strings
- Pluralization support for result counts
- Empty state messages
- Action button labels
- Confirmation dialog text

Translations added for all three locales (en-US, zh-CN, zh-TW).

* 🐛 fix: use getResourcePath() utility for plugin directory resolution

Replace manual path calculation with getResourcePath() utility which correctly
handles both development and production environments. This fixes the issue where
plugins were not loading because __dirname was resolving to the wrong location.

Fixes:
- Plugins now load correctly in development mode
- Path resolution consistent with other resource loading in the app
- Removed unused 'app' import from electron

* 🎨 fix: improve plugin UI scrolling and category filter layout

Fixes two UI issues:

1. Enable scrolling for plugin list:
   - Changed overflow-hidden to overflow-y-auto on tab containers
   - Plugin grid now scrollable when content exceeds viewport

2. Make category filter more compact:
   - Added max-h-24 (96px) height limit to category chip container
   - Enabled vertical scrolling for category chips
   - Prevents category filter from taking too much vertical space

UI improvements enhance usability when browsing large plugin collections.

* 🎨 fix: ensure both agent and command badges have visible backgrounds

Changed Chip variant from 'flat' to 'solid' for plugin type badges.
This ensures both agent (primary) and command (secondary) badges display
with consistent, visible background colors instead of command badges
appearing as text-only.

*  feat: add plugin detail modal for viewing full plugin information

Add modal to display complete plugin details when clicking on a card:

Features:
- Click any plugin card to view full details in a modal
- Shows complete description (not truncated)
- Displays all metadata: version, author, tools, allowed_tools, tags
- Shows file info: filename, size, source path, install date
- Install/uninstall actions available in modal
- Hover effect on cards to indicate clickability
- Button clicks don't trigger card click (event.stopPropagation)

Components:
- New PluginDetailModal component with scrollable content
- Updated PluginCard to be clickable (isPressable)
- Updated PluginBrowser to manage modal state

UI improvements provide better plugin exploration and decision-making.

* 🐛 fix: render plugin detail modal above agent settings modal

Use React portal to render PluginDetailModal directly to document.body,
ensuring it appears above the agent settings modal instead of being
blocked by it.

Changes:
- Import createPortal from react-dom
- Wrap modal content in createPortal(modalContent, document.body)
- Add z-[9999] to modal wrapper for proper layering

Fixes modal visibility issue where plugin details were hidden behind
the parent agent settings modal.

*  feat: add plugin content viewing and editing in detail modal

- Added IPC channels for reading and writing plugin content
- Implemented readContent() and writeContent() methods in PluginService
- Added IPC handlers for content operations with proper error handling
- Exposed plugin content API through preload bridge
- Updated PluginDetailModal to fetch and display markdown content
- Added edit mode with textarea for modifying plugin content
- Implemented save/cancel functionality with optimistic UI updates
- Added agentId prop to component chain for write operations
- Updated AgentConfigurationSchema to include all plugin metadata fields
- Moved plugin types to shared @types for cross-process access
- Added validation and security checks for content read/write
- Updated content hash in DB after successful edits

* 🐛 fix: change event handler from onPress to onClick for uninstall and install buttons

* 📝 docs: update AI Assistant Guide to clarify proposal and commit guidelines

* 📝 docs: add skills support extension spec for agent plugins management

*  feat: add secure file operation utilities for skills plugin system

- Implement copyDirectoryRecursive() with security protections
- Implement deleteDirectoryRecursive() with path validation
- Implement getDirectorySize() for folder size calculation
- Add path traversal protection using isPathInside()
- Handle symlinks securely to prevent attacks
- Add recursion depth limits to prevent stack overflow
- Preserve file permissions during copy
- Handle race conditions and missing files gracefully
- Skip special files (pipes, sockets, devices)

Security features:
- Path validation against allowedBasePath boundary
- Symlink detection and skip to prevent circular loops
- Input validation for null/empty/relative paths
- Comprehensive error handling and logging

Updated spec status to "In Progress" and added implementation progress checklist.

*  feat: add skill type support and skill metadata parsing

Type System Updates (plugin.ts):
- Add PluginType export for 'agent' | 'command' | 'skill'
- Update PluginMetadataSchema to include 'skill' in type enum
- Update InstalledPluginSchema to support skill type
- Update all option interfaces to support skill type
- Add skills array to ListAvailablePluginsResult
- Document filename semantics differences between types

Markdown Parser Updates (markdownParser.ts):
- Implement parseSkillMetadata() function for SKILL.md parsing
- Add comprehensive input validation (absolute path check)
- Add robust error handling with specific PluginErrors
- Add try-catch around file operations and YAML parsing
- Add type validation for frontmatter data fields
- Add proper logging using loggerService
- Handle getDirectorySize() failures gracefully
- Document hash scope decision (SKILL.md only vs entire folder)
- Use FAILSAFE_SCHEMA for safe YAML parsing

Security improvements:
- Path validation to ensure absolute paths
- Differentiate ENOENT from permission errors
- Type validation for all frontmatter fields
- Safe YAML parsing to prevent deserialization attacks

Updated spec progress tracking.

*  feat: implement complete skill support in PluginService

Core Infrastructure:
- Add imports for parseSkillMetadata and file operation utilities
- Add PluginType to imports for type-safe handling

Skill-Specific Methods:
- sanitizeFolderName() - validates folder names (no dots allowed)
- scanSkillDirectory() - scans skills/ for skill folders
- installSkill() - copies folders with transaction/rollback
- uninstallSkill() - removes folders with transaction/rollback

Updated Methods for Skills Support:
- listAvailable() - now scans and returns skills array
- install() - branches on type to handle skills vs files
- uninstall() - branches on type for skill/file handling
- ensureClaudeDirectory() - handles 'skills' subdirectory
- listInstalled() - validates skill folders on filesystem
- writeContent() - updated signature to accept PluginType

Key Implementation Details:
- Skills use folder names WITHOUT extensions
- Agents/commands use filenames WITH .md extension
- Different sanitization rules for folders vs files
- Transaction pattern with rollback for all operations
- Comprehensive logging and error handling
- Maintains backward compatibility with existing code

Updated spec progress tracking.

*  feat: add skill support to frontend hooks and UI components

Frontend Hooks (usePlugins.ts):
- Add skills state to useAvailablePlugins hook
- Return skills array in hook result
- Update install() to accept 'skill' type
- Update uninstall() to accept 'skill' type

UI Components:
- PluginCard: Add 'skill' type badge with success color
- PluginBrowser: Add skills prop and include in plugin list
- PluginBrowser: Update type definitions to include 'skill'
- PluginBrowser: Include skills in tab filtering

Complete frontend integration for skills plugin type.
Updated spec progress tracking.

* ♻️ refactor: remove unused variable in installSkill method

* 📝 docs: mark implementation as complete with summary

Implementation Status: COMPLETE (11/12 tasks)

Completed:
-  File operation utilities with security protections
-  Skill metadata parsing with validation
-  Plugin type system updated to include 'skill'
-  PluginService skill methods (scan, install, uninstall)
-  PluginService updated for skill support
-  IPC handlers (no changes needed - already generic)
-  Frontend hooks updated for skills
-  UI components updated (PluginCard, PluginBrowser)
-  Build check passed with lint fixes

Deferred (non-blocking):
- ⏸️ Session integration - requires further investigation
  into session handler location and implementation

The core skills plugin system is fully implemented and functional.
Skills can be browsed, installed, and uninstalled through the UI.
All security requirements met with path validation and transaction
rollback. Code passes lint checks and follows project patterns.

* 🐛 fix: pass skills prop to PluginBrowser component

Fixed "skills is not iterable" error by:
- Destructuring skills from useAvailablePlugins hook
- Updating type annotations to include 'skill' type
- Passing skills prop to PluginBrowser component

This completes the missing UI wiring for skills support.

*  feat: add Skills tab to plugin browser

Added missing Skills tab to PluginBrowser component:
- Added Skills tab to type tabs
- Added translations for skills in all locales (en-us, zh-cn, zh-tw)
  - English: "Skills"
  - Simplified Chinese: "技能"
  - Traditional Chinese: "技能"

This completes the UI integration for the skills plugin type.

*  feat: add 'skill' type to AgentConfiguration and GetAgentSessionResponse schemas

* ⬆️ chore: upgrade @anthropic-ai/claude-agent-sdk to v0.1.25 with patch

- Updated from v0.1.1 to v0.1.25
- Applied fork/IPC patch to new version
- Removed old patch file
- All tests passing

* 🐛 fix: resolve linting and TypeScript type errors in build check

- Add external/** and resources/data/claude-code-plugins/** to lint ignore patterns
  to exclude git submodules and plugin templates from linting
- Fix TypeScript error handling in IPC handlers by properly typing caught errors
- Fix AgentConfiguration type mismatches by providing default values for
  permission_mode and max_turns when spreading configuration
- Replace control character regex with String.fromCharCode() to avoid ESLint
  no-control-regex rule in sanitization functions
- Fix markdownParser yaml.load return type by adding type assertion
- Add getPluginErrorMessage helper to properly extract error messages from
  PluginError discriminated union types

Main process TypeScript errors: Fixed (0 errors)
Linting errors: Fixed (0 errors from 4397)
Remaining: 4 renderer TypeScript errors in settings components

* ♻️ refactor: improve plugin error handling and reorganize i18n structure

* ⬆️ chore: update @anthropic-ai/claude-agent-sdk to include patch and additional dependencies

* 🗑️ chore: remove unused Claude code plugins and related configurations

- Deleted `.gitmodules` and associated submodules for `claude-code-templates` and `anthropics-skills`.
- Updated `.gitignore`, `.oxlintrc.json`, and `eslint.config.mjs` to exclude `claude-code-plugins`.
- Modified `package.json` to remove the build script dependency on copying templates.
- Adjusted `PluginService.ts` to handle plugin paths without relying on removed resources.

* format code

* delete

* delete

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

*  feat: enhance PluginService and markdownParser with recursive skill directory search

- Added `findAllSkillDirectories` function to recursively locate directories containing `SKILL.md`.
- Updated `scanSkillDirectory` method in `PluginService` to utilize the new recursive search.
- Modified `PluginDetailModal` to append `/SKILL.md` to the source path for skill plugins.

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

* remove specs

* update claude code plugins files

---------

Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2025-10-29 13:33:11 +08:00
George·Dong
fc4f30feab fix: update Dashscope Anthropic API host and migrate old configs (#10973)
* fix: update Dashscope Anthropic API host and migrate old configs

* fix(migration): remove obsolete dashscope rewrite

* fix(migrate): overwrite Anthropic API host for dashscope provider
2025-10-29 09:20:19 +08:00
Carlton
888a183328 feat(knowledge, preprocess): Add OpenMinerU preprocessor, knowledge base supports using open-source version of MinerU to process documents (#10992)
* feat(knowledge): 为文档处理增加open-mineru的支持

* 移除OpenMineruPreprocessProvider多余的apiKey处理

* Add preprocessProviders migrator for open-mineru

* Remove invalid code from OpenMineruPreprocessProvider

* fix property '"open-mineru"' is missing in PREPROCESS_PROVIDER_CONFIG

* refactor(preprocess): improve OpenMinerU error handling and file cleanup
2025-10-29 09:19:18 +08:00
scientia
9a01e092f6 feat: allow new-topic bindkey to create new session for agent as well (#10862)
* fix: allow new-topic shortcut to create agent sessions

* revert: restore ProxyManager.ts

* fix: make agent session shortcut sidebar-independent

* refactor: centralize default session creation

* feat: add new session button to agent inputbar

* fix: encapsulate agent session creation state

* refactor: remove redundant useMemo in useCreateDefaultSession

The useMemo wrapper around the return object was unnecessary because:
- createDefaultSession is already memoized via useCallback
- creatingSession is a primitive boolean that doesn't need memoization
- The object gets recreated on every creatingSession change anyway

This simplifies the code and removes unnecessary overhead.

---------

Co-authored-by: wangdenghui <wangdenghui@xiaomi.com>
2025-10-28 22:59:44 +08:00
George·Dong
5986800c9d chore(ci): exempt all milestones and assignee from staling (#11008) 2025-10-28 19:20:05 +08:00
槑囿脑袋
56d68276e1 fix(knowledge): force choose knowledge aisdk error (#11006)
fix: aisdk error
2025-10-28 16:37:58 +08:00
Copilot
29c1173365 Fix Qwen3 thinking mode control for Ollama using aiCore middleware (#10947)
* Initial plan

* Fix Qwen3 thinking mode control for Ollama by using reasoning_effort instead of qwenThinkMode

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

* Refactor: Move Qwen thinking mode control from legacy to aiCore middleware

- Revert changes to legacy OpenAIApiClient.ts
- Create new qwenThinkingMiddleware for handling /think and /no_think suffixes
- Update AiSdkMiddlewareBuilder to add assistant field and apply Qwen middleware
- Middleware automatically handles Qwen models on Ollama, LM Studio, and NVIDIA providers
- This follows the modern aiCore architecture instead of deprecated legacy approach

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

* Fix: Explicitly pass assistant field to buildAiSdkMiddlewares

Ensure that the assistant field from ModernAiProviderConfig is explicitly passed
to buildAiSdkMiddlewares so that Qwen thinking middleware can access
assistant.settings.reasoning_effort correctly.

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

* Fix: Remove unnecessary whitespace in processPostsuffixQwen3Model tests

* Refactor: Simplify user message suffix handling in qwenThinkingMiddleware

* Refactor: Remove processPostsuffixQwen3Model tests to streamline ModelMessageService tests

* refactor: remove logger and debug statement from qwenThinkingMiddleware

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>
2025-10-28 14:26:54 +08:00
Jake Jia
c7ceb3035d fix: align and unify LocalBackupManager footer layout (#10985)
* fix: align and unify LocalBackupManager footer layout

- Use Space component to wrap footer buttons, consistent with S3BackupManager
- Optimize delete button i18n text by using count parameter instead of hardcoded concatenation

* fix: fix the i18n issue in the  delete button text
2025-10-28 13:26:53 +08:00
Phantom
7bcae6fba2 fix(Navbar): adjust min-height calculation for fullscreen mode on Mac (#10990)
Ensure the navbar height is correctly calculated when toggling fullscreen mode on macOS by considering the $isFullScreen prop
2025-10-28 10:29:28 +08:00
126 changed files with 9311 additions and 1406 deletions

View File

@@ -29,8 +29,10 @@ jobs:
days-before-close: 0 # Close immediately after stale
stale-issue-label: 'inactive'
close-issue-label: 'closed:no-response'
exempt-all-milestones: true
exempt-all-assignees: true
stale-issue-message: |
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
It will be closed now due to lack of additional information.
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
@@ -46,6 +48,8 @@ jobs:
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: ${{ env.daysBeforeClose }}
stale-issue-label: 'inactive'
exempt-all-milestones: true
exempt-all-assignees: true
stale-issue-message: |
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。

View File

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

View File

@@ -0,0 +1,26 @@
diff --git a/dist/index.js b/dist/index.js
index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4bb355172 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index a032505ec54e132dc386dde001dc51f710f84c83..5efada51b9a8b56e3f01b35e734908ebe3c37043 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -0,0 +1,76 @@
diff --git a/dist/index.js b/dist/index.js
index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f318026d9b2 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)(
message: import_v42.z.object({
role: import_v42.z.literal("assistant").nullish(),
content: import_v42.z.string().nullish(),
+ reasoning_content: import_v42.z.string().nullish(),
tool_calls: import_v42.z.array(
import_v42.z.object({
id: import_v42.z.string().nullish(),
@@ -340,6 +341,7 @@ var openaiChatChunkSchema = (0, import_provider_utils3.lazyValidator)(
delta: import_v42.z.object({
role: import_v42.z.enum(["assistant"]).nullish(),
content: import_v42.z.string().nullish(),
+ reasoning_content: import_v42.z.string().nullish(),
tool_calls: import_v42.z.array(
import_v42.z.object({
index: import_v42.z.number(),
@@ -785,6 +787,14 @@ var OpenAIChatLanguageModel = class {
if (text != null && text.length > 0) {
content.push({ type: "text", text });
}
+ const reasoning =
+ choice.message.reasoning_content;
+ if (reasoning != null && reasoning.length > 0) {
+ content.push({
+ type: 'reasoning',
+ text: reasoning,
+ });
+ }
for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) {
content.push({
type: "tool-call",
@@ -866,6 +876,7 @@ var OpenAIChatLanguageModel = class {
};
let isFirstChunk = true;
let isActiveText = false;
+ let isActiveReasoning = false;
const providerMetadata = { openai: {} };
return {
stream: response.pipeThrough(
@@ -920,6 +931,22 @@ var OpenAIChatLanguageModel = class {
return;
}
const delta = choice.delta;
+ const reasoningContent = delta.reasoning_content;
+ if (reasoningContent) {
+ if (!isActiveReasoning) {
+ controller.enqueue({
+ type: 'reasoning-start',
+ id: 'reasoning-0',
+ });
+ isActiveReasoning = true;
+ }
+
+ controller.enqueue({
+ type: 'reasoning-delta',
+ id: 'reasoning-0',
+ delta: reasoningContent,
+ });
+ }
if (delta.content != null) {
if (!isActiveText) {
controller.enqueue({ type: "text-start", id: "0" });
@@ -1032,6 +1059,9 @@ var OpenAIChatLanguageModel = class {
}
},
flush(controller) {
+ if (isActiveReasoning) {
+ controller.enqueue({ type: 'reasoning-end', id: 'reasoning-0' });
+ }
if (isActiveText) {
controller.enqueue({ type: "text-end", id: "0" });
}

View File

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

View File

@@ -10,8 +10,9 @@ This file provides guidance to AI coding assistants when working with code in th
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Seek review**: Ask a human developer to review substantial changes before merging.
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
- **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.
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, `
📝 docs:`).
## Development Commands

View File

@@ -21,7 +21,11 @@
"quoteStyle": "single"
}
},
"files": { "ignoreUnknown": false },
"files": {
"ignoreUnknown": false,
"includes": ["**"],
"maxSize": 2097152
},
"formatter": {
"attributePosition": "auto",
"bracketSameLine": false,

View File

@@ -0,0 +1,191 @@
# Cherry Studio 快捷键系统重构设计文档 v2.1
> 最近更新2025-01-30
> 维护者Architecture Team
## 目录
- [背景与目标](#背景与目标)
- [核心原则](#核心原则)
- [架构分层](#架构分层)
- [关键实现](#关键实现)
- [数据流](#数据流)
- [默认快捷键](#默认快捷键)
- [迁移与兼容性](#迁移与兼容性)
- [后续演进方向](#后续演进方向)
---
## 背景与目标
旧版快捷键系统存在以下问题:
1. 依赖已弃用的 `configManager`,与 v2 架构不兼容;
2. Redux store 与本地存储重复维护状态;
3. 处理器通过 `switch-case` 硬编码,可维护性差;
4. 快捷键定义分散,缺乏统一真相源;
5. 新增快捷键需要触达多处文件,易错且低效。
新版系统要实现:
- **单一真相源**:快捷键定义集中管理,保证一致性;
- **偏好服务优先**:所有运行时状态通过 `preferenceService` 管理;
- **处理器注册表**:解除 `switch-case` 依赖,改用 Map 注册;
- **类型安全**:从定义、存储到消费全链路具备 TypeScript 约束;
- **易扩展**:新增快捷键仅需「定义 → 注册处理器 → 使用」三步;
- **性能稳定**:支持 100+ 快捷键规模,主/渲染进程高效同步;
- **多窗口同步**:借助 `preferenceService` 自动推送变更。
---
## 核心原则
1. **关注点分离**
- 定义层:静态元数据(名称、默认绑定、作用域、分类等);
- 偏好层:用户可变配置(绑定、启用状态等);
- 服务层:主进程注册、电焦/失焦时的生命周期管理;
- UI 层:设置面板、快捷键提示等。
2. **复用基础设施**
- 所有持久化均依赖 `preferenceService`SQLite + 内存缓存 + IPC
- 变更通过订阅自动广播至所有窗口;
- 新增键位无需改动主进程/渲染进程的底层框架代码。
---
## 架构分层
```
┌──────────────────────────────────────────────┐
│ Shortcut 系统 │
├──────────────────────────────────────────────┤
│ 📋 Definitions (packages/shared/shortcuts) │
│ - types.ts类型、作用域、分类 │
│ - definitions.ts静态定义真相之源
│ - utils.ts转换/校验工具 │
│ │
│ 💾 Preferences (preferenceService) │
│ - preferenceSchemas.ts 默认值 │
│ - preferenceTypes.ts 类型导出 │
│ │
│ ⚙️ Services │
│ - src/main/services/ShortcutService.ts │
│ · 处理器注册表、focus/blur 生命周期 │
│ · preference 订阅、主进程快捷键注册 │
│ - 渲染进程 useShortcut/useShortcutDisplay │
│ │
│ 🎨 UI │
│ - 设置页 ShortcutSettings │
│ - 各功能模块中的 useShortcut/useShortcutDisplay │
└──────────────────────────────────────────────┘
```
---
## 关键实现
### 1. 静态定义
- 所有快捷键在 `packages/shared/shortcuts/definitions.ts` 中集中维护;
- 包含 `scope`main / renderer / both`category``persistOnBlur` 等元信息;
- `enabledWhen` 支持动态启用(如 mini window 与 quick assistant 开关关联);
- 新增快捷键步骤:
1.`preferenceSchemas.ts` 中声明默认值;
2.`definitions.ts` 中补充静态定义;
3. 在主/渲染进程相关模块注册处理器或消费 Hook。
### 2. 偏好系统
- 所有运行时配置通过 `preferenceService` 读写;
- 默认值与 `PreferenceShortcutType` 结构保持一致;
- `ShortcutService` / `useShortcuts` 访问偏好时统一调用 `coerceShortcutPreference`,确保 fallback 与类型安全;
- 批量重置通过 `preferenceService.setMultiple` 实现。
### 3. 主进程服务
- `ShortcutService` 负责:
- 生命周期:随着窗口 focus/blur 注册或卸载快捷键;
- 处理器注册Map 替换 `switch-case`
- 订阅偏好变更:自动重新注册;
- `persistOnBlur`:例如 `show_main_window` 在窗口失焦时仍可触发;
- `shortcut.app.show_settings` 会在需要时唤起窗口并调用 `window.navigate('/settings/provider')`,避免重复 blur/focus。
### 4. 渲染进程 Hook
- `useShortcut`:从偏好获取绑定 → 转为 `react-hotkeys-hook` 字符串 → 注册快捷键;
- `useShortcutDisplay`:转换为 UI 显示字符串(`⌘` / `Ctrl+` 等);
- `useAllShortcuts`:批量拉取配置 + diff 默认值,供设置面板使用;
- 新增 `enableOnContentEditable` 等配置支撑设置页和富文本场景。
### 5. 设置界面
- `ShortcutSettings` 直接消费 `useAllShortcuts`
- 支持录制、清空、重置默认、启用/禁用、冲突检测;
- 重新绑定时使用 `convertKeyToAccelerator` / `isValidShortcut` / `formatShortcutDisplay`
- “重置全部” 通过 `preferenceService.setMultiple` 一次性写入默认配置;
- 新增表格展示 `hasCustomBinding`,区分用户自定义与继承默认值。
---
## 数据流
### 启动阶段
1. `preferenceService.initialize()` 载入缓存;
2. `shortcutService` 构造时注册处理器与订阅;
3. 窗口创建后调用 `shortcutService.registerForWindow`,在 `focus` 时注册主进程快捷键。
### 运行时变更
1. 设置页或其他模块调用 `preferenceService.set` / `setMultiple`
2. 主进程订阅触发 → `globalShortcut.unregisterAll()` → 按新配置重注册;
3. 渲染进程通过 `usePreference`/`useMultiplePreferences` 自动收到更新UI 即时刷新。
---
## 默认快捷键
| preference key | 默认绑定 | 描述 / 备注 |
|----------------------------------------|-----------------------------|--------------------------------------|
| `shortcut.app.show_main_window` | `Cmd/Ctrl + Shift + A` | 主窗口显示(失焦持久) |
| `shortcut.app.show_mini_window` | `Cmd/Ctrl + E` | Mini 窗口(与 quick assistant 联动) |
| `shortcut.app.show_settings` | `Cmd/Ctrl + ,` | 设置页入口 |
| `shortcut.app.toggle_show_assistants` | `Cmd/Ctrl + [` | 助手侧边栏 |
| `shortcut.app.exit_fullscreen` | `Escape` | 系统级,不可编辑 |
| `shortcut.app.zoom_in/out/reset` | `Cmd/Ctrl + = / - / 0` | 包含数字键盘变体 |
| `shortcut.app.search_message` | `Cmd/Ctrl + Shift + F` | 全局搜索 |
| `shortcut.chat.clear` | `Cmd/Ctrl + L` | 清空消息 |
| `shortcut.chat.search_message` | `Cmd/Ctrl + F` | 聊天内搜索 |
| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl + K` | 新上下文 |
| `shortcut.chat.copy_last_message` | `Cmd/Ctrl + Shift + C` | 复制最后一条 |
| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl + Shift + E` | 编辑最后一条用户消息 |
| `shortcut.topic.new` | `Cmd/Ctrl + N` | 新增话题(默认启用) |
| `shortcut.topic.rename` | `Cmd/Ctrl + T` | 重命名话题(默认启用,自 2025-01 调整) |
| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl + ]` | 话题侧边栏 |
| `shortcut.selection.*` | 无默认绑定 | 划词助手开关、取词 |
> 具体配置以 `preferenceSchemas.ts` 为准,可在设置页查看或调整。
---
## 迁移与兼容性
- 已有用户偏好:沿用旧值;新增键(如 `shortcut.topic.rename`)在数据库不存在时继承新默认;
- 旧版 Redux store / `configManager` 已彻底移除;
- `IpcChannel.Shortcuts_Update``window.api.shortcuts.update` 相关逻辑已弃用;
- `PreferenceMigrator` 中保留与旧 keys 的映射,确保升级顺畅。
---
## 后续演进方向
1. **冲突检测增强**:主/渲染进程联动校验冲突并提示;
2. **导入导出**:允许用户批量备份/恢复自定义快捷键;
3. **多作用域绑定**:同一逻辑支持按窗口类型或上下文切换;
4. **可视化录制**:增加「录制模式」避免输入框手动录制;
5. **自动化测试**:补充主进程/渲染进程快捷键单元测试样板。
---
> 如需扩展或有疑问,请联系架构团队或在仓库中提交 Issue。
> 设计文档 v2.1 同步最新实现2025-01包含 `shortcut.topic.rename` 默认启用、`show_settings` 优化等补充说明。

View File

@@ -67,6 +67,10 @@ asarUnpack:
extraResources:
- from: "migrations/sqlite-drizzle"
to: "migrations/sqlite-drizzle"
# copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso
- from: "./node_modules/claude-code-plugins/plugins/"
to: "claude-code-plugins"
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}

View File

@@ -81,7 +81,7 @@
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
@@ -89,6 +89,8 @@
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
@@ -104,8 +106,8 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/amazon-bedrock": "^3.0.42",
"@ai-sdk/google-vertex": "^3.0.48",
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
@@ -198,6 +200,7 @@
"@types/fs-extra": "^11",
"@types/he": "^1",
"@types/html-to-text": "^9",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
@@ -227,7 +230,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.68",
"ai": "^5.0.76",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -237,6 +240,7 @@
"check-disk-space": "3.4.0",
"cheerio": "^1.1.2",
"chokidar": "^4.0.3",
"claude-code-plugins": "1.0.1",
"cli-progress": "^3.12.0",
"clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",
@@ -389,7 +393,8 @@
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.1.5",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch",
"@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.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",

View File

@@ -36,10 +36,10 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/anthropic": "^2.0.32",
"@ai-sdk/azure": "^2.0.53",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.12",

View File

@@ -96,6 +96,10 @@ export enum IpcChannel {
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
AgentMessage_GetHistory = 'agent-message:get-history',
AgentToolPermission_Request = 'agent-tool-permission:request',
AgentToolPermission_Response = 'agent-tool-permission:response',
AgentToolPermission_Result = 'agent-tool-permission:result',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -382,5 +386,14 @@ export enum IpcChannel {
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
Cherryai_GetSignature = 'cherryai:get-signature'
Cherryai_GetSignature = 'cherryai:get-signature',
// Claude Code Plugins
ClaudeCodePlugin_ListAvailable = 'claudeCodePlugin:list-available',
ClaudeCodePlugin_Install = 'claudeCodePlugin:install',
ClaudeCodePlugin_Uninstall = 'claudeCodePlugin:uninstall',
ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed',
ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache',
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content'
}

View File

@@ -373,6 +373,8 @@ export interface PreferenceSchemas {
'shortcut.chat.clear': Record<string, unknown>
// redux/shortcuts/shortcuts.copy_last_message
'shortcut.chat.copy_last_message': Record<string, unknown>
// redux/shortcuts/shortcuts.edit_last_user_message
'shortcut.chat.edit_last_user_message': Record<string, unknown>
// redux/shortcuts/shortcuts.search_message_in_chat
'shortcut.chat.search_message': Record<string, unknown>
// redux/shortcuts/shortcuts.toggle_new_context
@@ -383,6 +385,10 @@ export interface PreferenceSchemas {
'shortcut.selection.toggle_enabled': Record<string, unknown>
// redux/shortcuts/shortcuts.new_topic
'shortcut.topic.new': Record<string, unknown>
// redux/shortcuts/shortcuts.rename_topic
'shortcut.topic.rename': Record<string, unknown>
// redux/shortcuts/shortcuts.toggle_show_topics
'shortcut.topic.toggle_show_topics': Record<string, unknown>
// redux/settings/enableTopicNaming
'topic.naming.enabled': boolean
// redux/settings/topicNamingPrompt
@@ -638,6 +644,12 @@ export const DefaultPreferences: PreferenceSchemas = {
key: ['CommandOrControl', 'Shift', 'C'],
system: false
},
'shortcut.chat.edit_last_user_message': {
editable: true,
enabled: false,
key: ['CommandOrControl', 'Shift', 'E'],
system: false
},
'shortcut.chat.search_message': { editable: true, enabled: true, key: ['CommandOrControl', 'F'], system: false },
'shortcut.chat.toggle_new_context': {
editable: true,
@@ -648,6 +660,18 @@ export const DefaultPreferences: PreferenceSchemas = {
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true },
'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false },
'shortcut.topic.rename': {
editable: true,
enabled: true,
key: ['CommandOrControl', 'T'],
system: false
},
'shortcut.topic.toggle_show_topics': {
editable: true,
enabled: true,
key: ['CommandOrControl', ']'],
system: false
},
'topic.naming.enabled': true,
'topic.naming_prompt': '',
'topic.position': 'left',

View File

@@ -0,0 +1,148 @@
import type { ShortcutCategory, ShortcutDefinition } from './types'
export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
// ==================== 应用级快捷键 ====================
{
key: 'shortcut.app.show_main_window',
defaultKey: ['CommandOrControl', 'Shift', 'A'],
scope: 'main',
category: 'app',
persistOnBlur: true
},
{
key: 'shortcut.app.show_mini_window',
defaultKey: ['CommandOrControl', 'E'],
scope: 'main',
category: 'app',
persistOnBlur: true,
enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled')
},
{
key: 'shortcut.app.show_settings',
defaultKey: ['CommandOrControl', ','],
scope: 'both',
category: 'app'
},
{
key: 'shortcut.app.toggle_show_assistants',
defaultKey: ['CommandOrControl', '['],
scope: 'renderer',
category: 'app'
},
{
key: 'shortcut.app.exit_fullscreen',
defaultKey: ['Escape'],
scope: 'renderer',
category: 'app'
},
{
key: 'shortcut.app.zoom_in',
defaultKey: ['CommandOrControl', '='],
scope: 'main',
category: 'app',
variants: [['CommandOrControl', 'numadd']]
},
{
key: 'shortcut.app.zoom_out',
defaultKey: ['CommandOrControl', '-'],
scope: 'main',
category: 'app',
variants: [['CommandOrControl', 'numsub']]
},
{
key: 'shortcut.app.zoom_reset',
defaultKey: ['CommandOrControl', '0'],
scope: 'main',
category: 'app'
},
{
key: 'shortcut.app.search_message',
defaultKey: ['CommandOrControl', 'Shift', 'F'],
scope: 'renderer',
category: 'app'
},
// ==================== 聊天相关快捷键 ====================
{
key: 'shortcut.chat.clear',
defaultKey: ['CommandOrControl', 'L'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.search_message',
defaultKey: ['CommandOrControl', 'F'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.toggle_new_context',
defaultKey: ['CommandOrControl', 'K'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.copy_last_message',
defaultKey: ['CommandOrControl', 'Shift', 'C'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.edit_last_user_message',
defaultKey: ['CommandOrControl', 'Shift', 'E'],
scope: 'renderer',
category: 'chat'
},
// ==================== 话题管理快捷键 ====================
{
key: 'shortcut.topic.new',
defaultKey: ['CommandOrControl', 'N'],
scope: 'renderer',
category: 'topic'
},
{
key: 'shortcut.topic.rename',
defaultKey: ['CommandOrControl', 'T'],
scope: 'renderer',
category: 'topic'
},
{
key: 'shortcut.topic.toggle_show_topics',
defaultKey: ['CommandOrControl', ']'],
scope: 'renderer',
category: 'topic'
},
// ==================== 划词助手快捷键 ====================
{
key: 'shortcut.selection.toggle_enabled',
defaultKey: [],
scope: 'main',
category: 'selection',
persistOnBlur: true
},
{
key: 'shortcut.selection.get_text',
defaultKey: [],
scope: 'main',
category: 'selection',
persistOnBlur: true
}
] as const
export const getShortcutsByCategory = () => {
const groups: Record<ShortcutCategory, ShortcutDefinition[]> = {
app: [],
chat: [],
topic: [],
selection: []
}
SHORTCUT_DEFINITIONS.forEach((definition) => {
groups[definition.category].push(definition)
})
return groups
}
export const findShortcutDefinition = (key: string): ShortcutDefinition | undefined => {
return SHORTCUT_DEFINITIONS.find((definition) => definition.key === key)
}

View File

@@ -0,0 +1,40 @@
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/preference/preferenceTypes'
import type { BrowserWindow } from 'electron'
export type ShortcutScope = 'main' | 'renderer' | 'both'
export type ShortcutCategory = 'app' | 'chat' | 'topic' | 'selection'
export type ShortcutPreferenceKey = Extract<PreferenceKeyType, `shortcut.${string}`>
export type GetPreferenceFn = <K extends PreferenceKeyType>(key: K) => PreferenceDefaultScopeType[K]
export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boolean
export interface ShortcutDefinition {
key: ShortcutPreferenceKey
defaultKey: string[]
scope: ShortcutScope
category: ShortcutCategory
persistOnBlur?: boolean
variants?: string[][]
enabledWhen?: ShortcutEnabledPredicate
}
export interface ShortcutPreferenceValue {
binding: string[]
rawBinding: string[]
hasCustomBinding: boolean
enabled: boolean
editable: boolean
system: boolean
}
export interface ShortcutRuntimeConfig extends ShortcutDefinition {
binding: string[]
enabled: boolean
editable: boolean
system: boolean
}
export type ShortcutHandler = (window?: BrowserWindow) => void | Promise<void>

View File

@@ -0,0 +1,137 @@
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import type { ShortcutDefinition, ShortcutPreferenceValue } from './types'
const modifierKeys = ['CommandOrControl', 'Ctrl', 'Alt', 'Shift', 'Meta', 'Command']
const specialSingleKeys = ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']
export const convertKeyToAccelerator = (key: string): string => {
const keyMap: Record<string, string> = {
Command: 'CommandOrControl',
Cmd: 'CommandOrControl',
Control: 'Ctrl',
Meta: 'Meta',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
AltGraph: 'AltGr',
Slash: '/',
Semicolon: ';',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Quote: "'",
Comma: ',',
Minus: '-',
Equal: '='
}
return keyMap[key] || key
}
export const convertAcceleratorToHotkey = (accelerator: string[]): string => {
return accelerator
.map((key) => {
switch (key.toLowerCase()) {
case 'commandorcontrol':
return 'mod'
case 'command':
case 'cmd':
return 'meta'
case 'control':
case 'ctrl':
return 'ctrl'
case 'alt':
return 'alt'
case 'shift':
return 'shift'
case 'meta':
return 'meta'
default:
return key.toLowerCase()
}
})
.join('+')
}
export const formatShortcutDisplay = (keys: string[], isMac: boolean): string => {
return keys
.map((key) => {
switch (key.toLowerCase()) {
case 'ctrl':
case 'control':
return isMac ? '⌃' : 'Ctrl'
case 'command':
case 'cmd':
return isMac ? '⌘' : 'Win'
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
case 'alt':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'meta':
return isMac ? '⌘' : 'Win'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
})
.join(isMac ? '' : '+')
}
export const isValidShortcut = (keys: string[]): boolean => {
if (!keys.length) {
return false
}
const hasModifier = keys.some((key) => modifierKeys.includes(key))
const isSpecialKey = keys.length === 1 && specialSingleKeys.includes(keys[0])
return hasModifier || isSpecialKey
}
const ensureArray = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string')
}
return []
}
const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback)
export const getDefaultShortcutPreference = (definition: ShortcutDefinition): ShortcutPreferenceValue => {
const fallback = DefaultPreferences.default[definition.key] as PreferenceShortcutType
const rawBinding = ensureArray(fallback?.key)
const binding = rawBinding.length ? rawBinding : definition.defaultKey
return {
binding,
rawBinding: binding,
hasCustomBinding: false,
enabled: ensureBoolean(fallback?.enabled, true),
editable: ensureBoolean(fallback?.editable, true),
system: ensureBoolean(fallback?.system, false)
}
}
export const coerceShortcutPreference = (
definition: ShortcutDefinition,
value?: PreferenceShortcutType | null
): ShortcutPreferenceValue => {
const fallback = getDefaultShortcutPreference(definition)
const hasCustomBinding = Array.isArray((value as PreferenceShortcutType | undefined)?.key)
const rawBinding = hasCustomBinding ? ensureArray((value as PreferenceShortcutType).key) : fallback.binding
const binding = rawBinding.length > 0 ? rawBinding : fallback.binding
return {
binding,
rawBinding,
hasCustomBinding,
enabled: ensureBoolean(value?.enabled, fallback.enabled),
editable: ensureBoolean(value?.editable, fallback.editable),
system: ensureBoolean(value?.system, fallback.system)
}
}

View File

@@ -0,0 +1,368 @@
# Cherry Studio Design System 集成方案
本文档聚焦三个核心问题:
1. **如何将 todocss.css 集成到 Tailwind CSS v4**
2. **如何在项目中使用集成后的设计系统**
3. **如何平衡 UI 库和主包的需求**
---
## 一、集成策略
### 1.1 文件架构
```
todocss.css (设计师提供)
↓ 转换 & 优化
design-tokens.css (--ds-* 变量)
↓ @theme inline 映射
globals.css (cs-* 工具类)
↓ 开发者使用
React Components
```
### 1.2 核心转换规则
#### 变量简化
```css
/* todocss.css */
--Brand--Base_Colors--Primary: hsla(84, 81%, 44%, 1);
/* ↓ 转换为 design-tokens.css */
--ds-primary: hsla(84, 81%, 44%, 1);
/* ↓ 映射到 globals.css */
@theme inline {
--color-cs-primary: var(--ds-primary);
}
/* ↓ 生成工具类 */
bg-cs-primary, text-cs-primary, border-cs-primary
```
#### 去除冗余
- **间距/尺寸合并**: `--Spacing--md``--Sizing--md` 值相同 → 统一为 `--ds-size-md`
- **透明度废弃**: `--Opacity--Red--Red-80` → 使用 `bg-cs-destructive/80`
- **错误修正**: `--Font_weight--Regular: 400px``--ds-font-weight-regular: 400`
### 1.3 命名规范
| 层级 | 前缀 | 示例 | 用途 |
|------|------|------|------|
| 设计令牌 | `--ds-*` | `--ds-primary` | 定义值 |
| Tailwind 映射 | `--color-cs-*` | `--color-cs-primary` | 生成工具类 |
| 工具类 | `cs-*` | `bg-cs-primary` | 开发者使用 |
#### Tailwind v4 映射规则
| 变量前缀 | 生成的工具类 |
|----------|-------------|
| `--color-cs-*` | `bg-*`, `text-*`, `border-*`, `fill-*` |
| `--spacing-cs-*` | `p-*`, `m-*`, `gap-*` |
| `--size-cs-*` | `w-*`, `h-*`, `size-*` |
| `--radius-cs-*` | `rounded-*` |
| `--font-size-cs-*` | `text-*` |
### 1.4 为什么使用 @theme inline
```css
/* ❌ @theme - 静态编译,不支持运行时主题切换 */
@theme {
--color-primary: var(--ds-primary);
}
/* ✅ @theme inline - 保留变量引用,支持运行时切换 */
@theme inline {
--color-cs-primary: var(--ds-primary);
}
```
**关键差异**`@theme inline` 使 CSS 变量在运行时动态解析,实现明暗主题切换。
---
## 二、项目使用指南
### 2.1 在 UI 库中使用
#### 文件结构
```
packages/ui/
├── src/styles/
│ ├── design-tokens.css # 核心变量定义
│ └── globals.css # Tailwind 集成
└── package.json # 导出配置
```
#### globals.css 示例
```css
@import 'tailwindcss';
@import './design-tokens.css';
@theme inline {
/* 颜色 */
--color-cs-primary: var(--ds-primary);
--color-cs-bg: var(--ds-background);
--color-cs-fg: var(--ds-foreground);
/* 间距 */
--spacing-cs-xs: var(--ds-size-xs);
--spacing-cs-sm: var(--ds-size-sm);
--spacing-cs-md: var(--ds-size-md);
/* 尺寸 */
--size-cs-xs: var(--ds-size-xs);
--size-cs-sm: var(--ds-size-sm);
/* 圆角 */
--radius-cs-sm: var(--ds-radius-sm);
--radius-cs-md: var(--ds-radius-md);
}
@custom-variant dark (&:is(.dark *));
```
#### 组件中使用
```tsx
// packages/ui/src/components/Button.tsx
export const Button = ({ children }) => (
<button className="
bg-cs-primary
text-white
px-cs-sm
py-cs-xs
rounded-cs-md
hover:bg-cs-primary/90
transition-colors
">
{children}
</button>
)
```
### 2.2 在主项目中使用
#### 导入 UI 库样式
```css
/* src/renderer/src/assets/styles/tailwind.css */
@import 'tailwindcss' source('../../../../renderer');
@import '@cherrystudio/ui/styles/globals.css';
@custom-variant dark (&:is(.dark *));
```
#### 覆盖或扩展变量
```css
/* src/renderer/src/assets/styles/tailwind.css */
@import '@cherrystudio/ui/styles/globals.css';
/* 主项目特定覆盖 */
:root {
--ds-primary: #custom-color; /* 覆盖 UI 库的主题色 */
}
```
#### 在主项目组件中使用
```tsx
// src/renderer/src/pages/Home.tsx
export const Home = () => (
<div className="
bg-cs-bg
p-cs-md
rounded-cs-lg
">
<Button></Button>
</div>
)
```
### 2.3 主题切换实现
```tsx
// App.tsx
import { useState } from 'react'
export function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<div className={theme}>
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
</button>
{/* 所有子组件自动响应主题 */}
</div>
)
}
```
### 2.4 透明度修饰符
```tsx
<div className="
bg-cs-primary/10 /* 10% 透明度 */
bg-cs-primary/50 /* 50% 透明度 */
bg-cs-primary/[0.15] /* 自定义透明度 */
">
```
---
## 三、UI 库与主包平衡策略
### 3.1 UI 库职责
**目标**:提供可复用、可定制的基础设计系统
```json
// packages/ui/package.json
{
"exports": {
"./styles/design-tokens.css": "./src/styles/design-tokens.css",
"./styles/globals.css": "./src/styles/globals.css"
}
}
```
**原则**
- ✅ 定义通用的设计令牌(`--ds-*`
- ✅ 提供默认的 Tailwind 映射(`--color-cs-*`
- ✅ 保持变量语义化,不包含业务逻辑
- ❌ 不包含主项目特定的颜色或尺寸
### 3.2 主包职责
**目标**:导入 UI 库,根据业务需求扩展或覆盖
```css
/* src/renderer/src/assets/styles/tailwind.css */
@import '@cherrystudio/ui/styles/globals.css';
/* 主项目扩展 */
@theme inline {
--color-cs-brand-accent: #ff6b6b; /* 新增颜色 */
}
/* 主项目覆盖 */
:root {
--ds-primary: #custom-primary; /* 覆盖 UI 库的主题色 */
}
```
**原则**
- ✅ 导入 UI 库的 `globals.css`
- ✅ 通过覆盖 `--ds-*` 变量定制主题
- ✅ 添加项目特定的 `--color-cs-*` 映射
- ✅ 保留向后兼容的旧变量(如 `color.css`
### 3.3 向后兼容方案
#### 保留旧变量
```css
/* src/renderer/src/assets/styles/color.css */
:root {
--color-primary: #00b96b; /* 旧变量 */
--color-background: #181818; /* 旧变量 */
}
/* 映射到新系统 */
:root {
--ds-primary: var(--color-primary);
--ds-background: var(--color-background);
}
```
#### 渐进式迁移
```tsx
// 阶段 1旧代码继续工作
<div style={{ color: 'var(--color-primary)' }}></div>
// 阶段 2新代码使用工具类
<div className="text-cs-primary"></div>
// 阶段 3逐步替换旧代码
```
### 3.4 冲突处理
| 场景 | 策略 |
|------|------|
| UI 库与 Tailwind 默认类冲突 | 使用 `cs-` 前缀隔离 |
| 主包需要覆盖 UI 库颜色 | 覆盖 `--ds-*` 变量 |
| 主包需要新增颜色 | 添加新的 `--color-cs-*` 映射 |
| 旧变量与新系统共存 | 通过 `var()` 映射到 `--ds-*` |
### 3.5 独立发布 UI 库
```json
// packages/ui/package.json
{
"name": "@cherrystudio/ui",
"exports": {
"./styles/design-tokens.css": "./src/styles/design-tokens.css",
"./styles/globals.css": "./src/styles/globals.css"
},
"peerDependencies": {
"tailwindcss": "^4.1.13"
}
}
```
**外部项目使用**
```css
/* 其他项目的 tailwind.css */
@import 'tailwindcss';
@import '@cherrystudio/ui/styles/globals.css';
/* 覆盖主题色 */
:root {
--ds-primary: #your-brand-color;
}
```
---
## 四、完整映射示例
### todocss.css → design-tokens.css
| todocss.css | design-tokens.css | 说明 |
|-------------|-------------------|------|
| `--Brand--Base_Colors--Primary` | `--ds-primary` | 简化命名 |
| `--Spacing--md` + `--Sizing--md` | `--ds-size-md` | 合并重复 |
| `--Opacity--Red--Red-80` | *(删除)* | 使用 `/80` 修饰符 |
| `--Font_weight--Regular: 400px` | `--ds-font-weight-regular: 400` | 修正错误 |
| `--Brand--UI_Element_Colors--Primary_Button--Background` | `--ds-btn-primary` | 简化语义 |
### design-tokens.css → globals.css → 工具类
| design-tokens.css | globals.css | 工具类 |
|-------------------|-------------|--------|
| `--ds-primary` | `--color-cs-primary` | `bg-cs-primary` |
| `--ds-size-md` | `--spacing-cs-md` | `p-cs-md` |
| `--ds-size-md` | `--size-cs-md` | `w-cs-md` |
| `--ds-radius-lg` | `--radius-cs-lg` | `rounded-cs-lg` |
---
## 五、关键决策记录
1. **使用 `@theme inline`** - 支持运行时主题切换
2. **`cs-` 前缀** - 命名空间隔离,避免冲突
3. **合并 Spacing/Sizing** - 消除冗余
4. **废弃 Opacity 变量** - 使用 Tailwind 的 `/modifier` 语法
5. **双层变量系统** - `--ds-*` (定义) → `--color-cs-*` (映射)
6. **共存策略** - Tailwind 默认类 + `cs-` 品牌类

View File

@@ -1,4 +1,26 @@
# UI Component Library Migration Status
# Cherry Studio UI Migration Plan
## Overview
This document outlines the detailed plan for migrating Cherry Studio from antd + styled-components to shadcn/ui + Tailwind CSS. We will adopt a progressive migration strategy to ensure system stability and development efficiency, while gradually implementing UI refactoring in collaboration with UI designers.
## Migration Strategy
### Target Tech Stack
- **UI Component Library**: shadcn/ui (replacing antd and previously migrated HeroUI)
- **Styling Solution**: Tailwind CSS (replacing styled-components)
- **Design System**: Custom CSS variable system (see [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md))
- **Theme System**: CSS variables + shadcn/ui theme
### Migration Principles
1. **Backward Compatibility**: Old components continue working until new components are fully available
2. **Progressive Migration**: Migrate components one by one to avoid large-scale rewrites
3. **Feature Parity**: Ensure new components have all the functionality of old components
4. **Design Consistency**: Follow new design system specifications (see DESIGN_SYSTEM.md)
5. **Performance Priority**: Optimize bundle size and rendering performance
6. **Designer Collaboration**: Work with UI designers for gradual component encapsulation and UI optimization
## Usage Example
@@ -24,115 +46,68 @@ function MyComponent() {
@packages/ui/
├── src/
│ ├── components/ # Main components directory
│ │ ├── base/ # Basic components (buttons, inputs, labels, etc.)
│ │ ├── display/ # Display components (cards, lists, tables, etc.)
│ │ ├── layout/ # Layout components (containers, grids, spacing, etc.)
│ │ ── icons/ # Icon components
│ │ ├── interactive/ # Interactive components (modals, tooltips, dropdowns, etc.)
│ │ └── composite/ # Composite components (made from multiple base components)
│ │ ├── primitives/ # Basic/primitive components (Avatar, ErrorBoundary, Selector, etc.)
│ │ │ └── shadcn-io/ # shadcn/ui components (dropzone, etc.)
│ │ ├── icons/ # Icon components (Icon, FileIcons, etc.)
│ │ ── composites/ # Composite components (CodeEditor, ListItem, etc.)
│ ├── hooks/ # Custom React Hooks
── types/ # TypeScript type definitions
── styles/ # Global styles and CSS variables
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions
│ └── index.ts # Main export file
```
### Component Classification Guide
When submitting PRs, please place components in the correct directory based on their function:
- **base**: Most basic UI elements like buttons, inputs, switches, labels, etc.
- **display**: Components for displaying content like cards, lists, tables, tabs, etc.
- **layout**: Components for page layout like containers, grid systems, dividers, etc.
- **primitives**: Basic and primitive UI elements, shadcn/ui components
- `Avatar`: Avatar components
- `ErrorBoundary`: Error boundary components
- `Selector`: Selection components
- `shadcn-io/`: Direct shadcn/ui components or adaptations
- **icons**: All icon-related components
- **interactive**: Components requiring user interaction like modals, drawers, tooltips, dropdowns, etc.
- **composite**: Composite components made from multiple base components
- `Icon`: Icon factory and basic icons
- `FileIcons`: File-specific icons
- Loading/spinner icons (SvgSpinners180Ring, ToolsCallingIcon, etc.)
- **composites**: Complex components made from multiple primitives
- `CodeEditor`: Code editing components
- `ListItem`: List item components
- `ThinkingEffect`: Animation components
- Form and interaction components (DraggableList, EditableNumber, etc.)
## Migration Overview
## Component Extraction Criteria
- **Total Components**: 236
- **Migrated**: 34
- **Refactored**: 18
- **Pending Migration**: 184
### Extraction Standards
## Component Status Table
1. **Usage Frequency**: Component is used in ≥ 3 places in the codebase
2. **Future Reusability**: Expected to be used in multiple scenarios in the future
3. **Business Complexity**: Component contains complex interaction logic or state management
4. **Maintenance Cost**: Centralized management can reduce maintenance overhead
5. **Design Consistency**: Components that require unified visual and interaction experience
6. **Test Coverage**: As common components, they facilitate unit test writing and maintenance
| Category | Component Name | Migration Status | Refactoring Status | Description |
| ----------------- | ------------------------- | ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **base** | | | | Base components |
| | CopyButton | ✅ | ✅ | Copy button |
| | CustomTag | ✅ | ✅ | Custom tag |
| | DividerWithText | ✅ | ✅ | Divider with text |
| | EmojiIcon | ✅ | ✅ | Emoji icon |
| | ErrorBoundary | ✅ | ✅ | Error boundary (decoupled via props) |
| | StatusTag | ✅ | ✅ | Unified status tag (merged ErrorTag, SuccessTag, WarnTag, InfoTag) |
| | IndicatorLight | ✅ | ✅ | Indicator light |
| | Spinner | ✅ | ✅ | Loading spinner |
| | TextBadge | ✅ | ✅ | Text badge |
| | CustomCollapse | ✅ | ✅ | Custom collapse panel |
| **display** | | | | Display components |
| | Ellipsis | ✅ | ✅ | Text ellipsis |
| | ExpandableText | ✅ | ✅ | Expandable text |
| | ThinkingEffect | ✅ | ✅ | Thinking effect animation |
| | EmojiAvatar | ✅ | ✅ | Emoji avatar |
| | ListItem | ✅ | ✅ | List item |
| | MaxContextCount | ✅ | ✅ | Max context count display |
| | ProviderAvatar | ✅ | ✅ | Provider avatar |
| | CodeViewer | ❌ | ❌ | Code viewer (external deps) |
| | OGCard | ❌ | ❌ | OG card |
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown renderer |
| | Preview/* | ❌ | ❌ | Preview components |
| **layout** | | | | Layout components |
| | HorizontalScrollContainer | ✅ | ❌ | Horizontal scroll container |
| | Scrollbar | ✅ | ❌ | Scrollbar |
| | Layout/* | ✅ | ✅ | Layout components |
| | Tab/* | ❌ | ❌ | Tab (Redux dependency) |
| | TopView | ❌ | ❌ | Top view (window.api dependency) |
| **icons** | | | | Icon components |
| | Icon | ✅ | ✅ | Icon factory function and predefined icons (merged CopyIcon, DeleteIcon, EditIcon, RefreshIcon, ResetIcon, ToolIcon, VisionIcon, WebSearchIcon, WrapIcon, UnWrapIcon, OcrIcon) |
| | FileIcons | ✅ | ❌ | File icons (FileSvgIcon, FilePngIcon) |
| | ReasoningIcon | ✅ | ❌ | Reasoning icon |
| | SvgSpinners180Ring | ✅ | ❌ | Spinner loading icon |
| | ToolsCallingIcon | ✅ | ❌ | Tools calling icon |
| **interactive** | | | | Interactive components |
| | InfoTooltip | ✅ | ❌ | Info tooltip |
| | HelpTooltip | ✅ | ❌ | Help tooltip |
| | WarnTooltip | ✅ | ❌ | Warning tooltip |
| | EditableNumber | ✅ | ❌ | Editable number |
| | InfoPopover | ✅ | ❌ | Info popover |
| | CollapsibleSearchBar | ✅ | ❌ | Collapsible search bar |
| | ImageToolButton | ✅ | ❌ | Image tool button |
| | DraggableList | ✅ | ❌ | Draggable list |
| | CodeEditor | ✅ | ❌ | Code editor |
| | EmojiPicker | ❌ | ❌ | Emoji picker (useTheme dependency) |
| | Selector | ✅ | ❌ | Selector (i18n dependency) |
| | ModelSelector | ❌ | ❌ | Model selector (Redux dependency) |
| | LanguageSelect | ❌ | ❌ | Language select |
| | TranslateButton | ❌ | ❌ | Translate button (window.api dependency) |
| **composite** | | | | Composite components |
| | - | - | - | No composite components yet |
| **Uncategorized** | | | | Components needing categorization |
| | Popups/* (16+ files) | ❌ | ❌ | Popup components (business coupled) |
| | RichEditor/* (30+ files) | ❌ | ❌ | Rich text editor |
| | MarkdownEditor/* | ❌ | ❌ | Markdown editor |
| | MinApp/* | ❌ | ❌ | Mini app (Redux dependency) |
| | Avatar/* | ❌ | ❌ | Avatar components |
| | ActionTools/* | ❌ | ❌ | Action tools |
| | CodeBlockView/* | ❌ | ❌ | Code block view (window.api dependency) |
| | ContextMenu | ❌ | ❌ | Context menu (Electron API) |
| | WindowControls | ❌ | ❌ | Window controls (Electron API) |
| | ErrorBoundary | ❌ | ❌ | Error boundary (window.api dependency) |
### Extraction Principles
- **Single Responsibility**: Each component should only handle one clear function
- **Highly Configurable**: Provide flexible configuration options through props
- **Backward Compatible**: New versions maintain API backward compatibility
- **Complete Documentation**: Provide clear API documentation and usage examples
- **Type Safety**: Use TypeScript to ensure type safety
### Cases Not Recommended for Extraction
- Simple display components used only on a single page
- Overly customized business logic components
- Components tightly coupled to specific data sources
## Migration Steps
### Phase 1: Copy Migration (Current Phase)
- Copy components as-is to @packages/ui
- Retain original dependencies (antd, styled-components, etc.)
- Add original path comment at file top
### Phase 2: Refactor and Optimize
- Remove antd dependencies, replace with HeroUI
- Remove styled-components, replace with Tailwind CSS
- Optimize component APIs and type definitions
| Phase | Status | Main Tasks | Description |
| --- | --- | --- | --- |
| **Phase 1** | 🚧 **In Progress** | **Design System Integration** | • Integrate design system CSS variables (todocss.css → design-tokens.css → globals.css)<br>• Configure Tailwind CSS to use custom design tokens<br>• Establish basic style guidelines and theme system |
| **Phase 2** | ⏳ **To Start** | **Component Migration and Optimization** | • Filter components for migration based on extraction criteria<br>• Remove antd dependencies, replace with shadcn/ui<br>• Remove HeroUI dependencies, replace with shadcn/ui<br>• Remove styled-components, replace with Tailwind CSS + design system variables<br>• Optimize component APIs and type definitions |
| **Phase 3** | ⏳ **To Start** | **UI Refactoring and Optimization** | • Gradually implement UI refactoring with UI designers<br>• Ensure visual consistency and user experience<br>• Performance optimization and code quality improvement |
## Notes
@@ -143,9 +118,27 @@ When submitting PRs, please place components in the correct directory based on t
2. **Can migrate** but need decoupling later:
- Components using i18n (change i18n to props)
- Components using antd (replace with HeroUI later)
- Components using antd (replace with shadcn/ui later)
- Components using HeroUI (replace with shadcn/ui later)
3. **Submission Guidelines**:
- Each PR should focus on one category of components
- Ensure all migrated components are exported
- Update migration status in this document
- Follow component extraction criteria, only migrate qualified components
## Design System Integration
### CSS Variable System
- Refer to [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) for complete design system planning
- Design variables will be managed through CSS variable system, naming conventions TBD
- Support theme switching and responsive design
### Migration Priority Adjustment
1. **High Priority**: Basic components (buttons, inputs, tags, etc.)
2. **Medium Priority**: Display components (cards, lists, tables, etc.)
3. **Low Priority**: Composite components and business-coupled components
### UI Designer Collaboration
- All component designs need confirmation from UI designers
- Gradually implement UI refactoring to maintain visual consistency
- New components must comply with design system specifications

870
packages/ui/todocss.css Normal file
View File

@@ -0,0 +1,870 @@
:root {
/* Typography: Desktop mode */
--Font_family--Heading: Inter;
--Font_weight--Regular: 400px;
--Font_size--Heading--2xl: 60px;
--Font_size--Heading--xl: 48px;
--Font_size--Heading--lg: 40px;
--Font_size--Heading--md: 32px;
--Font_size--Heading--sm: 24px;
--Font_size--Heading--xs: 20px;
--Line_height--Heading--xl: 80px;
--Line_height--Body--lg: 28px;
--Line_height--Body--md: 24px;
--Line_height--Body--sm: 24px;
--Line_height--Body--xs: 20px;
--Paragraph_spacing--Body--lg: 18px;
--Paragraph_spacing--Body--md: 16px;
--Paragraph_spacing--Body--sm: 14px;
--Paragraph_spacing--Body--xs: 12px;
--Line_height--Heading--lg: 60px;
--Line_height--Heading--md: 48px;
--Line_height--Heading--sm: 40px;
--Line_height--Heading--xs: 32px;
--Font_size--Body--lg: 18px;
--Font_size--Body--md: 16px;
--Font_size--Body--sm: 14px;
--Font_size--Body--xs: 12px;
--Font_weight--Italic: 400px;
--Font_weight--Medium: 500px;
--Font_weight--Bold: 700px;
--Font_family--Body: Inter;
--Paragraph_spacing--Heading--2xl: 60px;
--Paragraph_spacing--Heading--xl: 48px;
--Paragraph_spacing--Heading--lg: 40px;
--Paragraph_spacing--Heading--md: 32px;
--Paragraph_spacing--Heading--sm: 24px;
--Paragraph_spacing--Heading--xs: 20px;
--typography_components--h1--font-family: Inter;
--typography_components--h2--font-family: Inter;
--typography_components--h2--font-size: 30px;
--typography_components--h2--line-height: 36px;
--typography_components--h2--font-weight: 600;
--typography_components--h2--letter-spacing: -0.4000000059604645px;
--typography_components--h1--font-size: 36px;
--typography_components--h1--font-size-lg: 48px;
--typography_components--h1--line-height: 40px;
--typography_components--h1--font-weight: 800;
--typography_components--h1--letter-spacing: -0.4000000059604645px;
--typography_components--h3--font-family: Inter;
--typography_components--h3--font-size: 24px;
--typography_components--h3--line-height: 32px;
--typography_components--h3--font-weight: 600;
--typography_components--h3--letter-spacing: -0.4000000059604645px;
--typography_components--h4--font-family: Inter;
--typography_components--h4--font-size: 20px;
--typography_components--h4--line-height: 28px;
--typography_components--h4--font-weight: 600;
--typography_components--h4--letter-spacing: -0.4000000059604645px;
--typography_components--p--font-family: Inter;
--typography_components--p--font-size: 16px;
--typography_components--p--line-height: 28px;
--typography_components--p--font-weight: 400;
--typography_components--p--letter-spacing: 0px;
--typography_components--blockquote--font-family: Inter;
--typography_components--blockquote--font-size: 16px;
--typography_components--blockquote--line-height: 24px;
--typography_components--blockquote--letter-spacing: 0px;
--typography_components--blockquote--font-style: italic;
--typography_components--list--font-family: Inter;
--typography_components--list--font-size: 16px;
--typography_components--list--line-height: 28px;
--typography_components--list--letter-spacing: 0px;
--typography_components--inline_code--font-family: Menlo;
--typography_components--inline_code--font-size: 14px;
--typography_components--inline_code--line-height: 20px;
--typography_components--inline_code--font-weight: 600;
--typography_components--inline_code--letter-spacing: 0px;
--typography_components--lead--font-family: Inter;
--typography_components--lead--font-size: 20px;
--typography_components--lead--line-height: 28px;
--typography_components--lead--font-weight: 400;
--typography_components--lead--letter-spacing: 0px;
--typography_components--large--font-family: Inter;
--typography_components--large--font-size: 18px;
--typography_components--large--line-height: 28px;
--typography_components--large--font-weight: 600;
--typography_components--large--letter-spacing: 0px;
--typography_components--small--font-family: Inter;
--typography_components--small--font-size: 14px;
--typography_components--small--line-height: 14px;
--typography_components--small--font-weight: 500;
--typography_components--table--font-family: Inter;
--typography_components--table--font-size: 16px;
--typography_components--table--font-weight: 400;
--typography_components--table--font-weight-bold: 700;
--typography_components--table--letter-spacing: 0px;
/* Spacing and sizing: Desktop */
--Border_width--sm: 1px;
--Border_width--md: 2px;
--Border_width--lg: 3px;
--Radius--4xs: 4px;
--Radius--3xs: 8px;
--Radius--2xs: 12px;
--Radius--xs: 16px;
--Radius--sm: 24px;
--Radius--md: 32px;
--Radius--lg: 40px;
--Radius--xl: 48px;
--Radius--2xl: 56px;
--Radius--3xl: 64px;
--Radius--round: 999px;
--Spacing--5xs: 4px;
--Spacing--4xs: 8px;
--Spacing--3xs: 12px;
--Spacing--2xs: 16px;
--Spacing--xs: 24px;
--Spacing--sm: 32px;
--Spacing--md: 40px;
--Spacing--lg: 48px;
--Spacing--xl: 56px;
--Spacing--2xl: 64px;
--Spacing--3xl: 72px;
--Spacing--4xl: 80px;
--Spacing--5xl: 88px;
--Spacing--6xl: 96px;
--Spacing--7xl: 104px;
--Spacing--8xl: 112px;
--Sizing--5xs: 4px;
--Sizing--4xs: 8px;
--Sizing--3xs: 12px;
--Sizing--2xs: 16px;
--Sizing--xs: 24px;
--Sizing--sm: 32px;
--Sizing--md: 40px;
--Sizing--lg: 48px;
--Sizing--xl: 56px;
--Sizing--2xl: 64px;
--Sizing--3xl: 72px;
--Sizing--4xl: 80px;
--Sizing--5xl: 88px;
/* Color: Light mode */
--Opacity--Red--Red-100: var(--Primitive--Red--600);
--Opacity--Red--Red-80: hsla(0, 72%, 51%, 0.8);
--Opacity--Red--Red-60: hsla(0, 72%, 51%, 0.6);
--Opacity--Red--Red-40: hsla(0, 72%, 51%, 0.4);
--Opacity--Red--Red-20: hsla(0, 72%, 51%, 0.2);
--Opacity--Red--Red-10: hsla(0, 72%, 51%, 0.1);
--Opacity--Green--Green-100: var(--Primitive--Green--600);
--Opacity--Green--Green-80: hsla(142, 76%, 36%, 0.8);
--Opacity--Green--Green-60: hsla(142, 76%, 36%, 0.6);
--Opacity--Green--Green-40: hsla(142, 76%, 36%, 0.4);
--Opacity--Green--Green-20: hsla(142, 76%, 36%, 0.2);
--Opacity--Green--Green-10: hsla(142, 76%, 36%, 0.1);
--Opacity--Yellow--Yellow-100: var(--Primitive--Amber--400);
--Opacity--Yellow--Yellow-80: hsla(48, 96%, 53%, 0.8);
--Opacity--Yellow--Yellow-60: hsla(48, 96%, 53%, 0.6);
--Opacity--Yellow--Yellow-40: hsla(48, 96%, 53%, 0.4);
--Opacity--Yellow--Yellow-20: hsla(48, 96%, 53%, 0.2);
--Opacity--Yellow--Yellow-10: hsla(48, 96%, 53%, 0.1);
--Opacity--Violet--Violet-100: var(--Primitive--Violet--500);
--Opacity--Violet--Violet-80: hsla(258, 90%, 66%, 0.8);
--Opacity--Violet--Violet-60: hsla(258, 90%, 66%, 0.6);
--Opacity--Violet--Violet-40: hsla(258, 90%, 66%, 0.4);
--Opacity--Violet--Violet-20: hsla(258, 90%, 66%, 0.2);
--Opacity--Violet--Violet-10: hsla(258, 90%, 66%, 0.1);
--Opacity--Indigo--Indigo-100: var(--Primitive--Indigo--500);
--Opacity--Indigo--Indigo-80: hsla(239, 84%, 67%, 0.8);
--Opacity--Indigo--Indigo-60: hsla(239, 84%, 67%, 0.6);
--Opacity--Indigo--Indigo-40: hsla(239, 84%, 67%, 0.4);
--Opacity--Indigo--Indigo-20: hsla(239, 84%, 67%, 0.2);
--Opacity--Indigo--Indigo-10: hsla(239, 84%, 67%, 0.1);
--Opacity--Blue--Blue-100: var(--Primitive--Blue--500);
--Opacity--Blue--Blue-80: hsla(217, 91%, 60%, 0.8);
--Opacity--Blue--Blue-60: hsla(217, 91%, 60%, 0.6);
--Opacity--Blue--Blue-40: hsla(217, 91%, 60%, 0.4);
--Opacity--Blue--Blue-20: hsla(217, 91%, 60%, 0.2);
--Opacity--Blue--Blue-10: hsla(217, 91%, 60%, 0.1);
--Opacity--Grey--Grey-100: var(--Primitive--Gray--500);
--Opacity--Grey--Grey-80: hsla(220, 9%, 46%, 0.8);
--Opacity--Grey--Grey-60: hsla(220, 9%, 46%, 0.6);
--Opacity--Grey--Grey-40: hsla(220, 9%, 46%, 0.4);
--Opacity--Grey--Grey-20: hsla(220, 9%, 46%, 0.2);
--Opacity--Grey--Grey-10: hsla(220, 9%, 46%, 0.1);
--Opacity--White--White-100: var(--Primitive--White);
--Opacity--White--White-80: hsla(0, 0%, 100%, 0.8);
--Opacity--White--White-60: hsla(0, 0%, 100%, 0.6);
--Opacity--White--White-40: hsla(0, 0%, 100%, 0.4);
--Opacity--White--White-20: hsla(0, 0%, 100%, 0.2);
--Opacity--White--White-10: hsla(0, 0%, 100%, 0.1);
--Opacity--White--White-0: hsla(0, 0%, 100%, 0);
--Status--Error--colorErrorBg: var(--color--Red--50);
--Status--Error--colorErrorBgHover: var(--color--Red--100);
--Status--Error--colorErrorBorder: var(--color--Red--200);
--Status--Error--colorErrorBorderHover: var(--color--Red--300);
--Status--Error--colorErrorBase: var(--color--Red--500);
--Status--Error--colorErrorActive: var(--color--Red--600);
--Status--Error--colorErrorTextHover: var(--color--Red--700);
--Status--Error--colorErrorText: var(--color--Red--800);
--Status--Success--colorSuccessBg: var(--color--Green--50);
--Status--Success--colorSuccessBgHover: var(--color--Green--100);
--Status--Success--colorSuccessBase: var(--color--Green--500);
--Status--Success--colorSuccessTextHover: var(--color--Green--700);
--Status--Warning--colorWarningBg: var(--color--Yellow--50);
--Status--Warning--colorWarningBgHover: var(--color--Yellow--100);
--Status--Warning--colorWarningBase: var(--color--Yellow--500);
--Status--Warning--colorWarningActive: var(--color--Yellow--600);
--Status--Warning--colorWarningTextHover: var(--color--Yellow--700);
--Primitive--Black: hsla(0, 0%, 0%, 1);
--Primitive--White: hsla(0, 0%, 100%, 1);
--Brand--Base_Colors--Primary: var(--Primitive--Lime--500);
--Primitive--Neutral--50: hsla(0, 0%, 98%, 1);
--Primitive--Neutral--100: hsla(0, 0%, 96%, 1);
--Primitive--Neutral--200: hsla(0, 0%, 90%, 1);
--Primitive--Neutral--300: hsla(0, 0%, 83%, 1);
--Primitive--Neutral--400: hsla(0, 0%, 64%, 1);
--Primitive--Neutral--500: hsla(0, 0%, 45%, 1);
--Primitive--Neutral--600: hsla(215, 14%, 34%, 1);
--Primitive--Neutral--700: hsla(0, 0%, 25%, 1);
--Primitive--Neutral--800: hsla(0, 0%, 15%, 1);
--Primitive--Neutral--900: hsla(0, 0%, 9%, 1);
--Primitive--Neutral--950: hsla(0, 0%, 4%, 1);
--Primitive--Stone--50: hsla(60, 9%, 98%, 1);
--Primitive--Stone--100: hsla(60, 5%, 96%, 1);
--Primitive--Stone--200: hsla(20, 6%, 90%, 1);
--Primitive--Stone--300: hsla(24, 6%, 83%, 1);
--Primitive--Stone--400: hsla(24, 5%, 64%, 1);
--Primitive--Stone--500: hsla(25, 5%, 45%, 1);
--Primitive--Stone--600: hsla(33, 5%, 32%, 1);
--Primitive--Stone--700: hsla(30, 6%, 25%, 1);
--Primitive--Stone--800: hsla(12, 6%, 15%, 1);
--Primitive--Stone--900: hsla(24, 10%, 10%, 1);
--Primitive--Stone--950: hsla(20, 14%, 4%, 1);
--Primitive--Zinc--50: hsla(0, 0%, 98%, 1);
--Primitive--Zinc--100: hsla(240, 5%, 96%, 1);
--Primitive--Zinc--200: hsla(240, 6%, 90%, 1);
--Primitive--Zinc--300: hsla(240, 5%, 84%, 1);
--Primitive--Zinc--400: hsla(240, 5%, 65%, 1);
--Primitive--Zinc--500: hsla(240, 4%, 46%, 1);
--Primitive--Zinc--600: hsla(240, 5%, 34%, 1);
--Primitive--Zinc--700: hsla(240, 5%, 26%, 1);
--Primitive--Zinc--800: hsla(240, 4%, 16%, 1);
--Primitive--Zinc--900: hsla(240, 6%, 10%, 1);
--Primitive--Zinc--950: hsla(240, 10%, 4%, 1);
--Primitive--Slate--50: hsla(210, 40%, 98%, 1);
--Primitive--Slate--100: hsla(210, 40%, 96%, 1);
--Primitive--Slate--200: hsla(214, 32%, 91%, 1);
--Primitive--Slate--300: hsla(213, 27%, 84%, 1);
--Primitive--Slate--400: hsla(215, 20%, 65%, 1);
--Primitive--Slate--500: hsla(215, 16%, 47%, 1);
--Primitive--Slate--600: hsla(215, 19%, 35%, 1);
--Primitive--Slate--700: hsla(215, 25%, 27%, 1);
--Primitive--Slate--800: hsla(217, 33%, 17%, 1);
--Primitive--Slate--900: hsla(222, 47%, 11%, 1);
--Primitive--Slate--950: hsla(229, 84%, 5%, 1);
--Primitive--Gray--50: hsla(210, 20%, 98%, 1);
--Primitive--Gray--100: hsla(220, 14%, 96%, 1);
--Primitive--Gray--200: hsla(220, 13%, 91%, 1);
--Primitive--Gray--300: hsla(216, 12%, 84%, 1);
--Primitive--Gray--400: hsla(218, 11%, 65%, 1);
--Primitive--Gray--500: hsla(220, 9%, 46%, 1);
--Primitive--Gray--600: hsla(0, 0%, 32%, 1);
--Primitive--Gray--700: hsla(217, 19%, 27%, 1);
--Primitive--Gray--800: hsla(215, 28%, 17%, 1);
--Primitive--Gray--900: hsla(221, 39%, 11%, 1);
--Primitive--Gray--950: hsla(224, 71%, 4%, 1);
--Primitive--Red--50: hsla(0, 86%, 97%, 1);
--Primitive--Red--100: hsla(0, 93%, 94%, 1);
--Primitive--Red--200: hsla(0, 96%, 89%, 1);
--Primitive--Red--300: hsla(0, 94%, 82%, 1);
--Primitive--Red--400: hsla(0, 91%, 71%, 1);
--Primitive--Red--500: hsla(0, 84%, 60%, 1);
--Primitive--Red--600: hsla(0, 72%, 51%, 1);
--Primitive--Red--700: hsla(0, 74%, 42%, 1);
--Primitive--Red--800: hsla(0, 70%, 35%, 1);
--Primitive--Red--900: hsla(0, 63%, 31%, 1);
--Primitive--Red--950: hsla(0, 75%, 15%, 1);
--Primitive--Orange--50: hsla(33, 100%, 96%, 1);
--Primitive--Orange--100: hsla(34, 100%, 92%, 1);
--Primitive--Orange--200: hsla(32, 98%, 83%, 1);
--Primitive--Orange--300: hsla(31, 97%, 72%, 1);
--Primitive--Orange--400: hsla(27, 96%, 61%, 1);
--Primitive--Orange--500: hsla(25, 95%, 53%, 1);
--Primitive--Orange--600: hsla(21, 90%, 48%, 1);
--Primitive--Orange--700: hsla(17, 88%, 40%, 1);
--Primitive--Orange--800: hsla(15, 79%, 34%, 1);
--Primitive--Orange--900: hsla(15, 75%, 28%, 1);
--Primitive--Orange--950: hsla(13, 81%, 15%, 1);
--Primitive--Amber--50: hsla(48, 100%, 96%, 1);
--Primitive--Amber--100: hsla(48, 96%, 89%, 1);
--Primitive--Amber--200: hsla(48, 97%, 77%, 1);
--Primitive--Amber--300: hsla(46, 97%, 65%, 1);
--Primitive--Amber--400: hsla(43, 96%, 56%, 1);
--Primitive--Amber--500: hsla(38, 92%, 50%, 1);
--Primitive--Amber--600: hsla(32, 95%, 44%, 1);
--Primitive--Amber--700: hsla(26, 90%, 37%, 1);
--Primitive--Amber--800: hsla(23, 83%, 31%, 1);
--Primitive--Amber--900: hsla(22, 78%, 26%, 1);
--Primitive--Amber--950: hsla(21, 92%, 14%, 1);
--Primitive--Yellow--50: hsla(55, 92%, 95%, 1);
--Primitive--Yellow--100: hsla(55, 97%, 88%, 1);
--Primitive--Yellow--200: hsla(53, 98%, 77%, 1);
--Primitive--Yellow--300: hsla(50, 98%, 64%, 1);
--Primitive--Yellow--400: hsla(48, 96%, 53%, 1);
--Primitive--Yellow--500: hsla(45, 93%, 47%, 1);
--Primitive--Yellow--600: hsla(41, 96%, 40%, 1);
--Primitive--Yellow--700: hsla(35, 92%, 33%, 1);
--Primitive--Yellow--800: hsla(32, 81%, 29%, 1);
--Primitive--Yellow--900: hsla(28, 73%, 26%, 1);
--Primitive--Yellow--950: hsla(26, 83%, 14%, 1);
--Primitive--Lime--50: hsla(78, 92%, 95%, 1);
--Primitive--Lime--100: hsla(80, 89%, 89%, 1);
--Primitive--Lime--200: hsla(81, 88%, 80%, 1);
--Primitive--Lime--300: hsla(82, 85%, 67%, 1);
--Primitive--Lime--400: hsla(83, 78%, 55%, 1);
--Primitive--Lime--500: hsla(84, 81%, 44%, 1);
--Primitive--Lime--600: hsla(85, 85%, 35%, 1);
--Primitive--Lime--700: hsla(86, 78%, 27%, 1);
--Primitive--Lime--800: hsla(86, 69%, 23%, 1);
--Primitive--Lime--900: hsla(88, 61%, 20%, 1);
--Primitive--Lime--950: hsla(89, 80%, 10%, 1);
--Primitive--Green--50: hsla(138, 76%, 97%, 1);
--Primitive--Green--100: hsla(141, 84%, 93%, 1);
--Primitive--Green--200: hsla(141, 79%, 85%, 1);
--Primitive--Green--300: hsla(142, 77%, 73%, 1);
--Primitive--Green--400: hsla(142, 69%, 58%, 1);
--Primitive--Green--500: hsla(142, 71%, 45%, 1);
--Primitive--Green--600: hsla(142, 76%, 36%, 1);
--Primitive--Green--700: hsla(142, 72%, 29%, 1);
--Primitive--Green--800: hsla(143, 64%, 24%, 1);
--Primitive--Green--900: hsla(144, 61%, 20%, 1);
--Primitive--Green--950: hsla(145, 80%, 10%, 1);
--Primitive--Emerald--50: hsla(152, 81%, 96%, 1);
--Primitive--Emerald--100: hsla(149, 80%, 90%, 1);
--Primitive--Emerald--200: hsla(152, 76%, 80%, 1);
--Primitive--Emerald--300: hsla(156, 72%, 67%, 1);
--Primitive--Emerald--400: hsla(158, 64%, 52%, 1);
--Primitive--Emerald--500: hsla(160, 84%, 39%, 1);
--Primitive--Emerald--600: hsla(161, 94%, 30%, 1);
--Primitive--Emerald--700: hsla(163, 94%, 24%, 1);
--Primitive--Emerald--800: hsla(163, 88%, 20%, 1);
--Primitive--Emerald--900: hsla(164, 86%, 16%, 1);
--Primitive--Emerald--950: hsla(166, 91%, 9%, 1);
--Primitive--Teal--50: hsla(166, 76%, 97%, 1);
--Primitive--Teal--100: hsla(167, 85%, 89%, 1);
--Primitive--Teal--200: hsla(168, 84%, 78%, 1);
--Primitive--Teal--300: hsla(171, 77%, 64%, 1);
--Primitive--Teal--400: hsla(172, 66%, 50%, 1);
--Primitive--Teal--500: hsla(173, 80%, 40%, 1);
--Primitive--Teal--600: hsla(175, 84%, 32%, 1);
--Primitive--Teal--700: hsla(175, 77%, 26%, 1);
--Primitive--Teal--800: hsla(176, 69%, 22%, 1);
--Primitive--Teal--900: hsla(176, 61%, 19%, 1);
--Primitive--Teal--950: hsla(179, 84%, 10%, 1);
--Primitive--Cyan--50: hsla(183, 100%, 96%, 1);
--Primitive--Cyan--100: hsla(185, 96%, 90%, 1);
--Primitive--Cyan--200: hsla(186, 94%, 82%, 1);
--Primitive--Cyan--300: hsla(187, 92%, 69%, 1);
--Primitive--Cyan--400: hsla(188, 86%, 53%, 1);
--Primitive--Cyan--500: hsla(189, 94%, 43%, 1);
--Primitive--Cyan--600: hsla(192, 91%, 36%, 1);
--Primitive--Cyan--700: hsla(193, 82%, 31%, 1);
--Primitive--Cyan--800: hsla(194, 70%, 27%, 1);
--Primitive--Cyan--900: hsla(196, 64%, 24%, 1);
--Primitive--Cyan--950: hsla(197, 79%, 15%, 1);
--Primitive--Sky--50: hsla(204, 100%, 97%, 1);
--Primitive--Sky--100: hsla(204, 94%, 94%, 1);
--Primitive--Sky--200: hsla(201, 94%, 86%, 1);
--Primitive--Sky--300: hsla(199, 95%, 74%, 1);
--Primitive--Sky--400: hsla(198, 93%, 60%, 1);
--Primitive--Sky--500: hsla(199, 89%, 48%, 1);
--Primitive--Sky--600: hsla(200, 98%, 39%, 1);
--Primitive--Sky--700: hsla(201, 96%, 32%, 1);
--Primitive--Sky--800: hsla(201, 90%, 27%, 1);
--Primitive--Sky--900: hsla(202, 80%, 24%, 1);
--Primitive--Sky--950: hsla(204, 80%, 16%, 1);
--Primitive--Blue--50: hsla(214, 100%, 97%, 1);
--Primitive--Blue--100: hsla(214, 95%, 93%, 1);
--Primitive--Blue--200: hsla(213, 97%, 87%, 1);
--Primitive--Blue--300: hsla(212, 96%, 78%, 1);
--Primitive--Blue--400: hsla(213, 94%, 68%, 1);
--Primitive--Blue--500: hsla(217, 91%, 60%, 1);
--Primitive--Blue--600: hsla(221, 83%, 53%, 1);
--Primitive--Blue--700: hsla(224, 76%, 48%, 1);
--Primitive--Blue--800: hsla(226, 71%, 40%, 1);
--Primitive--Blue--900: hsla(224, 64%, 33%, 1);
--Primitive--Blue--950: hsla(226, 57%, 21%, 1);
--Primitive--Indigo--50: hsla(226, 100%, 97%, 1);
--Primitive--Indigo--100: hsla(226, 100%, 94%, 1);
--Primitive--Indigo--200: hsla(228, 96%, 89%, 1);
--Primitive--Indigo--300: hsla(230, 94%, 82%, 1);
--Primitive--Indigo--400: hsla(234, 89%, 74%, 1);
--Primitive--Indigo--500: hsla(239, 84%, 67%, 1);
--Primitive--Indigo--600: hsla(243, 75%, 59%, 1);
--Primitive--Indigo--700: hsla(245, 58%, 51%, 1);
--Primitive--Indigo--800: hsla(244, 55%, 41%, 1);
--Primitive--Indigo--900: hsla(242, 47%, 34%, 1);
--Primitive--Indigo--950: hsla(244, 47%, 20%, 1);
--Primitive--Violet--50: hsla(250, 100%, 98%, 1);
--Primitive--Violet--100: hsla(251, 91%, 95%, 1);
--Primitive--Violet--200: hsla(251, 95%, 92%, 1);
--Primitive--Violet--300: hsla(253, 95%, 85%, 1);
--Primitive--Violet--400: hsla(255, 92%, 76%, 1);
--Primitive--Violet--500: hsla(258, 90%, 66%, 1);
--Primitive--Violet--600: hsla(262, 83%, 58%, 1);
--Primitive--Violet--700: hsla(263, 70%, 50%, 1);
--Primitive--Violet--800: hsla(263, 69%, 42%, 1);
--Primitive--Violet--900: hsla(264, 67%, 35%, 1);
--Primitive--Violet--950: hsla(262, 78%, 23%, 1);
--Primitive--Purple--50: hsla(270, 100%, 98%, 1);
--Primitive--Purple--100: hsla(269, 100%, 95%, 1);
--Primitive--Purple--200: hsla(269, 100%, 92%, 1);
--Primitive--Purple--300: hsla(269, 97%, 85%, 1);
--Primitive--Purple--400: hsla(270, 95%, 75%, 1);
--Primitive--Purple--500: hsla(271, 91%, 65%, 1);
--Primitive--Purple--600: hsla(271, 81%, 56%, 1);
--Primitive--Purple--700: hsla(272, 72%, 47%, 1);
--Primitive--Purple--800: hsla(273, 67%, 39%, 1);
--Primitive--Purple--900: hsla(274, 66%, 32%, 1);
--Primitive--Purple--950: hsla(274, 87%, 21%, 1);
--Primitive--Fuchsia--50: hsla(289, 100%, 98%, 1);
--Primitive--Fuchsia--100: hsla(287, 100%, 95%, 1);
--Primitive--Fuchsia--200: hsla(288, 96%, 91%, 1);
--Primitive--Fuchsia--300: hsla(291, 93%, 83%, 1);
--Primitive--Fuchsia--400: hsla(292, 91%, 73%, 1);
--Primitive--Fuchsia--500: hsla(292, 84%, 61%, 1);
--Primitive--Fuchsia--600: hsla(293, 69%, 49%, 1);
--Primitive--Fuchsia--700: hsla(295, 72%, 40%, 1);
--Primitive--Fuchsia--800: hsla(295, 70%, 33%, 1);
--Primitive--Fuchsia--900: hsla(297, 64%, 28%, 1);
--Primitive--Fuchsia--950: hsla(297, 90%, 16%, 1);
--Primitive--Pink--50: hsla(327, 73%, 97%, 1);
--Primitive--Pink--100: hsla(326, 78%, 95%, 1);
--Primitive--Pink--200: hsla(326, 85%, 90%, 1);
--Primitive--Pink--300: hsla(327, 87%, 82%, 1);
--Primitive--Pink--400: hsla(329, 86%, 70%, 1);
--Primitive--Pink--500: hsla(330, 81%, 60%, 1);
--Primitive--Pink--600: hsla(333, 71%, 51%, 1);
--Primitive--Pink--700: hsla(335, 78%, 42%, 1);
--Primitive--Pink--800: hsla(336, 74%, 35%, 1);
--Primitive--Pink--900: hsla(336, 69%, 30%, 1);
--Primitive--Pink--950: hsla(336, 84%, 17%, 1);
--Primitive--Rose--50: hsla(356, 100%, 97%, 1);
--Primitive--Rose--100: hsla(356, 100%, 95%, 1);
--Primitive--Rose--200: hsla(353, 96%, 90%, 1);
--Primitive--Rose--300: hsla(353, 96%, 82%, 1);
--Primitive--Rose--400: hsla(351, 95%, 71%, 1);
--Primitive--Rose--500: hsla(350, 89%, 60%, 1);
--Primitive--Rose--600: hsla(347, 77%, 50%, 1);
--Primitive--Rose--700: hsla(345, 83%, 41%, 1);
--Primitive--Rose--800: hsla(343, 80%, 35%, 1);
--Primitive--Rose--900: hsla(342, 75%, 30%, 1);
--Primitive--Rose--950: hsla(343, 88%, 16%, 1);
--Brand--Base_Colors--Destructive: var(--Primitive--Red--500);
--Brand--Base_Colors--Success: var(--Primitive--Green--500);
--Brand--Base_Colors--Warning: var(--Primitive--Amber--500);
--Brand--Base_Colors--White: var(--Primitive--White);
--Brand--Base_Colors--Black: var(--Primitive--Black);
--Brand--Semantic_Colors--Background: var(--Primitive--Zinc--50); /*页面背景色:应用在整个页面的最底层。*/
--Brand--Semantic_Colors--Background-subtle: hsla(
0,
0%,
0%,
0.02
); /*细微背景色:用于需要与主背景有微弱区分的区域,如代码块背景。*/
--Brand--Semantic_Colors--Foreground: hsla(0, 0%, 0%, 0.9); /*主要前景/文字色:用于正文、标题等。*/
--Brand--Semantic_Colors--Foreground-secondary: hsla(0, 0%, 0%, 0.6); /*次要前景/文字色:用于辅助性文本、描述。*/
--Brand--Semantic_Colors--Foreground-muted: hsla(0, 0%, 0%, 0.4); /*静默前景/文字色:用于禁用状态的文字、占位符。*/
--Brand--Semantic_Colors--Border: hsla(0, 0%, 0%, 0.1); /*默认边框色:用于卡片、输入框、分隔线。*/
--Brand--Semantic_Colors--Border-hover: hsla(0, 0%, 0%, 0.2); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Border-active: hsla(0, 0%, 0%, 0.3); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Ring: hsla(
84,
81%,
44%,
0.4
); /*聚焦环颜色:用于输入框等元素在聚焦 (Focus) 状态下的外发光。*/
--Brand--UI_Element_Colors--Modal--Backdrop: hsla(0, 0%, 0%, 0.4);
--Brand--UI_Element_Colors--Modal--Thumb: hsla(0, 0%, 0%, 0.2);
--Brand--UI_Element_Colors--Modal--Thumb_Hover: hsla(0, 0%, 0%, 0.3);
--Brand--UI_Element_Colors--Icon--Default: var(--Brand--Semantic_Colors--Foreground-secondary);
--Brand--UI_Element_Colors--Icon--Hover: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Input_Select--Background: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Input_Select--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Input_Select--Border_Hover: var(--Brand--Semantic_Colors--Border-hover);
--Brand--UI_Element_Colors--Input_Select--Border_Focus: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Primary_Button--Background: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Card_Container--Background: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Card_Container--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Ghost_Button--Background: hsla(0, 0%, 0%, 0);
--Brand--UI_Element_Colors--Ghost_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Ghost_Button--Background_Hover: hsla(0, 0%, 0%, 0.05);
--Brand--UI_Element_Colors--Ghost_Button--Background_Active: hsla(0, 0%, 0%, 0.1);
--Brand--UI_Element_Colors--Secondary_Button--Background: hsla(0, 0%, 0%, 0.05);
--Brand--UI_Element_Colors--Secondary_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Secondary_Button--Background_Hover: hsla(0, 0%, 0%, 0.85);
--Brand--UI_Element_Colors--Secondary_Button--Background_Active: hsla(0, 0%, 0%, 0.7);
--Brand--UI_Element_Colors--Secondary_Button--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Primary_Button--Text: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Primary_Button--Background_Hover: hsla(84, 81%, 44%, 0.85);
--Brand--UI_Element_Colors--Primary_Button--2nd_Background: hsla(84, 81%, 44%, 0.1);
--Brand--UI_Element_Colors--Primary_Button--3rd_Background: hsla(84, 81%, 44%, 0.05);
--Brand--UI_Element_Colors--Primary_Button--Background_Active: hsla(84, 81%, 44%, 0.7);
--Boolean: false;
/* Color: Dark mode */
--Opacity--Red--Red-100: var(--Primitive--Red--600);
--Opacity--Red--Red-80: hsla(0, 72%, 51%, 0.8);
--Opacity--Red--Red-60: hsla(0, 72%, 51%, 0.6);
--Opacity--Red--Red-40: hsla(0, 72%, 51%, 0.4);
--Opacity--Red--Red-20: hsla(0, 72%, 51%, 0.2);
--Opacity--Red--Red-10: hsla(0, 72%, 51%, 0.1);
--Opacity--Green--Green-100: var(--Primitive--Green--600);
--Opacity--Green--Green-80: hsla(142, 76%, 36%, 0.8);
--Opacity--Green--Green-60: hsla(142, 76%, 36%, 0.6);
--Opacity--Green--Green-40: hsla(142, 76%, 36%, 0.4);
--Opacity--Green--Green-20: hsla(142, 76%, 36%, 0.2);
--Opacity--Green--Green-10: hsla(142, 76%, 36%, 0.1);
--Opacity--Yellow--Yellow-100: var(--Primitive--Yellow--400);
--Opacity--Yellow--Yellow-80: hsla(48, 96%, 53%, 0.8);
--Opacity--Yellow--Yellow-60: hsla(48, 96%, 53%, 0.6);
--Opacity--Yellow--Yellow-40: hsla(48, 96%, 53%, 0.4);
--Opacity--Yellow--Yellow-20: hsla(48, 96%, 53%, 0.2);
--Opacity--Yellow--Yellow-10: hsla(48, 96%, 53%, 0.1);
--Opacity--Violet--Violet-100: var(--Primitive--Violet--500);
--Opacity--Violet--Violet-80: hsla(258, 90%, 66%, 0.8);
--Opacity--Violet--Violet-60: hsla(258, 90%, 66%, 0.6);
--Opacity--Violet--Violet-40: hsla(258, 90%, 66%, 0.4);
--Opacity--Violet--Violet-20: hsla(258, 90%, 66%, 0.2);
--Opacity--Violet--Violet-10: hsla(258, 90%, 66%, 0.1);
--Opacity--Indigo--Indigo-100: var(--Primitive--Indigo--500);
--Opacity--Indigo--Indigo-80: hsla(239, 84%, 67%, 0.8);
--Opacity--Indigo--Indigo-60: hsla(239, 84%, 67%, 0.6);
--Opacity--Indigo--Indigo-40: hsla(239, 84%, 67%, 0.4);
--Opacity--Indigo--Indigo-20: hsla(239, 84%, 67%, 0.2);
--Opacity--Indigo--Indigo-10: hsla(239, 84%, 67%, 0.1);
--Opacity--Blue--Blue-100: var(--Primitive--Blue--500);
--Opacity--Blue--Blue-80: hsla(217, 91%, 60%, 0.8);
--Opacity--Blue--Blue-60: hsla(217, 91%, 60%, 0.6);
--Opacity--Blue--Blue-40: hsla(217, 91%, 60%, 0.4);
--Opacity--Blue--Blue-20: hsla(217, 91%, 60%, 0.2);
--Opacity--Blue--Blue-10: hsla(217, 91%, 60%, 0.1);
--Opacity--Grey--Grey-100: var(--Primitive--Gray--500);
--Opacity--Grey--Grey-80: hsla(220, 9%, 46%, 0.8);
--Opacity--Grey--Grey-60: hsla(220, 9%, 46%, 0.6);
--Opacity--Grey--Grey-40: hsla(220, 9%, 46%, 0.4);
--Opacity--Grey--Grey-20: hsla(220, 9%, 46%, 0.2);
--Opacity--Grey--Grey-10: hsla(220, 9%, 46%, 0.1);
--Opacity--White--White-100: var(--Primitive--White);
--Opacity--White--White-80: hsla(0, 0%, 100%, 0.8);
--Opacity--White--White-60: hsla(0, 0%, 100%, 0.6);
--Opacity--White--White-40: hsla(0, 0%, 100%, 0.4);
--Opacity--White--White-20: hsla(0, 0%, 100%, 0.2);
--Opacity--White--White-10: hsla(0, 0%, 100%, 0.1);
--Opacity--White--White-0: hsla(0, 0%, 100%, 0);
--Status--Error--colorErrorBg: var(--color--Red--900);
--Status--Error--colorErrorBgHover: var(--color--Red--800);
--Status--Error--colorErrorBorder: var(--color--Red--700);
--Status--Error--colorErrorBorderHover: var(--color--Red--600);
--Status--Error--colorErrorBase: var(--color--Red--400);
--Status--Error--colorErrorActive: var(--color--Red--300);
--Status--Error--colorErrorTextHover: var(--color--Red--200);
--Status--Error--colorErrorText: var(--color--Red--100);
--Status--Success--colorSuccessBg: var(--color--Green--900);
--Status--Success--colorSuccessBgHover: var(--color--Green--800);
--Status--Success--colorSuccessBase: var(--color--Green--400);
--Status--Success--colorSuccessTextHover: var(--color--Green--200);
--Status--Warning--colorWarningBg: var(--color--Yellow--900);
--Status--Warning--colorWarningBgHover: var(--color--Yellow--800);
--Status--Warning--colorWarningBase: var(--color--Yellow--400);
--Status--Warning--colorWarningActive: var(--color--Yellow--300);
--Status--Warning--colorWarningTextHover: var(--color--Yellow--200);
--Primitive--Black: hsla(0, 0%, 0%, 1);
--Primitive--White: hsla(0, 0%, 100%, 1);
--Brand--Base_Colors--Primary: var(--Primitive--Lime--500);
--Primitive--Neutral--50: hsla(0, 0%, 98%, 1);
--Primitive--Neutral--100: hsla(0, 0%, 96%, 1);
--Primitive--Neutral--200: hsla(0, 0%, 90%, 1);
--Primitive--Neutral--300: hsla(0, 0%, 83%, 1);
--Primitive--Neutral--400: hsla(0, 0%, 64%, 1);
--Primitive--Neutral--500: hsla(0, 0%, 45%, 1);
--Primitive--Neutral--600: hsla(215, 14%, 34%, 1);
--Primitive--Neutral--700: hsla(0, 0%, 25%, 1);
--Primitive--Neutral--800: hsla(0, 0%, 15%, 1);
--Primitive--Neutral--900: hsla(0, 0%, 9%, 1);
--Primitive--Neutral--950: hsla(0, 0%, 4%, 1);
--Primitive--Stone--50: hsla(60, 9%, 98%, 1);
--Primitive--Stone--100: hsla(60, 5%, 96%, 1);
--Primitive--Stone--200: hsla(20, 6%, 90%, 1);
--Primitive--Stone--300: hsla(24, 6%, 83%, 1);
--Primitive--Stone--400: hsla(24, 5%, 64%, 1);
--Primitive--Stone--500: hsla(25, 5%, 45%, 1);
--Primitive--Stone--600: hsla(33, 5%, 32%, 1);
--Primitive--Stone--700: hsla(30, 6%, 25%, 1);
--Primitive--Stone--800: hsla(12, 6%, 15%, 1);
--Primitive--Stone--900: hsla(24, 10%, 10%, 1);
--Primitive--Stone--950: hsla(20, 14%, 4%, 1);
--Primitive--Zinc--50: hsla(0, 0%, 98%, 1);
--Primitive--Zinc--100: hsla(240, 5%, 96%, 1);
--Primitive--Zinc--200: hsla(240, 6%, 90%, 1);
--Primitive--Zinc--300: hsla(240, 5%, 84%, 1);
--Primitive--Zinc--400: hsla(240, 5%, 65%, 1);
--Primitive--Zinc--500: hsla(240, 4%, 46%, 1);
--Primitive--Zinc--600: hsla(240, 5%, 34%, 1);
--Primitive--Zinc--700: hsla(240, 5%, 26%, 1);
--Primitive--Zinc--800: hsla(240, 4%, 16%, 1);
--Primitive--Zinc--900: hsla(240, 6%, 10%, 1);
--Primitive--Zinc--950: hsla(240, 10%, 4%, 1);
--Primitive--Slate--50: hsla(210, 40%, 98%, 1);
--Primitive--Slate--100: hsla(210, 40%, 96%, 1);
--Primitive--Slate--200: hsla(214, 32%, 91%, 1);
--Primitive--Slate--300: hsla(213, 27%, 84%, 1);
--Primitive--Slate--400: hsla(215, 20%, 65%, 1);
--Primitive--Slate--500: hsla(215, 16%, 47%, 1);
--Primitive--Slate--600: hsla(215, 19%, 35%, 1);
--Primitive--Slate--700: hsla(215, 25%, 27%, 1);
--Primitive--Slate--800: hsla(217, 33%, 17%, 1);
--Primitive--Slate--900: hsla(222, 47%, 11%, 1);
--Primitive--Slate--950: hsla(229, 84%, 5%, 1);
--Primitive--Gray--50: hsla(210, 20%, 98%, 1);
--Primitive--Gray--100: hsla(220, 14%, 96%, 1);
--Primitive--Gray--200: hsla(220, 13%, 91%, 1);
--Primitive--Gray--300: hsla(216, 12%, 84%, 1);
--Primitive--Gray--400: hsla(218, 11%, 65%, 1);
--Primitive--Gray--500: hsla(220, 9%, 46%, 1);
--Primitive--Gray--600: hsla(0, 0%, 32%, 1);
--Primitive--Gray--700: hsla(217, 19%, 27%, 1);
--Primitive--Gray--800: hsla(215, 28%, 17%, 1);
--Primitive--Gray--900: hsla(221, 39%, 11%, 1);
--Primitive--Gray--950: hsla(224, 71%, 4%, 1);
--Primitive--Red--50: hsla(0, 86%, 97%, 1);
--Primitive--Red--100: hsla(0, 93%, 94%, 1);
--Primitive--Red--200: hsla(0, 96%, 89%, 1);
--Primitive--Red--300: hsla(0, 94%, 82%, 1);
--Primitive--Red--400: hsla(0, 91%, 71%, 1);
--Primitive--Red--500: hsla(0, 84%, 60%, 1);
--Primitive--Red--600: hsla(0, 72%, 51%, 1);
--Primitive--Red--700: hsla(0, 74%, 42%, 1);
--Primitive--Red--800: hsla(0, 70%, 35%, 1);
--Primitive--Red--900: hsla(0, 63%, 31%, 1);
--Primitive--Red--950: hsla(0, 75%, 15%, 1);
--Primitive--Orange--50: hsla(33, 100%, 96%, 1);
--Primitive--Orange--100: hsla(34, 100%, 92%, 1);
--Primitive--Orange--200: hsla(32, 98%, 83%, 1);
--Primitive--Orange--300: hsla(31, 97%, 72%, 1);
--Primitive--Orange--400: hsla(27, 96%, 61%, 1);
--Primitive--Orange--500: hsla(25, 95%, 53%, 1);
--Primitive--Orange--600: hsla(21, 90%, 48%, 1);
--Primitive--Orange--700: hsla(17, 88%, 40%, 1);
--Primitive--Orange--800: hsla(15, 79%, 34%, 1);
--Primitive--Orange--900: hsla(15, 75%, 28%, 1);
--Primitive--Orange--950: hsla(13, 81%, 15%, 1);
--Primitive--Amber--50: hsla(48, 100%, 96%, 1);
--Primitive--Amber--100: hsla(48, 96%, 89%, 1);
--Primitive--Amber--200: hsla(48, 97%, 77%, 1);
--Primitive--Amber--300: hsla(46, 97%, 65%, 1);
--Primitive--Amber--400: hsla(43, 96%, 56%, 1);
--Primitive--Amber--500: hsla(38, 92%, 50%, 1);
--Primitive--Amber--600: hsla(32, 95%, 44%, 1);
--Primitive--Amber--700: hsla(26, 90%, 37%, 1);
--Primitive--Amber--800: hsla(23, 83%, 31%, 1);
--Primitive--Amber--900: hsla(22, 78%, 26%, 1);
--Primitive--Amber--950: hsla(21, 92%, 14%, 1);
--Primitive--Yellow--50: hsla(55, 92%, 95%, 1);
--Primitive--Yellow--100: hsla(55, 97%, 88%, 1);
--Primitive--Yellow--200: hsla(53, 98%, 77%, 1);
--Primitive--Yellow--300: hsla(50, 98%, 64%, 1);
--Primitive--Yellow--400: hsla(48, 96%, 53%, 1);
--Primitive--Yellow--500: hsla(45, 93%, 47%, 1);
--Primitive--Yellow--600: hsla(41, 96%, 40%, 1);
--Primitive--Yellow--700: hsla(35, 92%, 33%, 1);
--Primitive--Yellow--800: hsla(32, 81%, 29%, 1);
--Primitive--Yellow--900: hsla(28, 73%, 26%, 1);
--Primitive--Yellow--950: hsla(26, 83%, 14%, 1);
--Primitive--Lime--50: hsla(78, 92%, 95%, 1);
--Primitive--Lime--100: hsla(80, 89%, 89%, 1);
--Primitive--Lime--200: hsla(81, 88%, 80%, 1);
--Primitive--Lime--300: hsla(82, 85%, 67%, 1);
--Primitive--Lime--400: hsla(83, 78%, 55%, 1);
--Primitive--Lime--500: hsla(84, 81%, 44%, 1);
--Primitive--Lime--600: hsla(85, 85%, 35%, 1);
--Primitive--Lime--700: hsla(86, 78%, 27%, 1);
--Primitive--Lime--800: hsla(86, 69%, 23%, 1);
--Primitive--Lime--900: hsla(88, 61%, 20%, 1);
--Primitive--Lime--950: hsla(89, 80%, 10%, 1);
--Primitive--Green--50: hsla(138, 76%, 97%, 1);
--Primitive--Green--100: hsla(141, 84%, 93%, 1);
--Primitive--Green--200: hsla(141, 79%, 85%, 1);
--Primitive--Green--300: hsla(142, 77%, 73%, 1);
--Primitive--Green--400: hsla(142, 69%, 58%, 1);
--Primitive--Green--500: hsla(142, 71%, 45%, 1);
--Primitive--Green--600: hsla(142, 76%, 36%, 1);
--Primitive--Green--700: hsla(142, 72%, 29%, 1);
--Primitive--Green--800: hsla(143, 64%, 24%, 1);
--Primitive--Green--900: hsla(144, 61%, 20%, 1);
--Primitive--Green--950: hsla(145, 80%, 10%, 1);
--Primitive--Emerald--50: hsla(152, 81%, 96%, 1);
--Primitive--Emerald--100: hsla(149, 80%, 90%, 1);
--Primitive--Emerald--200: hsla(152, 76%, 80%, 1);
--Primitive--Emerald--300: hsla(156, 72%, 67%, 1);
--Primitive--Emerald--400: hsla(158, 64%, 52%, 1);
--Primitive--Emerald--500: hsla(160, 84%, 39%, 1);
--Primitive--Emerald--600: hsla(161, 94%, 30%, 1);
--Primitive--Emerald--700: hsla(163, 94%, 24%, 1);
--Primitive--Emerald--800: hsla(163, 88%, 20%, 1);
--Primitive--Emerald--900: hsla(164, 86%, 16%, 1);
--Primitive--Emerald--950: hsla(166, 91%, 9%, 1);
--Primitive--Teal--50: hsla(166, 76%, 97%, 1);
--Primitive--Teal--100: hsla(167, 85%, 89%, 1);
--Primitive--Teal--200: hsla(168, 84%, 78%, 1);
--Primitive--Teal--300: hsla(171, 77%, 64%, 1);
--Primitive--Teal--400: hsla(172, 66%, 50%, 1);
--Primitive--Teal--500: hsla(173, 80%, 40%, 1);
--Primitive--Teal--600: hsla(175, 84%, 32%, 1);
--Primitive--Teal--700: hsla(175, 77%, 26%, 1);
--Primitive--Teal--800: hsla(176, 69%, 22%, 1);
--Primitive--Teal--900: hsla(176, 61%, 19%, 1);
--Primitive--Teal--950: hsla(179, 84%, 10%, 1);
--Primitive--Cyan--50: hsla(183, 100%, 96%, 1);
--Primitive--Cyan--100: hsla(185, 96%, 90%, 1);
--Primitive--Cyan--200: hsla(186, 94%, 82%, 1);
--Primitive--Cyan--300: hsla(187, 92%, 69%, 1);
--Primitive--Cyan--400: hsla(188, 86%, 53%, 1);
--Primitive--Cyan--500: hsla(189, 94%, 43%, 1);
--Primitive--Cyan--600: hsla(192, 91%, 36%, 1);
--Primitive--Cyan--700: hsla(193, 82%, 31%, 1);
--Primitive--Cyan--800: hsla(194, 70%, 27%, 1);
--Primitive--Cyan--900: hsla(196, 64%, 24%, 1);
--Primitive--Cyan--950: hsla(197, 79%, 15%, 1);
--Primitive--Sky--50: hsla(204, 100%, 97%, 1);
--Primitive--Sky--100: hsla(204, 94%, 94%, 1);
--Primitive--Sky--200: hsla(201, 94%, 86%, 1);
--Primitive--Sky--300: hsla(199, 95%, 74%, 1);
--Primitive--Sky--400: hsla(198, 93%, 60%, 1);
--Primitive--Sky--500: hsla(199, 89%, 48%, 1);
--Primitive--Sky--600: hsla(200, 98%, 39%, 1);
--Primitive--Sky--700: hsla(201, 96%, 32%, 1);
--Primitive--Sky--800: hsla(201, 90%, 27%, 1);
--Primitive--Sky--900: hsla(202, 80%, 24%, 1);
--Primitive--Sky--950: hsla(204, 80%, 16%, 1);
--Primitive--Blue--50: hsla(214, 100%, 97%, 1);
--Primitive--Blue--100: hsla(214, 95%, 93%, 1);
--Primitive--Blue--200: hsla(213, 97%, 87%, 1);
--Primitive--Blue--300: hsla(212, 96%, 78%, 1);
--Primitive--Blue--400: hsla(213, 94%, 68%, 1);
--Primitive--Blue--500: hsla(217, 91%, 60%, 1);
--Primitive--Blue--600: hsla(221, 83%, 53%, 1);
--Primitive--Blue--700: hsla(224, 76%, 48%, 1);
--Primitive--Blue--800: hsla(226, 71%, 40%, 1);
--Primitive--Blue--900: hsla(224, 64%, 33%, 1);
--Primitive--Blue--950: hsla(226, 57%, 21%, 1);
--Primitive--Indigo--50: hsla(226, 100%, 97%, 1);
--Primitive--Indigo--100: hsla(226, 100%, 94%, 1);
--Primitive--Indigo--200: hsla(228, 96%, 89%, 1);
--Primitive--Indigo--300: hsla(230, 94%, 82%, 1);
--Primitive--Indigo--400: hsla(234, 89%, 74%, 1);
--Primitive--Indigo--500: hsla(239, 84%, 67%, 1);
--Primitive--Indigo--600: hsla(243, 75%, 59%, 1);
--Primitive--Indigo--700: hsla(245, 58%, 51%, 1);
--Primitive--Indigo--800: hsla(244, 55%, 41%, 1);
--Primitive--Indigo--900: hsla(242, 47%, 34%, 1);
--Primitive--Indigo--950: hsla(244, 47%, 20%, 1);
--Primitive--Violet--50: hsla(250, 100%, 98%, 1);
--Primitive--Violet--100: hsla(251, 91%, 95%, 1);
--Primitive--Violet--200: hsla(251, 95%, 92%, 1);
--Primitive--Violet--300: hsla(253, 95%, 85%, 1);
--Primitive--Violet--400: hsla(255, 92%, 76%, 1);
--Primitive--Violet--500: hsla(258, 90%, 66%, 1);
--Primitive--Violet--600: hsla(262, 83%, 58%, 1);
--Primitive--Violet--700: hsla(263, 70%, 50%, 1);
--Primitive--Violet--800: hsla(263, 69%, 42%, 1);
--Primitive--Violet--900: hsla(264, 67%, 35%, 1);
--Primitive--Violet--950: hsla(262, 78%, 23%, 1);
--Primitive--Purple--50: hsla(270, 100%, 98%, 1);
--Primitive--Purple--100: hsla(269, 100%, 95%, 1);
--Primitive--Purple--200: hsla(269, 100%, 92%, 1);
--Primitive--Purple--300: hsla(269, 97%, 85%, 1);
--Primitive--Purple--400: hsla(270, 95%, 75%, 1);
--Primitive--Purple--500: hsla(271, 91%, 65%, 1);
--Primitive--Purple--600: hsla(271, 81%, 56%, 1);
--Primitive--Purple--700: hsla(272, 72%, 47%, 1);
--Primitive--Purple--800: hsla(273, 67%, 39%, 1);
--Primitive--Purple--900: hsla(274, 66%, 32%, 1);
--Primitive--Purple--950: hsla(274, 87%, 21%, 1);
--Primitive--Fuchsia--50: hsla(289, 100%, 98%, 1);
--Primitive--Fuchsia--100: hsla(287, 100%, 95%, 1);
--Primitive--Fuchsia--200: hsla(288, 96%, 91%, 1);
--Primitive--Fuchsia--300: hsla(291, 93%, 83%, 1);
--Primitive--Fuchsia--400: hsla(292, 91%, 73%, 1);
--Primitive--Fuchsia--500: hsla(292, 84%, 61%, 1);
--Primitive--Fuchsia--600: hsla(293, 69%, 49%, 1);
--Primitive--Fuchsia--700: hsla(295, 72%, 40%, 1);
--Primitive--Fuchsia--800: hsla(295, 70%, 33%, 1);
--Primitive--Fuchsia--900: hsla(297, 64%, 28%, 1);
--Primitive--Fuchsia--950: hsla(297, 90%, 16%, 1);
--Primitive--Pink--50: hsla(327, 73%, 97%, 1);
--Primitive--Pink--100: hsla(326, 78%, 95%, 1);
--Primitive--Pink--200: hsla(326, 85%, 90%, 1);
--Primitive--Pink--300: hsla(327, 87%, 82%, 1);
--Primitive--Pink--400: hsla(329, 86%, 70%, 1);
--Primitive--Pink--500: hsla(330, 81%, 60%, 1);
--Primitive--Pink--600: hsla(333, 71%, 51%, 1);
--Primitive--Pink--700: hsla(335, 78%, 42%, 1);
--Primitive--Pink--800: hsla(336, 74%, 35%, 1);
--Primitive--Pink--900: hsla(336, 69%, 30%, 1);
--Primitive--Pink--950: hsla(336, 84%, 17%, 1);
--Primitive--Rose--50: hsla(356, 100%, 97%, 1);
--Primitive--Rose--100: hsla(356, 100%, 95%, 1);
--Primitive--Rose--200: hsla(353, 96%, 90%, 1);
--Primitive--Rose--300: hsla(353, 96%, 82%, 1);
--Primitive--Rose--400: hsla(351, 95%, 71%, 1);
--Primitive--Rose--500: hsla(350, 89%, 60%, 1);
--Primitive--Rose--600: hsla(347, 77%, 50%, 1);
--Primitive--Rose--700: hsla(345, 83%, 41%, 1);
--Primitive--Rose--800: hsla(343, 80%, 35%, 1);
--Primitive--Rose--900: hsla(342, 75%, 30%, 1);
--Primitive--Rose--950: hsla(343, 88%, 16%, 1);
--Brand--Base_Colors--Destructive: var(--Primitive--Red--500);
--Brand--Base_Colors--Success: var(--Primitive--Green--500);
--Brand--Base_Colors--Warning: var(--Primitive--Amber--500);
--Brand--Base_Colors--White: var(--Primitive--White);
--Brand--Base_Colors--Black: var(--Primitive--Black);
--Brand--Semantic_Colors--Background: var(--Primitive--Zinc--900); /*页面背景色:应用在整个页面的最底层。*/
--Brand--Semantic_Colors--Background-subtle: hsla(
0,
0%,
100%,
0.02
); /*细微背景色:用于需要与主背景有微弱区分的区域,如代码块背景。*/
--Brand--Semantic_Colors--Foreground: hsla(0, 0%, 100%, 0.9); /*主要前景/文字色:用于正文、标题等。*/
--Brand--Semantic_Colors--Foreground-secondary: hsla(0, 0%, 100%, 0.6); /*次要前景/文字色:用于辅助性文本、描述。*/
--Brand--Semantic_Colors--Foreground-muted: hsla(0, 0%, 100%, 0.4); /*静默前景/文字色:用于禁用状态的文字、占位符。*/
--Brand--Semantic_Colors--Border: hsla(0, 0%, 100%, 0.1); /*默认边框色:用于卡片、输入框、分隔线。*/
--Brand--Semantic_Colors--Border-hover: hsla(0, 0%, 100%, 0.2); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Border-active: hsla(0, 0%, 100%, 0.3); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Ring: hsla(
84,
81%,
44%,
0.4
); /*聚焦环颜色:用于输入框等元素在聚焦 (Focus) 状态下的外发光。*/
--Brand--UI_Element_Colors--Modal--Backdrop: hsla(0, 0%, 0%, 0.06);
--Brand--UI_Element_Colors--Modal--Thumb: hsla(0, 0%, 100%, 0.2);
--Brand--UI_Element_Colors--Modal--Thumb_Hover: hsla(0, 0%, 100%, 0.3);
--Brand--UI_Element_Colors--Icon--Default: var(--Brand--Semantic_Colors--Foreground-secondary);
--Brand--UI_Element_Colors--Icon--Hover: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Input_Select--Background: var(--Brand--Base_Colors--Black);
--Brand--UI_Element_Colors--Input_Select--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Input_Select--Border_Hover: var(--Brand--Semantic_Colors--Border-hover);
--Brand--UI_Element_Colors--Input_Select--Border_Focus: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Primary_Button--Background: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Card_Container--Background: var(--Brand--Base_Colors--Black);
--Brand--UI_Element_Colors--Card_Container--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Ghost_Button--Background: hsla(0, 0%, 100%, 0);
--Brand--UI_Element_Colors--Ghost_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Ghost_Button--Background_Hover: var(--Opacity--White--White-10);
--Brand--UI_Element_Colors--Ghost_Button--Background_Active: hsla(0, 0%, 100%, 0.15);
--Brand--UI_Element_Colors--Secondary_Button--Background: var(--Opacity--White--White-10);
--Brand--UI_Element_Colors--Secondary_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Secondary_Button--Background_Hover: var(--Opacity--White--White-20);
--Brand--UI_Element_Colors--Secondary_Button--Background_Active: hsla(0, 0%, 100%, 0.25);
--Brand--UI_Element_Colors--Secondary_Button--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Primary_Button--Text: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Primary_Button--Background_Hover: hsla(84, 81%, 44%, 0.85);
--Brand--UI_Element_Colors--Primary_Button--2nd_Background: hsla(84, 81%, 44%, 0.1);
--Brand--UI_Element_Colors--Primary_Button--3rd_Background: hsla(84, 81%, 44%, 0.05);
--Brand--UI_Element_Colors--Primary_Button--Background_Active: hsla(84, 81%, 44%, 0.7);
--Boolean: false;
}

View File

@@ -29,7 +29,7 @@ import {
setupAppImageDeepLink
} from './services/ProtocolClient'
import selectionService, { initSelectionService } from './services/SelectionService'
import { registerShortcuts } from './services/ShortcutService'
import { shortcutService } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { dataRefactorMigrateService } from './data/migrate/dataRefactor/DataRefactorMigrateService'
@@ -216,7 +216,7 @@ if (!app.requestSingleInstanceLock()) {
}
})
registerShortcuts(mainWindow)
shortcutService.registerForWindow(mainWindow)
registerIpc(mainWindow, app)
replaceDevtoolsFont(mainWindow)

View File

@@ -19,8 +19,8 @@ import type {
FileMetadata,
Notification,
OcrProvider,
PluginError,
Provider,
Shortcut,
SupportedOcrFile
} from '@types'
import checkDiskSpace from 'check-disk-space'
@@ -34,7 +34,6 @@ import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { codeToolsService } from './services/CodeToolsService'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
@@ -49,12 +48,12 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { PluginService } from './services/PluginService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import {
addEndMessage,
addStreamMessage,
@@ -95,6 +94,18 @@ const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance()
const dxtService = new DxtService()
const ovmsManager = new OvmsManager()
const pluginService = PluginService.getInstance()
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function extractPluginError(error: unknown): PluginError | null {
if (error && typeof error === 'object' && 'type' in error && typeof (error as { type: unknown }).type === 'string') {
return error as PluginError
}
return null
}
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
@@ -567,16 +578,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
unregisterAllShortcuts()
registerShortcuts(mainWindow)
}
})
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete.bind(KnowledgeService))
@@ -894,6 +895,119 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
// Claude Code Plugins
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListAvailable, async () => {
try {
const data = await pluginService.listAvailable()
return { success: true, data }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list available plugins', pluginError)
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list available plugins', err)
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'list-available',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Install, async (_, options) => {
try {
const data = await pluginService.install(options)
return { success: true, data }
} catch (error) {
logger.error('Failed to install plugin', { options, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Uninstall, async (_, options) => {
try {
await pluginService.uninstall(options)
return { success: true, data: undefined }
} catch (error) {
logger.error('Failed to uninstall plugin', { options, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListInstalled, async (_, agentId: string) => {
try {
const data = await pluginService.listInstalled(agentId)
return { success: true, data }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list installed plugins', { agentId, error: err })
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'list-installed',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_InvalidateCache, async () => {
try {
pluginService.invalidateCache()
return { success: true, data: undefined }
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to invalidate plugin cache', pluginError)
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to invalidate plugin cache', err)
return {
success: false,
error: {
type: 'TRANSACTION_FAILED',
operation: 'invalidate-cache',
reason: err.message
}
}
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ReadContent, async (_, sourcePath: string) => {
try {
const data = await pluginService.readContent(sourcePath)
return { success: true, data }
} catch (error) {
logger.error('Failed to read plugin content', { sourcePath, error })
return { success: false, error }
}
})
ipcMain.handle(IpcChannel.ClaudeCodePlugin_WriteContent, async (_, options) => {
try {
await pluginService.writeContent(options.agentId, options.filename, options.type, options.content)
return { success: true, data: undefined }
} catch (error) {
logger.error('Failed to write plugin content', { options, error })
return { success: false, error }
}
})
// Preference handlers
PreferenceService.registerIpcHandler()
}

View File

@@ -1,7 +1,6 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import type { ApiClient } from '@types'
import { VoyageEmbeddings } from './VoyageEmbeddings'
@@ -9,7 +8,7 @@ import { VoyageEmbeddings } from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): BaseEmbeddings {
const batchSize = 10
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
const { model, provider, apiKey, baseURL } = embedApiClient
if (provider === 'voyageai') {
return new VoyageEmbeddings({
modelName: model,
@@ -38,16 +37,7 @@ export default class EmbeddingsFactory {
}
})
}
if (apiVersion !== undefined) {
return new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIEndpoint: baseURL,
dimensions,
batchSize
})
}
// NOTE: Azure OpenAI 也走 OpenAIEmbeddings, baseURL是https://xxxx.openai.azure.com/openai/v1
return new OpenAiEmbeddings({
model,
apiKey,

View File

@@ -0,0 +1,199 @@
import fs from 'node:fs'
import path from 'node:path'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import type { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import { net } from 'electron'
import FormData from 'form-data'
import BasePreprocessProvider from './BasePreprocessProvider'
const logger = loggerService.withContext('MineruPreprocessProvider')
export default class OpenMineruPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider, userId?: string) {
super(provider, userId)
}
public async parseFile(
sourceId: string,
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota: number }> {
try {
const filePath = fileStorage.getFilePathById(file)
logger.info(`Open MinerU preprocess processing started: ${filePath}`)
await this.validateFile(filePath)
// 1. Update progress
await this.sendPreprocessProgress(sourceId, 50)
logger.info(`File ${file.name} is starting processing...`)
// 2. Upload file and extract
const { path: outputPath } = await this.uploadFileAndExtract(file)
// 3. Check quota
const quota = await this.checkQuota()
// 4. Create processed file info
return {
processedFile: this.createProcessedFileInfo(file, outputPath),
quota
}
} catch (error) {
logger.error(`Open MinerU preprocess processing failed for:`, error as Error)
throw error
}
}
public async checkQuota() {
// self-hosted version always has enough quota
return Infinity
}
private async validateFile(filePath: string): Promise<void> {
const pdfBuffer = await fs.promises.readFile(filePath)
const doc = await this.readPdf(pdfBuffer)
// File page count must be less than 600 pages
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
// File size must be less than 200MB
if (pdfBuffer.length >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
}
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
// Find the main file after extraction
let finalPath = ''
let finalName = file.origin_name.replace('.pdf', '.md')
// Find the corresponding folder by file name
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
try {
const files = fs.readdirSync(outputPath)
const mdFile = files.find((f) => f.endsWith('.md'))
if (mdFile) {
const originalMdPath = path.join(outputPath, mdFile)
const newMdPath = path.join(outputPath, finalName)
// Rename file to original file name
try {
fs.renameSync(originalMdPath, newMdPath)
finalPath = newMdPath
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
} catch (renameError) {
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
// If rename fails, use the original file
finalPath = originalMdPath
finalName = mdFile
}
}
} catch (error) {
logger.warn(`Failed to read output directory ${outputPath}:`, error as Error)
finalPath = path.join(outputPath, `${file.id}.md`)
}
return {
...file,
name: finalName,
path: finalPath,
ext: '.md',
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
}
}
private async uploadFileAndExtract(
file: FileMetadata,
maxRetries: number = 5,
intervalMs: number = 5000
): Promise<{ path: string }> {
let retries = 0
const endpoint = `${this.provider.apiHost}/file_parse`
// Get file stream
const filePath = fileStorage.getFilePathById(file)
const fileBuffer = await fs.promises.readFile(filePath)
const formData = new FormData()
formData.append('return_md', 'true')
formData.append('response_format_zip', 'true')
formData.append('files', fileBuffer, {
filename: file.origin_name
})
while (retries < maxRetries) {
let zipPath: string | undefined
try {
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
token: this.userId ?? '',
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
...formData.getHeaders()
},
body: formData.getBuffer()
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
// Check if response header is application/zip
if (response.headers.get('content-type') !== 'application/zip') {
throw new Error(`Downloaded ZIP file has unexpected content-type: ${response.headers.get('content-type')}`)
}
const dirPath = this.storageDir
zipPath = path.join(dirPath, `${file.id}.zip`)
const extractPath = path.join(dirPath, `${file.id}`)
const arrayBuffer = await response.arrayBuffer()
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
logger.info(`Downloaded ZIP file: ${zipPath}`)
// Ensure extraction directory exists
if (!fs.existsSync(extractPath)) {
fs.mkdirSync(extractPath, { recursive: true })
}
// Extract files
const zip = new AdmZip(zipPath)
zip.extractAllTo(extractPath, true)
logger.info(`Extracted files to: ${extractPath}`)
return { path: extractPath }
} catch (error) {
logger.warn(
`Failed to upload and extract file: ${(error as Error).message}, retry ${retries + 1}/${maxRetries}`
)
if (retries === maxRetries - 1) {
throw error
}
} finally {
// Delete temporary ZIP file
if (zipPath && fs.existsSync(zipPath)) {
try {
fs.unlinkSync(zipPath)
logger.info(`Deleted temporary ZIP file: ${zipPath}`)
} catch (deleteError) {
logger.warn(`Failed to delete temporary ZIP file ${zipPath}:`, deleteError as Error)
}
}
}
retries++
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
throw new Error(`Processing timeout for file: ${file.id}`)
}
}

View File

@@ -5,6 +5,7 @@ import DefaultPreprocessProvider from './DefaultPreprocessProvider'
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
import MineruPreprocessProvider from './MineruPreprocessProvider'
import MistralPreprocessProvider from './MistralPreprocessProvider'
import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider'
export default class PreprocessProviderFactory {
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
switch (provider.id) {
@@ -14,6 +15,8 @@ export default class PreprocessProviderFactory {
return new MistralPreprocessProvider(provider)
case 'mineru':
return new MineruPreprocessProvider(provider, userId)
case 'open-mineru':
return new OpenMineruPreprocessProvider(provider, userId)
default:
return new DefaultPreprocessProvider(provider)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,298 +1,307 @@
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import { handleZoomFactor } from '@main/utils/zoom'
import type { Shortcut } from '@types'
import type { PreferenceDefaultScopeType } from '@shared/data/preference/preferenceTypes'
import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
import type {
ShortcutDefinition,
ShortcutHandler,
ShortcutPreferenceKey,
ShortcutPreferenceValue,
ShortcutRuntimeConfig
} from '@shared/shortcuts/types'
import { coerceShortcutPreference } from '@shared/shortcuts/utils'
import type { BrowserWindow } from 'electron'
import { globalShortcut } from 'electron'
import { configManager } from './ConfigManager'
import selectionService from './SelectionService'
import { windowService } from './WindowService'
const logger = loggerService.withContext('ShortcutService')
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
let selectionAssistantToggleAccelerator: string | null = null
let selectionAssistantSelectTextAccelerator: string | null = null
const toAccelerator = (keys: string[]): string => keys.join('+')
//indicate if the shortcuts are registered on app boot time
let isRegisterOnBoot = true
const relevantDefinitions = SHORTCUT_DEFINITIONS.filter((definition) => definition.scope !== 'renderer')
// store the focus and blur handlers for each window to unregister them later
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
export class ShortcutService {
private handlers = new Map<ShortcutPreferenceKey, ShortcutHandler>()
private windowLifecycleHandlers = new Map<
BrowserWindow,
{ onFocus: () => void; onBlur: () => void; onClosed: () => void }
>()
private currentWindow: BrowserWindow | null = null
private preferenceUnsubscribers: Array<() => void> = []
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
case 'zoom_in':
return (window: BrowserWindow) => handleZoomFactor([window], 0.1)
case 'zoom_out':
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
case 'zoom_reset':
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
case 'show_app':
return () => {
windowService.toggleMainWindow()
}
case 'mini_window':
return () => {
windowService.toggleMiniWindow()
}
case 'selection_assistant_toggle':
return () => {
if (selectionService) {
selectionService.toggleEnabled()
}
}
case 'selection_assistant_select_text':
return () => {
if (selectionService) {
selectionService.processSelectTextByShortcut()
}
}
default:
return null
constructor() {
this.registerBuiltInHandlers()
this.subscribeToPreferenceChanges()
}
}
function formatShortcutKey(shortcut: string[]): string {
return shortcut.join('+')
}
// convert the shortcut recorded by JS keyboard event key value to electron global shortcut format
// see: https://www.electronjs.org/zh/docs/latest/api/accelerator
const convertShortcutFormat = (shortcut: string | string[]): string => {
const accelerator = (() => {
if (Array.isArray(shortcut)) {
return shortcut
} else {
return shortcut.split('+').map((key) => key.trim())
public registerHandler(key: ShortcutPreferenceKey, handler: ShortcutHandler): void {
if (this.handlers.has(key)) {
logger.warn(`Handler for ${key} is being overwritten`)
}
})()
return accelerator
.map((key) => {
switch (key) {
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// case 'Command':
// return 'CommandOrControl'
// case 'Control':
// return 'Control'
// case 'Ctrl':
// return 'Control'
// NEW WAY FOR MODIFIER KEYS
// you can see all the modifier keys in the same
case 'CommandOrControl':
return 'CommandOrControl'
case 'Ctrl':
return 'Ctrl'
case 'Alt':
return 'Alt' // Use `Alt` instead of `Option`. The `Option` key only exists on macOS, whereas the `Alt` key is available on all platforms.
case 'Meta':
return 'Meta' // `Meta` key is mapped to the Windows key on Windows and Linux, `Cmd` on macOS.
case 'Shift':
return 'Shift'
// For backward compatibility with old data
case 'Command':
case 'Cmd':
return 'CommandOrControl'
case 'Control':
return 'Ctrl'
case 'ArrowUp':
return 'Up'
case 'ArrowDown':
return 'Down'
case 'ArrowLeft':
return 'Left'
case 'ArrowRight':
return 'Right'
case 'AltGraph':
return 'AltGr'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
default:
return key
}
})
.join('+')
}
export function registerShortcuts(window: BrowserWindow) {
if (isRegisterOnBoot) {
window.once('ready-to-show', () => {
if (preferenceService.get('app.tray.on_launch')) {
registerOnlyUniversalShortcuts()
}
})
isRegisterOnBoot = false
this.handlers.set(key, handler)
logger.debug(`Registered handler for ${key}`)
}
//only for clearer code
const registerOnlyUniversalShortcuts = () => {
register(true)
public registerForWindow(window: BrowserWindow): void {
if (this.windowLifecycleHandlers.has(window)) {
logger.warn(`Window ${window.id} already registered for shortcuts`)
return
}
const onFocus = () => {
logger.debug(`Window ${window.id} focused - registering shortcuts`)
this.currentWindow = window
this.registerAllShortcuts(window)
}
const onBlur = () => {
logger.debug(`Window ${window.id} blurred - unregistering non-persistent shortcuts`)
this.unregisterTransientShortcuts(window)
}
const onClosed = () => {
logger.debug(`Window ${window.id} closed - cleaning up shortcut registrations`)
this.unregisterWindow(window)
}
window.on('focus', onFocus)
window.on('blur', onBlur)
window.on('closed', onClosed)
this.windowLifecycleHandlers.set(window, { onFocus, onBlur, onClosed })
this.currentWindow = window
if (window.isFocused()) {
this.registerAllShortcuts(window)
} else {
this.unregisterTransientShortcuts(window)
}
logger.info(`ShortcutService attached to window ${window.id}`)
}
//onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window
//onlyUniversalShortcuts is needed when we launch to tray
const register = (onlyUniversalShortcuts: boolean = false) => {
if (window.isDestroyed()) return
public unregisterWindow(window: BrowserWindow): void {
const lifecycle = this.windowLifecycleHandlers.get(window)
if (!lifecycle) {
return
}
const shortcuts = configManager.getShortcuts()
if (!shortcuts) return
window.off('focus', lifecycle.onFocus)
window.off('blur', lifecycle.onBlur)
window.off('closed', lifecycle.onClosed)
shortcuts.forEach((shortcut) => {
try {
if (shortcut.shortcut.length === 0) {
this.windowLifecycleHandlers.delete(window)
if (this.currentWindow === window) {
this.currentWindow = null
globalShortcut.unregisterAll()
}
}
public cleanup(): void {
this.windowLifecycleHandlers.forEach((_handlers, window) => this.unregisterWindow(window))
this.windowLifecycleHandlers.clear()
this.handlers.clear()
this.currentWindow = null
this.preferenceUnsubscribers.forEach((unsubscribe) => unsubscribe())
this.preferenceUnsubscribers = []
globalShortcut.unregisterAll()
logger.info('ShortcutService cleaned up')
}
private registerBuiltInHandlers(): void {
this.registerHandler('shortcut.app.show_main_window', () => {
windowService.toggleMainWindow()
})
this.registerHandler('shortcut.app.show_settings', () => {
let targetWindow = windowService.getMainWindow()
if (
!targetWindow ||
targetWindow.isDestroyed() ||
targetWindow.isMinimized() ||
!targetWindow.isVisible() ||
!targetWindow.isFocused()
) {
windowService.showMainWindow()
targetWindow = windowService.getMainWindow()
}
if (!targetWindow || targetWindow.isDestroyed()) {
return
}
void targetWindow.webContents
.executeJavaScript(`typeof window.navigate === 'function' && window.navigate('/settings/provider')`, true)
.catch((error) => {
logger.warn('Failed to navigate to settings from shortcut:', error as Error)
})
})
this.registerHandler('shortcut.app.show_mini_window', () => {
windowService.toggleMiniWindow()
})
this.registerHandler('shortcut.app.zoom_in', (window) => {
if (window) {
handleZoomFactor([window], 0.1)
}
})
this.registerHandler('shortcut.app.zoom_out', (window) => {
if (window) {
handleZoomFactor([window], -0.1)
}
})
this.registerHandler('shortcut.app.zoom_reset', (window) => {
if (window) {
handleZoomFactor([window], 0, true)
}
})
this.registerHandler('shortcut.selection.toggle_enabled', () => {
if (selectionService) {
selectionService.toggleEnabled()
}
})
this.registerHandler('shortcut.selection.get_text', () => {
if (selectionService) {
selectionService.processSelectTextByShortcut()
}
})
}
private subscribeToPreferenceChanges(): void {
this.preferenceUnsubscribers = relevantDefinitions.map((definition) =>
preferenceService.subscribeChange(definition.key, () => {
logger.debug(`Shortcut preference changed: ${definition.key}`)
this.reregisterShortcuts()
})
)
}
private registerAllShortcuts(window: BrowserWindow): void {
globalShortcut.unregisterAll()
relevantDefinitions.forEach((definition) => {
const runtimeConfig = this.getRuntimeConfig(definition)
if (!runtimeConfig.enabled) {
return
}
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
logger.debug(`Skipping ${definition.key} - enabledWhen condition not met`)
return
}
const handler = this.handlers.get(definition.key)
if (!handler) {
logger.warn(`No handler registered for ${definition.key}`)
return
}
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
if (definition.variants) {
definition.variants.forEach((variant) => {
this.registerSingleShortcut(variant, handler, window)
})
}
})
}
private unregisterTransientShortcuts(window: BrowserWindow): void {
globalShortcut.unregisterAll()
relevantDefinitions
.filter((definition) => definition.persistOnBlur)
.forEach((definition) => {
const runtimeConfig = this.getRuntimeConfig(definition)
if (!runtimeConfig.enabled) {
return
}
//if not enabled, exit early from the process.
if (!shortcut.enabled) {
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
return
}
// only register universal shortcuts when needed
if (
onlyUniversalShortcuts &&
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
shortcut.key
)
) {
return
}
const handler = getShortcutHandler(shortcut)
const handler = this.handlers.get(definition.key)
if (!handler) {
return
}
switch (shortcut.key) {
case 'show_app':
showAppAccelerator = formatShortcutKey(shortcut.shortcut)
break
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
case 'mini_window':
//available only when QuickAssistant enabled
if (!preferenceService.get('feature.quick_assistant.enabled')) {
return
}
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_toggle':
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_select_text':
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
break
//the following ZOOMs will register shortcuts separately, so will return
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => handler(window))
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
return
case 'zoom_out':
globalShortcut.register('CommandOrControl+-', () => handler(window))
globalShortcut.register('CommandOrControl+numsub', () => handler(window))
return
case 'zoom_reset':
globalShortcut.register('CommandOrControl+0', () => handler(window))
return
if (definition.variants) {
definition.variants.forEach((variant) => {
this.registerSingleShortcut(variant, handler, window)
})
}
const accelerator = convertShortcutFormat(shortcut.shortcut)
globalShortcut.register(accelerator, () => handler(window))
} catch (error) {
logger.warn(`Failed to register shortcut ${shortcut.key}`)
}
})
})
}
const unregister = () => {
if (window.isDestroyed()) return
private registerSingleShortcut(keys: string[], handler: ShortcutHandler, window: BrowserWindow): void {
if (!keys.length) {
return
}
const accelerator = toAccelerator(keys)
try {
globalShortcut.unregisterAll()
if (showAppAccelerator) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
const accelerator = convertShortcutFormat(showAppAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (showMiniWindowAccelerator) {
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
const accelerator = convertShortcutFormat(showMiniWindowAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantToggleAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantSelectTextAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantSelectTextAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
globalShortcut.register(accelerator, () => {
logger.debug(`Shortcut triggered: ${accelerator}`)
const targetWindow = window?.isDestroyed?.() ? undefined : window
handler(targetWindow)
})
logger.verbose(`Registered shortcut: ${accelerator}`)
} catch (error) {
logger.warn('Failed to unregister shortcuts')
logger.error(`Failed to register shortcut ${accelerator}:`, error as Error)
}
}
// only register the event handlers once
if (undefined === windowOnHandlers.get(window)) {
// pass register() directly to listener, the func will receive Event as argument, it's not expected
const registerHandler = () => {
register()
private getRuntimeConfig(definition: ShortcutDefinition): ShortcutRuntimeConfig {
const preference = this.getPreference(definition)
return {
...definition,
binding: preference.binding,
enabled: preference.enabled,
editable: preference.editable,
system: preference.system
}
window.on('focus', registerHandler)
window.on('blur', unregister)
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
}
if (!window.isDestroyed() && window.isFocused()) {
register()
private getPreference(definition: ShortcutDefinition): ShortcutPreferenceValue {
const rawPreference = preferenceService.get(definition.key)
return coerceShortcutPreference(definition, rawPreference as any)
}
private getPreferenceValue = <K extends ShortcutPreferenceKey | keyof PreferenceDefaultScopeType>(
key: K
): PreferenceDefaultScopeType[K] => {
return preferenceService.get(key)
}
private reregisterShortcuts(): void {
if (!this.currentWindow || this.currentWindow.isDestroyed()) {
return
}
if (this.currentWindow.isFocused()) {
this.registerAllShortcuts(this.currentWindow)
return
}
this.unregisterTransientShortcuts(this.currentWindow)
}
}
export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
selectionAssistantToggleAccelerator = null
selectionAssistantSelectTextAccelerator = null
windowOnHandlers.forEach((handlers, window) => {
window.off('focus', handlers.onFocusHandler)
window.off('blur', handlers.onBlurHandler)
})
windowOnHandlers.clear()
globalShortcut.unregisterAll()
} catch (error) {
logger.warn('Failed to unregister all shortcuts')
}
}
export const shortcutService = new ShortcutService()

View File

@@ -2,7 +2,7 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import type { McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { query } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
@@ -12,10 +12,23 @@ import { app } from 'electron'
import type { GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { promptForToolApproval } from './tool-permissions'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
const require_ = createRequire(import.meta.url)
const logger = loggerService.withContext('ClaudeCodeService')
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
type UserInputMessage = {
type: 'user'
parent_tool_use_id: string | null
session_id: string
message: {
role: 'user'
content: string
}
}
class ClaudeCodeStream extends EventEmitter implements AgentStream {
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
@@ -100,6 +113,41 @@ class ClaudeCodeService implements AgentServiceInterface {
const errorChunks: string[] = []
const sessionAllowedTools = new Set<string>(session.allowed_tools ?? [])
const autoAllowTools = new Set<string>([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools])
const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name)
const canUseTool: CanUseTool = async (toolName, input, options) => {
logger.info('Handling tool permission check', {
toolName,
suggestionCount: options.suggestions?.length ?? 0
})
if (shouldAutoApproveTools) {
logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName })
return { behavior: 'allow', updatedInput: input }
}
if (options.signal.aborted) {
logger.debug('Permission request signal already aborted; denying tool', { toolName })
return {
behavior: 'deny',
message: 'Tool request was cancelled before prompting the user'
}
}
const normalizedToolName = normalizeToolName(toolName)
if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) {
logger.debug('Auto-allowing tool from allowed list', {
toolName,
normalizedToolName
})
return { behavior: 'allow', updatedInput: input }
}
return promptForToolApproval(toolName, input, options)
}
// Build SDK options from parameters
const options: Options = {
abortController,
@@ -122,7 +170,8 @@ class ClaudeCodeService implements AgentServiceInterface {
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,
maxTurns: session.configuration?.max_turns,
allowedTools: session.allowed_tools
allowedTools: session.allowed_tools,
canUseTool
}
if (session.accessible_paths.length > 1) {
@@ -161,9 +210,14 @@ class ClaudeCodeService implements AgentServiceInterface {
resume: options.resume
})
const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream(
prompt,
abortController.signal
)
// Start async processing on the next tick so listeners can subscribe first
setImmediate(() => {
this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => {
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
logger.error('Unhandled Claude Code stream error', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
@@ -177,17 +231,90 @@ class ClaudeCodeService implements AgentServiceInterface {
return aiStream
}
private async *userMessages(prompt: string) {
{
yield {
type: 'user' as const,
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user' as const,
content: prompt
private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) {
const queue: Array<UserInputMessage | null> = []
const waiters: Array<(value: UserInputMessage | null) => void> = []
let closed = false
const flushWaiters = (value: UserInputMessage | null) => {
const resolve = waiters.shift()
if (resolve) {
resolve(value)
return true
}
return false
}
const enqueue = (value: UserInputMessage | null) => {
if (closed) return
if (value === null) {
closed = true
}
if (!flushWaiters(value)) {
queue.push(value)
}
}
const close = () => {
if (closed) return
enqueue(null)
}
const onAbort = () => {
close()
}
if (abortSignal.aborted) {
close()
} else {
abortSignal.addEventListener('abort', onAbort, { once: true })
}
const iterator = (async function* () {
try {
while (true) {
let value: UserInputMessage | null
if (queue.length > 0) {
value = queue.shift() ?? null
} else if (closed) {
break
} else {
// Wait for next message or close signal
value = await new Promise<UserInputMessage | null>((resolve) => {
waiters.push(resolve)
})
}
if (value === null) {
break
}
yield value
}
} finally {
closed = true
abortSignal.removeEventListener('abort', onAbort)
while (waiters.length > 0) {
const resolve = waiters.shift()
resolve?.(null)
}
}
})()
enqueue({
type: 'user',
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user',
content: initialPrompt
}
})
return {
stream: iterator,
enqueue,
close
}
}
@@ -195,7 +322,8 @@ class ClaudeCodeService implements AgentServiceInterface {
* Process SDK query and emit stream events
*/
private async processSDKQuery(
prompt: string,
promptStream: AsyncIterable<UserInputMessage>,
closePromptStream: () => void,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[]
@@ -203,14 +331,10 @@ class ClaudeCodeService implements AgentServiceInterface {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
const streamState = new ClaudeStreamState()
try {
// Process streaming responses using SDK query
for await (const message of query({
prompt: this.userMessages(prompt),
options
})) {
for await (const message of query({ prompt: promptStream, options })) {
if (hasCompleted) break
jsonOutput.push(message)
@@ -221,10 +345,10 @@ class ClaudeCodeService implements AgentServiceInterface {
content: JSON.stringify(message.message.content)
})
} else if (message.type === 'stream_event') {
logger.silly('Claude stream event', {
message,
event: JSON.stringify(message.event)
})
// logger.silly('Claude stream event', {
// message,
// event: JSON.stringify(message.event)
// })
} else {
logger.silly('Claude response', {
message,
@@ -232,7 +356,6 @@ class ClaudeCodeService implements AgentServiceInterface {
})
}
// Transform SDKMessage to UIMessageChunks
const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) {
stream.emit('data', {
@@ -242,7 +365,6 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
// Successfully completed
hasCompleted = true
const duration = Date.now() - startTime
@@ -251,7 +373,6 @@ class ClaudeCodeService implements AgentServiceInterface {
messageCount: jsonOutput.length
})
// Emit completion event
stream.emit('data', {
type: 'complete'
})
@@ -260,8 +381,6 @@ class ClaudeCodeService implements AgentServiceInterface {
hasCompleted = true
const duration = Date.now() - startTime
// Check if this is an abort error
const errorObj = error as any
const isAborted =
errorObj?.name === 'AbortError' ||
@@ -270,7 +389,6 @@ class ClaudeCodeService implements AgentServiceInterface {
if (isAborted) {
logger.info('SDK query aborted by client disconnect', { duration })
// Simply cleanup and return - don't emit error events
stream.emit('data', {
type: 'cancelled',
error: new Error('Request aborted by client')
@@ -285,11 +403,13 @@ class ClaudeCodeService implements AgentServiceInterface {
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
stderr: errorChunks
})
// Emit error event
stream.emit('data', {
type: 'error',
error: new Error(errorMessage)
})
} finally {
closePromptStream()
}
}
}

View File

@@ -0,0 +1,323 @@
import { randomUUID } from 'node:crypto'
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { ipcMain } from 'electron'
import { windowService } from '../../../WindowService'
import { builtinTools } from './tools'
const logger = loggerService.withContext('ClaudeCodeService')
const TOOL_APPROVAL_TIMEOUT_MS = 30_000
const MAX_PREVIEW_LENGTH = 2_000
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
type ToolPermissionBehavior = 'allow' | 'deny'
type ToolPermissionResponsePayload = {
requestId: string
behavior: ToolPermissionBehavior
updatedInput?: unknown
message?: string
updatedPermissions?: PermissionUpdate[]
}
type PendingPermissionRequest = {
fulfill: (update: PermissionResult) => void
timeout: NodeJS.Timeout
signal?: AbortSignal
abortListener?: () => void
originalInput: Record<string, unknown>
toolName: string
}
type RendererPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
inputPreview: string
createdAt: number
expiresAt: number
suggestions: PermissionUpdate[]
}
type RendererPermissionResultPayload = {
requestId: string
behavior: ToolPermissionBehavior
message?: string
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
}
const pendingRequests = new Map<string, PendingPermissionRequest>()
let ipcHandlersInitialized = false
const jsonReplacer = (_key: string, value: unknown) => {
if (typeof value === 'bigint') return value.toString()
if (value instanceof Map) return Object.fromEntries(value.entries())
if (value instanceof Set) return Array.from(value.values())
if (value instanceof Date) return value.toISOString()
if (typeof value === 'function') return undefined
if (value === undefined) return undefined
return value
}
const sanitizeStructuredData = <T>(value: T): T => {
try {
return JSON.parse(JSON.stringify(value, jsonReplacer)) as T
} catch (error) {
logger.warn('Failed to sanitize structured data for tool permission payload', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
return value
}
}
const buildInputPreview = (value: unknown): string => {
let preview: string
try {
preview = JSON.stringify(value, null, 2)
} catch (error) {
preview = typeof value === 'string' ? value : String(value)
}
if (preview.length > MAX_PREVIEW_LENGTH) {
preview = `${preview.slice(0, MAX_PREVIEW_LENGTH)}...`
}
return preview
}
const broadcastToRenderer = (
channel: IpcChannel,
payload: RendererPermissionRequestPayload | RendererPermissionResultPayload
): boolean => {
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.warn('Unable to send agent tool permission payload main window unavailable', {
channel,
requestId: 'requestId' in payload ? payload.requestId : undefined
})
return false
}
mainWindow.webContents.send(channel, payload)
return true
}
const finalizeRequest = (
requestId: string,
update: PermissionResult,
reason: RendererPermissionResultPayload['reason']
) => {
const pending = pendingRequests.get(requestId)
if (!pending) {
logger.debug('Attempted to finalize unknown tool permission request', { requestId, reason })
return false
}
logger.debug('Finalizing tool permission request', {
requestId,
toolName: pending.toolName,
behavior: update.behavior,
reason
})
pendingRequests.delete(requestId)
clearTimeout(pending.timeout)
if (pending.signal && pending.abortListener) {
pending.signal.removeEventListener('abort', pending.abortListener)
}
pending.fulfill(update)
const resultPayload: RendererPermissionResultPayload = {
requestId,
behavior: update.behavior,
message: update.behavior === 'deny' ? update.message : undefined,
reason
}
const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload)
logger.debug('Sent tool permission result to renderer', {
requestId,
dispatched
})
return true
}
const ensureIpcHandlersRegistered = () => {
if (ipcHandlersInitialized) return
ipcHandlersInitialized = true
ipcMain.handle(IpcChannel.AgentToolPermission_Response, async (_event, payload: ToolPermissionResponsePayload) => {
logger.debug('main received AgentToolPermission_Response', payload)
const { requestId, behavior, updatedInput, message } = payload
const pending = pendingRequests.get(requestId)
if (!pending) {
logger.warn('Received renderer tool permission response for unknown request', { requestId })
return { success: false, error: 'unknown-request' }
}
logger.debug('Received renderer response for tool permission', {
requestId,
toolName: pending.toolName,
behavior,
hasUpdatedPermissions: Array.isArray(payload.updatedPermissions) && payload.updatedPermissions.length > 0
})
const maybeUpdatedInput =
updatedInput && typeof updatedInput === 'object' && !Array.isArray(updatedInput)
? (updatedInput as Record<string, unknown>)
: pending.originalInput
const sanitizedUpdatedPermissions = Array.isArray(payload.updatedPermissions)
? payload.updatedPermissions.map((perm) => sanitizeStructuredData(perm))
: undefined
const finalUpdate: PermissionResult =
behavior === 'allow'
? {
behavior: 'allow',
updatedInput: sanitizeStructuredData(maybeUpdatedInput),
updatedPermissions: sanitizedUpdatedPermissions
}
: {
behavior: 'deny',
message: message ?? 'User denied permission for this tool'
}
finalizeRequest(requestId, finalUpdate, 'response')
return { success: true }
})
}
export async function promptForToolApproval(
toolName: string,
input: Record<string, unknown>,
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
): Promise<PermissionResult> {
if (shouldAutoApproveTools) {
logger.debug('promptForToolApproval auto-approving tool for test', {
toolName
})
return { behavior: 'allow', updatedInput: input }
}
ensureIpcHandlersRegistered()
if (options?.signal?.aborted) {
logger.info('Skipping tool approval prompt because request signal is already aborted', { toolName })
return { behavior: 'deny', message: 'Tool request was cancelled before prompting the user' }
}
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.warn('Denying tool usage because no renderer window is available to obtain approval', { toolName })
return { behavior: 'deny', message: 'Unable to request approval renderer not ready' }
}
const toolMetadata = builtinTools.find((tool) => tool.name === toolName || tool.id === toolName)
const sanitizedInput = sanitizeStructuredData(input)
const inputPreview = buildInputPreview(sanitizedInput)
const sanitizedSuggestions = (options?.suggestions ?? []).map((suggestion) => sanitizeStructuredData(suggestion))
const requestId = randomUUID()
const createdAt = Date.now()
const expiresAt = createdAt + TOOL_APPROVAL_TIMEOUT_MS
logger.info('Requesting user approval for tool usage', {
requestId,
toolName,
description: toolMetadata?.description
})
const requestPayload: RendererPermissionRequestPayload = {
requestId,
toolName,
toolId: toolMetadata?.id ?? toolName,
description: toolMetadata?.description,
requiresPermissions: toolMetadata?.requirePermissions ?? false,
input: sanitizedInput,
inputPreview,
createdAt,
expiresAt,
suggestions: sanitizedSuggestions
}
const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' }
logger.debug('Registering tool permission request', {
requestId,
toolName,
requiresPermissions: requestPayload.requiresPermissions,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
suggestionCount: sanitizedSuggestions.length
})
return new Promise<PermissionResult>((resolve) => {
const timeout = setTimeout(() => {
logger.info('User tool permission request timed out', { requestId, toolName })
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
}, TOOL_APPROVAL_TIMEOUT_MS)
const pending: PendingPermissionRequest = {
fulfill: resolve,
timeout,
originalInput: sanitizedInput,
toolName,
signal: options?.signal
}
if (options?.signal) {
const abortListener = () => {
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
}
pending.abortListener = abortListener
options.signal.addEventListener('abort', abortListener, { once: true })
}
pendingRequests.set(requestId, pending)
logger.debug('Pending tool permission request count', {
count: pendingRequests.size
})
const sent = broadcastToRenderer(IpcChannel.AgentToolPermission_Request, requestPayload)
logger.debug('Broadcasted tool permission request to renderer', {
requestId,
toolName,
sent
})
if (!sent) {
finalizeRequest(
requestId,
{
behavior: 'deny',
message: 'Unable to request approval because the renderer window is unavailable'
},
'no-window'
)
}
})
}

View File

@@ -0,0 +1,223 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { loggerService } from '@logger'
import { isPathInside } from './file'
const logger = loggerService.withContext('Utils:FileOperations')
const MAX_RECURSION_DEPTH = 1000
/**
* Recursively copy a directory and all its contents
* @param source - Source directory path (must be absolute)
* @param destination - Destination directory path (must be absolute)
* @param options - Copy options
* @param depth - Current recursion depth (internal use)
* @throws If copy operation fails or paths are invalid
*/
export async function copyDirectoryRecursive(
source: string,
destination: string,
options?: { allowedBasePath?: string },
depth = 0
): Promise<void> {
// Input validation
if (!source || !destination) {
throw new TypeError('Source and destination paths are required')
}
if (!path.isAbsolute(source) || !path.isAbsolute(destination)) {
throw new Error('Source and destination paths must be absolute')
}
// Depth limit to prevent stack overflow
if (depth > MAX_RECURSION_DEPTH) {
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(source, options.allowedBasePath)) {
throw new Error(`Source path is outside allowed directory: ${source}`)
}
if (!isPathInside(destination, options.allowedBasePath)) {
throw new Error(`Destination path is outside allowed directory: ${destination}`)
}
}
try {
// Verify source exists and is a directory
const sourceStats = await fs.promises.lstat(source)
if (!sourceStats.isDirectory()) {
throw new Error(`Source is not a directory: ${source}`)
}
// Create destination directory
await fs.promises.mkdir(destination, { recursive: true })
logger.debug('Created destination directory', { destination })
// Read source directory
const entries = await fs.promises.readdir(source, { withFileTypes: true })
// Copy each entry
for (const entry of entries) {
const sourcePath = path.join(source, entry.name)
const destPath = path.join(destination, entry.name)
// Use lstat to detect symlinks and prevent following them
const entryStats = await fs.promises.lstat(sourcePath)
if (entryStats.isSymbolicLink()) {
logger.warn('Skipping symlink for security', { path: sourcePath })
continue
}
if (entryStats.isDirectory()) {
// Recursively copy subdirectory
await copyDirectoryRecursive(sourcePath, destPath, options, depth + 1)
} else if (entryStats.isFile()) {
// Copy file with error handling for race conditions
try {
await fs.promises.copyFile(sourcePath, destPath)
// Preserve file permissions
await fs.promises.chmod(destPath, entryStats.mode)
logger.debug('Copied file', { from: sourcePath, to: destPath })
} catch (error) {
// Handle race condition where file was deleted during copy
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('File disappeared during copy', { sourcePath })
continue
}
throw error
}
} else {
// Skip special files (pipes, sockets, devices, etc.)
logger.debug('Skipping special file', { path: sourcePath })
}
}
logger.info('Directory copied successfully', { from: source, to: destination, depth })
} catch (error) {
logger.error('Failed to copy directory', { source, destination, depth, error })
throw error
}
}
/**
* Recursively delete a directory and all its contents
* @param dirPath - Directory path to delete (must be absolute)
* @param options - Delete options
* @throws If deletion fails or path is invalid
*/
export async function deleteDirectoryRecursive(dirPath: string, options?: { allowedBasePath?: string }): Promise<void> {
// Input validation
if (!dirPath) {
throw new TypeError('Directory path is required')
}
if (!path.isAbsolute(dirPath)) {
throw new Error('Directory path must be absolute')
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(dirPath, options.allowedBasePath)) {
throw new Error(`Path is outside allowed directory: ${dirPath}`)
}
}
try {
// Verify path exists before attempting deletion
try {
const stats = await fs.promises.lstat(dirPath)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${dirPath}`)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.warn('Directory already deleted', { dirPath })
return
}
throw error
}
// Node.js 14.14+ has fs.rm with recursive option
await fs.promises.rm(dirPath, { recursive: true, force: true })
logger.info('Directory deleted successfully', { dirPath })
} catch (error) {
logger.error('Failed to delete directory', { dirPath, error })
throw error
}
}
/**
* Get total size of a directory (in bytes)
* @param dirPath - Directory path (must be absolute)
* @param options - Size calculation options
* @param depth - Current recursion depth (internal use)
* @returns Total size in bytes
* @throws If size calculation fails or path is invalid
*/
export async function getDirectorySize(
dirPath: string,
options?: { allowedBasePath?: string },
depth = 0
): Promise<number> {
// Input validation
if (!dirPath) {
throw new TypeError('Directory path is required')
}
if (!path.isAbsolute(dirPath)) {
throw new Error('Directory path must be absolute')
}
// Depth limit to prevent stack overflow
if (depth > MAX_RECURSION_DEPTH) {
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
}
// Path validation - ensure operations stay within allowed boundaries
if (options?.allowedBasePath) {
if (!isPathInside(dirPath, options.allowedBasePath)) {
throw new Error(`Path is outside allowed directory: ${dirPath}`)
}
}
let totalSize = 0
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name)
// Use lstat to detect symlinks and prevent following them
const entryStats = await fs.promises.lstat(entryPath)
if (entryStats.isSymbolicLink()) {
logger.debug('Skipping symlink in size calculation', { path: entryPath })
continue
}
if (entryStats.isDirectory()) {
// Recursively get size of subdirectory
totalSize += await getDirectorySize(entryPath, options, depth + 1)
} else if (entryStats.isFile()) {
// Get file size from lstat (already have it)
totalSize += entryStats.size
} else {
// Skip special files
logger.debug('Skipping special file in size calculation', { path: entryPath })
}
}
logger.debug('Calculated directory size', { dirPath, size: totalSize, depth })
return totalSize
} catch (error) {
logger.error('Failed to calculate directory size', { dirPath, depth, error })
throw error
}
}

View File

@@ -0,0 +1,309 @@
import { loggerService } from '@logger'
import type { PluginError, PluginMetadata } from '@types'
import * as crypto from 'crypto'
import * as fs from 'fs'
import matter from 'gray-matter'
import * as yaml from 'js-yaml'
import * as path from 'path'
import { getDirectorySize } from './fileOperations'
const logger = loggerService.withContext('Utils:MarkdownParser')
/**
* Parse plugin metadata from a markdown file with frontmatter
* @param filePath Absolute path to the markdown file
* @param sourcePath Relative source path from plugins directory
* @param category Category name derived from parent folder
* @param type Plugin type (agent or command)
* @returns PluginMetadata object with parsed frontmatter and file info
*/
export async function parsePluginMetadata(
filePath: string,
sourcePath: string,
category: string,
type: 'agent' | 'command'
): Promise<PluginMetadata> {
const content = await fs.promises.readFile(filePath, 'utf8')
const stats = await fs.promises.stat(filePath)
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
const { data } = matter(content, {
engines: {
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
}
})
// Calculate content hash for integrity checking
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
// Extract filename
const filename = path.basename(filePath)
// Parse allowed_tools - handle both array and comma-separated string
let allowedTools: string[] | undefined
if (data['allowed-tools'] || data.allowed_tools) {
const toolsData = data['allowed-tools'] || data.allowed_tools
if (Array.isArray(toolsData)) {
allowedTools = toolsData
} else if (typeof toolsData === 'string') {
allowedTools = toolsData
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tools - similar handling
let tools: string[] | undefined
if (data.tools) {
if (Array.isArray(data.tools)) {
tools = data.tools
} else if (typeof data.tools === 'string') {
tools = data.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tags
let tags: string[] | undefined
if (data.tags) {
if (Array.isArray(data.tags)) {
tags = data.tags
} else if (typeof data.tags === 'string') {
tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
return {
sourcePath,
filename,
name: data.name || filename.replace(/\.md$/, ''),
description: data.description,
allowed_tools: allowedTools,
tools,
category,
type,
tags,
version: data.version,
author: data.author,
size: stats.size,
contentHash
}
}
/**
* Recursively find all directories containing SKILL.md
*
* @param dirPath - Directory to search in
* @param basePath - Base path for calculating relative source paths
* @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops)
* @param currentDepth - Current search depth (used internally)
* @returns Array of objects with absolute folder path and relative source path
*/
export async function findAllSkillDirectories(
dirPath: string,
basePath: string,
maxDepth = 10,
currentDepth = 0
): Promise<Array<{ folderPath: string; sourcePath: string }>> {
const results: Array<{ folderPath: string; sourcePath: string }> = []
// Prevent excessive recursion
if (currentDepth > maxDepth) {
return results
}
// Check if current directory contains SKILL.md
const skillMdPath = path.join(dirPath, 'SKILL.md')
try {
await fs.promises.stat(skillMdPath)
// Found SKILL.md in this directory
const relativePath = path.relative(basePath, dirPath)
results.push({
folderPath: dirPath,
sourcePath: relativePath
})
return results
} catch {
// SKILL.md not in current directory
}
// Only search subdirectories if current directory doesn't have SKILL.md
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const subDirPath = path.join(dirPath, entry.name)
const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1)
results.push(...subResults)
}
}
} catch (error: any) {
// Ignore errors when reading subdirectories (e.g., permission denied)
logger.debug('Failed to read subdirectory during skill search', {
dirPath,
error: error.message
})
}
return results
}
/**
* Parse metadata from SKILL.md within a skill folder
*
* @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md)
* @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill")
* @param category - Category name (typically "skills" for flat structure)
* @returns PluginMetadata with folder name as filename (no extension)
* @throws PluginError if SKILL.md not found or parsing fails
*/
export async function parseSkillMetadata(
skillFolderPath: string,
sourcePath: string,
category: string
): Promise<PluginMetadata> {
// Input validation
if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) {
throw {
type: 'INVALID_METADATA',
reason: 'Skill folder path must be absolute',
path: skillFolderPath
} as PluginError
}
// Look for SKILL.md directly in this folder (no recursion)
const skillMdPath = path.join(skillFolderPath, 'SKILL.md')
// Check if SKILL.md exists
try {
await fs.promises.stat(skillMdPath)
} catch (error: any) {
if (error.code === 'ENOENT') {
logger.error('SKILL.md not found in skill folder', { skillMdPath })
throw {
type: 'FILE_NOT_FOUND',
path: skillMdPath,
message: 'SKILL.md not found in skill folder'
} as PluginError
}
throw error
}
// Read SKILL.md content
let content: string
try {
content = await fs.promises.readFile(skillMdPath, 'utf8')
} catch (error: any) {
logger.error('Failed to read SKILL.md', { skillMdPath, error })
throw {
type: 'READ_FAILED',
path: skillMdPath,
reason: error.message || 'Unknown error'
} as PluginError
}
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
let data: any
try {
const parsed = matter(content, {
engines: {
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
}
})
data = parsed.data
} catch (error: any) {
logger.error('Failed to parse SKILL.md frontmatter', { skillMdPath, error })
throw {
type: 'INVALID_METADATA',
reason: `Failed to parse frontmatter: ${error.message}`,
path: skillMdPath
} as PluginError
}
// Calculate hash of SKILL.md only (not entire folder)
// Note: This means changes to other files in the skill won't trigger cache invalidation
// This is intentional - only SKILL.md metadata changes should trigger updates
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
// Get folder name as identifier (NO EXTENSION)
const folderName = path.basename(skillFolderPath)
// Get total folder size
let folderSize: number
try {
folderSize = await getDirectorySize(skillFolderPath)
} catch (error: any) {
logger.error('Failed to calculate skill folder size', { skillFolderPath, error })
// Use 0 as fallback instead of failing completely
folderSize = 0
}
// Parse tools (skills use 'tools', not 'allowed_tools')
let tools: string[] | undefined
if (data.tools) {
if (Array.isArray(data.tools)) {
// Validate all elements are strings
tools = data.tools.filter((t) => typeof t === 'string')
} else if (typeof data.tools === 'string') {
tools = data.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Parse tags
let tags: string[] | undefined
if (data.tags) {
if (Array.isArray(data.tags)) {
// Validate all elements are strings
tags = data.tags.filter((t) => typeof t === 'string')
} else if (typeof data.tags === 'string') {
tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
}
// Validate and sanitize name
const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : folderName
// Validate and sanitize description
const description =
typeof data.description === 'string' && data.description.trim() ? data.description.trim() : undefined
// Validate version and author
const version = typeof data.version === 'string' ? data.version : undefined
const author = typeof data.author === 'string' ? data.author : undefined
logger.debug('Successfully parsed skill metadata', {
skillFolderPath,
folderName,
size: folderSize
})
return {
sourcePath, // e.g., "skills/my-skill"
filename: folderName, // e.g., "my-skill" (folder name, NO .md extension)
name,
description,
tools,
category, // "skills" for flat structure
type: 'skill',
tags,
version,
author,
size: folderSize,
contentHash // Hash of SKILL.md content only
}
}

View File

@@ -1,3 +1,4 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { electronAPI } from '@electron-toolkit/preload'
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import type { SpanContext } from '@opentelemetry/api'
@@ -42,6 +43,16 @@ import type { OpenDialogOptions } from 'electron'
import { contextBridge, ipcRenderer, shell, webUtils } from 'electron'
import type { CreateDirectoryOptions } from 'webdav'
import type {
InstalledPlugin,
InstallPluginOptions,
ListAvailablePluginsResult,
PluginMetadata,
PluginResult,
UninstallPluginOptions,
WritePluginContentOptions
} from '../renderer/src/types/plugin'
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
if (spanContext) {
const data = { type: 'trace', context: spanContext }
@@ -426,6 +437,15 @@ const api = {
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
},
agentTools: {
respondToPermission: (payload: {
requestId: string
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
message?: string
updatedPermissions?: PermissionUpdate[]
}) => ipcRenderer.invoke(IpcChannel.AgentToolPermission_Response, payload)
},
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
// setDisableHardwareAcceleration: (isDisable: boolean) =>
// ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
@@ -548,6 +568,21 @@ const api = {
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
},
claudeCodePlugin: {
listAvailable: (): Promise<PluginResult<ListAvailablePluginsResult>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListAvailable),
install: (options: InstallPluginOptions): Promise<PluginResult<PluginMetadata>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Install, options),
uninstall: (options: UninstallPluginOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Uninstall, options),
listInstalled: (agentId: string): Promise<PluginResult<InstalledPlugin[]>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListInstalled, agentId),
invalidateCache: (): Promise<PluginResult<void>> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_InvalidateCache),
readContent: (sourcePath: string): Promise<PluginResult<string>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
}
}

View File

@@ -6,7 +6,14 @@
import { loggerService } from '@logger'
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
import type { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
import type {
BaseTool,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
MCPToolResultContent,
NormalToolResponse
} from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
@@ -255,6 +262,7 @@ export class ToolCallChunkHandler {
type: 'tool-result'
} & TypedToolResult<ToolSet>
): void {
// TODO: 基于AI SDK为供应商内置工具做更好的展示和类型安全处理
const { toolCallId, output, input } = chunk
if (!toolCallId) {
@@ -300,12 +308,7 @@ export class ToolCallChunkHandler {
responses: [toolResponse]
})
const images: string[] = []
for (const content of toolResponse.response?.content || []) {
if (content.type === 'image' && content.data) {
images.push(`data:${content.mimeType};base64,${content.data}`)
}
}
const images = extractImagesFromToolOutput(toolResponse.response)
if (images.length) {
this.onChunk({
@@ -352,3 +355,41 @@ export class ToolCallChunkHandler {
}
export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler)
function extractImagesFromToolOutput(output: unknown): string[] {
if (!output) {
return []
}
const contents: unknown[] = []
if (isMcpCallToolResponse(output)) {
contents.push(...output.content)
} else if (Array.isArray(output)) {
contents.push(...output)
} else if (hasContentArray(output)) {
contents.push(...output.content)
}
return contents
.filter(isMcpImageContent)
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
}
function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse {
return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content)
}
function hasContentArray(value: unknown): value is { content: unknown[] } {
return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content)
}
function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } {
if (typeof content !== 'object' || content === null) {
return false
}
const resultContent = content as MCPToolResultContent
return resultContent.type === 'image' && typeof resultContent.data === 'string'
}

View File

@@ -14,6 +14,7 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
@@ -78,7 +79,7 @@ export default class ModernAiProvider {
return this.actualProvider
}
public async completions(modelId: string, params: StreamTextParams, config: ModernAiProviderConfig) {
public async completions(modelId: string, params: StreamTextParams, providerConfig: ModernAiProviderConfig) {
// 检查model是否存在
if (!this.model) {
throw new Error('Model is required for completions. Please use constructor with model parameter.')
@@ -86,7 +87,10 @@ export default class ModernAiProvider {
// 每次请求时重新生成配置以确保API key轮换生效
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
logger.debug('Generated provider config for completions', this.config)
if (SUPPORTED_IMAGE_ENDPOINT_LIST.includes(this.config.options.endpoint)) {
providerConfig.isImageGenerationEndpoint = true
}
// 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config)
@@ -97,12 +101,13 @@ export default class ModernAiProvider {
// 提前构建中间件
const middlewares = buildAiSdkMiddlewares({
...config,
provider: this.actualProvider
...providerConfig,
provider: this.actualProvider,
assistant: providerConfig.assistant
})
logger.debug('Built middlewares in completions', {
middlewareCount: middlewares.length,
isImageGeneration: config.isImageGenerationEndpoint
isImageGeneration: providerConfig.isImageGenerationEndpoint
})
if (!this.localProvider) {
throw new Error('Local provider not created')
@@ -110,7 +115,7 @@ export default class ModernAiProvider {
// 根据endpoint类型创建对应的模型
let model: AiSdkModel | undefined
if (config.isImageGenerationEndpoint) {
if (providerConfig.isImageGenerationEndpoint) {
model = this.localProvider.imageModel(modelId)
} else {
model = this.localProvider.languageModel(modelId)
@@ -126,15 +131,15 @@ export default class ModernAiProvider {
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
}
if (config.topicId && (await preferenceService.get('app.developer_mode.enabled'))) {
if (providerConfig.topicId && (await preferenceService.get('app.developer_mode.enabled'))) {
// TypeScript类型窄化确保topicId是string类型
const traceConfig = {
...config,
topicId: config.topicId
...providerConfig,
topicId: providerConfig.topicId
}
return await this._completionsForTrace(model, params, traceConfig)
} else {
return await this._completionsOrImageGeneration(model, params, config)
return await this._completionsOrImageGeneration(model, params, providerConfig)
}
}

View File

@@ -1,5 +1,4 @@
import type { Provider } from '@renderer/types'
import { isOpenAIProvider } from '@renderer/utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient'
@@ -202,36 +201,4 @@ describe('ApiClientFactory', () => {
expect(client).toBeDefined()
})
})
describe('isOpenAIProvider', () => {
it('should return true for openai type', () => {
const provider = createTestProvider('openai', 'openai')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return true for azure-openai type', () => {
const provider = createTestProvider('azure-openai', 'azure-openai')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return true for unknown type (fallback to OpenAI)', () => {
const provider = createTestProvider('unknown', 'unknown')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return false for vertexai type', () => {
const provider = createTestProvider('vertex', 'vertexai')
expect(isOpenAIProvider(provider)).toBe(false)
})
it('should return false for anthropic type', () => {
const provider = createTestProvider('anthropic', 'anthropic')
expect(isOpenAIProvider(provider)).toBe(false)
})
it('should return false for gemini type', () => {
const provider = createTestProvider('gemini', 'gemini')
expect(isOpenAIProvider(provider)).toBe(false)
})
})
})

View File

@@ -1,13 +1,18 @@
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
import { type MCPTool, type Message, type Model, type Provider } from '@renderer/types'
import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
import { isSupportEnableThinkingProvider } from '@renderer/config/providers'
import type { MCPTool } from '@renderer/types'
import { type Assistant, type Message, type Model, type Provider } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import type { LanguageModelMiddleware } from 'ai'
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { isEmpty } from 'lodash'
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
import { noThinkMiddleware } from './noThinkMiddleware'
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
import { qwenThinkingMiddleware } from './qwenThinkingMiddleware'
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
@@ -20,6 +25,7 @@ export interface AiSdkMiddlewareConfig {
onChunk?: (chunk: Chunk) => void
model?: Model
provider?: Provider
assistant?: Assistant
enableReasoning: boolean
// 是否开启提示词工具调用
isPromptToolUse: boolean
@@ -128,7 +134,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
const builder = new AiSdkMiddlewareBuilder()
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
if (config.knowledgeRecognition === 'off') {
if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') {
builder.add({
name: 'force-knowledge-first',
middleware: toolChoiceMiddleware('builtin_knowledge_search')
@@ -219,6 +225,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
if (!config.model || !config.provider) return
// Qwen models on providers that don't support enable_thinking parameter (like Ollama, LM Studio, NVIDIA)
// Use /think or /no_think suffix to control thinking mode
if (
config.provider &&
isSupportedThinkingTokenQwenModel(config.model) &&
!isSupportEnableThinkingProvider(config.provider)
) {
const enableThinking = config.assistant?.settings?.reasoning_effort !== undefined
builder.add({
name: 'qwen-thinking-control',
middleware: qwenThinkingMiddleware(enableThinking)
})
logger.debug(`Added Qwen thinking middleware with thinking ${enableThinking ? 'enabled' : 'disabled'}`)
}
// 可以根据模型ID或特性添加特定中间件
// 例如:图像生成模型、多模态模型等
if (isOpenRouterGeminiGenerateImageModel(config.model, config.provider)) {

View File

@@ -0,0 +1,39 @@
import type { LanguageModelMiddleware } from 'ai'
/**
* Qwen Thinking Middleware
* Controls thinking mode for Qwen models on providers that don't support enable_thinking parameter (like Ollama)
* Appends '/think' or '/no_think' suffix to user messages based on reasoning_effort setting
* @param enableThinking - Whether thinking mode is enabled (based on reasoning_effort !== undefined)
* @returns LanguageModelMiddleware
*/
export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMiddleware {
const suffix = enableThinking ? ' /think' : ' /no_think'
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
// Process messages in prompt
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
transformedParams.prompt = transformedParams.prompt.map((message) => {
// Only process user messages
if (message.role === 'user') {
// Process content array
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) {
part.text += suffix
}
}
}
}
return message
})
}
return transformedParams
}
}
}

View File

@@ -1,5 +1,5 @@
import type { AiPlugin } from '@cherrystudio/ai-core'
import { createPromptToolUsePlugin, googleToolsPlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import type { Assistant } from '@renderer/types'
@@ -68,9 +68,9 @@ export async function buildPlugins(
)
}
if (middlewareConfig.enableUrlContext) {
plugins.push(googleToolsPlugin({ urlContext: true }))
}
// if (middlewareConfig.enableUrlContext && middlewareConfig.) {
// plugins.push(googleToolsPlugin({ urlContext: true }))
// }
logger.debug(
'Final plugin list:',

View File

@@ -114,7 +114,7 @@ export async function handleGeminiFileUpload(file: FileMetadata, model: Model):
}
/**
* 处理OpenAI大文件上传
* 处理OpenAI兼容大文件上传
*/
export async function handleOpenAILargeFileUpload(
file: FileMetadata,

View File

@@ -3,6 +3,8 @@
* 构建AI SDK的流式和非流式参数
*/
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
import { vertex } from '@ai-sdk/google-vertex/edge'
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
@@ -97,10 +99,6 @@ export async function buildStreamTextParams(
let tools = setupToolsConfig(mcpTools)
// if (webSearchProviderId) {
// tools['builtin_web_search'] = webSearchTool(webSearchProviderId)
// }
// 构建真正的 providerOptions
const webSearchConfig: CherryWebSearchConfig = {
maxResults: store.getState().websearch.maxResults,
@@ -143,12 +141,34 @@ export async function buildStreamTextParams(
}
}
// google-vertex
if (enableUrlContext && aiSdkProviderId === 'google-vertex') {
if (enableUrlContext) {
if (!tools) {
tools = {}
}
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
switch (aiSdkProviderId) {
case 'google-vertex':
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
break
case 'google':
tools.url_context = google.tools.urlContext({}) as ProviderDefinedTool
break
case 'anthropic':
case 'google-vertex-anthropic':
tools.web_fetch = (
aiSdkProviderId === 'anthropic'
? anthropic.tools.webFetch_20250910({
maxUses: webSearchConfig.maxResults,
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
})
: vertexAnthropic.tools.webFetch_20250910({
maxUses: webSearchConfig.maxResults,
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
})
) as ProviderDefinedTool
break
}
}
// 构建基础参数

View File

@@ -32,7 +32,8 @@ const AIHUBMIX_RULES: RuleSet = {
match: (model) =>
(startsWith('gemini')(model) || startsWith('imagen')(model)) &&
!model.id.endsWith('-nothink') &&
!model.id.endsWith('-search'),
!model.id.endsWith('-search') &&
!model.id.includes('embedding'),
provider: (provider: Provider) => {
return extraProviderConfig({
...provider,

View File

@@ -7,24 +7,27 @@ import {
} from '@cherrystudio/ai-core/provider'
import { cacheService } from '@data/CacheService'
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
import { isNewApiProvider } from '@renderer/config/providers'
import {
isAnthropicProvider,
isAzureOpenAIProvider,
isGeminiProvider,
isNewApiProvider
} from '@renderer/config/providers'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockRegion,
getAwsBedrockSecretAccessKey
} from '@renderer/hooks/useAwsBedrock'
import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI'
import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import store from '@renderer/store'
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import { formatApiHost, formatAzureOpenAIApiHost, formatVertexApiHost, routeToEndpoint } from '@renderer/utils/api'
import { cloneDeep } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
/**
* 获取轮询的API key
@@ -56,13 +59,6 @@ function getRotatedApiKey(provider: Provider): string {
* 处理特殊provider的转换逻辑
*/
function handleSpecialProviders(model: Model, provider: Provider): Provider {
// if (provider.type === 'vertexai' && !isVertexProvider(provider)) {
// if (!isVertexAIConfigured()) {
// throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.')
// }
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
@@ -79,43 +75,30 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
}
/**
* 格式化provider的API Host
* 主要用来对齐AISdk的BaseURL格式
* @param provider
* @returns
*/
function formatAnthropicApiHost(host: string): string {
const trimmedHost = host?.trim()
if (!trimmedHost) {
return ''
}
if (trimmedHost.endsWith('/')) {
return trimmedHost
}
if (trimmedHost.endsWith('/v1')) {
return `${trimmedHost}/`
}
return formatApiHost(trimmedHost)
}
function formatProviderApiHost(provider: Provider): Provider {
const formatted = { ...provider }
if (formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatAnthropicApiHost(formatted.anthropicApiHost)
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost)
}
if (formatted.type === 'anthropic') {
if (isAnthropicProvider(provider)) {
const baseHost = formatted.anthropicApiHost || formatted.apiHost
formatted.apiHost = formatAnthropicApiHost(baseHost)
formatted.apiHost = formatApiHost(baseHost)
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else if (formatted.id === SystemProviderIds.copilot || formatted.id === SystemProviderIds.github) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else if (isGeminiProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, true, 'v1beta')
} else if (isAzureOpenAIProvider(formatted)) {
formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost)
} else if (isVertexProvider(formatted)) {
formatted.apiHost = formatVertexApiHost(formatted)
} else {
formatted.apiHost = formatApiHost(formatted.apiHost)
}
@@ -149,15 +132,15 @@ export function providerToAiSdkConfig(
options: ProviderSettingsMap[keyof ProviderSettingsMap]
} {
const aiSdkProviderId = getAiSdkProviderId(actualProvider)
logger.debug('providerToAiSdkConfig', { aiSdkProviderId })
// 构建基础配置
const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost)
const baseConfig = {
baseURL: trim(actualProvider.apiHost),
baseURL: baseURL,
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === 'copilot'
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
@@ -178,6 +161,7 @@ export function providerToAiSdkConfig(
// 处理OpenAI模式
const extraOptions: any = {}
extraOptions.endpoint = endpoint
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
extraOptions.mode = 'responses'
} else if (aiSdkProviderId === 'openai') {
@@ -199,13 +183,11 @@ export function providerToAiSdkConfig(
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
extraOptions.apiVersion = actualProvider.apiVersion
baseConfig.baseURL += '/openai'
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1不使用azure endpoint
if (actualProvider.apiVersion === 'preview') {
extraOptions.mode = 'responses'
} else {
extraOptions.mode = 'chat'
extraOptions.useDeploymentBasedUrls = true
}
}
@@ -227,22 +209,7 @@ export function providerToAiSdkConfig(
...googleCredentials,
privateKey: formatPrivateKey(googleCredentials.privateKey)
}
// extraOptions.headers = window.api.vertexAI.getAuthHeaders({
// projectId: project,
// serviceAccount: {
// privateKey: googleCredentials.privateKey,
// clientEmail: googleCredentials.clientEmail
// }
// })
if (baseConfig.baseURL.endsWith('/v1/')) {
baseConfig.baseURL = baseConfig.baseURL.slice(0, -4)
} else if (baseConfig.baseURL.endsWith('/v1')) {
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
}
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
}
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
}
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {

View File

@@ -5,6 +5,16 @@ import { describe, expect, it, vi } from 'vitest'
import { DraggableList } from '../'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
return {

View File

@@ -3,6 +3,16 @@ import { describe, expect, it, vi } from 'vitest'
import { DraggableVirtualList } from '../'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// Mock 依赖项
vi.mock('@hello-pangea/dnd', () => ({
__esModule: true,

View File

@@ -1,5 +1,5 @@
import { Button } from '@cherrystudio/ui'
import { memo, useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -20,18 +20,12 @@ const ExpandableText = ({
setIsExpanded((prev) => !prev)
}, [])
const button = useMemo(() => {
return (
<Button variant="ghost" onClick={toggleExpand} className="self-end">
{isExpanded ? t('common.collapse') : t('common.expand')}
</Button>
)
}, [isExpanded, t, toggleExpand])
return (
<Container ref={ref} style={style} $expanded={isExpanded}>
<TextContainer $expanded={isExpanded}>{text}</TextContainer>
{button}
<Button variant="ghost" onClick={toggleExpand} className="self-end">
{isExpanded ? t('common.collapse') : t('common.expand')}
</Button>
</Container>
)
}
@@ -48,4 +42,4 @@ const TextContainer = styled.div<{ $expanded?: boolean }>`
line-height: ${(props) => (props.$expanded ? 'unset' : '30px')};
`
export default memo(ExpandableText)
export default ExpandableText

View File

@@ -2,7 +2,7 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-
import { Button, Flex, Tooltip } from '@cherrystudio/ui'
import { restoreFromLocal } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Modal, Table } from 'antd'
import { Modal, Space, Table } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -221,6 +221,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
}
}
const footerContent = (
<Space align="center">
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
<ReloadOutlined />
{t('settings.data.local.backup.manager.refresh')}
</Button>
<Button
key="delete"
variant="destructive"
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}>
<DeleteOutlined />
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
</Button>
<Button key="close" onClick={onClose}>
{t('common.close')}
</Button>
</Space>
)
return (
<Modal
title={t('settings.data.local.backup.manager.title')}
@@ -229,24 +249,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
width={800}
centered
transitionName="animation-move-down"
classNames={{ footer: 'flex justify-end gap-1' }}
footer={[
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
<ReloadOutlined />
{t('settings.data.local.backup.manager.refresh')}
</Button>,
<Button
key="delete"
variant="destructive"
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}>
<DeleteOutlined />
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
</Button>,
<Button key="close" onClick={onClose}>
{t('common.close')}
</Button>
]}>
footer={footerContent}>
<Table
rowKey="fileName"
columns={columns}

View File

@@ -16,8 +16,8 @@ import {
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { permissionModeCards } from '@renderer/config/agent'
import { agentModelFilter, getModelLogoById } from '@renderer/config/models'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'

View File

@@ -12,8 +12,8 @@ vi.mock('react-i18next', () => ({
// Mock ImageToolButton
vi.mock('../ImageToolButton', () => ({
default: vi.fn(({ tooltip, onPress, icon }) => (
<button type="button" onClick={onPress} role="button" aria-label={tooltip}>
default: vi.fn(({ tooltip, onClick, icon }) => (
<button type="button" onClick={onClick} role="button" aria-label={tooltip}>
{icon}
</button>
))

View File

@@ -4,8 +4,8 @@ exports[`ImageToolButton > should match snapshot 1`] = `
<DocumentFragment>
<button
aria-label="Test tooltip"
class="rounded-full"
data-testid="button"
radius="full"
type="button"
>
<span

View File

@@ -1,4 +1,4 @@
import { makeSvgSizeAdaptive } from '@renderer/utils'
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
import DOMPurify from 'dompurify'
/**

View File

@@ -3,7 +3,7 @@ import { Button, Tooltip } from '@cherrystudio/ui'
import { restoreFromS3 } from '@renderer/services/BackupService'
import type { S3Config } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { Modal, Space, Table } from 'antd'
import { Modal, Table } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -18,6 +18,7 @@ interface BaseSelectorProps<V = string | number> {
options: SelectorOption<V>[]
placeholder?: string
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
style?: React.CSSProperties
/** 字体大小 */
size?: number
/** 是否禁用 */
@@ -45,6 +46,7 @@ const Selector = <V extends string | number>({
placement = 'bottomRight',
size = 13,
placeholder,
style,
disabled = false,
multiple = false
}: SelectorProps<V>) => {
@@ -137,7 +139,7 @@ const Selector = <V extends string | number>({
placement={placement}
open={open && !disabled}
onOpenChange={handleOpenChange}>
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
<Label style={style} $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
{label}
<LabelIcon size={size + 3} />
</Label>

View File

@@ -3,7 +3,7 @@ import { Box } from '@cherrystudio/ui'
import { getToastUtilities } from '@cherrystudio/ui'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
import { Modal } from 'antd'
import type { PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
@@ -35,8 +35,9 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
elementsRef.current = elements
const [modal, modalContextHolder] = Modal.useModal()
const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
const shortcuts = useAllShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.definition.key === 'shortcut.app.exit_fullscreen')
?.preference.enabled
useAppInit()

View File

@@ -9,6 +9,15 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k })
}))
// mock @cherrystudio/ui Button component
vi.mock('@cherrystudio/ui', () => ({
Button: ({ children, onPress, ...props }: any) => (
<button type="button" onClick={onPress} {...props}>
{children}
</button>
)
}))
describe('ExpandableText', () => {
const TEXT = 'This is a long text for testing.'

View File

@@ -23,6 +23,16 @@ const mocks = vi.hoisted(() => ({
}
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// Mock antd components to prevent flaky snapshot tests
vi.mock('antd', () => {
const MockSpaceCompact: React.FC<React.PropsWithChildren<{ style?: React.CSSProperties }>> = ({

View File

@@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
min-width: 100%;
display: flex;
flex-direction: row;
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0};
padding-left: ${({ $isFullScreen }) =>

View File

@@ -18,6 +18,15 @@ describe('Qwen Model Detection', () => {
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' })
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
})
test('isQwenReasoningModel', () => {
expect(isQwenReasoningModel({ id: 'qwen3-thinking' } as Model)).toBe(true)

View File

@@ -2,6 +2,16 @@ import { describe, expect, it, vi } from 'vitest'
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isLingReasoningModel } from '../models/reasoning'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
vi.mock('@renderer/services/AssistantService.ts', () => ({
getDefaultAssistant: () => {

View File

@@ -1,5 +1,6 @@
import ClaudeAvatar from '@renderer/assets/images/models/claude.png'
import type { AgentBase, AgentType } from '@renderer/types'
import type { PermissionModeCard } from '@renderer/types/agent'
// base agent config. no default config for now.
const DEFAULT_AGENT_CONFIG: Omit<AgentBase, 'model'> = {
@@ -19,3 +20,47 @@ export const getAgentTypeAvatar = (type: AgentType): string => {
return ''
}
}
export const permissionModeCards: PermissionModeCard[] = [
{
mode: 'default',
// t('agent.settings.tooling.permissionMode.default.title')
titleKey: 'agent.settings.tooling.permissionMode.default.title',
titleFallback: 'Default (ask before continuing)',
descriptionKey: 'agent.settings.tooling.permissionMode.default.description',
descriptionFallback: 'Read-only tools are pre-approved; everything else still needs permission.',
behaviorKey: 'agent.settings.tooling.permissionMode.default.behavior',
behaviorFallback: 'Read-only tools are pre-approved automatically.'
},
{
mode: 'plan',
// t('agent.settings.tooling.permissionMode.plan.title')
titleKey: 'agent.settings.tooling.permissionMode.plan.title',
titleFallback: 'Planning mode',
descriptionKey: 'agent.settings.tooling.permissionMode.plan.description',
descriptionFallback: 'Shares the default read-only tool set but presents a plan before execution.',
behaviorKey: 'agent.settings.tooling.permissionMode.plan.behavior',
behaviorFallback: 'Read-only defaults are pre-approved while execution remains disabled.'
},
{
mode: 'acceptEdits',
// t('agent.settings.tooling.permissionMode.acceptEdits.title')
titleKey: 'agent.settings.tooling.permissionMode.acceptEdits.title',
titleFallback: 'Auto-accept file edits',
descriptionKey: 'agent.settings.tooling.permissionMode.acceptEdits.description',
descriptionFallback: 'File edits and filesystem operations are automatically approved.',
behaviorKey: 'agent.settings.tooling.permissionMode.acceptEdits.behavior',
behaviorFallback: 'Pre-approves trusted filesystem tools so edits run immediately.'
},
{
mode: 'bypassPermissions',
// t('agent.settings.tooling.permissionMode.bypassPermissions.title')
titleKey: 'agent.settings.tooling.permissionMode.bypassPermissions.title',
titleFallback: 'Bypass permission checks',
descriptionKey: 'agent.settings.tooling.permissionMode.bypassPermissions.description',
descriptionFallback: 'All permission prompts are skipped — use with caution.',
behaviorKey: 'agent.settings.tooling.permissionMode.bypassPermissions.behavior',
behaviorFallback: 'Every tool is pre-approved automatically.',
caution: true
}
]

View File

@@ -1741,6 +1741,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
id: 'DeepSeek-R1',
provider: 'cephalon',
name: 'DeepSeek-R1满血版',
capabilities: [{ type: 'reasoning' }],
group: 'DeepSeek'
}
],

View File

@@ -1,7 +1,9 @@
import { getProviderByModel } from '@renderer/services/AssistantService'
import type { Model } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
import { isGeminiProvider, isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider } from '../providers'
import { isEmbeddingModel, isRerankModel } from './embedding'
import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
@@ -65,12 +67,16 @@ export function isWebSearchModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id, '/')
// 不管哪个供应商都判断了
if (isAnthropicModel(model)) {
// bedrock和vertex不支持
if (
isAnthropicModel(model) &&
(provider.id === SystemProviderIds['aws-bedrock'] || provider.id === SystemProviderIds.vertexai)
) {
return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(modelId)
}
if (provider.type === 'openai-response') {
// TODO: 当其他供应商采用Response端点时这个地方逻辑需要改进
if (isOpenAIProvider(provider)) {
if (isOpenAIWebSearchModel(model)) {
return true
}
@@ -78,11 +84,11 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (provider.id === 'perplexity') {
if (provider.id === SystemProviderIds.perplexity) {
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
}
if (provider.id === 'aihubmix') {
if (provider.id === SystemProviderIds.aihubmix) {
// modelId 不以-search结尾
if (!modelId.endsWith('-search') && GEMINI_SEARCH_REGEX.test(modelId)) {
return true
@@ -95,13 +101,13 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (provider?.type === 'openai') {
if (isOpenAICompatibleProvider(provider) || isNewApiProvider(provider)) {
if (GEMINI_SEARCH_REGEX.test(modelId) || isOpenAIWebSearchModel(model)) {
return true
}
}
if (provider.id === 'gemini' || provider?.type === 'gemini' || provider.type === 'vertexai') {
if (isGeminiProvider(provider) || provider.id === SystemProviderIds.vertexai) {
return GEMINI_SEARCH_REGEX.test(modelId)
}

View File

@@ -11,6 +11,8 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
return MistralLogo
case 'mineru':
return MinerULogo
case 'open-mineru':
return MinerULogo
default:
return undefined
}
@@ -36,5 +38,11 @@ export const PREPROCESS_PROVIDER_CONFIG: Record<PreprocessProviderId, Preprocess
official: 'https://mineru.net/',
apiKey: 'https://mineru.net/apiManage'
}
},
'open-mineru': {
websites: {
official: 'https://github.com/opendatalab/MinerU/',
apiKey: 'https://github.com/opendatalab/MinerU/'
}
}
}

View File

@@ -56,7 +56,14 @@ import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import type { AtLeast, Provider, ProviderType, SystemProvider, SystemProviderId } from '@renderer/types'
import type {
AtLeast,
AzureOpenAIProvider,
Provider,
ProviderType,
SystemProvider,
SystemProviderId
} from '@renderer/types'
import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types'
import { TOKENFLUX_HOST } from './constant'
@@ -348,7 +355,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
name: 'VertexAI',
type: 'vertexai',
apiKey: '',
apiHost: 'https://aiplatform.googleapis.com',
apiHost: '',
models: SYSTEM_MODELS.vertexai,
isSystem: true,
enabled: false,
@@ -412,7 +419,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy',
anthropicApiHost: 'https://dashscope.aliyuncs.com/apps/anthropic',
models: SYSTEM_MODELS.dashscope,
isSystem: true,
enabled: false
@@ -1288,7 +1295,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
},
vertexai: {
api: {
url: 'https://console.cloud.google.com/apis/api/aiplatform.googleapis.com/overview'
url: ''
},
websites: {
official: 'https://cloud.google.com/vertex-ai',
@@ -1368,7 +1375,8 @@ const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [
'baichuan',
'minimax',
'xirang',
'poe'
'poe',
'cephalon'
] as const satisfies SystemProviderId[]
/**
@@ -1433,10 +1441,15 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
)
}
const SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES = ['gemini', 'vertexai'] as const satisfies ProviderType[]
const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
'gemini',
'vertexai',
'anthropic',
'new-api'
] as const satisfies ProviderType[]
export const isSupportUrlContextProvider = (provider: Provider) => {
return SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
return SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
}
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
@@ -1449,3 +1462,37 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
}
/**
* 判断是否为 OpenAI 兼容的提供商
* @param {Provider} provider 提供商对象
* @returns {boolean} 是否为 OpenAI 兼容提供商
*/
export function isOpenAICompatibleProvider(provider: Provider): boolean {
return ['openai', 'new-api', 'mistral'].includes(provider.type)
}
export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider {
return provider.type === 'azure-openai'
}
export function isOpenAIProvider(provider: Provider): boolean {
return provider.type === 'openai-response'
}
export function isAnthropicProvider(provider: Provider): boolean {
return provider.type === 'anthropic'
}
export function isGeminiProvider(provider: Provider): boolean {
return provider.type === 'gemini'
}
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
export const isSupportAPIVersionProvider = (provider: Provider) => {
if (isSystemProvider(provider)) {
return !NOT_SUPPORT_API_VERSION_PROVIDERS.some((pid) => pid === provider.id)
}
return provider.apiOptions?.isNotSupportAPIVersion !== false
}

View File

@@ -1,53 +0,0 @@
import type { PermissionMode } from '@renderer/types'
export type PermissionModeCard = {
mode: PermissionMode
titleKey: string
titleFallback: string
descriptionKey: string
descriptionFallback: string
behaviorKey: string
behaviorFallback: string
caution?: boolean
unsupported?: boolean
}
export const permissionModeCards: PermissionModeCard[] = [
{
mode: 'default',
titleKey: 'agent.settings.tooling.permissionMode.default.title',
titleFallback: 'Default (ask before continuing)',
descriptionKey: 'agent.settings.tooling.permissionMode.default.description',
descriptionFallback: 'Read-only tools are pre-approved; everything else still needs permission.',
behaviorKey: 'agent.settings.tooling.permissionMode.default.behavior',
behaviorFallback: 'Read-only tools are pre-approved automatically.'
},
{
mode: 'plan',
titleKey: 'agent.settings.tooling.permissionMode.plan.title',
titleFallback: 'Planning mode',
descriptionKey: 'agent.settings.tooling.permissionMode.plan.description',
descriptionFallback: 'Shares the default read-only tool set but presents a plan before execution.',
behaviorKey: 'agent.settings.tooling.permissionMode.plan.behavior',
behaviorFallback: 'Read-only defaults are pre-approved while execution remains disabled.'
},
{
mode: 'acceptEdits',
titleKey: 'agent.settings.tooling.permissionMode.acceptEdits.title',
titleFallback: 'Auto-accept file edits',
descriptionKey: 'agent.settings.tooling.permissionMode.acceptEdits.description',
descriptionFallback: 'File edits and filesystem operations are automatically approved.',
behaviorKey: 'agent.settings.tooling.permissionMode.acceptEdits.behavior',
behaviorFallback: 'Pre-approves trusted filesystem tools so edits run immediately.'
},
{
mode: 'bypassPermissions',
titleKey: 'agent.settings.tooling.permissionMode.bypassPermissions.title',
titleFallback: 'Bypass permission checks',
descriptionKey: 'agent.settings.tooling.permissionMode.bypassPermissions.description',
descriptionFallback: 'All permission prompts are skipped — use with caution.',
behaviorKey: 'agent.settings.tooling.permissionMode.bypassPermissions.behavior',
behaviorFallback: 'Every tool is pre-approved automatically.',
caution: true
}
]

View File

@@ -1,5 +1,6 @@
/// <reference types="vite/client" />
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import type { ToastUtilities } from '@cherrystudio/ui'
import type { HookAPI } from 'antd/es/modal/useModal'
import type { NavigateFunction } from 'react-router-dom'
@@ -19,5 +20,14 @@ declare global {
store: any
navigate: NavigateFunction
toast: ToastUtilities
agentTools: {
respondToPermission: (payload: {
requestId: string
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
message?: string
updatedPermissions?: PermissionUpdate[]
}) => Promise<{ success: boolean }>
}
}
}

View File

@@ -1,29 +1,23 @@
import { useAppSelector } from '@renderer/store'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLocation, useNavigate } from 'react-router-dom'
const NavigationHandler: React.FC = () => {
const location = useLocation()
const navigate = useNavigate()
const showSettingsShortcutEnabled = useAppSelector(
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
)
useHotkeys(
'meta+, ! ctrl+,',
function () {
useShortcut(
'shortcut.app.show_settings',
() => {
if (location.pathname.startsWith('/settings')) {
return
}
navigate('/settings/provider')
},
{
splitKey: '!',
enableOnContentEditable: true,
enableOnFormTags: true,
enabled: showSettingsShortcutEnabled
enableOnContentEditable: true
}
)

View File

@@ -0,0 +1,49 @@
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { CreateSessionForm } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* Returns a stable callback that creates a default agent session and updates UI state.
*/
export const useCreateDefaultSession = (agentId: string | null) => {
const { agent } = useAgent(agentId)
const { createSession } = useSessions(agentId)
const dispatch = useAppDispatch()
const { t } = useTranslation()
const [creatingSession, setCreatingSession] = useState(false)
const createDefaultSession = useCallback(async () => {
if (!agentId || !agent || creatingSession) {
return null
}
setCreatingSession(true)
try {
const session = {
...agent,
id: undefined,
name: t('common.unnamed')
} satisfies CreateSessionForm
const created = await createSession(session)
if (created) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
dispatch(setActiveTopicOrSessionAction('session'))
}
return created
} finally {
setCreatingSession(false)
}
}, [agentId, agent, createSession, creatingSession, dispatch, t])
return {
createDefaultSession,
creatingSession
}
}

View File

@@ -13,12 +13,18 @@ import { useAppDispatch } from '@renderer/store'
import { useAppSelector } from '@renderer/store'
import { handleSaveData } from '@renderer/store'
import { selectMemoryConfig } from '@renderer/store/memory'
import {
type ToolPermissionRequestPayload,
type ToolPermissionResultPayload,
toolPermissionsActions
} from '@renderer/store/toolPermissions'
import { delay, runAsyncFunction } from '@renderer/utils'
import { checkDataLimit } from '@renderer/utils'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
@@ -27,6 +33,7 @@ import { useNavbarPosition } from './useNavbar'
const logger = loggerService.withContext('useAppInit')
export function useAppInit() {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const [language] = usePreference('app.language')
const [windowStyle] = usePreference('ui.window_style')
@@ -148,6 +155,64 @@ export function useAppInit() {
}
}, [customCss])
useEffect(() => {
if (!window.electron?.ipcRenderer) return
const requestListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => {
logger.debug('Renderer received tool permission request', {
requestId: payload.requestId,
toolName: payload.toolName,
expiresAt: payload.expiresAt,
suggestionCount: payload.suggestions.length
})
dispatch(toolPermissionsActions.requestReceived(payload))
}
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {
logger.debug('Renderer received tool permission result', {
requestId: payload.requestId,
behavior: payload.behavior,
reason: payload.reason
})
dispatch(toolPermissionsActions.requestResolved(payload))
if (payload.behavior === 'deny') {
const message =
payload.reason === 'timeout'
? (payload.message ?? t('agent.toolPermission.toast.timeout'))
: (payload.message ?? t('agent.toolPermission.toast.denied'))
if (payload.reason === 'no-window') {
logger.debug('Displaying deny toast for tool permission', {
requestId: payload.requestId,
behavior: payload.behavior,
reason: payload.reason
})
window.toast?.error?.(message)
} else if (payload.reason === 'timeout') {
logger.debug('Displaying timeout toast for tool permission', {
requestId: payload.requestId
})
window.toast?.warning?.(message)
} else {
logger.debug('Displaying info toast for tool permission deny', {
requestId: payload.requestId,
reason: payload.reason
})
window.toast?.info?.(message)
}
}
}
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Result, resultListener)
return () => {
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Result, resultListener)
}
}, [dispatch, t])
useEffect(() => {
// TODO: init data collection
}, [enableDataCollection])

View File

@@ -57,7 +57,7 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
label: t('settings.tool.preprocess.provider'),
title: t('settings.tool.preprocess.provider'),
options: preprocessProviders
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
.filter((p) => p.apiKey !== '' || ['mineru', 'open-mineru'].includes(p.id))
.map((p) => ({ value: p.id, label: p.name }))
}
return [preprocessOptions]

View File

@@ -0,0 +1,163 @@
import type { InstalledPlugin, PluginError, PluginMetadata } from '@renderer/types/plugin'
import { useCallback, useEffect, useState } from 'react'
/**
* Helper to extract error message from PluginError union type
*/
function getPluginErrorMessage(error: PluginError, defaultMessage: string): string {
if ('message' in error && error.message) return error.message
if ('reason' in error) return error.reason
if ('path' in error) return `Error with file: ${error.path}`
return defaultMessage
}
/**
* Hook to fetch and cache available plugins from the resources directory
* @returns Object containing available agents, commands, skills, loading state, and error
*/
export function useAvailablePlugins() {
const [agents, setAgents] = useState<PluginMetadata[]>([])
const [commands, setCommands] = useState<PluginMetadata[]>([])
const [skills, setSkills] = useState<PluginMetadata[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchAvailablePlugins = async () => {
setLoading(true)
setError(null)
try {
const result = await window.api.claudeCodePlugin.listAvailable()
if (result.success) {
setAgents(result.data.agents)
setCommands(result.data.commands)
setSkills(result.data.skills)
} else {
setError(getPluginErrorMessage(result.error, 'Failed to load available plugins'))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred')
} finally {
setLoading(false)
}
}
fetchAvailablePlugins()
}, [])
return { agents, commands, skills, loading, error }
}
/**
* Hook to fetch installed plugins for a specific agent
* @param agentId - The ID of the agent to fetch plugins for
* @returns Object containing installed plugins, loading state, error, and refresh function
*/
export function useInstalledPlugins(agentId: string | undefined) {
const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!agentId) {
setPlugins([])
setLoading(false)
setError(null)
return
}
setLoading(true)
setError(null)
try {
const result = await window.api.claudeCodePlugin.listInstalled(agentId)
if (result.success) {
setPlugins(result.data)
} else {
setError(getPluginErrorMessage(result.error, 'Failed to load installed plugins'))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred')
} finally {
setLoading(false)
}
}, [agentId])
useEffect(() => {
refresh()
}, [refresh])
return { plugins, loading, error, refresh }
}
/**
* Hook to provide install and uninstall actions for plugins
* @param agentId - The ID of the agent to perform actions for
* @param onSuccess - Optional callback to be called on successful operations
* @returns Object containing install, uninstall functions and their loading states
*/
export function usePluginActions(agentId: string, onSuccess?: () => void) {
const [installing, setInstalling] = useState<boolean>(false)
const [uninstalling, setUninstalling] = useState<boolean>(false)
const install = useCallback(
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
setInstalling(true)
try {
const result = await window.api.claudeCodePlugin.install({
agentId,
sourcePath,
type
})
if (result.success) {
onSuccess?.()
return { success: true as const, data: result.data }
} else {
const errorMessage = getPluginErrorMessage(result.error, 'Failed to install plugin')
return { success: false as const, error: errorMessage }
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
return { success: false as const, error: errorMessage }
} finally {
setInstalling(false)
}
},
[agentId, onSuccess]
)
const uninstall = useCallback(
async (filename: string, type: 'agent' | 'command' | 'skill') => {
setUninstalling(true)
try {
const result = await window.api.claudeCodePlugin.uninstall({
agentId,
filename,
type
})
if (result.success) {
onSuccess?.()
return { success: true as const }
} else {
const errorMessage = getPluginErrorMessage(result.error, 'Failed to uninstall plugin')
return { success: false as const, error: errorMessage }
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
return { success: false as const, error: errorMessage }
} finally {
setUninstalling(false)
}
},
[agentId, onSuccess]
)
return { install, uninstall, installing, uninstalling }
}

View File

@@ -1,7 +1,15 @@
import { isMac, isWin } from '@renderer/config/constant'
import { useAppSelector } from '@renderer/store'
import { orderBy } from 'lodash'
import { useCallback } from 'react'
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
import { isMac } from '@renderer/config/constant'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
import type { ShortcutDefinition, ShortcutPreferenceKey, ShortcutPreferenceValue } from '@shared/shortcuts/types'
import {
coerceShortcutPreference,
convertAcceleratorToHotkey,
formatShortcutDisplay,
getDefaultShortcutPreference
} from '@shared/shortcuts/utils'
import { useCallback, useMemo } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
interface UseShortcutOptions {
@@ -9,85 +17,175 @@ interface UseShortcutOptions {
enableOnFormTags?: boolean
enabled?: boolean
description?: string
enableOnContentEditable?: boolean
}
const defaultOptions: UseShortcutOptions = {
preventDefault: true,
enableOnFormTags: true,
enabled: true
enabled: true,
enableOnContentEditable: false
}
const resolvePreferenceValue = (
definition: ShortcutDefinition | undefined,
preference: PreferenceShortcutType | Record<string, unknown> | undefined
): ShortcutPreferenceValue | null => {
if (!definition) {
return null
}
return coerceShortcutPreference(definition, preference as PreferenceShortcutType | undefined)
}
export const useShortcut = (
shortcutKey: string,
callback: (e: KeyboardEvent) => void,
shortcutKey: ShortcutPreferenceKey,
callback: (event: KeyboardEvent) => void,
options: UseShortcutOptions = defaultOptions
) => {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
const [preference] = usePreference(shortcutKey)
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
const formatShortcut = useCallback((shortcut: string[]) => {
return shortcut
.map((key) => {
switch (key.toLowerCase()) {
case 'command':
return 'meta'
case 'commandorcontrol':
return isMac ? 'meta' : 'ctrl'
default:
return key.toLowerCase()
}
})
.join('+')
}, [])
const hotkey = useMemo(() => {
if (!definition || !preferenceState) {
return 'none'
}
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
if (definition.scope === 'main') {
return 'none'
}
if (!preferenceState.enabled) {
return 'none'
}
const effectiveBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
if (!effectiveBinding.length) {
return 'none'
}
return convertAcceleratorToHotkey(effectiveBinding)
}, [definition, preferenceState])
useHotkeys(
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
(e) => {
hotkey,
(event) => {
if (options.preventDefault) {
e.preventDefault()
event.preventDefault()
}
if (options.enabled !== false) {
callback(e)
callback(event)
}
},
{
enableOnFormTags: options.enableOnFormTags,
description: options.description || shortcutConfig?.key,
enabled: !!shortcutConfig?.enabled
}
description: options.description ?? shortcutKey,
enabled: hotkey !== 'none',
enableOnContentEditable: options.enableOnContentEditable
},
[hotkey, callback, options]
)
}
export function useShortcuts() {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
export const useShortcutDisplay = (shortcutKey: ShortcutPreferenceKey): string => {
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
const [preference] = usePreference(shortcutKey)
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
if (!definition || !preferenceState || !preferenceState.enabled) {
return ''
}
const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
if (!displayBinding.length) {
return ''
}
return formatShortcutDisplay(displayBinding, isMac)
}
export function useShortcutDisplay(key: string) {
const formatShortcut = useCallback((shortcut: string[]) => {
return shortcut
.map((key) => {
switch (key.toLowerCase()) {
case 'control':
return isMac ? '⌃' : 'Ctrl'
case 'ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'command':
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'alt':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
})
.join('+')
}, [])
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const shortcutConfig = shortcuts.find((s) => s.key === key)
return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : ''
export interface ShortcutListItem {
definition: ShortcutDefinition
preference: ShortcutPreferenceValue
defaultPreference: ShortcutPreferenceValue
updatePreference: (patch: Partial<PreferenceShortcutType>) => Promise<void>
}
export const useAllShortcuts = (): ShortcutListItem[] => {
const keyMap = useMemo(
() =>
SHORTCUT_DEFINITIONS.reduce<Record<string, ShortcutPreferenceKey>>((acc, definition) => {
acc[definition.key] = definition.key
return acc
}, {}),
[]
)
const [values, setValues] = useMultiplePreferences(keyMap)
const buildNextPreference = useCallback(
(
state: ShortcutPreferenceValue,
currentValue: PreferenceShortcutType | undefined,
patch: Partial<PreferenceShortcutType>
): PreferenceShortcutType => {
const current = (currentValue ?? {}) as PreferenceShortcutType
const nextKey = Array.isArray(patch.key) ? patch.key : Array.isArray(current.key) ? current.key : state.rawBinding
const nextEnabled =
typeof patch.enabled === 'boolean'
? patch.enabled
: typeof current.enabled === 'boolean'
? current.enabled
: state.enabled
const nextEditable =
typeof patch.editable === 'boolean'
? patch.editable
: typeof current.editable === 'boolean'
? current.editable
: state.editable
const nextSystem =
typeof patch.system === 'boolean'
? patch.system
: typeof current.system === 'boolean'
? current.system
: state.system
return {
key: nextKey,
enabled: nextEnabled,
editable: nextEditable,
system: nextSystem
}
},
[]
)
return useMemo(
() =>
SHORTCUT_DEFINITIONS.map((definition) => {
const rawValue = values[definition.key] as PreferenceShortcutType | undefined
const preference = coerceShortcutPreference(definition, rawValue)
const defaultPreference = getDefaultShortcutPreference(definition)
const updatePreference = async (patch: Partial<PreferenceShortcutType>) => {
const currentValue = values[definition.key] as PreferenceShortcutType | undefined
const nextValue = buildNextPreference(preference, currentValue, patch)
await setValues({ [definition.key]: nextValue } as Partial<Record<string, PreferenceShortcutType>>)
}
return {
definition,
preference,
defaultPreference,
updatePreference
}
}),
[buildNextPreference, setValues, values]
)
}

View File

@@ -42,7 +42,7 @@ export function getVertexAIServiceAccount() {
* 类型守卫:检查 Provider 是否为 VertexProvider
*/
export function isVertexProvider(provider: Provider): provider is VertexProvider {
return provider.type === 'vertexai' && 'googleCredentials' in provider
return provider.type === 'vertexai'
}
/**

View File

@@ -107,6 +107,50 @@
"title": "Advanced Settings"
},
"essential": "Essential Settings",
"plugins": {
"available": {
"title": "Available Plugins"
},
"confirm": {
"uninstall": "Are you sure you want to uninstall this plugin?"
},
"empty": {
"available": "No plugins found matching your filters. Try adjusting your search or category filters."
},
"error": {
"install": "Failed to install plugin",
"load": "Failed to load plugins",
"uninstall": "Failed to uninstall plugin"
},
"filter": {
"all": "All Categories"
},
"install": "Install",
"installed": {
"empty": "No plugins installed yet. Browse available plugins to get started.",
"title": "Installed Plugins"
},
"installing": "Installing...",
"results": "{{count}} plugin(s) found",
"search": {
"placeholder": "Search plugins..."
},
"success": {
"install": "Plugin installed successfully",
"uninstall": "Plugin uninstalled successfully"
},
"tab": "Plugins",
"type": {
"agent": "Agent",
"agents": "Agents",
"all": "All",
"command": "Command",
"commands": "Commands",
"skills": "Skills"
},
"uninstall": "Uninstall",
"uninstalling": "Uninstalling..."
},
"prompt": "Prompt Settings",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Allow tool request",
"denyRequest": "Deny tool request",
"hideDetails": "Hide tool details",
"runWithOptions": "Run with additional options",
"showDetails": "Show tool details"
},
"button": {
"cancel": "Cancel",
"run": "Run"
},
"confirmation": "Are you sure you want to run this Claude tool?",
"defaultDenyMessage": "User denied permission for this tool.",
"defaultDescription": "Executes code or system actions in your environment. Make sure the command looks safe before running it.",
"error": {
"sendFailed": "Failed to send your decision. Please try again."
},
"expired": "Expired",
"inputPreview": "Tool input preview",
"pending": "Pending ({{seconds}}s)",
"permissionExpired": "Permission request expired. Waiting for new instructions...",
"requiresElevatedPermissions": "This tool requires elevated permissions.",
"suggestion": {
"permissionUpdateMultiple": "Approving may update multiple session permissions if you chose to always allow this tool.",
"permissionUpdateSingle": "Approving may update your session permissions if you chose to always allow this tool."
},
"toast": {
"denied": "Tool request was denied.",
"timeout": "Tool request timed out before receiving approval."
},
"waiting": "Waiting for tool permission decision..."
},
"type": {
"label": "Agent Type",
"unknown": "Unknown Type"
@@ -2299,6 +2376,32 @@
"seed_tip": "Controls upscaling randomness"
}
},
"plugins": {
"actions": "Actions",
"agents": "Agents",
"all_categories": "All Categories",
"all_types": "All",
"category": "Category",
"commands": "Commands",
"confirm_uninstall": "Are you sure you want to uninstall {{name}}?",
"install": "Install",
"install_plugins_from_browser": "Browse available plugins to get started",
"installing": "Installing...",
"name": "Name",
"no_description": "No description available",
"no_installed_plugins": "No plugins installed yet",
"no_results": "No plugins found",
"search_placeholder": "Search plugins...",
"showing_results": "Showing {{count}} plugin",
"showing_results_one": "Showing {{count}} plugin",
"showing_results_other": "Showing {{count}} plugins",
"showing_results_plural": "Showing {{count}} plugins",
"skills": "Skills",
"try_different_search": "Try adjusting your search or category filters",
"type": "Type",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling..."
},
"preview": {
"copy": {
"image": "Copy as image"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Anthropic API Host",
"anthropic_api_host_preview": "Anthropic preview: {{url}}",
"anthropic_api_host_tip": "Only configure this when your provider exposes an Anthropic-compatible endpoint. Ending with / ignores v1, ending with # forces use of input address.",
"anthropic_api_host_tooltip": "Use only when the provider offers a Claude-compatible base URL.",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "Preview: {{url}}",
"reset": "Reset",
"tip": "Ending with / ignores v1, ending with # forces use of input address"
"tip": "ending with # forces use of input address"
}
},
"api_host": "API Host",
"api_host_no_valid": "API address is invalid",
"api_host_preview": "Preview: {{url}}",
"api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "高级设置"
},
"essential": "基础设置",
"plugins": {
"available": {
"title": "可用插件"
},
"confirm": {
"uninstall": "确定要卸载此插件吗?"
},
"empty": {
"available": "未找到匹配的插件。请尝试调整搜索或类别筛选。"
},
"error": {
"install": "安装插件失败",
"load": "加载插件失败",
"uninstall": "卸载插件失败"
},
"filter": {
"all": "所有类别"
},
"install": "安装",
"installed": {
"empty": "尚未安装任何插件。浏览可用插件以开始使用。",
"title": "已安装插件"
},
"installing": "安装中...",
"results": "找到 {{count}} 个插件",
"search": {
"placeholder": "搜索插件..."
},
"success": {
"install": "插件安装成功",
"uninstall": "插件卸载成功"
},
"tab": "插件",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "命令",
"commands": "命令",
"skills": "技能"
},
"uninstall": "卸载",
"uninstalling": "卸载中..."
},
"prompt": "提示词设置",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "允许工具请求",
"denyRequest": "拒绝工具请求",
"hideDetails": "隐藏工具详情",
"runWithOptions": "带选项运行",
"showDetails": "显示工具详情"
},
"button": {
"cancel": "取消",
"run": "运行"
},
"confirmation": "确定要运行此 Claude 工具吗?",
"defaultDenyMessage": "用户拒绝了该工具的权限。",
"defaultDescription": "在您的环境中执行代码或系统操作。运行前请确保命令安全。",
"error": {
"sendFailed": "发送您的决定失败,请重试。"
},
"expired": "已过期",
"inputPreview": "工具输入预览",
"pending": "等待中 ({{seconds}}秒)",
"permissionExpired": "权限请求已过期。等待新指令...",
"requiresElevatedPermissions": "此工具需要更高权限。",
"suggestion": {
"permissionUpdateMultiple": "如果您选择总是允许此工具,批准可能会更新多个会话权限。",
"permissionUpdateSingle": "如果您选择总是允许此工具,批准可能会更新您的会话权限。"
},
"toast": {
"denied": "工具请求已被拒绝。",
"timeout": "工具请求在收到批准前超时。"
},
"waiting": "等待工具权限决定..."
},
"type": {
"label": "智能体类型",
"unknown": "未知类型"
@@ -2299,6 +2376,32 @@
"seed_tip": "控制放大结果的随机性"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "所有类别",
"all_types": "全部",
"category": "类别",
"commands": "命令",
"confirm_uninstall": "确定要卸载 {{name}} 吗?",
"install": "安装",
"install_plugins_from_browser": "浏览可用插件以开始使用",
"installing": "安装中...",
"name": "名称",
"no_description": "无描述",
"no_installed_plugins": "尚未安装任何插件",
"no_results": "未找到插件",
"search_placeholder": "搜索插件...",
"showing_results": "显示 {{count}} 个插件",
"showing_results_one": "显示 {{count}} 个插件",
"showing_results_other": "显示 {{count}} 个插件",
"showing_results_plural": "显示 {{count}} 个插件",
"skills": "技能",
"try_different_search": "请尝试调整搜索或类别筛选",
"type": "类型",
"uninstall": "卸载",
"uninstalling": "卸载中..."
},
"preview": {
"copy": {
"image": "复制为图片"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Anthropic API 地址",
"anthropic_api_host_preview": "Anthropic 预览:{{url}}",
"anthropic_api_host_tip": "仅在服务商提供兼容 Anthropic 的地址时填写。以 / 结尾会忽略自动追加的 v1以 # 结尾则强制使用原始地址。",
"anthropic_api_host_tooltip": "仅当服务商提供 Claude 兼容的基础地址时填写。",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "预览: {{url}}",
"reset": "重置",
"tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址"
"tip": "# 结尾强制使用输入地址"
}
},
"api_host": "API 地址",
"api_host_no_valid": "API 地址不合法",
"api_host_preview": "预览:{{url}}",
"api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "進階設定"
},
"essential": "必要設定",
"plugins": {
"available": {
"title": "可用外掛"
},
"confirm": {
"uninstall": "確定要解除安裝此外掛嗎?"
},
"empty": {
"available": "未找到符合的外掛。請嘗試調整搜尋或類別篩選。"
},
"error": {
"install": "安裝外掛失敗",
"load": "載入外掛失敗",
"uninstall": "解除安裝外掛失敗"
},
"filter": {
"all": "所有類別"
},
"install": "安裝",
"installed": {
"empty": "尚未安裝任何外掛。瀏覽可用外掛以開始使用。",
"title": "已安裝外掛"
},
"installing": "安裝中...",
"results": "找到 {{count}} 個外掛",
"search": {
"placeholder": "搜尋外掛..."
},
"success": {
"install": "外掛安裝成功",
"uninstall": "外掛解除安裝成功"
},
"tab": "外掛",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "指令",
"commands": "指令",
"skills": "技能"
},
"uninstall": "解除安裝",
"uninstalling": "解除安裝中..."
},
"prompt": "提示設定",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "允許工具請求",
"denyRequest": "拒絕工具請求",
"hideDetails": "隱藏工具詳情",
"runWithOptions": "帶選項執行",
"showDetails": "顯示工具詳情"
},
"button": {
"cancel": "取消",
"run": "執行"
},
"confirmation": "確定要執行此 Claude 工具嗎?",
"defaultDenyMessage": "使用者拒絕了該工具的權限。",
"defaultDescription": "在您的環境中執行程式碼或系統操作。執行前請確保指令安全。",
"error": {
"sendFailed": "傳送您的決定失敗,請重試。"
},
"expired": "已過期",
"inputPreview": "工具輸入預覽",
"pending": "等待中 ({{seconds}}秒)",
"permissionExpired": "權限請求已過期。等待新指令...",
"requiresElevatedPermissions": "此工具需要提升的權限。",
"suggestion": {
"permissionUpdateMultiple": "如果您選擇總是允許此工具,核准可能會更新多個工作階段權限。",
"permissionUpdateSingle": "如果您選擇總是允許此工具,核准可能會更新您的工作階段權限。"
},
"toast": {
"denied": "工具請求已被拒絕。",
"timeout": "工具請求在收到核准前逾時。"
},
"waiting": "等待工具權限決定..."
},
"type": {
"label": "代理類型",
"unknown": "未知類型"
@@ -2299,6 +2376,32 @@
"seed_tip": "控制放大結果的隨機性"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "所有類別",
"all_types": "全部",
"category": "類別",
"commands": "指令",
"confirm_uninstall": "確定要解除安裝 {{name}} 嗎?",
"install": "安裝",
"install_plugins_from_browser": "瀏覽可用外掛以開始使用",
"installing": "安裝中...",
"name": "名稱",
"no_description": "無描述",
"no_installed_plugins": "尚未安裝任何外掛",
"no_results": "未找到外掛",
"search_placeholder": "搜尋外掛...",
"showing_results": "顯示 {{count}} 個外掛",
"showing_results_one": "顯示 {{count}} 個外掛",
"showing_results_other": "顯示 {{count}} 個外掛",
"showing_results_plural": "顯示 {{count}} 個外掛",
"skills": "技能",
"try_different_search": "請嘗試調整搜尋或類別篩選",
"type": "類型",
"uninstall": "解除安裝",
"uninstalling": "解除安裝中..."
},
"preview": {
"copy": {
"image": "複製為圖片"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Anthropic API 主機地址",
"anthropic_api_host_preview": "Anthropic 預覽:{{url}}",
"anthropic_api_host_tip": "僅在服務商提供與 Anthropic 相容的網址時設定。以 / 結尾會忽略自動附加的 v1以 # 結尾則強制使用原始地址。",
"anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "預覽:{{url}}",
"reset": "重設",
"tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址"
"tip": "# 結尾強制使用輸入位址"
}
},
"api_host": "API 主機地址",
"api_host_no_valid": "API 位址不合法",
"api_host_preview": "預覽:{{url}}",
"api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "Erweiterte Einstellungen"
},
"essential": "Grundeinstellungen",
"plugins": {
"available": {
"title": "Verfügbare Plugins"
},
"confirm": {
"uninstall": "Sind Sie sicher, dass Sie dieses Plugin deinstallieren möchten?"
},
"empty": {
"available": "Keine Plugins gefunden, die deinen Filtern entsprechen. Versuche, deine Such- oder Kategoriefilter anzupassen."
},
"error": {
"install": "Fehler beim Installieren des Plugins",
"load": "Fehler beim Laden der Plugins",
"uninstall": "Fehler beim Deinstallieren des Plugins"
},
"filter": {
"all": "Alle Kategorien"
},
"install": "Installieren",
"installed": {
"empty": "Noch keine Plugins installiert. Durchsuche verfügbare Plugins, um loszulegen.",
"title": "Installierte Plugins"
},
"installing": "Wird installiert...",
"results": "{{count}} Plugin(s) gefunden",
"search": {
"placeholder": "Such-Plugins..."
},
"success": {
"install": "Plugin erfolgreich installiert",
"uninstall": "Plugin erfolgreich deinstalliert"
},
"tab": "Plugins",
"type": {
"agent": "Agent",
"agents": "Agenten",
"all": "Alle",
"command": "Befehl",
"commands": "Befehle",
"skills": "Fähigkeiten"
},
"uninstall": "Deinstallieren",
"uninstalling": "Deinstallation läuft..."
},
"prompt": "Prompt-Einstellungen",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Werkzeuganfrage zulassen",
"denyRequest": "Werkzeuganfrage ablehnen",
"hideDetails": "Werkzeugdetails ausblenden",
"runWithOptions": "Mit zusätzlichen Optionen ausführen",
"showDetails": "Zeige Werkzeugdetails"
},
"button": {
"cancel": "Abbrechen",
"run": "Laufen"
},
"confirmation": "Bist du sicher, dass du dieses Claude-Tool ausführen möchtest?",
"defaultDenyMessage": "Der Benutzer hat die Berechtigung für dieses Tool verweigert.",
"defaultDescription": "Führt Code oder Systemaktionen in Ihrer Umgebung aus. Vergewissern Sie sich, dass der Befehl sicher aussieht, bevor Sie ihn ausführen.",
"error": {
"sendFailed": "Ihre Entscheidung konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
},
"expired": "Abgelaufen",
"inputPreview": "Vorschau der Werkzeugeingabe",
"pending": "Ausstehend ({{seconds}}s)",
"permissionExpired": "Berechtigungsanfrage abgelaufen. Warte auf neue Anweisungen...",
"requiresElevatedPermissions": "Dieses Tool erfordert erhöhte Berechtigungen.",
"suggestion": {
"permissionUpdateMultiple": "Das Genehmigen kann mehrere Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen.",
"permissionUpdateSingle": "Das Genehmigen kann Ihre Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen."
},
"toast": {
"denied": "Tool-Anfrage wurde abgelehnt.",
"timeout": "Tool-Anfrage ist abgelaufen, bevor eine Genehmigung eingegangen ist."
},
"waiting": "Warten auf Entscheidung über Tool-Berechtigung..."
},
"type": {
"label": "Agent-Typ",
"unknown": "Unbekannter Typ"
@@ -2299,6 +2376,32 @@
"seed_tip": "Kontrolle der Zufälligkeit des Upscale-Ergebnisses"
}
},
"plugins": {
"actions": "Aktionen",
"agents": "Agenten",
"all_categories": "Alle Kategorien",
"all_types": "Alle",
"category": "Kategorie",
"commands": "Befehle",
"confirm_uninstall": "Sind Sie sicher, dass Sie {{name}} deinstallieren möchten?",
"install": "Installieren",
"install_plugins_from_browser": "Durchsuche verfügbare Plugins, um loszulegen",
"installing": "Installiere…",
"name": "Name",
"no_description": "Keine Beschreibung verfügbar",
"no_installed_plugins": "Noch keine Plugins installiert",
"no_results": "Keine Plugins gefunden",
"search_placeholder": "Such-Plugins...",
"showing_results": "{{count}} Plugin anzeigen",
"showing_results_one": "{{count}} Plugin anzeigen",
"showing_results_other": "Zeige {{count}} Plugins",
"showing_results_plural": "{{count}} Plugins anzeigen",
"skills": "Fähigkeiten",
"try_different_search": "Versuchen Sie, Ihre Suche oder die Kategoriefilter anzupassen.",
"type": "Typ",
"uninstall": "Deinstallieren",
"uninstalling": "Deinstallation läuft..."
},
"preview": {
"copy": {
"image": "Als Bild kopieren"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Anthropic API-Adresse",
"anthropic_api_host_preview": "Anthropic-Vorschau: {{url}}",
"anthropic_api_host_tip": "Nur bei Anbietern, die ein Anthropic-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
"anthropic_api_host_tooltip": "Nur bei Anbietern, die ein Claude-kompatibles Basis-Endpunkt anbieten.",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "Vorschau: {{url}}",
"reset": "Zurücksetzen",
"tip": "/ am Ende ignorieren v1-Version, # am Ende erzwingt die Verwendung der Eingabe-Adresse"
"tip": "# am Ende erzwingt die Verwendung der Eingabe-Adresse"
}
},
"api_host": "API-Adresse",
"api_host_no_valid": "API-Adresse ist ungültig",
"api_host_preview": "Vorschau: {{url}}",
"api_host_tooltip": "Nur bei Anbietern, die ein OpenAI-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "Ρυθμίσεις για προχωρημένους"
},
"essential": "Βασικές Ρυθμίσεις",
"plugins": {
"available": {
"title": "Διαθέσιμα πρόσθετα"
},
"confirm": {
"uninstall": "Είστε βέβαιοι ότι θέλετε να απεγκαταστήσετε αυτό το πρόσθετο;"
},
"empty": {
"available": "Δεν βρέθηκε συμβατό πρόσθετο. Δοκιμάστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών."
},
"error": {
"install": "Η εγκατάσταση του πρόσθετου απέτυχε",
"load": "Η φόρτωση του πρόσθετου απέτυχε",
"uninstall": "Η απεγκατάσταση του πρόσθετου απέτυχε"
},
"filter": {
"all": "Όλες οι κατηγορίες"
},
"install": "εγκατάσταση",
"installed": {
"empty": "Δεν έχει εγκατασταθεί κανένα πρόσθετο. Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε.",
"title": "Έχει εγκατασταθεί το πρόσθετο"
},
"installing": "Εγκατάσταση...",
"results": "Βρέθηκαν {{count}} πρόσθετα",
"search": {
"placeholder": "Αναζήτηση πρόσθετου..."
},
"success": {
"install": "Η εγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία",
"uninstall": "Η απεγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία"
},
"tab": "Πρόσθετο",
"type": {
"agent": "αντιπρόσωπος",
"agents": "αντιπρόσωπος",
"all": "όλα",
"command": "εντολή",
"commands": "εντολή",
"skills": "δεξιότητα"
},
"uninstall": "απεγκατάσταση",
"uninstalling": "Απεγκατάσταση..."
},
"prompt": "Ρυθμίσεις Προτροπής",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Επίτρεψη αίτησης εργαλείου",
"denyRequest": "Απόρριψη αιτήματος εργαλείου",
"hideDetails": "Απόκρυψη λεπτομερειών εργαλείου",
"runWithOptions": "Εκτέλεση με επιπλέον επιλογές",
"showDetails": "Εμφάνιση λεπτομερειών εργαλείου"
},
"button": {
"cancel": "Ακύρωση",
"run": "Τρέξε"
},
"confirmation": "Είσαι σίγουρος ότι θέλεις να εκτελέσεις αυτό το εργαλείο Claude;",
"defaultDenyMessage": "Ο χρήστης αρνήθηκε την άδεια για αυτό το εργαλείο.",
"defaultDescription": "Εκτελεί κώδικα ή ενέργειες συστήματος στο περιβάλλον σας. Βεβαιωθείτε ότι η εντολή φαίνεται ασφαλής πριν την εκτελέσετε.",
"error": {
"sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά."
},
"expired": "Ληγμένο",
"inputPreview": "Προεπισκόπηση εισόδου εργαλείου",
"pending": "Εκκρεμεί ({{seconds}}δ)",
"permissionExpired": "Το αίτημα άδειας έληξε. Αναμονή για νέες οδηγίες...",
"requiresElevatedPermissions": "Αυτό το εργαλείο απαιτεί αυξημένα δικαιώματα.",
"suggestion": {
"permissionUpdateMultiple": "Η έγκριση μπορεί να ενημερώσει πολλές άδειες συνεδρίας αν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο.",
"permissionUpdateSingle": "Η έγκριση ενδέχεται να ενημερώσει τα δικαιώματα περιόδου σύνδεσής σας, εάν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο."
},
"toast": {
"denied": "Το αίτημα για εργαλείο απορρίφθηκε.",
"timeout": "Το αίτημα για το εργαλείο έληξε πριν λάβει έγκριση."
},
"waiting": "Αναμονή για απόφαση άδειας εργαλείου..."
},
"type": {
"label": "Τύπος Πράκτορα",
"unknown": "Άγνωστος Τύπος"
@@ -2299,6 +2376,32 @@
"seed_tip": "Ελέγχει την τυχαιότητα του αποτελέσματος μεγέθυνσης"
}
},
"plugins": {
"actions": "Λειτουργία",
"agents": "αντιπρόσωπος",
"all_categories": "Όλες οι κατηγορίες",
"all_types": "ολόκληρο",
"category": "Κατηγορία",
"commands": "εντολή",
"confirm_uninstall": "Είστε σίγουροι ότι θέλετε να απεγκαταστήσετε το {{name}};",
"install": "εγκατάσταση",
"install_plugins_from_browser": "Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε",
"installing": "Εγκατάσταση...",
"name": "Όνομα",
"no_description": "Χωρίς περιγραφή",
"no_installed_plugins": "Δεν έχει εγκατασταθεί κανένα πρόσθετο",
"no_results": "Δεν βρέθηκε πρόσθετο",
"search_placeholder": "Πρόσθετο αναζήτησης...",
"showing_results": "Εμφάνιση {{count}} προσθέτων",
"showing_results_one": "Εμφάνιση {{count}} προσθέτων",
"showing_results_other": "Εμφάνιση {{count}} προσθέτων",
"showing_results_plural": "Εμφάνιση {{count}} πρόσθετων",
"skills": "δεξιότητα",
"try_different_search": "Προσπαθήστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών",
"type": "τύπος",
"uninstall": "κατάργηση εγκατάστασης",
"uninstalling": "Απεγκατάσταση..."
},
"preview": {
"copy": {
"image": "Αντιγραφή ως εικόνα"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Διεύθυνση API Anthropic",
"anthropic_api_host_preview": "Προεπισκόπηση Anthropic: {{url}}",
"anthropic_api_host_tip": "Συμπληρώστε μόνο εάν ο πάροχος προσφέρει συμβατή με Anthropic διεύθυνση. Η λήξη με / αγνοεί το v1 που προστίθεται αυτόματα, η λήξη με # επιβάλλει τη χρήση της αρχικής διεύθυνσης.",
"anthropic_api_host_tooltip": "Συμπληρώστε μόνο όταν ο πάροχος παρέχει βασική διεύθυνση συμβατή με Claude.",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "Προεπισκόπηση: {{url}}",
"reset": "Επαναφορά",
"tip": "/τέλος αγνόηση v1 έκδοσης, #τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
}
},
"api_host": "Διεύθυνση API",
"api_host_no_valid": "Η διεύθυνση API δεν είναι έγκυρη",
"api_host_preview": "Προεπισκόπηση: {{url}}",
"api_host_tooltip": "Αντικατάσταση μόνο όταν ο πάροχος απαιτεί προσαρμοσμένη διεύθυνση συμβατή με OpenAI.",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "Configuración avanzada"
},
"essential": "Configuraciones esenciales",
"plugins": {
"available": {
"title": "Complementos disponibles"
},
"confirm": {
"uninstall": "¿Estás seguro de que quieres desinstalar este complemento?"
},
"empty": {
"available": "No se encontró ningún complemento que coincida. Intenta ajustar la búsqueda o los filtros de categoría."
},
"error": {
"install": "Error al instalar el complemento",
"load": "Error al cargar el complemento",
"uninstall": "Error al desinstalar el complemento"
},
"filter": {
"all": "Todas las categorías"
},
"install": "instalación",
"installed": {
"empty": "Aún no se ha instalado ningún complemento. Explora los complementos disponibles para comenzar.",
"title": "Complemento instalado"
},
"installing": "Instalando...",
"results": "Encontrados {{count}} complementos",
"search": {
"placeholder": "Buscar complemento..."
},
"success": {
"install": "Complemento instalado con éxito",
"uninstall": "Complemento desinstalado correctamente"
},
"tab": "complemento",
"type": {
"agent": "agente",
"agents": "Agente",
"all": "todo",
"command": "comando",
"commands": "comando",
"skills": "habilidad"
},
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"prompt": "Configuración de indicaciones",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Permitir solicitud de herramienta",
"denyRequest": "Denegar solicitud de herramienta",
"hideDetails": "Ocultar detalles de la herramienta",
"runWithOptions": "Ejecutar con opciones adicionales",
"showDetails": "Mostrar detalles de la herramienta"
},
"button": {
"cancel": "Cancelar",
"run": "Correr"
},
"confirmation": "¿Estás seguro de que quieres ejecutar esta herramienta de Claude?",
"defaultDenyMessage": "El usuario denegó el permiso para esta herramienta.",
"defaultDescription": "Ejecuta código o acciones del sistema en tu entorno. Asegúrate de que el comando parezca seguro antes de ejecutarlo.",
"error": {
"sendFailed": "No se pudo enviar tu decisión. Por favor, inténtalo de nuevo."
},
"expired": "Caducado",
"inputPreview": "Vista previa de entrada de herramienta",
"pending": "Pendiente ({{seconds}}s)",
"permissionExpired": "Solicitud de permiso expirada. Esperando nuevas instrucciones...",
"requiresElevatedPermissions": "Esta herramienta requiere permisos elevados.",
"suggestion": {
"permissionUpdateMultiple": "Aprobar puede actualizar varios permisos de sesión si elegiste permitir siempre esta herramienta.",
"permissionUpdateSingle": "Aprobar puede actualizar los permisos de tu sesión si elegiste permitir siempre esta herramienta."
},
"toast": {
"denied": "La solicitud de herramienta fue denegada.",
"timeout": "La solicitud de herramienta expiró antes de recibir la aprobación."
},
"waiting": "Esperando la decisión de permiso de la herramienta..."
},
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo desconocido"
@@ -2299,6 +2376,32 @@
"seed_tip": "Controla la aleatoriedad del resultado de la ampliación"
}
},
"plugins": {
"actions": "Operación",
"agents": "Agente",
"all_categories": "Todas las categorías",
"all_types": "todo",
"category": "Categoría",
"commands": "comando",
"confirm_uninstall": "¿Estás seguro de que quieres desinstalar {{name}}?",
"install": "instalación",
"install_plugins_from_browser": "Explora los complementos disponibles para empezar a usar",
"installing": "Instalando...",
"name": "Nombre",
"no_description": "Sin descripción",
"no_installed_plugins": "Aún no se ha instalado ningún complemento",
"no_results": "No se encontró el complemento",
"search_placeholder": "Buscar complemento...",
"showing_results": "Mostrar {{count}} complementos",
"showing_results_one": "Mostrar {{count}} complementos",
"showing_results_other": "Mostrar {{count}} complementos",
"showing_results_plural": "Mostrar {{count}} complementos",
"skills": "habilidad",
"try_different_search": "Por favor, intenta ajustar la búsqueda o los filtros de categoría.",
"type": "tipo",
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"preview": {
"copy": {
"image": "Copiar como imagen"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Dirección API de Anthropic",
"anthropic_api_host_preview": "Vista previa de Anthropic: {{url}}",
"anthropic_api_host_tip": "Rellenar solo si el proveedor ofrece una dirección compatible con Anthropic. Terminar con / ignora el v1 añadido automáticamente, terminar con # fuerza el uso de la dirección original.",
"anthropic_api_host_tooltip": "Rellenar solo cuando el proveedor proporcione una dirección base compatible con Claude.",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "Vista previa: {{url}}",
"reset": "Restablecer",
"tip": "Ignorar v1 al final con /, forzar uso de dirección de entrada con # al final"
"tip": "forzar uso de dirección de entrada con # al final"
}
},
"api_host": "Dirección API",
"api_host_no_valid": "La dirección de la API no es válida",
"api_host_preview": "Vista previa: {{url}}",
"api_host_tooltip": "Sobrescribir solo cuando el proveedor necesite una dirección compatible con OpenAI personalizada.",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "Paramètres avancés"
},
"essential": "Paramètres essentiels",
"plugins": {
"available": {
"title": "Plugins disponibles"
},
"confirm": {
"uninstall": "Êtes-vous sûr de vouloir désinstaller ce plugin ?"
},
"empty": {
"available": "Aucun plugin correspondant trouvé. Veuillez essayer dajuster la recherche ou les filtres de catégorie."
},
"error": {
"install": "Échec de l'installation du plugin",
"load": "Échec du chargement du plugin",
"uninstall": "Échec de la désinstallation du plugin"
},
"filter": {
"all": "Toutes les catégories"
},
"install": "Installation",
"installed": {
"empty": "Aucun plugin n'est encore installé. Parcourez les plugins disponibles pour commencer.",
"title": "Extension installée"
},
"installing": "Installation en cours...",
"results": "{{count}} modules complémentaires trouvés",
"search": {
"placeholder": "Recherche de plug-ins..."
},
"success": {
"install": "Installation du plugin réussie",
"uninstall": "Désinstallation du plugin réussie"
},
"tab": "Module d'extension",
"type": {
"agent": "mandataire",
"agents": "mandataire",
"all": "Tout",
"command": "commande",
"commands": "commande",
"skills": "compétence"
},
"uninstall": "Désinstaller",
"uninstalling": "Désinstallation en cours..."
},
"prompt": "Paramètres de l'invite",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Autoriser la demande d'outil",
"denyRequest": "Refuser la demande d'outil",
"hideDetails": "Masquer les détails de l'outil",
"runWithOptions": "Exécuter avec des options supplémentaires",
"showDetails": "Afficher les détails de l'outil"
},
"button": {
"cancel": "Annuler",
"run": "Courir"
},
"confirmation": "Êtes-vous sûr de vouloir exécuter cet outil Claude ?",
"defaultDenyMessage": "L'utilisateur a refusé l'autorisation pour cet outil.",
"defaultDescription": "Exécute du code ou des actions système dans votre environnement. Assurez-vous que la commande semble sûre avant de lexécuter.",
"error": {
"sendFailed": "Échec de l'envoi de votre décision. Veuillez réessayer."
},
"expired": "Expiré",
"inputPreview": "Aperçu de l'entrée de l'outil",
"pending": "En attente ({{seconds}}s)",
"permissionExpired": "Demande de permission expirée. En attente de nouvelles instructions...",
"requiresElevatedPermissions": "Cet outil nécessite des autorisations élevées.",
"suggestion": {
"permissionUpdateMultiple": "Approuver peut mettre à jour plusieurs autorisations de session si vous avez choisi de toujours autoriser cet outil.",
"permissionUpdateSingle": "Approuver peut mettre à jour vos permissions de session si vous avez choisi de toujours autoriser cet outil."
},
"toast": {
"denied": "La demande d'outil a été refusée.",
"timeout": "La demande d'outil a expiré avant d'obtenir l'approbation."
},
"waiting": "En attente de la décision d'autorisation de l'outil..."
},
"type": {
"label": "Type d'agent",
"unknown": "Type inconnu"
@@ -2299,6 +2376,32 @@
"seed_tip": "Contrôle la randomisation du résultat d'agrandissement"
}
},
"plugins": {
"actions": "Opération",
"agents": "mandataire",
"all_categories": "Toutes les catégories",
"all_types": "Tout",
"category": "Catégorie",
"commands": "commande",
"confirm_uninstall": "Êtes-vous sûr de vouloir désinstaller {{name}} ?",
"install": "Installation",
"install_plugins_from_browser": "Parcourir les plugins disponibles pour commencer",
"installing": "Installation en cours...",
"name": "Nom",
"no_description": "Sans description",
"no_installed_plugins": "Aucun plugin nest encore installé",
"no_results": "Aucun plugin trouvé",
"search_placeholder": "Rechercher des modules d'extension...",
"showing_results": "Afficher {{count}} extensions",
"showing_results_one": "Afficher {{count}} modules dextension",
"showing_results_other": "Afficher {{count}} modules d'extension",
"showing_results_plural": "Afficher {{count}} modules d'extension",
"skills": "compétence",
"try_different_search": "Veuillez essayer dajuster la recherche ou le filtre de catégorie.",
"type": "type",
"uninstall": "Désinstaller",
"uninstalling": "Désinstallation en cours..."
},
"preview": {
"copy": {
"image": "Copier en tant qu'image"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Adresse API Anthropic",
"anthropic_api_host_preview": "Aperçu Anthropic : {{url}}",
"anthropic_api_host_tip": "Remplir seulement si le fournisseur propose une adresse compatible Anthropic. Se terminant par / ignore le v1 ajouté automatiquement, se terminant par # force l'utilisation de l'adresse originale.",
"anthropic_api_host_tooltip": "Remplir seulement lorsque le fournisseur propose une adresse de base compatible Claude.",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "Aperçu : {{url}}",
"reset": "Réinitialiser",
"tip": "Ignorer la version v1 si terminé par /, forcer l'utilisation de l'adresse d'entrée si terminé par #"
"tip": "forcer l'utilisation de l'adresse d'entrée si terminé par #"
}
},
"api_host": "Adresse API",
"api_host_no_valid": "Adresse API invalide",
"api_host_preview": "Aperçu : {{url}}",
"api_host_tooltip": "Remplacer seulement lorsque le fournisseur nécessite une adresse compatible OpenAI personnalisée.",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "高級設定"
},
"essential": "必須設定",
"plugins": {
"available": {
"title": "利用可能なプラグイン"
},
"confirm": {
"uninstall": "このプラグインをアンインストールしてもよろしいですか?"
},
"empty": {
"available": "一致するプラグインが見つかりませんでした。検索キーワードやカテゴリフィルターを調整してみてください。"
},
"error": {
"install": "プラグインのインストールに失敗しました",
"load": "プラグインの読み込みに失敗しました",
"uninstall": "プラグインのアンインストールに失敗しました"
},
"filter": {
"all": "すべてのカテゴリー"
},
"install": "インストール",
"installed": {
"empty": "まだプラグインがインストールされていません。利用可能なプラグインを見てみましょう。",
"title": "インストール済みプラグイン"
},
"installing": "インストール中...",
"results": "{{count}} 個のプラグインが見つかりました",
"search": {
"placeholder": "検索プラグイン..."
},
"success": {
"install": "プラグインのインストールが成功しました",
"uninstall": "プラグインのアンインストールが成功しました"
},
"tab": "プラグイン",
"type": {
"agent": "代理",
"agents": "代理",
"all": "全部",
"command": "命令",
"commands": "命令",
"skills": "技能"
},
"uninstall": "アンインストール",
"uninstalling": "アンインストール中..."
},
"prompt": "プロンプト設定",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "ツールリクエストを許可",
"denyRequest": "ツールリクエストを拒否",
"hideDetails": "ツールの詳細を非表示",
"runWithOptions": "追加オプションで実行",
"showDetails": "ツールの詳細を表示"
},
"button": {
"cancel": "キャンセル",
"run": "走る"
},
"confirmation": "このClaudeツールを実行してもよろしいですか",
"defaultDenyMessage": "ユーザーはこのツールの使用を拒否しました。",
"defaultDescription": "環境内でコードまたはシステムアクションを実行します。実行前にコマンドが安全であることを確認してください。",
"error": {
"sendFailed": "決定の送信に失敗しました。もう一度お試しください。"
},
"expired": "期限切れ",
"inputPreview": "ツール入力プレビュー",
"pending": "保留中({{seconds}}秒)",
"permissionExpired": "許可リクエストの期限が切れました。新しい指示を待っています...",
"requiresElevatedPermissions": "このツールは昇格した権限が必要です。",
"suggestion": {
"permissionUpdateMultiple": "承認すると、このツールを常に許可することを選択した場合、複数のセッション権限が更新されることがあります。",
"permissionUpdateSingle": "承認すると、このツールを常に許可することを選択した場合、セッションの権限が更新されることがあります。"
},
"toast": {
"denied": "ツールリクエストは拒否されました。",
"timeout": "ツールリクエストは承認を受ける前にタイムアウトしました。"
},
"waiting": "ツールの許可決定を待っています..."
},
"type": {
"label": "エージェントタイプ",
"unknown": "不明なタイプ"
@@ -2299,6 +2376,32 @@
"seed_tip": "拡大結果のランダム性を制御します"
}
},
"plugins": {
"actions": "操作",
"agents": "代理",
"all_categories": "すべてのカテゴリー",
"all_types": "全部",
"category": "カテゴリー",
"commands": "命令",
"confirm_uninstall": "{{name}}をアンインストールしてもよろしいですか?",
"install": "インストール",
"install_plugins_from_browser": "利用可能なプラグインを閲覧して、使用を開始してください",
"installing": "インストール中...",
"name": "名称",
"no_description": "説明なし",
"no_installed_plugins": "まだプラグインがインストールされていません",
"no_results": "プラグインが見つかりません",
"search_placeholder": "検索プラグイン...",
"showing_results": "{{count}} 個のプラグインを表示",
"showing_results_one": "{{count}} 個のプラグインを表示",
"showing_results_other": "{{count}} 個のプラグインを表示",
"showing_results_plural": "{{count}} 個のプラグインを表示",
"skills": "スキル",
"try_different_search": "検索またはカテゴリフィルターを調整してみてください",
"type": "タイプ",
"uninstall": "アンインストール",
"uninstalling": "アンインストール中..."
},
"preview": {
"copy": {
"image": "画像としてコピー"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Anthropic APIアドレス",
"anthropic_api_host_preview": "Anthropic プレビュー:{{url}}",
"anthropic_api_host_tip": "サービスプロバイダーがAnthropic互換のアドレスを提供する場合のみ入力してください。/で終わる場合は自動追加されるv1を無視し、#で終わる場合は元のアドレスを強制的に使用します。",
"anthropic_api_host_tooltip": "サービスプロバイダーがClaude互換のベースアドレスを提供する場合のみ入力してください。",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "プレビュー: {{url}}",
"reset": "リセット",
"tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します"
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
}
},
"api_host": "APIホスト",
"api_host_no_valid": "APIアドレスが無効です",
"api_host_preview": "プレビュー:{{url}}",
"api_host_tooltip": "サービスプロバイダーがカスタムOpenAI互換アドレスを必要とする場合のみ上書きしてください。",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "Configurações avançadas"
},
"essential": "Configurações Essenciais",
"plugins": {
"available": {
"title": "Plugins disponíveis"
},
"confirm": {
"uninstall": "Tem certeza de que deseja desinstalar este plugin?"
},
"empty": {
"available": "Nenhum plugin correspondente encontrado. Tente ajustar a pesquisa ou os filtros de categoria."
},
"error": {
"install": "Falha na instalação do plugin",
"load": "Falha ao carregar o plugin",
"uninstall": "Falha ao desinstalar o plug-in"
},
"filter": {
"all": "Todas as categorias"
},
"install": "Instalação",
"installed": {
"empty": "Nenhum plugin foi instalado ainda. Explore os plugins disponíveis para começar.",
"title": "Plugin instalado"
},
"installing": "Instalando...",
"results": "Encontrados {{count}} plugins",
"search": {
"placeholder": "Pesquisar extensão..."
},
"success": {
"install": "Plugin instalado com sucesso",
"uninstall": "插件 desinstalado com sucesso"
},
"tab": "plug-in",
"type": {
"agent": "agente",
"agents": "agente",
"all": "tudo",
"command": "comando",
"commands": "comando",
"skills": "habilidade"
},
"uninstall": "desinstalar",
"uninstalling": "Desinstalando..."
},
"prompt": "Configurações de Prompt",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Permitir solicitação de ferramenta",
"denyRequest": "Negar solicitação de ferramenta",
"hideDetails": "Ocultar detalhes da ferramenta",
"runWithOptions": "Executar com opções adicionais",
"showDetails": "Mostrar detalhes da ferramenta"
},
"button": {
"cancel": "Cancelar",
"run": "Correr"
},
"confirmation": "Tem certeza de que quer executar esta ferramenta Claude?",
"defaultDenyMessage": "Usuário negou permissão para esta ferramenta.",
"defaultDescription": "Executa código ou ações do sistema no seu ambiente. Certifique-se de que o comando parece seguro antes de executá-lo.",
"error": {
"sendFailed": "Falha ao enviar sua decisão. Por favor, tente novamente."
},
"expired": "Expirado",
"inputPreview": "Pré-visualização da entrada da ferramenta",
"pending": "Pendente ({{seconds}}s)",
"permissionExpired": "Solicitação de permissão expirou. Aguardando novas instruções...",
"requiresElevatedPermissions": "Esta ferramenta requer permissões elevadas.",
"suggestion": {
"permissionUpdateMultiple": "Aprovar pode atualizar várias permissões de sessão se você escolheu sempre permitir esta ferramenta.",
"permissionUpdateSingle": "Aprovar pode atualizar as permissões da sua sessão se você escolheu sempre permitir esta ferramenta."
},
"toast": {
"denied": "Solicitação de ferramenta foi negada.",
"timeout": "A solicitação da ferramenta expirou antes de receber aprovação."
},
"waiting": "Aguardando decisão de permissão da ferramenta..."
},
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo Desconhecido"
@@ -2299,6 +2376,32 @@
"seed_tip": "Controla a aleatoriedade do resultado de ampliação"
}
},
"plugins": {
"actions": "Operação",
"agents": "agente",
"all_categories": "Todas as categorias",
"all_types": "Tudo",
"category": "categoria",
"commands": "comando",
"confirm_uninstall": "Tem certeza de que deseja desinstalar {{name}}?",
"install": "Instalação",
"install_plugins_from_browser": "Navegue pelos plugins disponíveis para começar a usar",
"installing": "Instalando...",
"name": "Nome",
"no_description": "Sem descrição",
"no_installed_plugins": "Nenhum plugin foi instalado ainda",
"no_results": "Plugin não encontrado",
"search_placeholder": "Pesquisar plugin...",
"showing_results": "Exibir {{count}} extensões",
"showing_results_one": "Mostrar {{count}} extensões",
"showing_results_other": "Exibir {{count}} extensões",
"showing_results_plural": "Exibir {{count}} extensões",
"skills": "habilidade",
"try_different_search": "Por favor, tente ajustar a pesquisa ou os filtros de categoria.",
"type": "tipo",
"uninstall": "Desinstalar",
"uninstalling": "Desinstalando..."
},
"preview": {
"copy": {
"image": "Copiar como imagem"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Endereço da API Anthropic",
"anthropic_api_host_preview": "Pré-visualização Anthropic: {{url}}",
"anthropic_api_host_tip": "Preencher apenas se o fornecedor oferecer um endereço compatível com Anthropic. Terminar com / ignora o v1 adicionado automaticamente, terminar com # força o uso do endereço original.",
"anthropic_api_host_tooltip": "Preencher apenas quando o fornecedor fornece um endereço base compatível com Claude.",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "Pré-visualização: {{url}}",
"reset": "Redefinir",
"tip": "Ignorar v1 na versão finalizada com /, usar endereço de entrada forçado se terminar com #"
"tip": "e forçar o uso do endereço original quando terminar com '#'"
}
},
"api_host": "Endereço API",
"api_host_no_valid": "O endereço da API é inválido",
"api_host_preview": "Pré-visualização: {{url}}",
"api_host_tooltip": "Substituir apenas quando o fornecedor necessita de um endereço compatível com OpenAI personalizado.",
"api_key": {

View File

@@ -107,6 +107,50 @@
"title": "Расширенные настройки"
},
"essential": "Основные настройки",
"plugins": {
"available": {
"title": "Доступные плагины"
},
"confirm": {
"uninstall": "Вы уверены, что хотите удалить этот плагин?"
},
"empty": {
"available": "Совпадающие плагины не найдены. Попробуйте изменить поиск или фильтр категорий."
},
"error": {
"install": "Ошибка установки плагина",
"load": "Ошибка загрузки плагина",
"uninstall": "Не удалось удалить плагин"
},
"filter": {
"all": "Все категории"
},
"install": "установка",
"installed": {
"empty": "Плагины ещё не установлены. Просмотрите доступные плагины, чтобы начать.",
"title": "Установленный плагин"
},
"installing": "Установка...",
"results": "Найдено {{count}} плагинов",
"search": {
"placeholder": "Поиск плагинов..."
},
"success": {
"install": "Плагин успешно установлен",
"uninstall": "Плагин успешно удалён"
},
"tab": "плагин",
"type": {
"agent": "агент",
"agents": "Прокси",
"all": "всё",
"command": "команда",
"commands": "команда",
"skills": "навык"
},
"uninstall": "Удаление",
"uninstalling": "Удаление..."
},
"prompt": "Настройки подсказки",
"tooling": {
"mcp": {
@@ -191,6 +235,39 @@
"toggle": "{{defaultValue}}"
}
},
"toolPermission": {
"aria": {
"allowRequest": "Разрешить запрос инструмента",
"denyRequest": "Отклонить запрос на инструмент",
"hideDetails": "Скрыть сведения об инструменте",
"runWithOptions": "Запустить с дополнительными параметрами",
"showDetails": "Показать сведения об инструменте"
},
"button": {
"cancel": "Отмена",
"run": "Беги"
},
"confirmation": "Вы уверены, что хотите запустить этот инструмент Claude?",
"defaultDenyMessage": "Пользователь отказал в разрешении на использование этого инструмента.",
"defaultDescription": "Выполняет код или системные действия в вашей среде. Убедитесь, что команда выглядит безопасно, прежде чем запускать её.",
"error": {
"sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз."
},
"expired": "Истёк",
"inputPreview": "Предварительный просмотр ввода инструмента",
"pending": "Ожидание ({{seconds}}с)",
"permissionExpired": "Срок действия запроса на разрешение истёк. Ожидание новых инструкций...",
"requiresElevatedPermissions": "Этому инструменту требуются повышенные разрешения.",
"suggestion": {
"permissionUpdateMultiple": "Одобрение может обновить разрешения для нескольких сеансов, если вы выбрали всегда разрешать использование этого инструмента.",
"permissionUpdateSingle": "Одобрение может обновить разрешения вашей сессии, если вы выбрали всегда разрешать использование этого инструмента."
},
"toast": {
"denied": "Запрос на инструмент был отклонён.",
"timeout": "Запрос на инструмент превысил время ожидания до получения подтверждения."
},
"waiting": "Ожидание решения о разрешении на использование инструмента..."
},
"type": {
"label": "Тип агента",
"unknown": "Неизвестный тип"
@@ -2299,6 +2376,32 @@
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
}
},
"plugins": {
"actions": "Операция",
"agents": "агент",
"all_categories": "Все категории",
"all_types": "всё",
"category": "категория",
"commands": "команда",
"confirm_uninstall": "Вы уверены, что хотите удалить {{name}}?",
"install": "установка",
"install_plugins_from_browser": "Просмотрите доступные плагины, чтобы начать работу",
"installing": "Установка...",
"name": "название",
"no_description": "Без описания",
"no_installed_plugins": "Плагины ещё не установлены",
"no_results": "Плагин не найден",
"search_placeholder": "Поиск плагинов...",
"showing_results": "Отображено {{count}} плагинов",
"showing_results_one": "Отображено {{count}} плагинов",
"showing_results_other": "Отображено {{count}} плагинов",
"showing_results_plural": "Отображение {{count}} плагинов",
"skills": "навык",
"try_different_search": "Пожалуйста, попробуйте изменить поиск или фильтры категорий",
"type": "тип",
"uninstall": "Удаление",
"uninstalling": "Удаление..."
},
"preview": {
"copy": {
"image": "Скопировать как изображение"
@@ -4045,7 +4148,6 @@
},
"anthropic_api_host": "Адрес API Anthropic",
"anthropic_api_host_preview": "Предпросмотр Anthropic: {{url}}",
"anthropic_api_host_tip": "Заполняйте только если провайдер предоставляет совместимый с Anthropic адрес. Окончание на / игнорирует автоматически добавляемое v1, окончание на # принудительно использует оригинальный адрес.",
"anthropic_api_host_tooltip": "Заполняйте только когда провайдер предоставляет базовый адрес, совместимый с Claude.",
"api": {
"key": {
@@ -4090,10 +4192,11 @@
"url": {
"preview": "Предпросмотр: {{url}}",
"reset": "Сброс",
"tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес"
"tip": "заканчивая на # принудительно использует введенный адрес"
}
},
"api_host": "Хост API",
"api_host_no_valid": "Недопустимый адрес API",
"api_host_preview": "Предпросмотр: {{url}}",
"api_host_tooltip": "Переопределяйте только когда провайдер требует пользовательский адрес, совместимый с OpenAI.",
"api_key": {

View File

@@ -72,7 +72,7 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
},
dashscope: {
anthropic: {
api_base_url: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy'
api_base_url: 'https://dashscope.aliyuncs.com/apps/anthropic'
}
},
modelscope: {

View File

@@ -7,6 +7,7 @@ import { ContentSearch } from '@renderer/components/ContentSearch'
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
@@ -56,6 +57,8 @@ const Chat: FC<Props> = (props) => {
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
const sessionAgentId = activeTopicOrSession === 'session' ? activeAgentId : null
const { createDefaultSession } = useCreateDefaultSession(sessionAgentId)
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
@@ -67,7 +70,7 @@ const Chat: FC<Props> = (props) => {
contentSearchRef.current?.disable()
})
useShortcut('search_message_in_chat', () => {
useShortcut('shortcut.chat.search_message', () => {
try {
const selectedText = window.getSelection()?.toString().trim()
contentSearchRef.current?.enable(selectedText)
@@ -76,7 +79,7 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut('rename_topic', async () => {
useShortcut('shortcut.topic.rename', async () => {
const topic = props.activeTopic
if (!topic) return
@@ -94,6 +97,21 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut(
'shortcut.topic.new',
() => {
if (activeTopicOrSession !== 'session' || !activeAgentId) {
return
}
void createDefaultSession()
},
{
enabled: activeTopicOrSession === 'session',
preventDefault: true,
enableOnFormTags: true
}
)
const contentSearchFilter: NodeFilter = {
acceptNode(node) {
const container = node.parentElement?.closest('.message-content-container')

View File

@@ -37,9 +37,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { showTopics, toggleShowTopics } = useShowTopics()
const { isTopNavbar } = useNavbarPosition()
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
useShortcut('toggle_show_topics', () => {
useShortcut('shortcut.topic.toggle_show_topics', () => {
if (topicPosition === 'right') {
toggleShowTopics()
} else {
@@ -47,7 +47,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
}
})
useShortcut('search_message', () => {
useShortcut('shortcut.app.search_message', () => {
SearchPopup.show()
})

View File

@@ -2,11 +2,12 @@ import { Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelView } from '@renderer/components/QuickPanel'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSession } from '@renderer/hooks/agents/useSession'
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
import { getModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { useTimer } from '@renderer/hooks/useTimer'
import PasteService from '@renderer/services/PasteService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
@@ -22,7 +23,7 @@ import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
import TextArea, { type TextAreaRef } from 'antd/es/input/TextArea'
import { isEmpty } from 'lodash'
import { CirclePause } from 'lucide-react'
import { CirclePause, MessageSquareDiff } from 'lucide-react'
import type { CSSProperties, FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -45,8 +46,9 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { session } = useSession(agentId, sessionId)
const { agent } = useAgent(agentId)
const { apiServer } = useSettings()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)
@@ -88,6 +90,22 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}, [topicMessages])
const canAbort = loading && streamingAskIds.length > 0
const createSessionDisabled = creatingSession || !apiServer.enabled
const handleCreateSession = useCallback(async () => {
if (createSessionDisabled) {
return
}
try {
const created = await createDefaultSession()
if (created) {
focusTextarea()
}
} catch (error) {
logger.warn('Failed to create agent session via toolbar:', error as Error)
}
}, [createDefaultSession, createSessionDisabled, focusTextarea])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
//to check if the SendMessage key is pressed
@@ -165,7 +183,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const userMessageBlocks: MessageBlock[] = [mainBlock]
// Extract the actual model ID from session.model (format: "provider:modelId")
const [providerId, actualModelId] = agent?.model.split(':') ?? [undefined, undefined]
const [providerId, actualModelId] = session?.model?.split(':') ?? [undefined, undefined]
// Try to find the actual model from providers
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
@@ -211,7 +229,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
logger.warn('Failed to send message:', error as Error)
}
}, [
agent?.model,
session?.model,
agentId,
dispatch,
sendDisabled,
@@ -287,8 +305,16 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}}
onBlur={() => setInputFocus(false)}
/>
<div className="flex justify-end px-1">
<div className="flex items-center gap-1">
<Toolbar>
<ToolbarGroup>
<Tooltip placement="top" content={t('chat.input.new_topic', { Command: newTopicShortcut })} delay={0}>
<ActionIconButton
onClick={handleCreateSession}
disabled={createSessionDisabled}
icon={<MessageSquareDiff size={19} />}></ActionIconButton>
</Tooltip>
</ToolbarGroup>
<ToolbarGroup>
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
{canAbort && (
<Tooltip placement="top" content={t('chat.input.pause')}>
@@ -299,8 +325,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
/>
</Tooltip>
)}
</div>
</div>
</ToolbarGroup>
</Toolbar>
</InputBarContainer>
</Container>
</NarrowLayout>
@@ -346,6 +372,25 @@ const InputBarContainer = styled.div`
}
`
const Toolbar = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 5px 8px;
height: 40px;
gap: 16px;
position: relative;
z-index: 2;
flex-shrink: 0;
`
const ToolbarGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '6px 15px 0px' // 减小顶部padding

View File

@@ -723,13 +723,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}, [onPaste])
useShortcut('new_topic', () => {
useShortcut('shortcut.topic.new', () => {
addNewTopic()
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
focusTextarea()
})
useShortcut('clear_topic', clearTopic)
useShortcut('shortcut.chat.clear', clearTopic)
useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })

View File

@@ -5,6 +5,7 @@ import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import {
isAnthropicModel,
isGeminiModel,
isGenerateImageModel,
isMandatoryWebSearchModel,
@@ -193,8 +194,8 @@ const InputbarTools = ({
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
}, [assistant.enableGenerateImage, updateAssistant])
const newTopicShortcut = useShortcutDisplay('new_topic')
const clearTopicShortcut = useShortcutDisplay('clear_topic')
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
const clearTopicShortcut = useShortcutDisplay('shortcut.chat.clear')
const toggleToolVisibility = useCallback(
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
@@ -391,7 +392,7 @@ const InputbarTools = ({
label: t('chat.input.url_context'),
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
condition:
isGeminiModel(model) &&
(isGeminiModel(model) || isAnthropicModel(model)) &&
(isSupportUrlContextProvider(getProviderByModel(model)) || model.endpoint_type === 'gemini')
},
{

View File

@@ -9,10 +9,10 @@ interface Props {
}
const NewContextButton: FC<Props> = ({ onNewContext }) => {
const newContextShortcut = useShortcutDisplay('toggle_new_context')
const newContextShortcut = useShortcutDisplay('shortcut.chat.toggle_new_context')
const { t } = useTranslation()
useShortcut('toggle_new_context', onNewContext)
useShortcut('shortcut.chat.toggle_new_context', onNewContext)
return (
<Tooltip content={t('chat.input.new.context', { Command: newContextShortcut })} closeDelay={0}>

View File

@@ -268,7 +268,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
)
}, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer])
useShortcut('copy_last_message', () => {
useShortcut('shortcut.chat.copy_last_message', () => {
const lastMessage = last(messages)
if (lastMessage) {
navigator.clipboard.writeText(getMainTextContent(lastMessage))
@@ -276,7 +276,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
}
})
useShortcut('edit_last_user_message', () => {
useShortcut('shortcut.chat.edit_last_user_message', () => {
const lastUserMessage = messagesRef.current.findLast((m) => m.role === 'user' && m.type !== 'clear')
if (lastUserMessage) {
EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, lastUserMessage.id)

View File

@@ -6,6 +6,7 @@ import type { NormalToolResponse } from '@renderer/types'
export * from './types'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
import { BashTool } from './BashTool'
import { EditTool } from './EditTool'
@@ -78,12 +79,16 @@ function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?:
// 统一的组件渲染入口
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
const { arguments: args, response, tool } = toolResponse
const { arguments: args, response, tool, status } = toolResponse
logger.info('Rendering agent tool response', {
tool: tool,
arguments: args,
response
})
if (status === 'pending') {
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
}

View File

@@ -0,0 +1,235 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types'
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('ToolPermissionRequestCard')
interface Props {
toolResponse: NormalToolResponse
}
export function ToolPermissionRequestCard({ toolResponse }: Props) {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const request = useAppSelector((state) =>
selectPendingPermissionByToolName(state.toolPermissions, toolResponse.tool.name)
)
const [now, setNow] = useState(() => Date.now())
const [showDetails, setShowDetails] = useState(false)
useEffect(() => {
if (!request) return
logger.debug('Rendering inline tool permission card', {
requestId: request.requestId,
toolName: request.toolName,
expiresAt: request.expiresAt
})
setNow(Date.now())
const interval = window.setInterval(() => {
setNow(Date.now())
}, 500)
return () => {
window.clearInterval(interval)
}
}, [request])
const remainingMs = useMemo(() => {
if (!request) return 0
return Math.max(0, request.expiresAt - now)
}, [request, now])
const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs])
const isExpired = remainingMs <= 0
const isSubmittingAllow = request?.status === 'submitting-allow'
const isSubmittingDeny = request?.status === 'submitting-deny'
const isSubmitting = isSubmittingAllow || isSubmittingDeny
const hasSuggestions = (request?.suggestions?.length ?? 0) > 0
const handleDecision = useCallback(
async (
behavior: 'allow' | 'deny',
extra?: {
updatedInput?: Record<string, unknown>
updatedPermissions?: PermissionUpdate[]
message?: string
}
) => {
if (!request) return
logger.debug('Submitting inline tool permission decision', {
requestId: request.requestId,
toolName: request.toolName,
behavior
})
dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior }))
try {
const payload = {
requestId: request.requestId,
behavior,
...(behavior === 'allow'
? {
updatedInput: extra?.updatedInput ?? request.input,
updatedPermissions: extra?.updatedPermissions
}
: {
message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage')
})
}
const response = await window.api.agentTools.respondToPermission(payload)
if (!response?.success) {
throw new Error('Renderer response rejected by main process')
}
logger.debug('Tool permission decision acknowledged by main process', {
requestId: request.requestId,
behavior
})
} catch (error) {
logger.error('Failed to send tool permission response', error as Error)
window.toast?.error?.(t('agent.toolPermission.error.sendFailed'))
dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId }))
}
},
[dispatch, request, t]
)
if (!request) {
return (
<div className="rounded-xl border border-default-200 bg-default-100 px-4 py-3 text-default-500 text-sm">
{t('agent.toolPermission.waiting')}
</div>
)
}
return (
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
<div className="text-default-500 text-xs">
{request.description?.trim() || t('agent.toolPermission.defaultDescription')}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Chip color={isExpired ? 'danger' : 'warning'} size="sm" variant="flat">
{isExpired
? t('agent.toolPermission.expired')
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
</Chip>
<div className="flex items-center gap-1">
<Button
aria-label={t('agent.toolPermission.aria.denyRequest')}
className="h-8"
color="danger"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingDeny}
onPress={() => handleDecision('deny')}
startContent={<CircleX size={16} />}
variant="bordered">
{t('agent.toolPermission.button.cancel')}
</Button>
{hasSuggestions ? (
<ButtonGroup className="h-8">
<Button
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
{t('agent.toolPermission.button.run')}
</Button>
<Button
aria-label={t('agent.toolPermission.aria.runWithOptions')}
className="h-8 rounded-l-none"
color="success"
isDisabled={isSubmitting || isExpired}
isIconOnly
variant="solid"></Button>
</ButtonGroup>
) : (
<Button
aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
{t('agent.toolPermission.button.run')}
</Button>
)}
<Button
aria-label={
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
}
className="h-8"
isIconOnly
onPress={() => setShowDetails((value) => !value)}
variant="light">
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />
</Button>
</div>
</div>
</div>
{showDetails && (
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
<div className="rounded-lg bg-default-200/60 px-3 py-2 text-default-600 text-sm">
{t('agent.toolPermission.confirmation')}
</div>
<div className="rounded-md border border-default-200 bg-default-100 p-3">
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
{t('agent.toolPermission.inputPreview')}
</p>
<ScrollShadow className="max-h-48 font-mono text-xs" hideScrollBar>
<pre className="whitespace-pre-wrap break-all text-left">{request.inputPreview}</pre>
</ScrollShadow>
</div>
{request.requiresPermissions && (
<div className="rounded-md border border-warning-300 bg-warning-50 p-3 text-warning-700 text-xs">
{t('agent.toolPermission.requiresElevatedPermissions')}
</div>
)}
{request.suggestions.length > 0 && (
<div className="rounded-md border border-default-200 bg-default-50 p-3 text-default-500 text-xs">
{request.suggestions.length === 1
? t('agent.toolPermission.suggestion.permissionUpdateSingle')
: t('agent.toolPermission.suggestion.permissionUpdateMultiple')}
</div>
)}
</div>
)}
{isExpired && !isSubmitting && (
<div className="text-center text-danger-500 text-xs">{t('agent.toolPermission.permissionExpired')}</div>
)}
</div>
</div>
)
}
export default ToolPermissionRequestCard

View File

@@ -38,9 +38,9 @@ const HeaderNavbar: FC<Props> = ({
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { showTopics, toggleShowTopics } = useShowTopics()
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
useShortcut('toggle_show_topics', () => {
useShortcut('shortcut.topic.toggle_show_topics', () => {
if (topicPosition === 'right') {
toggleShowTopics()
} else {
@@ -48,7 +48,7 @@ const HeaderNavbar: FC<Props> = ({
}
})
useShortcut('search_message', () => {
useShortcut('shortcut.app.search_message', () => {
SearchPopup.show()
})

View File

@@ -1,6 +1,6 @@
import { Alert, Spinner } from '@heroui/react'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
@@ -10,7 +10,6 @@ import {
setActiveTopicOrSessionAction,
setSessionWaitingAction
} from '@renderer/store/runtime'
import type { CreateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { motion } from 'framer-motion'
import { memo, useCallback, useEffect } from 'react'
@@ -27,11 +26,11 @@ interface SessionsProps {
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { t } = useTranslation()
const { agent } = useAgent(agentId)
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
const { sessions, isLoading, error, deleteSession } = useSessions(agentId)
const { chat } = useRuntime()
const { activeSessionIdMap } = chat
const dispatch = useAppDispatch()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const setActiveSessionId = useCallback(
(agentId: string, sessionId: string | null) => {
@@ -41,19 +40,6 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
[dispatch]
)
const handleCreateSession = useCallback(async () => {
if (!agent) return
const session = {
...agent,
id: undefined,
name: t('common.unnamed')
} satisfies CreateSessionForm
const created = await createSession(session)
if (created) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
}
}, [agent, agentId, createSession, dispatch, t])
const handleDeleteSession = useCallback(
async (id: string) => {
if (sessions.length === 1) {
@@ -110,7 +96,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
return (
<div className="sessions-tab flex h-full w-full flex-col p-2">
<AddButton onClick={handleCreateSession} className="mb-2">
<AddButton onClick={createDefaultSession} className="mb-2" disabled={creatingSession}>
{t('agent.session.add.title')}
</AddButton>
{/* h-9 */}

View File

@@ -93,7 +93,7 @@ const KnowledgePage: FC = () => {
[deleteKnowledgeBase, handleEditKnowledgeBase, renameKnowledgeBase, t]
)
useShortcut('search_message', () => {
useShortcut('shortcut.app.search_message', () => {
if (selectedBase) {
KnowledgeSearchPopup.show({ base: selectedBase }).then()
}

View File

@@ -120,7 +120,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
return
}
try {
target.findInPage(text, options)
target.findInPage(text, options || {})
} catch (error) {
logger.error('findInPage failed', { error })
window.toast?.error(t('common.error'))

View File

@@ -19,6 +19,15 @@ vi.mock('react-i18next', () => ({
})
}))
// mock @cherrystudio/ui Button component to handle onClick
vi.mock('@cherrystudio/ui', () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button type="button" onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
)
}))
const createWebviewMock = () => {
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
const findInPageMock = vi.fn()
@@ -255,7 +264,7 @@ describe('WebviewSearch', () => {
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
})
await act(async () => {
@@ -307,7 +316,7 @@ describe('WebviewSearch', () => {
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
})
findInPageMock.mockClear()

View File

@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import AdvancedSettings from './AdvancedSettings'
import EssentialSettings from './EssentialSettings'
import PluginSettings from './PluginSettings'
import PromptSettings from './PromptSettings'
import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared'
import ToolingSettings from './ToolingSettings'
@@ -20,7 +21,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
resolve: () => void
}
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'session-mcps'
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps'
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
const [open, setOpen] = useState(true)
@@ -56,6 +57,10 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
key: 'tooling',
label: t('agent.settings.tooling.tab', 'Tooling & permissions')
},
{
key: 'plugins',
label: t('agent.settings.plugins.tab', 'Plugins')
},
{
key: 'advanced',
label: t('agent.settings.advance.title', 'Advanced Settings')
@@ -75,6 +80,9 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
</div>
)
}
if (!agent) {
return null
}
return (
<div className="flex w-full flex-1">
<LeftMenu>
@@ -90,6 +98,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
{menu === 'essential' && <EssentialSettings agentBase={agent} update={updateAgent} />}
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
{menu === 'plugins' && <PluginSettings agentBase={agent} update={updateAgent} />}
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
</Settings>
</div>

View File

@@ -1,6 +1,6 @@
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
import type { AgentEntity, UpdateAgentForm } from '@renderer/types'
import { isAgentType } from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,13 +20,11 @@ export const AvatarSetting: React.FC<AvatarSettingsProps> = ({ agent, update })
const updateAvatar = useCallback(
(avatar: string) => {
const parsedConfiguration = AgentConfigurationSchema.parse(agent.configuration ?? {})
const payload = {
id: agent.id,
// hard-encoded default values. better to implement incremental update for configuration
configuration: {
...agent.configuration,
permission_mode: agent.configuration?.permission_mode ?? 'default',
max_turns: agent.configuration?.max_turns ?? 100,
...parsedConfiguration,
avatar
}
} satisfies UpdateAgentForm

View File

@@ -0,0 +1,115 @@
import { Card, CardBody, Tab, Tabs } from '@heroui/react'
import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins'
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentBaseForm } from '@renderer/types/agent'
import type { FC } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { InstalledPluginsList } from './components/InstalledPluginsList'
import { PluginBrowser } from './components/PluginBrowser'
import { SettingsContainer } from './shared'
interface PluginSettingsProps {
agentBase: GetAgentResponse | GetAgentSessionResponse
update: (partial: UpdateAgentBaseForm) => Promise<void>
}
const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
const { t } = useTranslation()
// Fetch available plugins
const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins()
// Fetch installed plugins
const { plugins, loading: loadingInstalled, error: errorInstalled, refresh } = useInstalledPlugins(agentBase.id)
// Plugin actions
const { install, uninstall, installing, uninstalling } = usePluginActions(agentBase.id, refresh)
// Handle install action
const handleInstall = useCallback(
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
const result = await install(sourcePath, type)
if (result.success) {
window.toast.success(t('agent.settings.plugins.success.install'))
} else {
window.toast.error(t('agent.settings.plugins.error.install') + (result.error ? ': ' + result.error : ''))
}
},
[install, t]
)
// Handle uninstall action
const handleUninstall = useCallback(
async (filename: string, type: 'agent' | 'command' | 'skill') => {
const result = await uninstall(filename, type)
if (result.success) {
window.toast.success(t('agent.settings.plugins.success.uninstall'))
} else {
window.toast.error(t('agent.settings.plugins.error.uninstall') + (result.error ? ': ' + result.error : ''))
}
},
[uninstall, t]
)
return (
<SettingsContainer>
<Tabs
aria-label="Plugin settings tabs"
classNames={{
base: 'w-full',
tabList: 'w-full',
panel: 'w-full flex-1 overflow-hidden'
}}>
<Tab key="available" title={t('agent.settings.plugins.available.title')}>
<div className="flex h-full flex-col overflow-y-auto pt-4">
{errorAvailable ? (
<Card className="bg-danger-50 dark:bg-danger-900/20">
<CardBody>
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorAvailable}
</p>
</CardBody>
</Card>
) : (
<PluginBrowser
agentId={agentBase.id}
agents={agents}
commands={commands}
skills={skills}
installedPlugins={plugins}
onInstall={handleInstall}
onUninstall={handleUninstall}
loading={loadingAvailable || installing || uninstalling}
/>
)}
</div>
</Tab>
<Tab key="installed" title={t('agent.settings.plugins.installed.title')}>
<div className="flex h-full flex-col overflow-y-auto pt-4">
{errorInstalled ? (
<Card className="bg-danger-50 dark:bg-danger-900/20">
<CardBody>
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorInstalled}
</p>
</CardBody>
</Card>
) : (
<InstalledPluginsList
plugins={plugins}
onUninstall={handleUninstall}
loading={loadingInstalled || uninstalling}
/>
)}
</div>
</Tab>
</Tabs>
</SettingsContainer>
)
}
export default PluginSettings

View File

@@ -1,6 +1,5 @@
import { Alert, Card, CardBody, CardHeader, Chip, Input, Switch } from '@heroui/react'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgentClient } from '@renderer/hooks/agents/useAgentClient'
import { permissionModeCards } from '@renderer/config/agent'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import type {
@@ -17,9 +16,8 @@ import { AgentConfigurationSchema } from '@renderer/types'
import { Modal } from 'antd'
import { ShieldAlert, ShieldCheck, Wrench } from 'lucide-react'
import type { FC } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
@@ -37,6 +35,13 @@ type AgentConfigurationState = AgentConfiguration & Record<string, unknown>
const defaultConfiguration: AgentConfigurationState = AgentConfigurationSchema.parse({})
/**
* Computes the list of tool IDs that should be automatically approved for a given permission mode.
*
* @param mode - The permission mode to compute defaults for.
* @param tools - The full list of available tools.
* @returns An array of tool IDs that are approved by default for the specified mode.
*/
const computeModeDefaults = (mode: PermissionMode, tools: Tool[]): string[] => {
const defaultToolIds = tools.filter((tool) => !tool.requirePermissions).map((tool) => tool.id)
switch (mode) {
@@ -66,52 +71,34 @@ const unique = (values: string[]) => Array.from(new Set(values))
export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, update }) => {
const { containerRef, handleScroll } = useScrollPosition('AgentToolingSettings', 100)
const { t } = useTranslation()
const client = useAgentClient()
const { mcpServers: allServers } = useMCPServers()
const [modal, contextHolder] = Modal.useModal()
const [configuration, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration)
const [selectedMode, setSelectedMode] = useState<PermissionMode>(defaultConfiguration.permission_mode)
const [autoToolIds, setAutoToolIds] = useState<string[]>([])
const [approvedToolIds, setApprovedToolIds] = useState<string[]>([])
const configuration: AgentConfigurationState = useMemo(
() => agentBase?.configuration ?? defaultConfiguration,
[agentBase?.configuration]
)
const selectedMode = useMemo(
() => agentBase?.configuration?.permission_mode ?? defaultConfiguration.permission_mode,
[agentBase?.configuration?.permission_mode]
)
const availableTools = useMemo(() => agentBase?.tools ?? [], [agentBase?.tools])
const autoToolIds = useMemo(() => computeModeDefaults(selectedMode, availableTools), [availableTools, selectedMode])
const approvedToolIds = useMemo(() => {
const allowed = agentBase?.allowed_tools ?? []
const sanitized = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
// Ensure defaults are included even if backend omitted them
const merged = unique([...sanitized, ...autoToolIds])
return merged
}, [agentBase?.allowed_tools, autoToolIds, availableTools])
const selectedMcpIds = useMemo(() => agentBase?.mcps ?? [], [agentBase?.mcps])
const [searchTerm, setSearchTerm] = useState('')
const [isUpdatingMode, setIsUpdatingMode] = useState(false)
const [isUpdatingTools, setIsUpdatingTools] = useState(false)
const [selectedMcpIds, setSelectedMcpIds] = useState<string[]>([])
const [isUpdatingMcp, setIsUpdatingMcp] = useState(false)
const availableTools = useMemo(() => agentBase?.tools ?? [], [agentBase?.tools])
const availableServers = useMemo(() => allServers ?? [], [allServers])
useEffect(() => {
if (!agentBase) {
setConfiguration(defaultConfiguration)
setSelectedMode(defaultConfiguration.permission_mode)
setApprovedToolIds([])
setAutoToolIds([])
setSelectedMcpIds([])
return
}
const parsed: AgentConfigurationState = AgentConfigurationSchema.parse(agentBase.configuration ?? {})
setConfiguration(parsed)
setSelectedMode(parsed.permission_mode)
const defaults = computeModeDefaults(parsed.permission_mode, availableTools)
setAutoToolIds(defaults)
const allowed = agentBase.allowed_tools ?? []
setApprovedToolIds((prev) => {
const sanitized = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
const isSame = sanitized.length === prev.length && sanitized.every((id) => prev.includes(id))
if (isSame) {
return prev
}
// Ensure defaults are included even if backend omitted them
const merged = unique([...sanitized, ...defaults])
return merged
})
setSelectedMcpIds(agentBase.mcps ?? [])
}, [agentBase, availableTools])
const filteredTools = useMemo(() => {
if (!searchTerm.trim()) {
return availableTools
@@ -147,10 +134,6 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
configuration: nextConfiguration,
allowed_tools: merged
} satisfies UpdateAgentBaseForm)
setConfiguration(nextConfiguration)
setSelectedMode(nextMode)
setAutoToolIds(defaults)
setApprovedToolIds(merged)
} finally {
setIsUpdatingMode(false)
}
@@ -212,33 +195,25 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
)
const handleToggleTool = useCallback(
(toolId: string, isApproved: boolean) => {
async (toolId: string, isApproved: boolean) => {
if (!agentBase || isUpdatingTools) {
return
}
startTransition(() => {
setApprovedToolIds((prev) => {
const exists = prev.includes(toolId)
if (isApproved === exists) {
return prev
}
const next = isApproved ? [...prev, toolId] : prev.filter((id) => id !== toolId)
const sanitized = unique(
next.filter((id) => availableTools.some((tool) => tool.id === id)).concat(autoToolIds)
)
setIsUpdatingTools(true)
void (async () => {
try {
await update({ id: agentBase.id, allowed_tools: sanitized } satisfies UpdateAgentBaseForm)
} finally {
setIsUpdatingTools(false)
}
})()
return sanitized
})
})
const exists = approvedToolIds.includes(toolId)
if (isApproved === exists) {
return
}
setIsUpdatingTools(true)
const next = isApproved ? [...approvedToolIds, toolId] : approvedToolIds.filter((id) => id !== toolId)
const sanitized = unique(next.filter((id) => availableTools.some((tool) => tool.id === id)).concat(autoToolIds))
try {
await update({ id: agentBase.id, allowed_tools: sanitized } satisfies UpdateAgentBaseForm)
} finally {
setIsUpdatingTools(false)
}
},
[agentBase, isUpdatingTools, availableTools, autoToolIds, update]
[agentBase, isUpdatingTools, approvedToolIds, autoToolIds, availableTools, update]
)
const { agentSummary, autoCount, customCount } = useMemo(() => {
@@ -258,31 +233,24 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
}, [selectedMode, autoToolIds, userAddedIds, availableTools.length, selectedMcpIds.length])
const handleToggleMcp = useCallback(
(serverId: string, enabled: boolean) => {
async (serverId: string, enabled: boolean) => {
if (!agentBase || isUpdatingMcp) {
return
}
setSelectedMcpIds((prev) => {
const exists = prev.includes(serverId)
if (enabled === exists) {
return prev
}
const next = enabled ? [...prev, serverId] : prev.filter((id) => id !== serverId)
setIsUpdatingMcp(true)
void (async () => {
try {
await update({ id: agentBase.id, mcps: next } satisfies UpdateAgentBaseForm)
const refreshed = await client.getAgent(agentBase.id)
const key = client.agentPaths.withId(agentBase.id)
mutate(key, refreshed, false)
} finally {
setIsUpdatingMcp(false)
}
})()
return next
})
const exists = selectedMcpIds.includes(serverId)
if (enabled === exists) {
return
}
const next = enabled ? [...selectedMcpIds, serverId] : selectedMcpIds.filter((id) => id !== serverId)
setIsUpdatingMcp(true)
try {
await update({ id: agentBase.id, mcps: next } satisfies UpdateAgentBaseForm)
} finally {
setIsUpdatingMcp(false)
}
},
[agentBase, isUpdatingMcp, client, update]
[agentBase, isUpdatingMcp, selectedMcpIds, update]
)
if (!agentBase) {

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