Compare commits

...

28 Commits

Author SHA1 Message Date
GitHub Action
6c758d1400 fix(i18n): Auto update translations for PR #10090 2025-10-29 14:41:41 +00:00
icarus
a4b19f7650 docs(i18n): add missing terminal translation key 2025-10-29 22:40:46 +08:00
GitHub Action
97b3608786 fix(i18n): Auto update translations for PR #10090 2025-10-29 12:54:58 +00:00
icarus
793625d009 docs: add terminal implementation guide for electron applications
Add comprehensive documentation analyzing VS Code terminal implementation and providing two approaches:
1. Pure JavaScript solution (recommended) - no compilation required
2. node-pty solution - more complete but requires compilation

The guide covers architecture, implementation details, performance considerations, and security best practices for both approaches.
2025-10-29 20:54:13 +08:00
icarus
00ee90a857 feat(terminal): add placeholder for unimplemented terminal page 2025-10-29 20:54:13 +08:00
icarus
1200f9973c build: add @xterm/xterm dependency for terminal functionality 2025-10-29 20:54:13 +08:00
icarus
4f107e9305 feat(terminal): add terminal page with routing and navigation
- Add new terminal page component
- Include terminal route in router configuration
- Add terminal title to i18n labels
- Add terminal icon to tab component
2025-10-29 20:54:13 +08:00
icarus
eaf4bd18a3 feat(launchpad): add terminal entry to launchpad menu
Add new terminal option to the launchpad with corresponding icon and route. The terminal entry uses a dark gradient background to match its command-line interface theme.
2025-10-29 20:53:51 +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
115 changed files with 9306 additions and 872 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,

File diff suppressed because it is too large Load Diff

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",
@@ -226,8 +229,9 @@
"@vitest/web-worker": "^3.2.4",
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xterm/xterm": "^5.5.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 +241,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 +394,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

@@ -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

@@ -19,6 +19,7 @@ import type {
FileMetadata,
Notification,
OcrProvider,
PluginError,
Provider,
Shortcut,
SupportedOcrFile
@@ -49,6 +50,7 @@ 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'
@@ -95,6 +97,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()
@@ -894,6 +908,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

@@ -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

@@ -20,6 +20,7 @@ import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import AssistantPresetsPage from './pages/store/assistants/presets/AssistantPresetsPage'
import { TerminalPage } from './pages/terminal/TerminalPage'
import TranslatePage from './pages/translate/TranslatePage'
const Router: FC = () => {
@@ -40,6 +41,7 @@ const Router: FC = () => {
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/terminal" element={<TerminalPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
</ErrorBoundary>

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

@@ -19,6 +19,7 @@ import { classNames } from '@renderer/utils'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import type { LRUCache } from 'lru-cache'
import {
Code,
FileSearch,
Folder,
Hammer,
@@ -106,6 +107,8 @@ const getTabIcon = (
case 'settings':
return <Settings size={14} />
case 'code':
return <Code size={14} />
case 'terminal':
return <Terminal size={14} />
default:
return null

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

@@ -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

@@ -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

@@ -143,7 +143,8 @@ const titleKeyMap = {
notes: 'title.notes',
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate'
translate: 'title.translate',
terminal: 'title.terminal'
} as const
export const getTitleLabel = (key: string): string => {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "Paintings",
"settings": "Settings",
"store": "Assistant Library",
"terminal": "Terminal",
"translate": "Translate"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "绘画",
"settings": "设置",
"store": "助手库",
"terminal": "终端",
"translate": "翻译"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "繪畫",
"settings": "設定",
"store": "助手庫",
"terminal": "終端機",
"translate": "翻譯"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "Zeichnen",
"settings": "Einstellungen",
"store": "Assistenten-Bibliothek",
"terminal": "Terminal",
"translate": "Übersetzen"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "Ζωγραφική",
"settings": "Ρυθμίσεις",
"store": "Βιβλιοθήκη βοηθών",
"terminal": "Τερματικό",
"translate": "Μετάφραση"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "Pinturas",
"settings": "Configuración",
"store": "Biblioteca de asistentes",
"terminal": "Terminal",
"translate": "Traducir"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "Peintures",
"settings": "Paramètres",
"store": "Bibliothèque d'assistants",
"terminal": "Terminal",
"translate": "Traduire"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "ペインティング",
"settings": "設定",
"store": "アシスタントライブラリ",
"terminal": "ターミナル",
"translate": "翻訳"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "Pinturas",
"settings": "Configurações",
"store": "Biblioteca de assistentes",
"terminal": "Terminal",
"translate": "Traduzir"
},
"trace": {

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": {
@@ -4512,6 +4615,7 @@
"paintings": "Рисунки",
"settings": "Настройки",
"store": "Библиотека помощников",
"terminal": "Терминал",
"translate": "Перевод"
},
"trace": {

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)
@@ -94,6 +97,21 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut(
'new_topic',
() => {
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

@@ -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('new_topic')
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

@@ -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,
@@ -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

@@ -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

@@ -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

@@ -1,7 +1,7 @@
import App from '@renderer/components/MinApp/MinApp'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useSettings } from '@renderer/hooks/useSettings'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle, Terminal } from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -57,6 +57,12 @@ const LaunchpadPage: FC = () => {
path: '/code',
bgColor: 'linear-gradient(135deg, #1F2937, #374151)' // Code CLI高级暗黑色代表专业和技术
},
{
icon: <Terminal size={32} className="icon" />,
text: t('title.terminal'),
path: '/terminal',
bgColor: 'linear-gradient(135deg, #000000, #1F2937)' // Terminal纯黑渐变代表命令行界面
},
{
icon: <NotepadText size={32} className="icon" />,
text: t('title.notes'),

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

View File

@@ -0,0 +1,53 @@
import { Chip } from '@heroui/react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
export interface CategoryFilterProps {
categories: string[]
selectedCategories: string[]
onChange: (categories: string[]) => void
}
export const CategoryFilter: FC<CategoryFilterProps> = ({ categories, selectedCategories, onChange }) => {
const { t } = useTranslation()
const isAllSelected = selectedCategories.length === 0
const handleCategoryClick = (category: string) => {
if (selectedCategories.includes(category)) {
onChange(selectedCategories.filter((c) => c !== category))
} else {
onChange([...selectedCategories, category])
}
}
const handleAllClick = () => {
onChange([])
}
return (
<div className="flex max-h-24 flex-wrap gap-2 overflow-y-auto">
<Chip
variant={isAllSelected ? 'solid' : 'bordered'}
color={isAllSelected ? 'primary' : 'default'}
onClick={handleAllClick}
className="cursor-pointer">
{t('plugins.all_categories')}
</Chip>
{categories.map((category) => {
const isSelected = selectedCategories.includes(category)
return (
<Chip
key={category}
variant={isSelected ? 'solid' : 'bordered'}
color={isSelected ? 'primary' : 'default'}
onClick={() => handleCategoryClick(category)}
className="cursor-pointer">
{category}
</Chip>
)
})}
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { Button, Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react'
import type { InstalledPlugin } from '@renderer/types/plugin'
import { Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
export interface InstalledPluginsListProps {
plugins: InstalledPlugin[]
onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void
loading: boolean
}
export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, onUninstall, loading }) => {
const { t } = useTranslation()
const [uninstallingPlugin, setUninstallingPlugin] = useState<string | null>(null)
const handleUninstall = useCallback(
(plugin: InstalledPlugin) => {
const confirmed = window.confirm(
t('plugins.confirm_uninstall', { name: plugin.metadata.name || plugin.filename })
)
if (confirmed) {
setUninstallingPlugin(plugin.filename)
onUninstall(plugin.filename, plugin.type)
// Reset after a delay to allow the operation to complete
setTimeout(() => setUninstallingPlugin(null), 2000)
}
},
[onUninstall, t]
)
if (loading) {
return (
<div className="space-y-2">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
)
}
if (plugins.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-default-400">{t('plugins.no_installed_plugins')}</p>
<p className="text-default-300 text-small">{t('plugins.install_plugins_from_browser')}</p>
</div>
)
}
return (
<Table aria-label="Installed plugins table" removeWrapper>
<TableHeader>
<TableColumn>{t('plugins.name')}</TableColumn>
<TableColumn>{t('plugins.type')}</TableColumn>
<TableColumn>{t('plugins.category')}</TableColumn>
<TableColumn width={100}>{t('plugins.actions')}</TableColumn>
</TableHeader>
<TableBody>
{plugins.map((plugin) => (
<TableRow key={plugin.filename}>
<TableCell>
<div className="flex flex-col">
<span className="font-semibold text-small">{plugin.metadata.name}</span>
{plugin.metadata.description && (
<span className="line-clamp-1 text-default-400 text-tiny">{plugin.metadata.description}</span>
)}
</div>
</TableCell>
<TableCell>
<Chip size="sm" variant="flat" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
{plugin.type}
</Chip>
</TableCell>
<TableCell>
<Chip size="sm" variant="dot">
{plugin.metadata.category}
</Chip>
</TableCell>
<TableCell>
<Button
size="sm"
color="danger"
variant="light"
isIconOnly
onPress={() => handleUninstall(plugin)}
isLoading={uninstallingPlugin === plugin.filename}
isDisabled={loading}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@@ -0,0 +1,228 @@
import { Input, Pagination, Tab, Tabs } from '@heroui/react'
import type { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin'
import { Search } from 'lucide-react'
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CategoryFilter } from './CategoryFilter'
import { PluginCard } from './PluginCard'
import { PluginDetailModal } from './PluginDetailModal'
export interface PluginBrowserProps {
agentId: string
agents: PluginMetadata[]
commands: PluginMetadata[]
skills: PluginMetadata[]
installedPlugins: InstalledPlugin[]
onInstall: (sourcePath: string, type: 'agent' | 'command' | 'skill') => void
onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void
loading: boolean
}
type PluginType = 'all' | 'agent' | 'command' | 'skill'
const ITEMS_PER_PAGE = 12
export const PluginBrowser: FC<PluginBrowserProps> = ({
agentId,
agents,
commands,
skills,
installedPlugins,
onInstall,
onUninstall,
loading
}) => {
const { t } = useTranslation()
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [activeType, setActiveType] = useState<PluginType>('all')
const [currentPage, setCurrentPage] = useState(1)
const [actioningPlugin, setActioningPlugin] = useState<string | null>(null)
const [selectedPlugin, setSelectedPlugin] = useState<PluginMetadata | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
// Combine all plugins based on active type
const allPlugins = useMemo(() => {
switch (activeType) {
case 'agent':
return agents
case 'command':
return commands
case 'skill':
return skills
case 'all':
default:
return [...agents, ...commands, ...skills]
}
}, [agents, commands, skills, activeType])
// Extract all unique categories
const allCategories = useMemo(() => {
const categories = new Set<string>()
allPlugins.forEach((plugin) => {
if (plugin.category) {
categories.add(plugin.category)
}
})
return Array.from(categories).sort()
}, [allPlugins])
// Filter plugins based on search query and selected categories
const filteredPlugins = useMemo(() => {
return allPlugins.filter((plugin) => {
// Filter by search query
const searchLower = searchQuery.toLowerCase()
const matchesSearch =
!searchQuery ||
plugin.name.toLowerCase().includes(searchLower) ||
plugin.description?.toLowerCase().includes(searchLower) ||
plugin.tags?.some((tag) => tag.toLowerCase().includes(searchLower))
// Filter by selected categories
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(plugin.category)
return matchesSearch && matchesCategory
})
}, [allPlugins, searchQuery, selectedCategories])
// Paginate filtered plugins
const paginatedPlugins = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
const endIndex = startIndex + ITEMS_PER_PAGE
return filteredPlugins.slice(startIndex, endIndex)
}, [filteredPlugins, currentPage])
const totalPages = Math.ceil(filteredPlugins.length / ITEMS_PER_PAGE)
// Check if a plugin is installed
const isPluginInstalled = (plugin: PluginMetadata): boolean => {
return installedPlugins.some(
(installed) => installed.filename === plugin.filename && installed.type === plugin.type
)
}
// Handle install with loading state
const handleInstall = async (plugin: PluginMetadata) => {
setActioningPlugin(plugin.sourcePath)
await onInstall(plugin.sourcePath, plugin.type)
setActioningPlugin(null)
}
// Handle uninstall with loading state
const handleUninstall = async (plugin: PluginMetadata) => {
setActioningPlugin(plugin.sourcePath)
await onUninstall(plugin.filename, plugin.type)
setActioningPlugin(null)
}
// Reset to first page when filters change
const handleSearchChange = (value: string) => {
setSearchQuery(value)
setCurrentPage(1)
}
const handleCategoryChange = (categories: string[]) => {
setSelectedCategories(categories)
setCurrentPage(1)
}
const handleTypeChange = (type: string | number) => {
setActiveType(type as PluginType)
setCurrentPage(1)
}
const handlePluginClick = (plugin: PluginMetadata) => {
setSelectedPlugin(plugin)
setIsModalOpen(true)
}
const handleModalClose = () => {
setIsModalOpen(false)
setSelectedPlugin(null)
}
return (
<div className="flex flex-col gap-4">
{/* Search Input */}
<Input
placeholder={t('plugins.search_placeholder')}
value={searchQuery}
onValueChange={handleSearchChange}
startContent={<Search className="h-4 w-4 text-default-400" />}
isClearable
classNames={{
input: 'text-small',
inputWrapper: 'h-10'
}}
/>
{/* Category Filter */}
<CategoryFilter
categories={allCategories}
selectedCategories={selectedCategories}
onChange={handleCategoryChange}
/>
{/* Type Tabs */}
<Tabs selectedKey={activeType} onSelectionChange={handleTypeChange} variant="underlined">
<Tab key="all" title={t('plugins.all_types')} />
<Tab key="agent" title={t('plugins.agents')} />
<Tab key="command" title={t('plugins.commands')} />
<Tab key="skill" title={t('plugins.skills')} />
</Tabs>
{/* Result Count */}
<div className="flex items-center justify-between">
<p className="text-default-500 text-small">{t('plugins.showing_results', { count: filteredPlugins.length })}</p>
</div>
{/* Plugin Grid */}
{paginatedPlugins.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-default-400">{t('plugins.no_results')}</p>
<p className="text-default-300 text-small">{t('plugins.try_different_search')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{paginatedPlugins.map((plugin) => {
const installed = isPluginInstalled(plugin)
const isActioning = actioningPlugin === plugin.sourcePath
return (
<PluginCard
key={`${plugin.type}-${plugin.sourcePath}`}
plugin={plugin}
installed={installed}
onInstall={() => handleInstall(plugin)}
onUninstall={() => handleUninstall(plugin)}
loading={loading || isActioning}
onClick={() => handlePluginClick(plugin)}
/>
)
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center">
<Pagination total={totalPages} page={currentPage} onChange={setCurrentPage} showControls />
</div>
)}
{/* Plugin Detail Modal */}
<PluginDetailModal
agentId={agentId}
plugin={selectedPlugin}
isOpen={isModalOpen}
onClose={handleModalClose}
installed={selectedPlugin ? isPluginInstalled(selectedPlugin) : false}
onInstall={() => selectedPlugin && handleInstall(selectedPlugin)}
onUninstall={() => selectedPlugin && handleUninstall(selectedPlugin)}
loading={selectedPlugin ? actioningPlugin === selectedPlugin.sourcePath : false}
/>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { Button, Card, CardBody, CardFooter, CardHeader, Chip, Spinner } from '@heroui/react'
import type { PluginMetadata } from '@renderer/types/plugin'
import { Download, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
export interface PluginCardProps {
plugin: PluginMetadata
installed: boolean
onInstall: () => void
onUninstall: () => void
loading: boolean
onClick: () => void
}
export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall, onUninstall, loading, onClick }) => {
const { t } = useTranslation()
return (
<Card className="w-full cursor-pointer transition-shadow hover:shadow-md" isPressable onPress={onClick}>
<CardHeader className="flex flex-col items-start gap-2 pb-2">
<div className="flex w-full items-center justify-between">
<h3 className="font-semibold text-medium">{plugin.name}</h3>
<Chip
size="sm"
variant="solid"
color={plugin.type === 'agent' ? 'primary' : plugin.type === 'skill' ? 'success' : 'secondary'}>
{plugin.type}
</Chip>
</div>
<Chip size="sm" variant="dot" color="default">
{plugin.category}
</Chip>
</CardHeader>
<CardBody className="py-2">
<p className="line-clamp-3 text-default-500 text-small">{plugin.description || t('plugins.no_description')}</p>
{plugin.tags && plugin.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered" className="text-tiny">
{tag}
</Chip>
))}
</div>
)}
</CardBody>
<CardFooter className="pt-2">
{installed ? (
<Button
color="danger"
variant="flat"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onUninstall()
}}
isDisabled={loading}
fullWidth>
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button>
) : (
<Button
color="primary"
variant="flat"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onInstall()
}}
isDisabled={loading}
fullWidth>
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,321 @@
import {
Button,
Chip,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea
} from '@heroui/react'
import type { PluginMetadata } from '@renderer/types/plugin'
import { Download, Edit, Save, Trash2, X } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
export interface PluginDetailModalProps {
agentId: string
plugin: PluginMetadata | null
isOpen: boolean
onClose: () => void
installed: boolean
onInstall: () => void
onUninstall: () => void
loading: boolean
}
export const PluginDetailModal: FC<PluginDetailModalProps> = ({
agentId,
plugin,
isOpen,
onClose,
installed,
onInstall,
onUninstall,
loading
}) => {
const { t } = useTranslation()
const [content, setContent] = useState<string>('')
const [contentLoading, setContentLoading] = useState(false)
const [contentError, setContentError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState<string>('')
const [saving, setSaving] = useState(false)
// Fetch plugin content when modal opens or plugin changes
useEffect(() => {
if (!isOpen || !plugin) {
setContent('')
setContentError(null)
setIsEditing(false)
setEditedContent('')
return
}
const fetchContent = async () => {
setContentLoading(true)
setContentError(null)
setIsEditing(false)
setEditedContent('')
try {
let sourcePath = plugin.sourcePath
if (plugin.type === 'skill') {
sourcePath = sourcePath + '/' + 'SKILL.md'
}
const result = await window.api.claudeCodePlugin.readContent(sourcePath)
if (result.success) {
setContent(result.data)
} else {
setContentError(`Failed to load content: ${result.error.type}`)
}
} catch (error) {
setContentError(`Error loading content: ${error instanceof Error ? error.message : String(error)}`)
} finally {
setContentLoading(false)
}
}
fetchContent()
}, [isOpen, plugin])
const handleEdit = () => {
setEditedContent(content)
setIsEditing(true)
}
const handleCancelEdit = () => {
setIsEditing(false)
setEditedContent('')
}
const handleSave = async () => {
if (!plugin) return
setSaving(true)
try {
const result = await window.api.claudeCodePlugin.writeContent({
agentId,
filename: plugin.filename,
type: plugin.type,
content: editedContent
})
if (result.success) {
setContent(editedContent)
setIsEditing(false)
window.toast?.success('Plugin content saved successfully')
} else {
window.toast?.error(`Failed to save: ${result.error.type}`)
}
} catch (error) {
window.toast?.error(`Error saving: ${error instanceof Error ? error.message : String(error)}`)
} finally {
setSaving(false)
}
}
if (!plugin) return null
const modalContent = (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
wrapper: 'z-[9999]'
}}>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="font-bold text-xl">{plugin.name}</h2>
<Chip size="sm" variant="solid" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
{plugin.type}
</Chip>
</div>
<div className="flex items-center gap-2">
<Chip size="sm" variant="dot" color="default">
{plugin.category}
</Chip>
{plugin.version && (
<Chip size="sm" variant="bordered">
v{plugin.version}
</Chip>
)}
</div>
</ModalHeader>
<ModalBody>
{/* Description */}
{plugin.description && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Description</h3>
<p className="text-default-600 text-small">{plugin.description}</p>
</div>
)}
{/* Author */}
{plugin.author && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Author</h3>
<p className="text-default-600 text-small">{plugin.author}</p>
</div>
)}
{/* Tools (for agents) */}
{plugin.tools && plugin.tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat">
{tool}
</Chip>
))}
</div>
</div>
)}
{/* Allowed Tools (for commands) */}
{plugin.allowed_tools && plugin.allowed_tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Allowed Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.allowed_tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat">
{tool}
</Chip>
))}
</div>
</div>
)}
{/* Tags */}
{plugin.tags && plugin.tags.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tags</h3>
<div className="flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered">
{tag}
</Chip>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Metadata</h3>
<div className="space-y-1 text-small">
<div className="flex justify-between">
<span className="text-default-500">File:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.filename}</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Size:</span>
<span className="text-default-600">{(plugin.size / 1024).toFixed(2)} KB</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Source:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.sourcePath}</span>
</div>
{plugin.installedAt && (
<div className="flex justify-between">
<span className="text-default-500">Installed:</span>
<span className="text-default-600">{new Date(plugin.installedAt).toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Content */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-small">Content</h3>
{installed && !contentLoading && !contentError && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button
size="sm"
variant="flat"
color="danger"
startContent={<X className="h-3 w-3" />}
onPress={handleCancelEdit}
isDisabled={saving}>
Cancel
</Button>
<Button
size="sm"
color="primary"
startContent={saving ? <Spinner size="sm" color="current" /> : <Save className="h-3 w-3" />}
onPress={handleSave}
isDisabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</>
) : (
<Button size="sm" variant="flat" startContent={<Edit className="h-3 w-3" />} onPress={handleEdit}>
Edit
</Button>
)}
</div>
)}
</div>
{contentLoading ? (
<div className="flex items-center justify-center py-4">
<Spinner size="sm" />
</div>
) : contentError ? (
<div className="rounded-md bg-danger-50 p-3 text-danger text-small">{contentError}</div>
) : isEditing ? (
<Textarea
value={editedContent}
onValueChange={setEditedContent}
minRows={20}
classNames={{
input: 'font-mono text-tiny'
}}
/>
) : (
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-default-100 p-3 font-mono text-tiny">
{content}
</pre>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Close
</Button>
{installed ? (
<Button
color="danger"
variant="flat"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
onPress={onUninstall}
isDisabled={loading}>
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button>
) : (
<Button
color="primary"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onPress={onInstall}
isDisabled={loading}>
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
return createPortal(modalContent, document.body)
}

View File

@@ -0,0 +1,10 @@
export type { CategoryFilterProps } from './CategoryFilter'
export { CategoryFilter } from './CategoryFilter'
export type { InstalledPluginsListProps } from './InstalledPluginsList'
export { InstalledPluginsList } from './InstalledPluginsList'
export type { PluginBrowserProps } from './PluginBrowser'
export { PluginBrowser } from './PluginBrowser'
export type { PluginCardProps } from './PluginCard'
export { PluginCard } from './PluginCard'
export type { PluginDetailModalProps } from './PluginDetailModal'
export { PluginDetailModal } from './PluginDetailModal'

View File

@@ -77,7 +77,6 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
src={getPreprocessProviderLogo(preprocessProvider.id)}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
/>
<ProviderName> {preprocessProvider.name}</ProviderName>
{officialWebsite && preprocessProviderConfig?.websites && (
<Link target="_blank" href={preprocessProviderConfig.websites.official}>

View File

@@ -2,11 +2,22 @@ import { Button, Flex, RowFlex, Switch, Tooltip, WarnTooltip } from '@cherrystud
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
import { LoadingIcon } from '@renderer/components/Icons'
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
import Selector from '@renderer/components/Selector'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { PROVIDER_URLS } from '@renderer/config/providers'
import {
isAnthropicProvider,
isAzureOpenAIProvider,
isGeminiProvider,
isNewApiProvider,
isOpenAICompatibleProvider,
isOpenAIProvider,
isSupportAPIVersionProvider,
PROVIDER_URLS
} from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { isVertexProvider } from '@renderer/hooks/useVertexAI'
import i18n from '@renderer/i18n'
import AnthropicSettings from '@renderer/pages/settings/ProviderSettings/AnthropicSettings'
import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList'
@@ -14,15 +25,17 @@ import { checkApi } from '@renderer/services/ApiService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { useAppDispatch } from '@renderer/store'
import { updateWebSearchProvider } from '@renderer/store/websearch'
import type { SystemProviderId } from '@renderer/types'
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
import type { ApiKeyConnectivity } from '@renderer/types/healthCheck'
import { HealthStatus } from '@renderer/types/healthCheck'
import {
formatApiHost,
formatApiKeys,
formatAzureOpenAIApiHost,
formatVertexApiHost,
getFancyProviderName,
isAnthropicProvider,
isOpenAIProvider
validateApiHost
} from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error'
import { Divider, Input, Select, Space } from 'antd'
@@ -65,7 +78,9 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
SystemProviderIds.dashscope,
SystemProviderIds.modelscope,
SystemProviderIds.aihubmix,
SystemProviderIds.grok
SystemProviderIds.grok,
SystemProviderIds.cherryin,
SystemProviderIds.longcat
] as const
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]
@@ -74,6 +89,8 @@ const isAnthropicCompatibleProviderId = (id: string): id is AnthropicCompatibleP
return ANTHROPIC_COMPATIBLE_PROVIDER_ID_SET.has(id)
}
type HostField = 'apiHost' | 'anthropicApiHost'
const ProviderSetting: FC<Props> = ({ providerId }) => {
const { provider, updateProvider, models } = useProvider(providerId)
const allProviders = useAllProviders()
@@ -81,19 +98,23 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const [apiHost, setApiHost] = useState(provider.apiHost)
const [anthropicApiHost, setAnthropicHost] = useState<string | undefined>(provider.anthropicApiHost)
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
const [activeHostField, setActiveHostField] = useState<HostField>('apiHost')
const { t } = useTranslation()
const { theme } = useTheme()
const { setTimeoutTimer } = useTimer()
const dispatch = useAppDispatch()
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
const isAzureOpenAI = isAzureOpenAIProvider(provider)
const isDmxapi = provider.id === 'dmxapi'
const hideApiInput = ['vertexai', 'aws-bedrock'].includes(provider.id)
const noAPIInputProviders = ['aws-bedrock'] as const satisfies SystemProviderId[]
const hideApiInput = noAPIInputProviders.some((id) => id === provider.id)
const noAPIKeyInputProviders = ['copilot', 'vertexai'] as const satisfies SystemProviderId[]
const hideApiKeyInput = noAPIKeyInputProviders.some((id) => id === provider.id)
const providerConfig = PROVIDER_URLS[provider.id]
const officialWebsite = providerConfig?.websites?.official
const apiKeyWebsite = providerConfig?.websites?.apiKey
const configedApiHost = providerConfig?.api?.url
const configuredApiHost = providerConfig?.api?.url
const fancyProviderName = getFancyProviderName(provider)
@@ -153,7 +174,12 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
)
const onUpdateApiHost = () => {
if (apiHost.trim()) {
if (!validateApiHost(apiHost)) {
setApiHost(provider.apiHost)
window.toast.error(t('settings.provider.api_host_no_valid'))
return
}
if (isVertexProvider(provider) || apiHost.trim()) {
updateProvider({ apiHost })
} else {
setApiHost(provider.apiHost)
@@ -240,27 +266,46 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}
}
const onReset = () => {
setApiHost(configedApiHost)
updateProvider({ apiHost: configedApiHost })
}
const onReset = useCallback(() => {
setApiHost(configuredApiHost)
updateProvider({ apiHost: configuredApiHost })
}, [configuredApiHost, updateProvider])
const isApiHostResettable = useMemo(() => {
return !isEmpty(configuredApiHost) && apiHost !== configuredApiHost
}, [configuredApiHost, apiHost])
const hostPreview = () => {
if (apiHost.endsWith('#')) {
return apiHost.replace('#', '')
}
if (provider.type === 'openai') {
return formatApiHost(apiHost) + 'chat/completions'
if (isOpenAICompatibleProvider(provider)) {
return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions'
}
if (provider.type === 'azure-openai') {
return formatApiHost(apiHost) + 'openai/v1'
if (isAzureOpenAIProvider(provider)) {
const apiVersion = provider.apiVersion
const path = !['preview', 'v1'].includes(apiVersion)
? `/v1/chat/completion?apiVersion=v1`
: `/v1/responses?apiVersion=v1`
return formatAzureOpenAIApiHost(apiHost) + path
}
if (provider.type === 'anthropic') {
return formatApiHost(apiHost) + 'messages'
if (isAnthropicProvider(provider)) {
return formatApiHost(apiHost) + '/messages'
}
return formatApiHost(apiHost) + 'responses'
if (isGeminiProvider(provider)) {
return formatApiHost(apiHost, true, 'v1beta') + '/models'
}
if (isOpenAIProvider(provider)) {
return formatApiHost(apiHost) + '/responses'
}
if (isVertexProvider(provider)) {
return formatVertexApiHost(provider) + '/publishers/google'
}
return formatApiHost(apiHost)
}
// API key 连通性检查状态指示器,目前仅在失败时显示
@@ -289,31 +334,44 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}, [provider.anthropicApiHost])
const canConfigureAnthropicHost = useMemo(() => {
if (isNewApiProvider(provider)) {
return true
}
return (
provider.type !== 'anthropic' && isSystemProviderId(provider.id) && isAnthropicCompatibleProviderId(provider.id)
)
}, [provider])
const anthropicHostPreview = useMemo(() => {
const rawHost = (anthropicApiHost ?? provider.anthropicApiHost)?.trim()
if (!rawHost) {
return ''
}
if (/\/messages\/?$/.test(rawHost)) {
return rawHost.replace(/\/$/, '')
}
let normalizedHost = rawHost
if (/\/v\d+(?:\/)?$/i.test(normalizedHost)) {
normalizedHost = normalizedHost.replace(/\/$/, '')
} else {
normalizedHost = formatApiHost(normalizedHost).replace(/\/$/, '')
}
const rawHost = anthropicApiHost ?? provider.anthropicApiHost
const normalizedHost = formatApiHost(rawHost)
return `${normalizedHost}/messages`
}, [anthropicApiHost, provider.anthropicApiHost])
const hostSelectorOptions = useMemo(() => {
const options: { value: HostField; label: string }[] = [
{ value: 'apiHost', label: t('settings.provider.api_host') }
]
if (canConfigureAnthropicHost) {
options.push({ value: 'anthropicApiHost', label: t('settings.provider.anthropic_api_host') })
}
return options
}, [canConfigureAnthropicHost, t])
useEffect(() => {
if (!canConfigureAnthropicHost && activeHostField === 'anthropicApiHost') {
setActiveHostField('apiHost')
}
}, [canConfigureAnthropicHost, activeHostField])
const hostSelectorTooltip =
activeHostField === 'anthropicApiHost'
? t('settings.provider.anthropic_api_host_tooltip')
: t('settings.provider.api_host_tooltip')
const isAnthropicOAuth = () => provider.id === 'anthropic' && provider.authType === 'oauth'
return (
@@ -372,104 +430,119 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
)}
{!hideApiInput && !isAnthropicOAuth() && (
<>
<SettingSubtitle
style={{
marginTop: 5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key.label')}
{provider.id !== 'copilot' && (
<Tooltip content={t('settings.provider.api.key.list.open')} delay={500}>
<Button variant="ghost" onClick={openApiKeyList} size="icon">
<Settings2 size={16} />
{!hideApiKeyInput && (
<>
<SettingSubtitle
style={{
marginTop: 5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key.label')}
{provider.id !== 'copilot' && (
<Tooltip title={t('settings.provider.api.key.list.open')} delay={500}>
<Button variant="ghost" onClick={openApiKeyList} size="icon">
<Settings2 size={16} />
</Button>
</Tooltip>
)}
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
value={localApiKey}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setLocalApiKey(e.target.value)}
spellCheck={false}
autoFocus={provider.enabled && provider.apiKey === '' && !isProviderSupportAuth(provider)}
disabled={provider.id === 'copilot'}
suffix={renderStatusIndicator()}
/>
<Button
variant={isApiKeyConnectable ? 'ghost' : undefined}
onClick={onCheckApi}
disabled={!apiHost || apiKeyConnectivity.checking}>
{apiKeyConnectivity.checking ? (
<LoadingIcon />
) : apiKeyConnectivity.status === HealthStatus.SUCCESS ? (
<Check size={16} className="lucide-custom" />
) : (
t('settings.provider.check')
)}
</Button>
</Tooltip>
)}
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
value={localApiKey}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setLocalApiKey(e.target.value)}
spellCheck={false}
autoFocus={provider.enabled && provider.apiKey === '' && !isProviderSupportAuth(provider)}
disabled={provider.id === 'copilot'}
suffix={renderStatusIndicator()}
/>
<Button
variant={isApiKeyConnectable ? 'ghost' : undefined}
onClick={onCheckApi}
disabled={!apiHost || apiKeyConnectivity.checking}>
{apiKeyConnectivity.checking ? (
<LoadingIcon />
) : apiKeyConnectivity.status === HealthStatus.SUCCESS ? (
<Check size={16} className="lucide-custom" />
) : (
t('settings.provider.check')
)}
</Button>
</Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<RowFlex>
{apiKeyWebsite && !isDmxapi && (
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
)}
</RowFlex>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
{!isDmxapi && !isAnthropicOAuth() && (
</Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<RowFlex>
{apiKeyWebsite && !isDmxapi && (
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
)}
</RowFlex>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
</>
)}
{!isDmxapi && (
<>
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Tooltip content={t('settings.provider.api_host_tooltip')} delay={300}>
<SubtitleLabel>{t('settings.provider.api_host')}</SubtitleLabel>
<Tooltip title={hostSelectorTooltip} delay={300}>
<Selector
size={14}
value={activeHostField}
onChange={(value) => setActiveHostField(value as HostField)}
options={hostSelectorOptions}
style={{ paddingLeft: 1, fontWeight: 'bold' }}
placement="bottomLeft"
/>
</Tooltip>
<Button variant="ghost" onClick={() => CustomHeaderPopup.show({ provider })} size="icon">
<Settings2 size={16} />
</Button>
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
{!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
<Button variant="destructive" onClick={onReset}>
{t('settings.provider.api.url.reset')}
</Button>
)}
</Space.Compact>
{(isOpenAIProvider(provider) || isAnthropicProvider(provider)) && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<SettingHelpText
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
{t('settings.provider.api_host_preview', { url: hostPreview() })}
</SettingHelpText>
<SettingHelpText style={{ minWidth: 'fit-content' }}>
{t('settings.provider.api.url.tip')}
</SettingHelpText>
</SettingHelpTextRow>
{activeHostField === 'apiHost' && (
<>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
{isApiHostResettable && (
<Button variant="destructive" onClick={onReset}>
{t('settings.provider.api.url.reset')}
</Button>
)}
</Space.Compact>
{isVertexProvider(provider) && (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.vertex_ai.api_host_help')}</SettingHelpText>
</SettingHelpTextRow>
)}
{(isOpenAICompatibleProvider(provider) ||
isAzureOpenAIProvider(provider) ||
isAnthropicProvider(provider) ||
isGeminiProvider(provider) ||
isVertexProvider(provider) ||
isOpenAIProvider(provider)) && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<SettingHelpText
style={{
marginLeft: 6,
marginRight: '1em',
whiteSpace: 'break-spaces',
wordBreak: 'break-all'
}}>
{t('settings.provider.api_host_preview', { url: hostPreview() })}
</SettingHelpText>
</SettingHelpTextRow>
)}
</>
)}
{canConfigureAnthropicHost && (
{activeHostField === 'anthropicApiHost' && canConfigureAnthropicHost && (
<>
<SettingSubtitle
style={{
marginTop: 5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Tooltip content={t('settings.provider.anthropic_api_host_tooltip')} delay={300}>
<SubtitleLabel>{t('settings.provider.anthropic_api_host')}</SubtitleLabel>
</Tooltip>
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={anthropicApiHost ?? ''}
@@ -484,9 +557,6 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
url: anthropicHostPreview || '—'
})}
</SettingHelpText>
<SettingHelpText style={{ marginLeft: 6 }}>
{t('settings.provider.anthropic_api_host_tip')}
</SettingHelpText>
</SettingHelpTextRow>
</>
)}
@@ -516,21 +586,12 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
{provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
{provider.id === 'aws-bedrock' && <AwsBedrockSettings />}
{provider.id === 'vertexai' && <VertexAISettings providerId={provider.id} />}
{provider.id === 'vertexai' && <VertexAISettings />}
<ModelList providerId={provider.id} />
</SettingContainer>
)
}
const SubtitleLabel = styled.span`
display: inline-flex;
align-items: center;
line-height: inherit;
font-size: inherit;
font-weight: inherit;
color: inherit;
`
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;

View File

@@ -1,19 +1,13 @@
import { RowFlex } from '@cherrystudio/ui'
import { PROVIDER_URLS } from '@renderer/config/providers'
import { useProvider } from '@renderer/hooks/useProvider'
import { useVertexAISettings } from '@renderer/hooks/useVertexAI'
import { Alert, Input, Space } from 'antd'
import type { FC } from 'react'
import { Alert, Input } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..'
interface Props {
providerId: string
}
const VertexAISettings: FC<Props> = ({ providerId }) => {
const VertexAISettings = () => {
const { t } = useTranslation()
const {
projectId,
@@ -28,16 +22,9 @@ const VertexAISettings: FC<Props> = ({ providerId }) => {
const [localProjectId, setLocalProjectId] = useState(projectId)
const [localLocation, setLocalLocation] = useState(location)
const { provider, updateProvider } = useProvider(providerId)
const [apiHost, setApiHost] = useState(provider.apiHost)
const providerConfig = PROVIDER_URLS['vertexai']
const apiKeyWebsite = providerConfig?.websites?.apiKey
const onUpdateApiHost = () => {
updateProvider({ apiHost })
}
const handleProjectIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalProjectId(e.target.value)
}
@@ -73,18 +60,6 @@ const VertexAISettings: FC<Props> = ({ providerId }) => {
return (
<>
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
</Space.Compact>
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.vertex_ai.api_host_help')}</SettingHelpText>
</SettingHelpTextRow>
<SettingSubtitle style={{ marginTop: 5 }}>
{t('settings.provider.vertex_ai.service_account.title')}
</SettingSubtitle>

View File

@@ -0,0 +1,19 @@
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
export const TerminalPage: FC = () => {
// const { pathname } = useLocation()
const { t } = useTranslation()
// const isRoute = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
return (
<div>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('title.terminal')}</NavbarCenter>
</Navbar>
<div>🥲 Not Implemented</div>
</div>
)
}

View File

@@ -3,6 +3,7 @@ import type { Span } from '@opentelemetry/api'
import AiProvider from '@renderer/aiCore'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { isGeminiProvider } from '@renderer/config/providers'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import store from '@renderer/store'
import type {
@@ -41,7 +42,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
let host = aiProvider.getBaseURL()
const rerankHost = rerankAiProvider.getBaseURL()
if (provider.type === 'gemini') {
if (isGeminiProvider(provider)) {
host = host + '/v1beta/openai/'
}

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