Compare commits

..

103 Commits

Author SHA1 Message Date
icarus
2808a8aab1 Merge branch 'refactor/ocr' of github.com:CherryHQ/cherry-studio into refactor/ocr 2025-10-28 19:53:42 +08:00
icarus
1733a383e1 refactor(S3BackupManager): remove unused Space import from antd 2025-10-28 19:53:28 +08:00
GitHub Action
794c5311ef fix(i18n): Auto update translations for PR #10829 2025-10-28 11:51:19 +00:00
icarus
35ff0c63f4 Merge branch 'v2' of github.com:CherryHQ/cherry-studio into refactor/ocr 2025-10-28 19:49:07 +08:00
GitHub Action
835bce9079 fix(i18n): Auto update translations for PR #10829 2025-10-24 10:08:11 +00:00
icarus
ab9e1bf5a3 Merge branch 'v2' of github.com:CherryHQ/cherry-studio into refactor/ocr 2025-10-24 18:06:39 +08:00
icarus
472f2b1a6f Merge branch 'v2' of github.com:CherryHQ/cherry-studio into refactor/ocr 2025-10-21 14:02:33 +08:00
icarus
2420716983 feat(ocr): add new OcrProviderService with CRUD operations
Implement new service layer for OCR provider management following the IBaseService interface. Includes basic CRUD operations, pagination, and special methods for built-in providers. This is an early version pending final data architecture design.
2025-10-21 14:00:53 +08:00
icarus
332ff8b8cf refactor(db): remove unused schema index file 2025-10-21 13:35:52 +08:00
icarus
aae10322b8 refactor(db): move ocr provider schema to root schemas directory
The ocr provider schema was moved from `schemas/ocr/provider.ts` to `schemas/ocrProvider.ts` to simplify the directory structure and make imports more straightforward. All related imports were updated accordingly.
2025-10-21 13:34:57 +08:00
icarus
aee134110b feat(i18n): add translations for multiple languages
- Translate provider-related error messages in zh-tw, ja-jp, pt-pt, ru-ru, el-gr, es-es, fr-fr
- Add search section translations in ja-jp, pt-pt, ru-ru, el-gr, es-es, fr-fr
- Complete OVMS runtime error messages in all languages
2025-10-21 02:59:54 +08:00
icarus
4f2eaf4aed fix(ocr): include imageProviderId in error message and dependencies
Add imageProviderId to error message for better debugging and include it in useCallback dependencies to ensure consistency
2025-10-21 00:55:19 +08:00
icarus
d19e0de486 docs: update OCR architecture documentation with IPC details
Update both English and Chinese versions of the OCR architecture documentation to reflect current implementation where IPC serves as API layer. Clarify direct communication between renderer and business layer, and enhance data flow diagrams with new components and security aspects.
2025-10-21 00:12:43 +08:00
icarus
2f141e4761 docs: add OCR architecture documentation in English and Chinese
Add comprehensive technical documentation for the OCR system architecture, covering:
- Layered architecture design
- Provider system implementation
- Data flow and type system
- Configuration management
- Error handling and security
- Development guidelines

The documentation was automatically generated based on code analysis and reflects the current implementation state.
2025-10-20 23:24:25 +08:00
icarus
64c7601cc9 chore: update sharp and related dependencies to 0.34.4
Update sharp package and its platform-specific variants to version 0.34.4, including corresponding libvips dependencies. This ensures compatibility and includes latest fixes/improvements from the sharp library.
2025-10-20 23:04:19 +08:00
icarus
0c5a20a2e4 fix(translate): correct regex pattern for language code validation
fix(ocr): improve debug log by showing full provider details
2025-10-20 22:49:07 +08:00
icarus
917864be1c feat(utils): add safe json parsing utility
Add safeParseJson function to handle JSON parsing with error catching
2025-10-20 22:45:51 +08:00
icarus
e7e36d7df6 style(migrations): format json files with consistent indentation 2025-10-20 22:41:12 +08:00
icarus
0176cf7679 feat(ocr): add config validation and pass provider config to ocr handlers
Add type guards for OCR provider configs and ensure config is passed to OCR handlers
Update all built-in OCR services to validate config before processing
2025-10-20 22:40:12 +08:00
icarus
96f71f12ec fix(translate): show detailed error message when file processing fails 2025-10-20 22:21:00 +08:00
icarus
7942147ce0 feat(migrations): add initial sqlite migration for ocr_provider table
Add initial database migration files including schema definition for ocr_provider table and related metadata files. This sets up the foundation for OCR provider management in the system.
2025-10-20 22:15:55 +08:00
icarus
b7a6ed6b24 style: reorganize imports in ocr-related type files 2025-10-20 22:07:42 +08:00
icarus
790df761f0 refactor(types): move translate types to dedicated module
Centralize translate-related types and schemas in a dedicated module for better organization and maintainability. This change involves moving types from the shared index file to a new translate-specific file and updating import paths accordingly.
2025-10-20 21:58:34 +08:00
icarus
9215256d68 refactor(ocr): remove deprecated ocr slice actions and selectors
All functionality has been migrated elsewhere as indicated by the deprecation notice.
2025-10-20 21:52:49 +08:00
icarus
12b9b64ca8 refactor(ocr): move TimestampExtendShape to data.ts and clean up imports
Move TimestampExtendShape definition from api.ts to data.ts where it's primarily used
Clean up type imports and remove unnecessary comments
2025-10-20 21:02:26 +08:00
icarus
74e7979764 refactor(ocr): simplify response handling by removing wrapper objects
Remove unnecessary response wrapper objects ({ data: ... }) from OCR service methods and update types accordingly
Update API handlers to maintain consistent response structure
2025-10-20 20:58:08 +08:00
icarus
e0781e1bb0 refactor(ocr): restructure ocr types into modular files for better maintainability
- Split monolithic ocr.ts into separate files for base types, providers, models, and layers (api, data, business)
- Update related imports and references across the codebase
- Rename API request/response types to be more consistent (Patch->Update, Put->Replace)
- Adjust repository and service implementations to match new type structure
2025-10-20 20:39:24 +08:00
icarus
327d0dab7f refactor(ocr): remove ocr types to a single folder 2025-10-20 20:19:39 +08:00
icarus
75f513edb0 feat(i18n): add provider unavailable message for multiple locales
Add translation key for provider unavailable status in zh-cn locale and placeholders for other locales
2025-10-20 19:47:36 +08:00
icarus
52e2aff005 fix(ocr): add missing error message for unavailable provider
Add "not_availabel" translation key and use it when provider is unavailable. Also update type name from ImageOcrProvider to OcrProvider to better reflect its usage.
2025-10-20 19:46:57 +08:00
icarus
933d26e0f4 refactor(ocr): improve readability of updateProvider method signature
Split long method signature into multiple lines for better readability
2025-10-20 19:46:41 +08:00
icarus
4fd3300ed0 refactor(ocr): restructure ocr service and repository layers
- Extract database operations to new OcrProviderRepository
- Improve service initialization and provider management
- Add better error handling and logging
- Update API handlers to use new service methods
2025-10-20 19:35:39 +08:00
icarus
ad67d2558a refactor(ocr): update ocr settings components to use props instead of hooks
- Remove useOcrProvider hook usage in favor of direct props passing
- Add proper type casting for updateConfig functions
- Maintain consistent state management across all OCR provider settings
2025-10-20 09:15:41 +08:00
icarus
d47c3b1d63 refactor(ocr): restructure ocr provider settings and hooks
- Simplify useOcrImageProvider by directly using useOcrProvider
- Make useOcrProvider handle null provider IDs
- Update provider settings components to use passed props
- Remove styled-components in favor of tailwind classes
2025-10-20 09:10:04 +08:00
icarus
741bb94c8b refactor(hooks): rename provider to data for consistency with api response 2025-10-20 08:42:37 +08:00
icarus
46772b4f2a fix(ocr): include id in provider config update request
The id parameter was missing in the update request body, causing potential issues with identifying which provider to update. Add id to the request body to ensure correct provider is updated.
2025-10-20 08:39:46 +08:00
icarus
8aaf26e420 refactor(data): simplify ocr preferences mapping structure
Remove redundant ocr provider config mappings and consolidate to a single image provider id mapping
2025-10-20 08:33:59 +08:00
icarus
281632f859 feat(ocr): add validation for OCR provider operations
- Add params validation in API handlers to ensure path ID matches body ID
- Introduce isDbOcrProvider type guard for runtime validation
- Validate provider data before database operations
2025-10-20 08:28:15 +08:00
icarus
e4b5e70c34 refactor(ocr): update timestamp handling to use milliseconds
Use dayjs().valueOf() instead of dayjs().unix() to get timestamps in milliseconds for consistency with the updated schema comment
2025-10-20 08:21:18 +08:00
icarus
6f635472f3 refactor(ocr): improve provider schema and update handling
- Export DbOcrProviderSchema and add DbOcrProvider type
- Simplify provider update logic by merging entire object
- Add timestamps to create/update operations
- Maintain createdAt when updating existing providers
2025-10-20 08:18:54 +08:00
icarus
eb4927260a refactor(OcrImageSettings): remove logger and optimize setImageProvider
Replace direct logger usage with commented code and wrap setImageProvider in useCallback
2025-10-20 08:10:14 +08:00
icarus
a2e628d7e9 refactor(ocr): improve ocr provider handling and error states
- Add ListOcrProvidersQuery type for better type safety
- Update useOcrProviders hook to accept query params and handle undefined data
- Improve error handling and loading states in OcrImageSettings component
- Memoize filtered image providers for better performance
2025-10-20 08:07:32 +08:00
icarus
389dfc08f6 feat(ocr): add filtering by registration status to provider list
Add optional query parameter to filter OCR providers by registration status
Prevent modification and deletion of built-in OCR providers
2025-10-20 07:54:50 +08:00
icarus
7ea7e7134d refactor(ocr): add BuiltinOcrProviderIds constant for provider ids
Use objectValues utility to create a frozen array of provider ids for better maintainability and type safety
2025-10-20 07:47:11 +08:00
icarus
1423163b3a refactor(ocr): rename BuiltinOcrProviderIds to BuiltinOcrProviderIdMap for consistency 2025-10-20 07:45:53 +08:00
icarus
f9ed8343fe feat(ocr): implement delete provider API endpoint
Add DELETE endpoint for OCR providers with proper type definitions and handler implementation. The endpoint removes the provider from both the registry and database after validation checks.
2025-10-20 07:40:31 +08:00
icarus
a042892250 feat(ocr): implement create and update provider endpoints
add POST handler for creating new OCR providers
add PUT handler for updating existing OCR providers
add required request/response types and schemas
2025-10-20 07:35:03 +08:00
icarus
b67b4c8178 feat(ocr): update provider config by merging with existing values
Use lodash merge to combine existing provider config with updates instead of overwriting
2025-10-20 07:27:09 +08:00
icarus
4ab6961fcc feat(ocr): add type for OcrProviderId and getProvider method
Add OcrProviderId type definition and implement getProvider method in OcrService to fetch a single OCR provider by ID
2025-10-20 07:23:41 +08:00
icarus
4e7a67df59 feat(ocr): implement PATCH endpoint for OCR provider updates
Add PATCH handler for OCR provider updates with request/response schemas
Implement patchProvider method in OcrService to update provider data
2025-10-20 07:19:08 +08:00
icarus
1e9014b080 feat(ocr): implement ocr providers list endpoint
Add DbOcrProviderSchema and update response schemas for list and get endpoints
Implement the GET /ocr/providers endpoint using ocrService
2025-10-20 07:00:23 +08:00
icarus
8ac9344fef feat(i18n): add provider error messages and search translations
Add error messages for provider operations (create, delete, get, list, update) in multiple languages
Include search-related translations for various languages
Add new OVMS runtime error codes for installation process
2025-10-20 06:54:48 +08:00
icarus
3250d982fc docs(ocr): add todo comment for builtin providers registration 2025-10-20 06:48:49 +08:00
icarus
4dcfe276ac refactor(ocr): change provider listing to include db data
Replace simple registry key listing with combined db query to filter available providers
2025-10-20 06:47:44 +08:00
icarus
78126c3d0b refactor(ocr): simplify useOcrProvider hook by using data api
Replace complex provider and config management with useQuery and useMutation hooks
Add loading states and error handling
Remove unused imports and simplify return type
2025-10-20 06:47:22 +08:00
icarus
37ad896f6a refactor(ocr): restructure OCR provider configuration and types
- Remove separate configs from store and move them into provider definitions
- Add Zod schemas for OCR provider types and configurations
- Update migration to use new provider structure
- Make OCR provider config non-nullable in database schema
- Clean up unused OCR preference settings
2025-10-20 06:47:02 +08:00
icarus
84a513a6ae refactor(ocr): move provider registration to constructor
Initialize built-in OCR providers during service instantiation instead of after creation for better encapsulation and initialization control
2025-10-20 05:18:51 +08:00
icarus
f538e89976 Revert "refactor(ocr): simplify ocr providers api by returning string array"
This reverts commit 695afb6f75.
2025-10-20 05:16:35 +08:00
icarus
f10f0b21f9 Revert "refactor(db): remove unused ocr provider schema table"
This reverts commit 9c740f82ad.
2025-10-20 05:08:24 +08:00
icarus
49c80620ae refactor(ocr): simplify ocr service interface and params handling
- Replace OcrProvider with OcrParams to simplify interface
- Remove unused OcrApiClientFactory and related code
- Consolidate ocr service calls to use consistent params structure
2025-10-20 05:07:53 +08:00
Phantom
68aaf9df4a fix: use consistent sharp dependencies (#10832)
build: update sharp dependencies to version 0.34.3

Update sharp image processing library dependencies to latest version 0.34.3 across all platforms (darwin, linux, win32) to ensure consistent behavior and security fixes
2025-10-20 04:33:17 +08:00
icarus
b31b48fcaf refactor(ocr): remove unused OCR list providers functionality 2025-10-20 04:31:02 +08:00
icarus
82b244471b refactor(OcrImageSettings): simplify provider selection logic and improve UI
Remove unused imports and simplify the provider filtering logic by removing platform-specific checks
Update UI styling to use Tailwind classes instead of inline styles
2025-10-20 03:36:04 +08:00
icarus
062cbcc259 feat(ui): add skeleton component to shadcn-io exports
Export new Skeleton component from shadcn-io directory and add comment about potential future organization
2025-10-20 03:35:21 +08:00
icarus
b50d8b2a23 refactor(ocr): remove unused error message and simplify provider check
Move provider availability check outside of useCallback and remove unused error message from translations
2025-10-20 03:19:38 +08:00
icarus
b262410518 refactor(ocr): use config from useOcrProvider hook directly
Update OCR settings components to use config object returned by useOcrProvider hook instead of accessing it through provider.config. This provides more direct access to the configuration data and improves consistency across components.
2025-10-20 03:13:25 +08:00
icarus
a34426d431 refactor(ocr): improve type safety and config handling in useOcrProvider
- Replace dynamic provider lookup with type-safe registry pattern
- Add separate config management for each provider type
- Remove unused imports and simplify provider fallback logic
2025-10-20 03:06:11 +08:00
icarus
94ed39ab27 refactor(ocr): simplify provider fallback logic and remove unused methods
Remove unused provider management methods (add/remove) and simplify the fallback logic in useOcrProvider to always use Tesseract when provider is not found
2025-10-20 02:30:57 +08:00
icarus
ed8501961a refactor(ocr): extract image provider logic to separate hook
Move image provider related state and logic from useOcrProviders to new useOcrImageProvider hook
Update all components to use the new hook for better separation of concerns
2025-10-20 02:27:10 +08:00
icarus
78000816e5 refactor(useOcrProvider): rename useOcrProvider from tsx to ts 2025-10-20 02:21:29 +08:00
icarus
5900ff0c6e feat(ocr): add provider availability check and error message
Add validation to ensure OCR provider can process images before attempting OCR
2025-10-20 02:17:28 +08:00
icarus
b310ea1407 feat(ocr): add type guard for OcrProvider
Add isOcrProvider type guard function to validate unknown inputs against OcrProviderSchema
2025-10-20 02:11:29 +08:00
icarus
beb44eea61 refactor(ocr): move provider logo logic to component and consolidate hooks
Move OcrProviderLogo implementation from useOcrProviders hook to the component file
Extract common OCR provider logic into a separate useOcrProviders hook
Clean up and reorganize related imports and exports
2025-10-20 02:07:12 +08:00
icarus
7658b1e79f refactor(ocr): reorganize ocr hooks into dedicated directory
Move useOcr and useOcrProvider hooks to new ocr directory under hooks
Update all imports in settings components to reflect new paths
2025-10-20 02:01:56 +08:00
icarus
ea1aa6e5a8 refactor(ocr): remove unused langs config from ovocr provider
The langs configuration for ovocr provider is not currently configurable, so it's removed from both type definition and default config.
2025-10-20 01:58:03 +08:00
icarus
e823d97e31 feat(ocr): add provider config mappings and default preferences
Add OCR provider configuration mappings to PreferencesMappings.ts and define default preferences for OCR providers in preferenceSchemas.ts. This enables support for multiple OCR providers with their respective configurations.
2025-10-20 01:54:22 +08:00
icarus
515d3cd596 refactor(data): update PreferencesMappings type with PreferenceSchemas
Add type import for PreferenceSchemas and update REDUX_STORE_MAPPINGS type to use keyof PreferenceSchemas
Mark several mappings with TODO comments for future fixes
2025-10-20 01:53:49 +08:00
icarus
47366064ca refactor(ocr): move ocr config to shared and add utility function
Migrate ocr configuration from renderer to shared config and introduce getDefaultOcrProvider utility function to centralize default provider logic
2025-10-20 01:44:23 +08:00
icarus
61a71a0486 refactor(utils): reorganize utils files into module structure
Move defaultAppHeaders function from utils.ts to new net.ts module and create index.ts for exports
2025-10-20 01:40:01 +08:00
icarus
e640beb874 refactor(ocr): move ocr config to shared package for reuse
Centralize OCR configuration in shared package to avoid duplication and improve maintainability. This change affects multiple components that previously imported from renderer config.
2025-10-20 01:37:00 +08:00
icarus
9386a4d482 refactor(ocr): restructure ocr provider config handling
move provider configs from individual providers to a centralized config map
add migration for new ocr config structure
2025-10-20 01:26:37 +08:00
icarus
90e02e64b7 refactor(types): mark OcrProvider.config as deprecated
The config property is being phased out in favor of a more streamlined type structure. This change marks it as deprecated while maintaining backward compatibility.
2025-10-20 01:09:30 +08:00
icarus
08d8f70752 refactor(data): add type constraint to REDUX_STORE_MAPPINGS 2025-10-20 01:06:11 +08:00
icarus
695afb6f75 refactor(ocr): simplify ocr providers api by returning string array
Remove unused OcrProvider type and related endpoints. The GET endpoint now returns a simple array of provider IDs instead of full provider objects, as the detailed provider data will be handled separately.
2025-10-20 01:02:17 +08:00
icarus
471b1fae2d docs(IBaseService): add type parameter documentation to interface 2025-10-20 00:59:44 +08:00
icarus
9c740f82ad refactor(db): remove unused ocr provider schema table 2025-10-20 00:59:28 +08:00
icarus
ab7fed8907 docs(ocr): update provider schema comments with more details
Add more context about ID format for custom providers and clarify name usage for built-in providers
Explain JSON config validation requirements and mark timestamps as potentially unused
2025-10-20 00:48:40 +08:00
icarus
ec68886e4a refactor(ocr): convert OcrProvider type to zod schema
Use zod schema for better type safety and validation capabilities
2025-10-20 00:37:13 +08:00
icarus
a3bc279c74 feat(types): add OcrOvConfig to OcrProviderConfig union type 2025-10-20 00:36:04 +08:00
icarus
2e400d3f1c refactor(ocr): convert OcrProviderBaseConfig to zod schema
Use zod schema for type validation and inference to improve type safety
2025-10-20 00:35:26 +08:00
icarus
ed791a3bb3 refactor(ocr): replace manual type check with zod schema validation
Simplify type checking logic by using zod schema validation instead of manual type checks for OcrProviderApiConfig
2025-10-20 00:34:34 +08:00
icarus
2a8f819bee refactor(types): convert OcrModel interface to zod schema
Use zod schema for better type safety and validation capabilities
2025-10-20 00:34:08 +08:00
icarus
35280b4b8c refactor(ocr): replace manual record type with zod schema inference
Use zod's partialRecord and inference to define OcrProviderCapabilityRecord for better type safety and maintainability
2025-10-20 00:33:33 +08:00
icarus
b93ff89e9e refactor(types): add satisfies constraint to type assertions
Add satisfies constraint to BuiltinOcrProviderIds and OcrProviderCapabilities to ensure type safety and better intellisense
2025-10-20 00:31:31 +08:00
icarus
dedc591e1c refactor(ocr): replace manual capability check with zod schema
Use zod schema validation for OCR provider capabilities instead of manual object property check for better type safety and maintainability
2025-10-20 00:31:24 +08:00
icarus
5c049911ee refactor(ocr): replace manual type check with zod schema for provider ids
Use zod schema validation for BuiltinOcrProviderId type to improve type safety and maintainability
2025-10-20 00:30:08 +08:00
icarus
399f8cbd41 feat(db): add ocr provider schema with capabilities and config
Add new schema for OCR providers including fields for id, name, capabilities, and config. Capabilities and config are stored as JSON to accommodate various provider types and configurations.
2025-10-20 00:27:52 +08:00
icarus
c780552197 feat(ocr): add api schemas and handlers for ocr providers
Implement API schemas and handlers for OCR providers endpoints
Add TODO comments for future migration tasks
Fix endpoint path in OcrImageSettings component
2025-10-19 23:21:54 +08:00
icarus
d366ec5932 refactor(ocr-settings): simplify ocr settings by removing unused tab logic
Since only image OCR is currently supported, remove the tab component and related unused code while keeping the core functionality
2025-10-19 22:36:40 +08:00
icarus
d35d7029f7 refactor(ocr): simplify image provider state management
- Remove unnecessary state propagation between components
- Store image provider ID in preferences instead of redux
- Add null checks for provider existence
- Update tab navigation to use new ui components
2025-10-19 22:32:00 +08:00
icarus
2c78f5f906 feat(ui): add shadcn tabs component
Add new tabs component using @radix-ui/react-tabs as base implementation. Includes Tabs, TabsList, TabsTrigger and TabsContent subcomponents with styling utilities.
2025-10-19 22:28:37 +08:00
icarus
92638d138d refactor(ocr): rename OCR_ocr to OCR_Ocr for consistent naming 2025-10-19 19:11:37 +08:00
icarus
2dbf7c1c51 refactor(ocr): improve service initialization and registration
Move availability checks to service instantiation
Update registry to store service instances directly
Simplify registration logic by removing redundant bind calls
2025-10-19 19:00:13 +08:00
141 changed files with 7664 additions and 15250 deletions

View File

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

View File

@@ -10,9 +10,8 @@ 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.
- **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:`).
- **Seek review**: Ask a human developer to review substantial changes before merging.
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
## Development Commands

View File

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

View File

@@ -0,0 +1,260 @@
> [!NOTE]
> This technical documentation was automatically generated by Claude Code based on analysis of the current OCR implementation in the codebase. The content reflects the architecture as of the current branch state.
# OCR Architecture
## Overview
Cherry Studio's OCR (Optical Character Recognition) system is a modular, extensible architecture designed to support multiple OCR providers and file types. The architecture follows a layered approach with clear separation of concerns between data access, business logic, and provider implementations.
## Architecture Layers
The OCR architecture follows a layered approach where data interactions occur through RESTful APIs, while IPC serves as part of the API layer, allowing the renderer to interact directly with the business layer:
### 1. API Layer
**Location**: `src/main/data/api/handlers/`, `src/main/ipc.ts`, `src/preload/index.ts`
- **IPC Bridge**: Serves as API layer connecting renderer to main process
- **Request Routing**: Routes IPC calls to appropriate service methods
- **Type Safety**: Zod schemas for request/response validation
- **Error Handling**: Centralized error propagation across process boundaries
- **Security**: Secure communication sandbox between renderer and main processes
### 2. OCR Service Layer (Business Layer)
**Location**: `src/main/services/ocr/`
- **OcrService**: Main business logic orchestrator and central coordinator
- **Provider Registry**: Manages registered OCR providers
- **Data Integration**: Direct interaction with data layer for provider management
- **Lifecycle Management**: Handles provider initialization and disposal
- **Validation**: Ensures provider availability and data integrity
- **Orchestration**: Coordinates between providers and data services
- **Direct IPC Access**: Renderer can directly invoke business layer methods via IPC
### 3. Provider Services Layer
**Location**: `src/main/services/ocr/builtin/`
- **Base Service**: Abstract `OcrBaseService` defines common interface
- **Data Independence**: No direct database interactions, relies on injected data
- **Built-in Providers**:
- `TesseractService`: Local Tesseract.js implementation
- `SystemOcrService`: Platform-specific system OCR
- `PpocrService`: PaddleOCR integration
- `OvOcrService`: Intel OpenVINO (NPU) OCR
- **Pure OCR Logic**: Focus solely on OCR processing capabilities
### 4. Data Layer
**Location**: `src/main/data/db/schemas/ocr/`, `src/main/data/repositories/`
- **Database Schema**: Uses Drizzle ORM with SQLite database
- **Repository Pattern**: `OcrProviderRepository` handles all database operations
- **Provider Storage**: Stores provider configurations in `ocr_provider` table
- **JSON Configuration**: Polymorphic `config` field stores provider-specific settings
- **Data Access**: Exclusively accessed by OCR Service layer
### 5. Frontend Layer
**Location**: `src/renderer/src/services/ocr/`, `src/renderer/src/hooks/ocr/`
- **Direct IPC Communication**: Direct interaction with business layer via IPC
- **React Hooks**: Custom hooks for OCR operations and state management
- **Configuration UI**: Settings pages for provider configuration
- **State Management**: Frontend state synchronization with backend data
## Data Flow
```mermaid
graph TD
A[Frontend UI] --> B[Frontend OCR Service]
B --> C[API Layer - IPC Bridge]
C --> D[OCR Service Layer - Business Logic]
D --> E[Data Layer - Provider Repository]
D --> F[Provider Services Layer]
F --> G[OCR Processing]
G --> H[Result]
H --> F
F --> D
D --> C
C --> B
B --> A
style D fill:#e1f5fe
style F fill:#f3e5f5
style E fill:#e8f5e8
style C fill:#fff3e0
```
**Key Flow Characteristics:**
- **Direct Business Access**: Frontend communicates directly with OCR Service layer via IPC
- **IPC as API Gateway**: IPC bridge functions as the API layer, handling routing and validation
- **Data Isolation**: Only business layer interacts with data persistence
- **Provider Independence**: OCR providers remain isolated from data concerns
## Provider System
### Provider Registration
- **Built-in Providers**: Automatically registered on service initialization
- **Custom Providers**: Support for extensible provider system
- **Configuration**: Each provider has its own configuration schema
### Provider Capabilities
```typescript
interface OcrProviderCapabilityRecord {
image?: boolean // Image file OCR support
pdf?: boolean // PDF file OCR support (future)
}
```
### Configuration Architecture
- **Polymorphic Config**: JSON-based configuration adapts to provider needs
- **Type Safety**: Zod schemas validate provider-specific configurations
- **Runtime Validation**: Configuration validation before OCR operations
## Type System
### Core Types
- **`OcrProvider`**: Base provider interface
- **`OcrParams`**: OCR operation parameters
- **`OcrResult`**: Standardized OCR result format
- **`SupportedOcrFile`**: File types supported for OCR
### Business Types
- **`OcrProviderBusiness`**: Domain-level provider representation
- **Operations**: Create, Update, Replace, Delete operations
- **Queries**: List providers with filtering options
### Provider-Specific Types
- **TesseractConfig**: Language selection, model paths
- **SystemOcrConfig**: Language preferences
- **PaddleOCRConfig**: API endpoints, authentication
- **OpenVINOConfig**: Device selection, model paths
## Built-in Providers
### Tesseract OCR
- **Engine**: Tesseract.js
- **Languages**: Multi-language support with automatic download
- **Configuration**: Language selection, cache management
- **Performance**: Worker pooling for concurrent processing
### System OCR
- **Windows**: Windows Media Foundation OCR
- **macOS**: Vision framework OCR
- **Linux**: Platform-specific implementations
- **Features**: Native performance, system integration
### PaddleOCR
- **Deployment**: Remote API integration
- **Languages**: Chinese, English, and mixed language support
- **Configuration**: API endpoints and authentication
### Intel OpenVINO OCR
- **Hardware**: NPU acceleration support
- **Performance**: Optimized for Intel hardware
- **Use Case**: High-performance OCR scenarios
## Configuration Management
### Database Schema
```sql
CREATE TABLE ocr_provider (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
capabilities TEXT NOT NULL, -- JSON
config TEXT NOT NULL, -- JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
### Provider Defaults
- **Initial Configuration**: Defined in `packages/shared/config/ocr.ts`
- **Migration System**: Automatic provider initialization on startup
- **User Customization**: Runtime configuration updates
## Error Handling
### Error Categories
- **Provider Errors**: OCR engine failures, missing dependencies
- **Configuration Errors**: Invalid settings, missing parameters
- **File Errors**: Unsupported formats, corrupted files
- **System Errors**: Resource exhaustion, permissions
### Error Propagation
- **Logging**: Centralized logging with context
- **User Feedback**: Translated error messages
- **Recovery**: Graceful fallback options
## Performance Considerations
### Resource Management
- **Worker Disposal**: Proper cleanup of OCR workers
- **Memory Management**: Limits on file sizes and concurrent operations
- **Caching**: Model and result caching where applicable
### Optimization
- **Lazy Loading**: Providers initialized on demand
- **Concurrent Processing**: Multiple workers for parallel operations
- **Hardware Acceleration**: NPU and GPU support where available
## Security
### Input Validation
- **File Type Checking**: Strict validation of supported formats
- **Size Limits**: Protection against resource exhaustion
- **Path Validation**: Prevention of path traversal attacks
### Configuration Security
- **API Key Storage**: Secure storage of sensitive configuration
- **Validation**: Runtime validation of configuration parameters
- **Sandboxing**: Isolated execution of OCR operations
## Extension Points
### Custom Providers
- **Interface**: Implement `OcrBaseService` for new providers
- **Registration**: Dynamic provider registration system
- **Configuration**: Extensible configuration schemas
### File Type Support
- **Handlers**: Modular file type processors
- **Capabilities**: Declarative provider capabilities
- **Future Support**: PDF, document formats planned
## Migration Strategy
### Legacy System
- **Data Migration**: Automatic migration from old configuration formats
- **Compatibility**: Backward compatibility during transition
- **Testing**: Comprehensive test coverage for migration paths
### Future Enhancements
- **PDF Support**: Planned extension to document OCR
- **Cloud Providers**: API-based OCR services integration
- **AI Enhancement**: Post-processing and accuracy improvements
## Development Guidelines
### Adding New Providers
1. Create provider service extending `OcrBaseService`
2. Define provider-specific configuration schema
3. Register provider in `OcrService`
4. Add configuration UI components
5. Include comprehensive tests
> [!WARNING]
> Provider services should never directly access the data layer. All data operations must go through the OCR Service layer to maintain proper separation of concerns.
### Configuration Changes
1. Update provider configuration schema
2. Add migration logic for existing configurations
3. Update UI validation and error handling
4. Test with various configuration scenarios
> [!WARNING]
> Always validate configuration changes before saving to the database. Use Zod schemas for runtime validation to prevent corrupted provider configurations.
### Testing
- **Unit Tests**: Provider implementation testing
- **Integration Tests**: End-to-end OCR workflows
- **Performance Tests**: Resource usage and timing
- **Error Scenarios**: Comprehensive error handling testing

View File

@@ -0,0 +1,260 @@
> [!NOTE]
> 本技术文档由 Claude Code 基于对当前代码库中 OCR 实现的分析自动生成。内容反映了当前分支状态的架构设计。
# OCR 架构文档
## 概述
Cherry Studio 的 OCR光学字符识别系统是一个模块化、可扩展的架构旨在支持多个 OCR 提供商和文件类型。该架构采用分层设计,在数据访问、业务逻辑和提供商实现之间有明确的关注点分离。
## 架构分层
OCR 架构采用分层方法,其中数据交互通过 RESTful API 进行,而 IPC 作为 API 层的一部分,允许 Renderer 直接与业务层交互:
### 1. API 层
**位置**: `src/main/data/api/handlers/`, `src/main/ipc.ts`, `src/preload/index.ts`
- **IPC 桥接**: 作为 API 层连接 Renderer 到主进程
- **请求路由**: 将 IPC 调用路由到相应的服务方法
- **类型安全**: 使用 Zod 模式进行请求/响应验证
- **错误处理**: 跨进程边界的集中式错误传播
- **安全**: Renderer 和主进程之间的安全通信沙盒
### 2. OCR 服务层(业务层)
**位置**: `src/main/services/ocr/`
- **OcrService**: 主要业务逻辑协调器和中央协调器
- **提供商注册表**: 管理已注册的 OCR 提供商
- **数据集成**: 与数据层直接交互进行提供商管理
- **生命周期管理**: 处理提供商初始化和销毁
- **验证**: 确保提供商可用性和数据完整性
- **协调**: 协调提供商和数据服务之间的交互
- **直接 IPC 访问**: Renderer 可通过 IPC 直接调用业务层方法
### 3. 提供商服务层
**位置**: `src/main/services/ocr/builtin/`
- **基础服务**: 抽象的 `OcrBaseService` 定义通用接口
- **数据独立性**: 无直接数据库交互,依赖外部传入的数据
- **内置提供商**:
- `TesseractService`: 本地 Tesseract.js 实现
- `SystemOcrService`: 平台特定的系统 OCR
- `PpocrService`: PaddleOCR 集成
- `OvOcrService`: Intel OpenVINO (NPU) OCR
- **纯 OCR 逻辑**: 专注于 OCR 处理能力
### 4. 数据层
**位置**: `src/main/data/db/schemas/ocr/`, `src/main/data/repositories/`
- **数据库架构**: 使用 Drizzle ORM 和 SQLite 数据库
- **仓储模式**: `OcrProviderRepository` 处理所有数据库操作
- **提供商存储**: 在 `ocr_provider` 表中存储提供商配置
- **JSON 配置**: 多态的 `config` 字段存储提供商特定的设置
- **数据访问**: 仅由 OCR 服务层访问
### 5. Renderer 层
**位置**: `src/renderer/src/services/ocr/`, `src/renderer/src/hooks/ocr/`
- **直接 IPC 通信**: 通过 IPC 与业务层直接交互
- **React Hooks**: 用于 OCR 操作和状态管理的自定义钩子
- **配置 UI**: 提供商配置的设置页面
- **状态管理**: Renderer 状态与后端数据同步
## 数据流
```mermaid
graph TD
A[Renderer UI] --> B[Renderer OCR 服务]
B --> C[API 层 - IPC 桥接]
C --> D[OCR 服务层 - 业务逻辑]
D --> E[数据层 - 提供商仓储]
D --> F[提供商服务层]
F --> G[OCR 处理]
G --> H[结果]
H --> F
F --> D
D --> C
C --> B
B --> A
style D fill:#e1f5fe
style F fill:#f3e5f5
style E fill:#e8f5e8
style C fill:#fff3e0
```
**关键流程特征**:
- **直接业务访问**: Renderer 通过 IPC 与 OCR 服务层直接通信
- **IPC 作为 API 网关**: IPC 桥接作为 API 层,处理路由和验证
- **数据隔离**: 只有业务层与数据持久化交互
- **提供商独立性**: OCR 提供商保持与数据关注点的隔离
## 提供商系统
### 提供商注册
- **内置提供商**: 在服务初始化时自动注册
- **自定义提供商**: 支持可扩展的提供商系统
- **配置**: 每个提供商都有自己的配置模式
### 提供商能力
```typescript
interface OcrProviderCapabilityRecord {
image?: boolean // 图像文件 OCR 支持
pdf?: boolean // PDF 文件 OCR 支持(未来)
}
```
### 配置架构
- **多态配置**: 基于 JSON 的配置适应提供商需求
- **类型安全**: Zod 模式验证提供商特定的配置
- **运行时验证**: OCR 操作前的配置验证
## 类型系统
### 核心类型
- **`OcrProvider`**: 基础提供商接口
- **`OcrParams`**: OCR 操作参数
- **`OcrResult`**: 标准化的 OCR 结果格式
- **`SupportedOcrFile`**: 支持 OCR 的文件类型
### 业务类型
- **`OcrProviderBusiness`**: 域级别的提供商表示
- **操作**: 创建、更新、替换、删除操作
- **查询**: 带过滤选项的提供商列表
### 提供商特定类型
- **TesseractConfig**: 语言选择、模型路径
- **SystemOcrConfig**: 语言偏好
- **PaddleOCRConfig**: API 端点、认证
- **OpenVINOConfig**: 设备选择、模型路径
## 内置提供商
### Tesseract OCR
- **引擎**: Tesseract.js
- **语言**: 支持多语言,自动下载
- **配置**: 语言选择、缓存管理
- **性能**: 工作池用于并发处理
### 系统 OCR
- **Windows**: Windows Media Foundation OCR
- **macOS**: Vision 框架 OCR
- **Linux**: 平台特定实现
- **特性**: 原生性能、系统集成
### PaddleOCR
- **部署**: 远程 API 集成
- **语言**: 中文、英文和混合语言支持
- **配置**: API 端点和认证
### Intel OpenVINO OCR
- **硬件**: NPU 加速支持
- **性能**: 为 Intel 硬件优化
- **用例**: 高性能 OCR 场景
## 配置管理
### 数据库架构
```sql
CREATE TABLE ocr_provider (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
capabilities TEXT NOT NULL, -- JSON
config TEXT NOT NULL, -- JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
### 提供商默认值
- **初始配置**: 在 `packages/shared/config/ocr.ts` 中定义
- **迁移系统**: 启动时自动提供商初始化
- **用户自定义**: 运行时配置更新
## 错误处理
### 错误类别
- **提供商错误**: OCR 引擎故障、缺少依赖
- **配置错误**: 无效设置、缺少参数
- **文件错误**: 不支持的格式、损坏的文件
- **系统错误**: 资源耗尽、权限问题
### 错误传播
- **日志**: 带上下文的集中日志记录
- **用户反馈**: 翻译的错误消息
- **恢复**: 优雅的回退选项
## 性能考虑
### 资源管理
- **工作器销毁**: OCR 工作器的适当清理
- **内存管理**: 文件大小和并发操作限制
- **缓存**: 模型和结果缓存(如适用)
### 优化
- **延迟加载**: 按需初始化提供商
- **并发处理**: 多工作器用于并行操作
- **硬件加速**: NPU 和 GPU 支持(如可用)
## 安全
### 输入验证
- **文件类型检查**: 严格验证支持的格式
- **大小限制**: 防止资源耗尽
- **路径验证**: 防止路径遍历攻击
### 配置安全
- **API 密钥存储**: 敏感配置的安全存储
- **验证**: 配置参数的运行时验证
- **沙盒**: OCR 操作的隔离执行
## 扩展点
### 自定义提供商
- **接口**: 为新提供商实现 `OcrBaseService`
- **注册**: 动态提供商注册系统
- **配置**: 可扩展的配置模式
### 文件类型支持
- **处理器**: 模块化文件类型处理器
- **能力**: 声明式提供商能力
- **未来支持**: PDF、文档格式计划中
## 迁移策略
### 遗留系统
- **数据迁移**: 从旧配置格式自动迁移
- **兼容性**: 过渡期间的向后兼容性
- **测试**: 迁移路径的全面测试覆盖
### 未来增强
- **PDF 支持**: 计划扩展到文档 OCR
- **云提供商**: 基于 API 的 OCR 服务集成
- **AI 增强**: 后处理和准确性改进
## 开发指南
### 添加新提供商
1. 创建扩展 `OcrBaseService` 的提供商服务
2. 定义提供商特定的配置模式
3.`OcrService` 中注册提供商
4. 添加配置 UI 组件
5. 包含全面的测试
> [!WARNING]
> 提供商服务绝不应直接访问数据层。所有数据操作必须通过 OCR 服务层进行,以保持适当的关注点分离。
### 配置更改
1. 更新提供商配置模式
2. 为现有配置添加迁移逻辑
3. 更新 UI 验证和错误处理
4. 测试各种配置场景
> [!WARNING]
> 在保存到数据库之前,务必验证配置更改。使用 Zod 模式进行运行时验证,防止提供商配置损坏。
### 测试
- **单元测试**: 提供商实现测试
- **集成测试**: 端到端 OCR 工作流
- **性能测试**: 资源使用和时间
- **错误场景**: 全面的错误处理测试

View File

@@ -67,10 +67,6 @@ 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

@@ -0,0 +1,10 @@
CREATE TABLE `ocr_provider` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`capabilities` text NOT NULL,
`config` text NOT NULL,
`created_at` integer,
`updated_at` integer
);
--> statement-breakpoint
CREATE INDEX `name` ON `ocr_provider` (`name`);

View File

@@ -0,0 +1,172 @@
{
"version": "6",
"dialect": "sqlite",
"id": "64f7ad88-7111-4574-988c-d7ef429e375d",
"prevId": "de8009d7-95b9-4f99-99fa-4b8795708f21",
"tables": {
"app_state": {
"name": "app_state",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ocr_provider": {
"name": "ocr_provider",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"name": {
"name": "name",
"columns": ["name"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"preference": {
"name": "preference",
"columns": {
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"scope_name_idx": {
"name": "scope_name_idx",
"columns": ["scope", "key"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -7,6 +7,13 @@
"tag": "0000_solid_lord_hawal",
"version": "6",
"when": 1754745234572
},
{
"idx": 1,
"version": "6",
"when": 1760969721294,
"tag": "0001_previous_sir_ram",
"breakpoints": true
}
],
"version": "7"

View File

@@ -81,22 +81,21 @@
"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.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@radix-ui/react-tabs": "^1.1.13",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"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",
"os-proxy-config": "^1.1.2",
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"sharp": "0.34.4",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
@@ -200,7 +199,6 @@
"@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",
@@ -240,7 +238,6 @@
"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",
@@ -394,12 +391,12 @@
"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",
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"@img/sharp-darwin-arm64": "0.34.4",
"@img/sharp-darwin-x64": "0.34.4",
"@img/sharp-linux-arm": "0.34.4",
"@img/sharp-linux-arm64": "0.34.4",
"@img/sharp-linux-x64": "0.34.4",
"@img/sharp-win32-x64": "0.34.4",
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0"
},
"packageManager": "yarn@4.9.1",

View File

@@ -96,10 +96,6 @@ 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',
@@ -204,9 +200,7 @@ export enum IpcChannel {
Export_Word = 'export:word',
Shortcuts_GetAll = 'shortcuts:getAll',
Shortcuts_Update = 'shortcuts:update',
Shortcuts_Updated = 'shortcuts:updated',
// backup
Backup_Backup = 'backup:backup',
@@ -375,8 +369,7 @@ export enum IpcChannel {
CodeTools_RemoveCustomTerminalPath = 'code-tools:remove-custom-terminal-path',
// OCR
OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
OCR_Ocr = 'ocr:ocr',
// OVMS
Ovms_AddModel = 'ovms:add-model',
@@ -388,14 +381,5 @@ export enum IpcChannel {
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
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'
Cherryai_GetSignature = 'cherryai:get-signature'
}

View File

@@ -0,0 +1,176 @@
import type {
BuiltinOcrProvider,
BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider,
OcrSystemProvider,
OcrTesseractProvider,
TesseractLangCode
} from '@types'
import type { TranslateLanguageCode } from '../../../src/renderer/src/types/translate'
export const tesseract: OcrTesseractProvider = {
id: 'tesseract',
name: 'Tesseract',
capabilities: {
image: true
},
config: {
langs: {
chi_sim: true,
chi_tra: true,
eng: true
},
enabled: false
}
} as const
export const systemOcr: OcrSystemProvider = {
id: 'system',
name: 'System',
capabilities: {
image: true
// pdf: true
},
config: {
langs: ['en-us'],
enabled: false
}
} as const satisfies OcrSystemProvider
export const ppocrOcr: OcrPpocrProvider = {
id: 'paddleocr',
name: 'PaddleOCR',
capabilities: {
image: true
// pdf: true
},
config: { apiUrl: '', enabled: false }
} as const
export const ovOcr: OcrOvProvider = {
id: 'ovocr',
name: 'Intel OV(NPU) OCR',
capabilities: {
image: true
// pdf: true
},
config: {
enabled: false
}
} as const satisfies OcrOvProvider
export const INITIAL_BUILTIN_OCR_PROVIDER_MAP = {
tesseract,
system: systemOcr,
paddleocr: ppocrOcr,
ovocr: ovOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(INITIAL_BUILTIN_OCR_PROVIDER_MAP)
export const TESSERACT_LANG_MAP: Record<TranslateLanguageCode, TesseractLangCode> = {
'af-za': 'afr',
'am-et': 'amh',
'ar-sa': 'ara',
'as-in': 'asm',
'az-az': 'aze',
'az-cyrl-az': 'aze_cyrl',
'be-by': 'bel',
'bn-bd': 'ben',
'bo-cn': 'bod',
'bs-ba': 'bos',
'bg-bg': 'bul',
'ca-es': 'cat',
'ceb-ph': 'ceb',
'cs-cz': 'ces',
'zh-cn': 'chi_sim',
'zh-tw': 'chi_tra',
'chr-us': 'chr',
'cy-gb': 'cym',
'da-dk': 'dan',
'de-de': 'deu',
'dz-bt': 'dzo',
'el-gr': 'ell',
'en-us': 'eng',
'enm-gb': 'enm',
'eo-world': 'epo',
'et-ee': 'est',
'eu-es': 'eus',
'fa-ir': 'fas',
'fi-fi': 'fin',
'fr-fr': 'fra',
'frk-de': 'frk',
'frm-fr': 'frm',
'ga-ie': 'gle',
'gl-es': 'glg',
'grc-gr': 'grc',
'gu-in': 'guj',
'ht-ht': 'hat',
'he-il': 'heb',
'hi-in': 'hin',
'hr-hr': 'hrv',
'hu-hu': 'hun',
'iu-ca': 'iku',
'id-id': 'ind',
'is-is': 'isl',
'it-it': 'ita',
'ita-it': 'ita_old',
'jv-id': 'jav',
'ja-jp': 'jpn',
'kn-in': 'kan',
'ka-ge': 'kat',
'kat-ge': 'kat_old',
'kk-kz': 'kaz',
'km-kh': 'khm',
'ky-kg': 'kir',
'ko-kr': 'kor',
'ku-tr': 'kur',
'la-la': 'lao',
'la-va': 'lat',
'lv-lv': 'lav',
'lt-lt': 'lit',
'ml-in': 'mal',
'mr-in': 'mar',
'mk-mk': 'mkd',
'mt-mt': 'mlt',
'ms-my': 'msa',
'my-mm': 'mya',
'ne-np': 'nep',
'nl-nl': 'nld',
'no-no': 'nor',
'or-in': 'ori',
'pa-in': 'pan',
'pl-pl': 'pol',
'pt-pt': 'por',
'ps-af': 'pus',
'ro-ro': 'ron',
'ru-ru': 'rus',
'sa-in': 'san',
'si-lk': 'sin',
'sk-sk': 'slk',
'sl-si': 'slv',
'es-es': 'spa',
'spa-es': 'spa_old',
'sq-al': 'sqi',
'sr-rs': 'srp',
'sr-latn-rs': 'srp_latn',
'sw-tz': 'swa',
'sv-se': 'swe',
'syr-sy': 'syr',
'ta-in': 'tam',
'te-in': 'tel',
'tg-tj': 'tgk',
'tl-ph': 'tgl',
'th-th': 'tha',
'ti-er': 'tir',
'tr-tr': 'tur',
'ug-cn': 'uig',
'uk-ua': 'ukr',
'ur-pk': 'urd',
'uz-uz': 'uzb',
'uz-cyrl-uz': 'uzb_cyrl',
'vi-vn': 'vie',
'yi-us': 'yid'
}

View File

@@ -1,5 +1,18 @@
// NOTE: Types are defined inline in the schema for simplicity
// If needed, specific types can be imported from './apiModels'
import type {
CreateOcrProviderRequest,
CreateOcrProviderResponse,
GetOcrProviderResponse,
ListOcrProvidersQuery,
ListOcrProvidersResponse,
OcrProviderId,
ReplaceOcrProviderRequest,
ReplaceOcrProviderResponse,
UpdateOcrProviderRequest,
UpdateOcrProviderResponse
} from '@types'
import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths'
import type { HttpMethod, PaginatedResponse, PaginationParams } from './apiTypes'
@@ -345,6 +358,38 @@ export interface ApiSchemas {
}>
}
}
'/ocr/providers': {
GET: {
query: ListOcrProvidersQuery
response: ListOcrProvidersResponse
}
POST: {
body: CreateOcrProviderRequest
response: CreateOcrProviderResponse
}
}
'/ocr/providers/:id': {
GET: {
params: { id: OcrProviderId }
response: GetOcrProviderResponse
}
PATCH: {
params: { id: OcrProviderId }
body: UpdateOcrProviderRequest
response: UpdateOcrProviderResponse
}
PUT: {
params: { id: OcrProviderId }
body: ReplaceOcrProviderRequest
response: ReplaceOcrProviderResponse
}
DELETE: {
params: { id: OcrProviderId }
response: void
}
}
}
/**

View File

@@ -11,23 +11,12 @@
import { TRANSLATE_PROMPT } from '@shared/config/prompts'
import * as PreferenceTypes from '@shared/data/preference/preferenceTypes'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
/* eslint @typescript-eslint/member-ordering: ["error", {
"interfaces": { "order": "alphabetically" },
"typeLiterals": { "order": "alphabetically" }
}] */
const defaultShortcutPreferences: PreferenceTypes.ShortcutPreferencesValue = Object.fromEntries(
shortcutDefinitions.map((definition) => [
definition.name,
{
enabled: definition.defaultEnabled,
key: [...definition.defaultKey]
}
])
)
export interface PreferenceSchemas {
default: {
// redux/settings/enableDeveloperMode
@@ -362,6 +351,8 @@ export interface PreferenceSchemas {
'feature.translate.model_prompt': string
// redux/settings/targetLanguage
'feature.translate.target_language': string
// redux/ocr/imageProviderId
'ocr.settings.image_provider_id': string | null
// redux/shortcuts/shortcuts.exit_fullscreen
'shortcut.app.exit_fullscreen': Record<string, unknown>
// redux/shortcuts/shortcuts.search_message
@@ -388,8 +379,6 @@ export interface PreferenceSchemas {
'shortcut.chat.search_message': Record<string, unknown>
// redux/shortcuts/shortcuts.toggle_new_context
'shortcut.chat.toggle_new_context': Record<string, unknown>
// unified shortcut overrides
'shortcut.preferences': PreferenceTypes.ShortcutPreferencesValue
// redux/shortcuts/shortcuts.selection_assistant_select_text
'shortcut.selection.get_text': Record<string, unknown>
// redux/shortcuts/shortcuts.selection_assistant_toggle
@@ -625,6 +614,7 @@ export const DefaultPreferences: PreferenceSchemas = {
'feature.selection.trigger_mode': PreferenceTypes.SelectionTriggerMode.Selected,
'feature.translate.model_prompt': TRANSLATE_PROMPT,
'feature.translate.target_language': 'en-us',
'ocr.settings.image_provider_id': null,
'shortcut.app.exit_fullscreen': { editable: false, enabled: true, key: ['Escape'], system: true },
'shortcut.app.search_message': {
editable: true,
@@ -658,7 +648,6 @@ export const DefaultPreferences: PreferenceSchemas = {
key: ['CommandOrControl', 'K'],
system: false
},
'shortcut.preferences': defaultShortcutPreferences,
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true },
'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false },

View File

@@ -1,5 +1,3 @@
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
import type { PreferenceSchemas } from './preferenceSchemas'
export type PreferenceDefaultScopeType = PreferenceSchemas['default']
@@ -16,8 +14,6 @@ export type PreferenceShortcutType = {
system: boolean
}
export type ShortcutPreferencesValue = ShortcutPreferenceMap
export enum SelectionTriggerMode {
Selected = 'selected',
Ctrlkey = 'ctrlkey',

View File

@@ -1,175 +0,0 @@
import type { ShortcutDefinition } from './types'
export const shortcutDefinitions: ShortcutDefinition[] = [
{
name: 'show_app',
defaultKey: [],
defaultEnabled: true,
description: 'Show or hide the main window',
scope: 'main',
editable: true,
system: true
},
{
name: 'show_mini_window',
defaultKey: ['CommandOrControl', 'E'],
defaultEnabled: false,
description: 'Show or hide the mini window',
scope: 'main',
editable: true,
system: true
},
{
name: 'selection_assistant_toggle',
defaultKey: [],
defaultEnabled: false,
description: 'Enable or disable the selection assistant',
scope: 'main',
editable: true,
system: true
},
{
name: 'selection_assistant_select_text',
defaultKey: [],
defaultEnabled: false,
description: 'Trigger selection assistant text capture',
scope: 'main',
editable: true,
system: true
},
{
name: 'zoom_in',
defaultKey: ['CommandOrControl', '='],
defaultEnabled: true,
description: 'Zoom in',
scope: 'main',
editable: false,
system: true
},
{
name: 'zoom_out',
defaultKey: ['CommandOrControl', '-'],
defaultEnabled: true,
description: 'Zoom out',
scope: 'main',
editable: false,
system: true
},
{
name: 'zoom_reset',
defaultKey: ['CommandOrControl', '0'],
defaultEnabled: true,
description: 'Reset zoom',
scope: 'main',
editable: false,
system: true
},
{
name: 'show_settings',
defaultKey: ['CommandOrControl', ','],
defaultEnabled: true,
description: 'Open settings',
scope: 'renderer',
editable: false,
system: true
},
{
name: 'new_topic',
defaultKey: ['CommandOrControl', 'N'],
defaultEnabled: true,
description: 'Start a new chat topic',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'rename_topic',
defaultKey: ['CommandOrControl', 'T'],
defaultEnabled: false,
description: 'Rename current topic',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'toggle_show_assistants',
defaultKey: ['CommandOrControl', '['],
defaultEnabled: true,
description: 'Toggle assistant sidebar',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'toggle_show_topics',
defaultKey: ['CommandOrControl', ']'],
defaultEnabled: true,
description: 'Toggle topic sidebar',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'copy_last_message',
defaultKey: ['CommandOrControl', 'Shift', 'C'],
defaultEnabled: false,
description: 'Copy the last assistant reply',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'edit_last_user_message',
defaultKey: ['CommandOrControl', 'Shift', 'E'],
defaultEnabled: false,
description: 'Edit the last user message',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'search_message_in_chat',
defaultKey: ['CommandOrControl', 'F'],
defaultEnabled: true,
description: 'Search messages in current chat',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'search_message',
defaultKey: ['CommandOrControl', 'Shift', 'F'],
defaultEnabled: true,
description: 'Search messages globally',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'clear_topic',
defaultKey: ['CommandOrControl', 'L'],
defaultEnabled: true,
description: 'Clear current topic',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'toggle_new_context',
defaultKey: ['CommandOrControl', 'K'],
defaultEnabled: true,
description: 'Toggle new context mode',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'exit_fullscreen',
defaultKey: ['Escape'],
defaultEnabled: true,
description: 'Exit fullscreen mode',
scope: 'renderer',
editable: false,
system: true
}
]

View File

@@ -1,25 +0,0 @@
export type ShortcutScope = 'main' | 'renderer'
export interface ShortcutDefinition {
name: string
defaultKey: string[]
defaultEnabled: boolean
description: string
scope: ShortcutScope
editable: boolean
system: boolean
}
export interface ShortcutPreferenceEntry {
key?: string[]
enabled?: boolean
}
export type ShortcutPreferenceMap = Record<string, ShortcutPreferenceEntry>
export type HydratedShortcut = ShortcutDefinition & {
key: string[]
enabled: boolean
}
export type HydratedShortcutMap = Record<string, HydratedShortcut>

View File

@@ -0,0 +1,2 @@
export * from './json'
export * from './net'

View File

@@ -0,0 +1,7 @@
export function safeParseJson(text: string): unknown | null {
try {
return JSON.parse(text)
} catch {
return null
}
}

View File

@@ -87,3 +87,5 @@ export * from './primitives/dialog'
export * from './primitives/popover'
export * from './primitives/radioGroup'
export * from './primitives/shadcn-io/dropzone'
export * from './primitives/shadcn-io/skeleton'
export * from './primitives/shadcn-io/tabs'

View File

@@ -0,0 +1,7 @@
import { cn } from '@cherrystudio/ui/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="skeleton" className={cn('bg-accent animate-pulse rounded-md', className)} {...props} />
}
export { Skeleton }

View File

@@ -0,0 +1,39 @@
import { cn } from '@cherrystudio/ui/utils'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import * as React from 'react'
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props} />
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className
)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content data-slot="tabs-content" className={cn('flex-1 outline-none', className)} {...props} />
}
export { Tabs, TabsContent, TabsList, TabsTrigger }

View File

@@ -4,9 +4,9 @@ const { downloadNpmPackage } = require('./utils')
// if you want to add new prebuild binaries packages with different architectures, you can add them here
// please add to allX64 and allArm64 from yarn.lock
const allArm64 = {
'@img/sharp-darwin-arm64': '0.34.3',
'@img/sharp-win32-arm64': '0.34.3',
'@img/sharp-linux-arm64': '0.34.3',
'@img/sharp-darwin-arm64': '0.34.4',
'@img/sharp-win32-arm64': '0.34.4',
'@img/sharp-linux-arm64': '0.34.4',
'@img/sharp-libvips-darwin-arm64': '1.2.0',
'@img/sharp-libvips-linux-arm64': '1.2.0',
@@ -20,9 +20,9 @@ const allArm64 = {
}
const allX64 = {
'@img/sharp-darwin-x64': '0.34.3',
'@img/sharp-linux-x64': '0.34.3',
'@img/sharp-win32-x64': '0.34.3',
'@img/sharp-darwin-x64': '0.34.4',
'@img/sharp-linux-x64': '0.34.4',
'@img/sharp-win32-x64': '0.34.4',
'@img/sharp-libvips-darwin-x64': '1.2.0',
'@img/sharp-libvips-linux-x64': '1.2.0',

View File

@@ -5,6 +5,7 @@
* TypeScript will error if any endpoint is missing.
*/
import { ocrService } from '@main/services/ocr/OcrService'
import type { ApiImplementation } from '@shared/data/api/apiSchemas'
import { TestService } from '../services/TestService'
@@ -12,6 +13,7 @@ import { TestService } from '../services/TestService'
// Service instances
const testService = TestService.getInstance()
// Defining all handlers here feels a bit bloated; perhaps we should modularize things?
/**
* Complete API handlers implementation
* Must implement every path+method combination from ApiSchemas
@@ -207,5 +209,40 @@ export const apiHandlers: ApiImplementation = {
data: { executed: true, timestamp: new Date().toISOString() }
}))
}
},
'/ocr/providers': {
GET: async ({ query }) => {
const result = await ocrService.listProviders(query)
return { data: result }
},
POST: async ({ body }) => {
const result = await ocrService.createProvider(body)
return { data: result }
}
},
'/ocr/providers/:id': {
GET: async ({ params }) => {
const result = await ocrService.getProvider(params.id)
return { data: result }
},
PATCH: async ({ params, body }) => {
if (params.id !== body.id) {
throw new Error('Provider ID in path does not match ID in body')
}
const result = await ocrService.updateProvider(params.id, body)
return { data: result }
},
PUT: async ({ params, body }) => {
if (params.id !== body.id) {
throw new Error('Provider ID in path does not match ID in body')
}
const result = await ocrService.replaceProvider(body)
return { data: result }
},
DELETE: async ({ params }) => {
return ocrService.deleteProvider(params.id)
}
}
}

View File

@@ -3,6 +3,9 @@ import type { PaginationParams, ServiceOptions } from '@shared/data/api/apiTypes
/**
* Standard service interface for data operations
* Defines the contract that all services should implement
* @template T - Type of the entity returned by service methods
* @template TCreate - Type of the data required to create a new entity
* @template TUpdate - Type of the data required to update an existing entity
*/
export interface IBaseService<T = any, TCreate = any, TUpdate = any> {
/**

View File

@@ -0,0 +1,299 @@
import { loggerService } from '@logger'
import { dbService } from '@main/data/db/DbService'
import { ocrProviderTable } from '@main/data/db/schemas/ocrProvider'
import type { PaginationParams, ServiceOptions } from '@shared/data/api/apiTypes'
import type { DbOcrProvider, DbOcrProviderCreate, DbOcrProviderReplace, DbOcrProviderUpdate } from '@types'
import { BuiltinOcrProviderIds, isDbOcrProvider } from '@types'
import dayjs from 'dayjs'
import { eq } from 'drizzle-orm'
import { merge } from 'lodash'
import type { IBaseService } from './IBaseService'
const logger = loggerService.withContext('OcrProviderService')
/**
* Service layer for OCR providers
* Implements the standard service interface and handles all OCR provider operations
* NOTE: Not completely finished since data architecture is not completely designed and implemented.
* It's a early version.
*/
export class OcrProviderService implements IBaseService<DbOcrProvider, DbOcrProviderCreate, DbOcrProviderUpdate> {
/**
* Find OCR provider by ID
*/
async findById(id: string, _options?: ServiceOptions): Promise<DbOcrProvider | null> {
try {
const providers = await dbService
.getDb()
.select()
.from(ocrProviderTable)
.where(eq(ocrProviderTable.id, id))
.limit(1)
if (providers.length === 0) {
logger.warn(`OCR provider ${id} not found`)
return null
}
logger.debug(`Retrieved OCR provider: ${id}`)
return providers[0]
} catch (error) {
logger.error(`Failed to find OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Find multiple OCR providers with pagination
*/
async findMany(
params: PaginationParams & Record<string, any>,
_options?: ServiceOptions
): Promise<{
items: DbOcrProvider[]
total: number
hasNext?: boolean
nextCursor?: string
}> {
try {
const { page = 1, limit = 20, cursor } = params
let providers = await dbService.getDb().select().from(ocrProviderTable)
// Apply filters if provided
if (params.registered) {
// This filter would need access to the OCR service registry
// For now, we'll return all providers and let the service layer filter
logger.debug('Registered filter requested - returning all providers for service layer filtering')
}
const total = providers.length
// Apply pagination
if (cursor) {
// Cursor-based pagination
const index = providers.findIndex((p) => p.id === cursor)
if (index !== -1) {
providers = providers.slice(index + 1, index + 1 + limit)
}
} else {
// Offset-based pagination
const startIndex = (page - 1) * limit
providers = providers.slice(startIndex, startIndex + limit)
}
const hasNext =
providers.length === limit && (cursor ? providers[providers.length - 1] !== undefined : page * limit < total)
logger.debug(`Retrieved ${providers.length} OCR providers`, { total, page, limit })
return {
items: providers,
total,
hasNext,
nextCursor: hasNext && providers.length > 0 ? providers[providers.length - 1].id : undefined
}
} catch (error) {
logger.error('Failed to find OCR providers', error as Error)
throw error
}
}
/**
* Create new OCR provider
*/
async create(data: DbOcrProviderCreate, _options?: ServiceOptions): Promise<DbOcrProvider> {
try {
// Check if provider already exists
const existing = await this.findById(data.id)
if (existing) {
throw new Error(`OCR provider ${data.id} already exists`)
}
const timestamp = dayjs().valueOf()
const newProvider = {
...data,
createdAt: timestamp,
updatedAt: timestamp
} satisfies DbOcrProvider
// Validate data structure
if (!isDbOcrProvider(newProvider)) {
throw new Error('Invalid OCR provider data')
}
const [created] = await dbService.getDb().insert(ocrProviderTable).values(newProvider).returning()
logger.info(`Created OCR provider: ${data.id}`)
return created
} catch (error) {
logger.error(`Failed to create OCR provider ${data.id}`, error as Error)
throw error
}
}
/**
* Update existing OCR provider
*/
async update(id: string, data: DbOcrProviderUpdate, _options?: ServiceOptions): Promise<DbOcrProvider> {
try {
const existing = await this.findById(id)
if (!existing) {
throw new Error(`OCR provider ${id} not found`)
}
const newProvider = {
...merge({}, existing, data),
updatedAt: dayjs().valueOf()
} satisfies DbOcrProvider
// Validate data structure
if (!isDbOcrProvider(newProvider)) {
throw new Error('Invalid OCR provider data')
}
const [updated] = await dbService
.getDb()
.update(ocrProviderTable)
.set(newProvider)
.where(eq(ocrProviderTable.id, id))
.returning()
logger.info(`Updated OCR provider: ${id}`)
return updated
} catch (error) {
logger.error(`Failed to update OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Delete OCR provider
*/
async delete(id: string, _options?: ServiceOptions): Promise<void> {
try {
// Check if it's a built-in provider
if (BuiltinOcrProviderIds.some((pid) => pid === id)) {
throw new Error('Built-in OCR providers cannot be deleted.')
}
// Check if provider exists
const existing = await this.findById(id)
if (!existing) {
throw new Error(`OCR provider ${id} not found`)
}
await dbService.getDb().delete(ocrProviderTable).where(eq(ocrProviderTable.id, id))
logger.info(`Deleted OCR provider: ${id}`)
} catch (error) {
logger.error(`Failed to delete OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Check if OCR provider exists
*/
async exists(id: string, _options?: ServiceOptions): Promise<boolean> {
try {
const provider = await this.findById(id)
return provider !== null
} catch (error) {
logger.error(`Failed to check if OCR provider ${id} exists`, error as Error)
throw error
}
}
/**
* Replace OCR provider (full update)
* This method is specific to OCR providers and not part of IBaseService
*/
async replace(data: DbOcrProviderReplace): Promise<DbOcrProvider> {
try {
// Check if it's a built-in provider
if (BuiltinOcrProviderIds.some((pid) => pid === data.id)) {
throw new Error('Built-in OCR providers cannot be modified with PUT method.')
}
const timestamp = dayjs().valueOf()
const existing = await this.exists(data.id)
let newProvider: DbOcrProvider
if (existing) {
// Update existing
const current = await this.findById(data.id)
if (!current) {
throw new Error(`OCR provider ${data.id} not found during replace operation`)
}
newProvider = {
...data,
updatedAt: timestamp,
createdAt: current.createdAt
}
} else {
// Create new
newProvider = {
...data,
createdAt: timestamp,
updatedAt: timestamp
}
}
// Validate data structure
if (!isDbOcrProvider(newProvider)) {
throw new Error('Invalid OCR provider data')
}
const [saved] = await dbService
.getDb()
.insert(ocrProviderTable)
.values(newProvider)
.onConflictDoUpdate({
target: ocrProviderTable.id,
set: newProvider
})
.returning()
logger.info(`Replaced OCR provider: ${data.id}`)
return saved
} catch (error) {
logger.error(`Failed to replace OCR provider ${data.id}`, error as Error)
throw error
}
}
/**
* Initialize built-in providers in database
* This method is specific to OCR providers and not part of IBaseService
*/
async initializeBuiltInProviders(): Promise<void> {
try {
// Import built-in provider configurations
const { BUILTIN_OCR_PROVIDERS } = await import('@shared/config/ocr')
logger.info('Initializing built-in OCR providers')
// Check and create each built-in provider if it doesn't exist
for (const provider of BUILTIN_OCR_PROVIDERS) {
const exists = await this.exists(provider.id)
if (!exists) {
logger.info(`Creating built-in OCR provider: ${provider.id}`)
await this.create(provider)
} else {
logger.debug(`Built-in OCR provider already exists: ${provider.id}`)
}
}
logger.info(`Initialized ${BUILTIN_OCR_PROVIDERS.length} built-in OCR providers`)
} catch (error) {
logger.error('Failed to initialize built-in OCR providers', error as Error)
throw error
}
}
}
// Export singleton instance
export const ocrProviderService = new OcrProviderService()

View File

@@ -0,0 +1,49 @@
import type { OcrProviderCapabilityRecord, OcrProviderConfig } from '@types'
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps } from './columnHelpers'
export const ocrProviderTable = sqliteTable(
'ocr_provider',
{
/**
* Unique identifier for the provider.
* For built-in providers, it's 'tesseract', 'system', etc.
* For custom providers, it can be any unique string (we typically use UUID v4).
* As the primary key, it ensures the uniqueness of each provider.
*/
id: text('id').primaryKey(),
/**
* Display name of the provider, e.g., "Tesseract OCR".
* For built-in providers, this value is used internally and is not exposed to users; the display name shown in the UI is locale-based by i18n.
* Cannot be null.
*/
name: text('name').notNull(),
/**
* Object describing the provider's capabilities, e.g., { image: true }.
* Stored as JSON in a text column. Drizzle's `mode: 'json'` handles
* serialization and deserialization automatically. `$type` provides strong typing.
* Cannot be null; should store an empty object `{}` even if no specific capabilities.
*/
capabilities: text('capabilities', { mode: 'json' }).$type<OcrProviderCapabilityRecord>().notNull(),
/**
* Provider-specific configuration. This is a polymorphic field, its structure varies by provider type.
* For example, Tesseract's configuration is entirely different from PaddleOCR's.
* Storing it as JSON is the most flexible approach to accommodate any configuration structure.
* Since this is a polymorphic field, both frontend and backend must validate
* that the structure matches the expected schema for the corresponding provider type
* before saving.
*/
config: text('config', { mode: 'json' }).$type<OcrProviderConfig>().notNull(),
/** Unix timestamp (milliseconds since epoch) for creation and last update. */
...createUpdateTimestamps
},
(t) => [index('name').on(t.name)]
)
export type OcrProviderInsert = typeof ocrProviderTable.$inferInsert
export type OcrProviderSelect = typeof ocrProviderTable.$inferSelect

View File

@@ -8,6 +8,8 @@
* === AUTO-GENERATED CONTENT START ===
*/
import type { PreferenceSchemas } from '@shared/data/preference/preferenceSchemas'
/**
* ElectronStore映射关系 - 简单一层结构
*
@@ -252,6 +254,8 @@ export const REDUX_STORE_MAPPINGS = {
},
{
originalKey: 'mathEngine',
// TODO
// @ts-expect-error check how to fix it later
targetKey: 'chat.message.math_engine'
},
{
@@ -336,6 +340,8 @@ export const REDUX_STORE_MAPPINGS = {
},
{
originalKey: 'topicNamingPrompt',
// TODO
// @ts-expect-error check how to fix it later
targetKey: 'topic.naming.prompt'
},
{
@@ -664,6 +670,8 @@ export const REDUX_STORE_MAPPINGS = {
},
{
originalKey: 'nutstoreSyncState',
// TODO
// @ts-expect-error check how to fix it later
targetKey: 'data.backup.nutstore.sync_state'
},
{
@@ -736,8 +744,17 @@ export const REDUX_STORE_MAPPINGS = {
originalKey: 'shortcuts.exit_fullscreen',
targetKey: 'shortcut.app.exit_fullscreen'
}
],
ocr: [
{
originalKey: 'ocr.imageProviderId',
targetKey: 'ocr.settings.image_provider_id'
}
]
} as const
} as const satisfies Record<
string,
Array<{ originalKey: string; targetKey: keyof PreferenceSchemas[keyof PreferenceSchemas] }>
>
// === AUTO-GENERATED CONTENT END ===

View File

@@ -0,0 +1,256 @@
import { dbService } from '@data/db/DbService'
import { ocrProviderTable } from '@data/db/schemas/ocrProvider'
import { loggerService } from '@logger'
import type {
DbOcrProvider,
DbOcrProviderCreate,
DbOcrProviderReplace,
DbOcrProviderUpdate,
OcrProviderId
} from '@types'
import { BuiltinOcrProviderIds, isDbOcrProvider } from '@types'
import dayjs from 'dayjs'
import { eq } from 'drizzle-orm'
import { merge } from 'lodash'
const logger = loggerService.withContext('OcrProviderRepository')
/**
* Data access layer for OCR providers
* Handles all database operations and data validation
*
* TODO: This class is already functional, but the data interaction service should be
* migrated to src/main/data/api/services.
*
* The reason why the migration hasn't been completed yet is that the data
* architecture is still under development, and we need to wait until the
* architectural design is finalized before proceeding with the migration.
*/
export class OcrProviderRepository {
/**
* Get all OCR providers
*/
public async findAll(): Promise<DbOcrProvider[]> {
try {
const providers = await dbService.getDb().select().from(ocrProviderTable)
return providers
} catch (error) {
logger.error('Failed to find all OCR providers', error as Error)
throw error
}
}
/**
* Get OCR provider by ID
*/
public async findById(id: OcrProviderId): Promise<DbOcrProvider> {
try {
const providers = await dbService
.getDb()
.select()
.from(ocrProviderTable)
.where(eq(ocrProviderTable.id, id))
.limit(1)
if (providers.length === 0) {
throw new Error(`OCR provider ${id} not found`)
}
return providers[0]
} catch (error) {
logger.error(`Failed to find OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Check if provider exists
*/
public async exists(id: OcrProviderId): Promise<boolean> {
try {
const providers = await dbService
.getDb()
.select({ id: ocrProviderTable.id })
.from(ocrProviderTable)
.where(eq(ocrProviderTable.id, id))
.limit(1)
return providers.length > 0
} catch (error) {
logger.error(`Failed to check if OCR provider ${id} exists`, error as Error)
throw error
}
}
/**
* Create new OCR provider
*/
public async create(param: DbOcrProviderCreate): Promise<DbOcrProvider> {
try {
// Check if provider already exists
if (await this.exists(param.id)) {
throw new Error(`OCR provider ${param.id} already exists`)
}
const timestamp = dayjs().valueOf()
const newProvider = {
...param,
createdAt: timestamp,
updatedAt: timestamp
} satisfies DbOcrProvider
// Validate data structure
if (!isDbOcrProvider(newProvider)) {
throw new Error('Invalid OCR provider data')
}
const [created] = await dbService.getDb().insert(ocrProviderTable).values(newProvider).returning()
logger.info(`Created OCR provider: ${param.id}`)
return created
} catch (error) {
logger.error(`Failed to create OCR provider ${param.id}`, error as Error)
throw error
}
}
/**
* Update OCR provider (partial update)
*/
public async update(id: OcrProviderId, update: DbOcrProviderUpdate): Promise<DbOcrProvider> {
try {
const existing = await this.findById(id)
const newProvider = {
...merge({}, existing, update),
updatedAt: dayjs().valueOf()
} satisfies DbOcrProvider
// Validate data structure
if (!isDbOcrProvider(newProvider)) {
throw new Error('Invalid OCR provider data')
}
const [updated] = await dbService
.getDb()
.update(ocrProviderTable)
.set(newProvider)
.where(eq(ocrProviderTable.id, id))
.returning()
logger.info(`Updated OCR provider: ${id}`)
return updated
} catch (error) {
logger.error(`Failed to update OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Replace OCR provider (full update)
*/
public async replace(data: DbOcrProviderReplace): Promise<DbOcrProvider> {
try {
// Check if it's a built-in provider
if (BuiltinOcrProviderIds.some((pid) => pid === data.id)) {
throw new Error('Built-in OCR providers cannot be modified with PUT method.')
}
const timestamp = dayjs().valueOf()
const existing = await this.exists(data.id)
let newProvider: DbOcrProvider
if (existing) {
// Update existing
const current = await this.findById(data.id)
newProvider = {
...data,
updatedAt: timestamp,
createdAt: current.createdAt
}
} else {
// Create new
newProvider = {
...data,
createdAt: timestamp,
updatedAt: timestamp
}
}
// Validate data structure
if (!isDbOcrProvider(newProvider)) {
throw new Error('Invalid OCR provider data')
}
const [saved] = await dbService
.getDb()
.insert(ocrProviderTable)
.values(newProvider)
.onConflictDoUpdate({
target: ocrProviderTable.id,
set: newProvider
})
.returning()
logger.info(`Replaced OCR provider: ${data.id}`)
return saved
} catch (error) {
logger.error(`Failed to replace OCR provider ${data.id}`, error as Error)
throw error
}
}
/**
* Delete OCR provider
*/
public async delete(id: OcrProviderId): Promise<void> {
try {
// Check if it's a built-in provider
if (BuiltinOcrProviderIds.some((pid) => pid === id)) {
throw new Error('Built-in OCR providers cannot be deleted.')
}
// Check if provider exists
await this.findById(id)
await dbService.getDb().delete(ocrProviderTable).where(eq(ocrProviderTable.id, id))
logger.info(`Deleted OCR provider: ${id}`)
} catch (error) {
logger.error(`Failed to delete OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Initialize built-in providers in database
*/
public async initializeBuiltInProviders(): Promise<void> {
try {
// Import built-in provider configurations
const { BUILTIN_OCR_PROVIDERS } = await import('@shared/config/ocr')
logger.info('Initializing built-in OCR providers')
// Check and create each built-in provider if it doesn't exist
for (const provider of BUILTIN_OCR_PROVIDERS) {
const exists = await this.exists(provider.id)
if (!exists) {
logger.info(`Creating built-in OCR provider: ${provider.id}`)
await this.create(provider)
} else {
logger.debug(`Built-in OCR provider already exists: ${provider.id}`)
}
}
logger.info(`Initialized ${BUILTIN_OCR_PROVIDERS.length} built-in OCR providers`)
} catch (error) {
logger.error('Failed to initialize built-in OCR providers', error as Error)
throw error
}
}
}
export const ocrProviderRepository = new OcrProviderRepository()

View File

@@ -14,13 +14,11 @@ import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
import { IpcChannel } from '@shared/IpcChannel'
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
import type {
AgentPersistedMessage,
FileMetadata,
Notification,
OcrProvider,
PluginError,
OcrParams,
Provider,
Shortcut,
SupportedOcrFile
@@ -36,6 +34,7 @@ import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { codeToolsService } from './services/CodeToolsService'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
@@ -50,12 +49,12 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { PluginService } from './services/PluginService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import {
addEndMessage,
addStreamMessage,
@@ -96,18 +95,6 @@ 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()
@@ -581,19 +568,13 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// shortcuts
ipcMain.handle(IpcChannel.Shortcuts_Update, async (_, shortcuts: Shortcut[]) => {
const existingPreferences = preferenceService.get('shortcut.preferences') ?? {}
const nextPreferences: ShortcutPreferenceMap = { ...existingPreferences }
for (const shortcut of shortcuts) {
const name = shortcut.key === 'mini_window' ? 'show_mini_window' : shortcut.key
nextPreferences[name] = {
key: [...shortcut.shortcut],
enabled: shortcut.enabled
}
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
unregisterAllShortcuts()
registerShortcuts(mainWindow)
}
await preferenceService.set('shortcut.preferences', nextPreferences)
})
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
@@ -894,10 +875,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
)
// OCR
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider)
)
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
ipcMain.handle(IpcChannel.OCR_Ocr, (_, file: SupportedOcrFile, params: OcrParams) => ocrService.ocr(file, params))
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
@@ -913,119 +891,6 @@ 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,199 +0,0 @@
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,7 +5,6 @@ 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) {
@@ -15,8 +14,6 @@ export default class PreprocessProviderFactory {
return new MistralPreprocessProvider(provider)
case 'mineru':
return new MineruPreprocessProvider(provider, userId)
case 'open-mineru':
return new OpenMineruPreprocessProvider(provider, userId)
default:
return new DefaultPreprocessProvider(provider)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,349 +1,298 @@
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import { handleZoomFactor } from '@main/utils/zoom'
import { IpcChannel } from '@shared/IpcChannel'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
import type { HydratedShortcut, ShortcutDefinition, ShortcutPreferenceMap } from '@shared/shortcuts/types'
import type { Shortcut } from '@types'
import type { BrowserWindow } from 'electron'
import { BrowserWindow as ElectronBrowserWindow, globalShortcut, ipcMain } from 'electron'
import { globalShortcut } from 'electron'
import { configManager } from './ConfigManager'
import selectionService from './SelectionService'
import { windowService } from './WindowService'
const logger = loggerService.withContext('ShortcutService')
type ShortcutHandler = (window: BrowserWindow | undefined) => void
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
let selectionAssistantToggleAccelerator: string | null = null
let selectionAssistantSelectTextAccelerator: string | null = null
class ShortcutService {
private handlers = new Map<string, ShortcutHandler>()
private hydratedShortcuts = new Map<string, HydratedShortcut>()
private registeredAccelerators = new Map<string, string[]>()
private readonly definitionMap = new Map<string, ShortcutDefinition>()
private ipcRegistered = false
//indicate if the shortcuts are registered on app boot time
let isRegisterOnBoot = true
constructor() {
this.definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
// store the focus and blur handlers for each window to unregister them later
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
this.setupIpcHandlers()
this.registerDefaultHandlers()
this.hydrateShortcuts()
this.registerPreferenceListeners()
}
public registerHandler(name: string, handler: ShortcutHandler) {
if (this.handlers.has(name)) {
logger.warn(`Handler for shortcut '${name}' is being overwritten.`)
}
this.handlers.set(name, handler)
}
public registerMainProcessShortcuts(window?: BrowserWindow) {
const targetWindow = this.getTargetWindow(window)
this.unregisterTrackedAccelerators()
for (const config of this.hydratedShortcuts.values()) {
if (config.scope !== 'main') {
continue
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
case 'zoom_in':
return (window: BrowserWindow) => handleZoomFactor([window], 0.1)
case 'zoom_out':
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
case 'zoom_reset':
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
case 'show_app':
return () => {
windowService.toggleMainWindow()
}
if (!config.enabled || config.key.length === 0) {
continue
case 'mini_window':
return () => {
windowService.toggleMiniWindow()
}
const handler = this.handlers.get(config.name)
if (!handler) {
logger.warn(`No handler registered for shortcut '${config.name}'.`)
continue
}
const accelerators = this.buildAccelerators(config)
if (accelerators.length === 0) {
continue
}
for (const accelerator of accelerators) {
try {
const registered = globalShortcut.register(accelerator, () => {
try {
handler(this.getTargetWindow(targetWindow))
} catch (error) {
logger.error(`Error while executing handler for shortcut '${config.name}':`, error as Error)
}
})
if (!registered) {
logger.warn(`Electron rejected shortcut accelerator '${accelerator}' for '${config.name}'.`)
continue
}
this.trackAccelerator(config.name, accelerator)
} catch (error) {
logger.warn(`Failed to register shortcut '${config.name}' with accelerator '${accelerator}':`, error as Error)
case 'selection_assistant_toggle':
return () => {
if (selectionService) {
selectionService.toggleEnabled()
}
}
}
this.broadcastShortcuts()
}
public unregisterAllShortcuts() {
this.unregisterTrackedAccelerators()
}
public getHydratedShortcuts(): Record<string, HydratedShortcut> {
return Object.fromEntries(
[...this.hydratedShortcuts.entries()].map(([name, config]) => [
name,
{
...config,
key: [...config.key]
}
])
)
}
private setupIpcHandlers() {
if (this.ipcRegistered) {
return
}
ipcMain.handle(IpcChannel.Shortcuts_GetAll, () => {
return this.getHydratedShortcuts()
})
this.ipcRegistered = true
}
private registerPreferenceListeners() {
preferenceService.subscribeChange('shortcut.preferences', (newPreferences) => {
this.hydrateAndRegister(newPreferences)
})
}
private hydrateAndRegister(preferences?: ShortcutPreferenceMap) {
this.hydrateShortcuts(preferences)
this.registerMainProcessShortcuts()
}
private hydrateShortcuts(preferences?: ShortcutPreferenceMap) {
const preferenceSnapshot = preferences ?? preferenceService.get('shortcut.preferences')
this.hydratedShortcuts.clear()
for (const definition of shortcutDefinitions) {
const userPreference = preferenceSnapshot?.[definition.name]
const key =
userPreference?.key && userPreference.key.length > 0 ? [...userPreference.key] : [...definition.defaultKey]
const enabled = typeof userPreference?.enabled === 'boolean' ? userPreference.enabled : definition.defaultEnabled
this.hydratedShortcuts.set(definition.name, {
...definition,
key,
enabled
})
}
}
private broadcastShortcuts() {
const payload = this.getHydratedShortcuts()
for (const window of ElectronBrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue
}
try {
window.webContents.send(IpcChannel.Shortcuts_Updated, payload)
} catch (error) {
logger.warn('Failed to broadcast shortcut update to renderer window:', error as Error)
}
}
}
private unregisterTrackedAccelerators() {
for (const accelerators of this.registeredAccelerators.values()) {
for (const accelerator of accelerators) {
try {
globalShortcut.unregister(accelerator)
} catch (error) {
logger.warn(`Failed to unregister accelerator '${accelerator}':`, error as Error)
case 'selection_assistant_select_text':
return () => {
if (selectionService) {
selectionService.processSelectTextByShortcut()
}
}
}
this.registeredAccelerators.clear()
}
private trackAccelerator(name: string, accelerator: string) {
if (!this.registeredAccelerators.has(name)) {
this.registeredAccelerators.set(name, [])
}
this.registeredAccelerators.get(name)!.push(accelerator)
}
private buildAccelerators(config: HydratedShortcut): string[] {
if (config.key.length === 0) {
return []
}
const baseAccelerator = this.normalizeAccelerator(config.key)
if (!baseAccelerator) {
logger.warn(`Invalid shortcut configuration for '${config.name}', skipping registration.`)
return []
}
if (config.name === 'zoom_in' && this.isUsingDefaultKey(config)) {
return [baseAccelerator, 'CommandOrControl+numadd']
}
if (config.name === 'zoom_out' && this.isUsingDefaultKey(config)) {
return [baseAccelerator, 'CommandOrControl+numsub']
}
if (config.name === 'zoom_reset' && this.isUsingDefaultKey(config)) {
return [baseAccelerator, 'CommandOrControl+num0']
}
return [baseAccelerator]
}
private isUsingDefaultKey(config: HydratedShortcut): boolean {
const definition = this.definitionMap.get(config.name)
if (!definition) {
return false
}
if (definition.defaultKey.length !== config.key.length) {
return false
}
return definition.defaultKey.every((key, index) => key === config.key[index])
}
private normalizeAccelerator(keys: string[]): string | null {
const normalizedKeys = keys.map((key) => this.normalizeKeyForElectron(key)).filter((key): key is string => !!key)
if (normalizedKeys.length !== keys.length) {
default:
return null
}
return normalizedKeys.join('+')
}
private normalizeKeyForElectron(key: string): string | null {
switch (key) {
case 'CommandOrControl':
case 'Ctrl':
case 'Alt':
case 'Meta':
case 'Shift':
return key
case 'Command':
case 'Cmd':
return 'CommandOrControl'
case 'Control':
return 'Ctrl'
case 'ArrowUp':
return 'Up'
case 'ArrowDown':
return 'Down'
case 'ArrowLeft':
return 'Left'
case 'ArrowRight':
return 'Right'
case 'AltGraph':
return 'AltGr'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
case 'Space':
return 'Space'
default:
return key
}
}
private registerDefaultHandlers() {
this.registerHandler('zoom_in', (window) => {
const target = this.getTargetWindow(window)
if (!target) {
return
}
handleZoomFactor([target], 0.1)
})
this.registerHandler('zoom_out', (window) => {
const target = this.getTargetWindow(window)
if (!target) {
return
}
handleZoomFactor([target], -0.1)
})
this.registerHandler('zoom_reset', (window) => {
const target = this.getTargetWindow(window)
if (!target) {
return
}
handleZoomFactor([target], 0, true)
})
this.registerHandler('show_app', () => {
windowService.toggleMainWindow()
})
this.registerHandler('show_mini_window', () => {
if (!preferenceService.get('feature.quick_assistant.enabled')) {
return
}
windowService.toggleMiniWindow()
})
this.registerHandler('selection_assistant_toggle', () => {
selectionService?.toggleEnabled()
})
this.registerHandler('selection_assistant_select_text', () => {
selectionService?.processSelectTextByShortcut()
})
}
private getTargetWindow(window?: BrowserWindow): BrowserWindow | undefined {
if (window && !window.isDestroyed()) {
return window
}
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
return mainWindow
}
return undefined
}
}
export const shortcutService = new ShortcutService()
function formatShortcutKey(shortcut: string[]): string {
return shortcut.join('+')
}
// convert the shortcut recorded by JS keyboard event key value to electron global shortcut format
// see: https://www.electronjs.org/zh/docs/latest/api/accelerator
const convertShortcutFormat = (shortcut: string | string[]): string => {
const accelerator = (() => {
if (Array.isArray(shortcut)) {
return shortcut
} else {
return shortcut.split('+').map((key) => key.trim())
}
})()
return accelerator
.map((key) => {
switch (key) {
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// case 'Command':
// return 'CommandOrControl'
// case 'Control':
// return 'Control'
// case 'Ctrl':
// return 'Control'
// NEW WAY FOR MODIFIER KEYS
// you can see all the modifier keys in the same
case 'CommandOrControl':
return 'CommandOrControl'
case 'Ctrl':
return 'Ctrl'
case 'Alt':
return 'Alt' // Use `Alt` instead of `Option`. The `Option` key only exists on macOS, whereas the `Alt` key is available on all platforms.
case 'Meta':
return 'Meta' // `Meta` key is mapped to the Windows key on Windows and Linux, `Cmd` on macOS.
case 'Shift':
return 'Shift'
// For backward compatibility with old data
case 'Command':
case 'Cmd':
return 'CommandOrControl'
case 'Control':
return 'Ctrl'
case 'ArrowUp':
return 'Up'
case 'ArrowDown':
return 'Down'
case 'ArrowLeft':
return 'Left'
case 'ArrowRight':
return 'Right'
case 'AltGraph':
return 'AltGr'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
default:
return key
}
})
.join('+')
}
export function registerShortcuts(window: BrowserWindow) {
shortcutService.registerMainProcessShortcuts(window)
if (isRegisterOnBoot) {
window.once('ready-to-show', () => {
if (preferenceService.get('app.tray.on_launch')) {
registerOnlyUniversalShortcuts()
}
})
isRegisterOnBoot = false
}
//only for clearer code
const registerOnlyUniversalShortcuts = () => {
register(true)
}
//onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window
//onlyUniversalShortcuts is needed when we launch to tray
const register = (onlyUniversalShortcuts: boolean = false) => {
if (window.isDestroyed()) return
const shortcuts = configManager.getShortcuts()
if (!shortcuts) return
shortcuts.forEach((shortcut) => {
try {
if (shortcut.shortcut.length === 0) {
return
}
//if not enabled, exit early from the process.
if (!shortcut.enabled) {
return
}
// only register universal shortcuts when needed
if (
onlyUniversalShortcuts &&
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
shortcut.key
)
) {
return
}
const handler = getShortcutHandler(shortcut)
if (!handler) {
return
}
switch (shortcut.key) {
case 'show_app':
showAppAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'mini_window':
//available only when QuickAssistant enabled
if (!preferenceService.get('feature.quick_assistant.enabled')) {
return
}
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_toggle':
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_select_text':
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
break
//the following ZOOMs will register shortcuts separately, so will return
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => handler(window))
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
return
case 'zoom_out':
globalShortcut.register('CommandOrControl+-', () => handler(window))
globalShortcut.register('CommandOrControl+numsub', () => handler(window))
return
case 'zoom_reset':
globalShortcut.register('CommandOrControl+0', () => handler(window))
return
}
const accelerator = convertShortcutFormat(shortcut.shortcut)
globalShortcut.register(accelerator, () => handler(window))
} catch (error) {
logger.warn(`Failed to register shortcut ${shortcut.key}`)
}
})
}
const unregister = () => {
if (window.isDestroyed()) return
try {
globalShortcut.unregisterAll()
if (showAppAccelerator) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
const accelerator = convertShortcutFormat(showAppAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (showMiniWindowAccelerator) {
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
const accelerator = convertShortcutFormat(showMiniWindowAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantToggleAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantSelectTextAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantSelectTextAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
} catch (error) {
logger.warn('Failed to unregister shortcuts')
}
}
// only register the event handlers once
if (undefined === windowOnHandlers.get(window)) {
// pass register() directly to listener, the func will receive Event as argument, it's not expected
const registerHandler = () => {
register()
}
window.on('focus', registerHandler)
window.on('blur', unregister)
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
}
if (!window.isDestroyed() && window.isFocused()) {
register()
}
}
export function unregisterAllShortcuts() {
shortcutService.unregisterAllShortcuts()
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
selectionAssistantToggleAccelerator = null
selectionAssistantSelectTextAccelerator = null
windowOnHandlers.forEach((handlers, window) => {
window.off('focus', handlers.onFocusHandler)
window.off('blur', handlers.onBlurHandler)
})
windowOnHandlers.clear()
globalShortcut.unregisterAll()
} catch (error) {
logger.warn('Failed to unregister all shortcuts')
}
}

View File

@@ -2,7 +2,7 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type { 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,23 +12,10 @@ 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
@@ -113,41 +100,6 @@ 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,
@@ -170,8 +122,7 @@ class ClaudeCodeService implements AgentServiceInterface {
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,
maxTurns: session.configuration?.max_turns,
allowedTools: session.allowed_tools,
canUseTool
allowedTools: session.allowed_tools
}
if (session.accessible_paths.length > 1) {
@@ -210,14 +161,9 @@ 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(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => {
logger.error('Unhandled Claude Code stream error', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
@@ -231,90 +177,17 @@ class ClaudeCodeService implements AgentServiceInterface {
return aiStream
}
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)
private async *userMessages(prompt: string) {
{
yield {
type: 'user' as const,
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user' as const,
content: prompt
}
}
})()
enqueue({
type: 'user',
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user',
content: initialPrompt
}
})
return {
stream: iterator,
enqueue,
close
}
}
@@ -322,8 +195,7 @@ class ClaudeCodeService implements AgentServiceInterface {
* Process SDK query and emit stream events
*/
private async processSDKQuery(
promptStream: AsyncIterable<UserInputMessage>,
closePromptStream: () => void,
prompt: string,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[]
@@ -331,10 +203,14 @@ class ClaudeCodeService implements AgentServiceInterface {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
const streamState = new ClaudeStreamState()
const streamState = new ClaudeStreamState()
try {
for await (const message of query({ prompt: promptStream, options })) {
// Process streaming responses using SDK query
for await (const message of query({
prompt: this.userMessages(prompt),
options
})) {
if (hasCompleted) break
jsonOutput.push(message)
@@ -345,10 +221,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,
@@ -356,6 +232,7 @@ class ClaudeCodeService implements AgentServiceInterface {
})
}
// Transform SDKMessage to UIMessageChunks
const chunks = transformSDKMessageToStreamParts(message, streamState)
for (const chunk of chunks) {
stream.emit('data', {
@@ -365,6 +242,7 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
// Successfully completed
hasCompleted = true
const duration = Date.now() - startTime
@@ -373,6 +251,7 @@ class ClaudeCodeService implements AgentServiceInterface {
messageCount: jsonOutput.length
})
// Emit completion event
stream.emit('data', {
type: 'complete'
})
@@ -381,6 +260,8 @@ 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' ||
@@ -389,6 +270,7 @@ 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')
@@ -403,13 +285,11 @@ 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

@@ -1,323 +0,0 @@
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

@@ -1,8 +1,21 @@
import { loggerService } from '@logger'
import { isLinux } from '@main/constant'
import type { OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { BuiltinOcrProviderIds } from '@types'
import { ocrProviderRepository } from '@main/data/repositories/OcrProviderRepository'
import type {
DbOcrProvider,
ListOcrProvidersQuery,
OcrParams,
OcrProvider,
OcrProviderBusiness,
OcrProviderCreateBusiness,
OcrProviderKeyBusiness,
OcrProviderReplaceBusiness,
OcrProviderUpdateBusiness,
OcrResult,
SupportedOcrFile
} from '@types'
import { BuiltinOcrProviderIdMap } from '@types'
import type { OcrBaseService } from './builtin/OcrBaseService'
import { ovOcrService } from './builtin/OvOcrService'
import { ppocrService } from './builtin/PpocrService'
import { systemOcrService } from './builtin/SystemOcrService'
@@ -10,40 +23,285 @@ import { tesseractService } from './builtin/TesseractService'
const logger = loggerService.withContext('OcrService')
export class OcrService {
private registry: Map<string, OcrHandler> = new Map()
/**
* Business logic layer for OCR operations
* Handles OCR provider registration, orchestration, and core OCR functionality
*/
class OcrService {
private registry: Map<OcrProviderKeyBusiness, OcrBaseService> = new Map()
private initialized: boolean = false
register(providerId: string, handler: OcrHandler): void {
if (this.registry.has(providerId)) {
logger.warn(`Provider ${providerId} has existing handler. Overwrited.`)
constructor() {
this.registerBuiltinProviders()
}
/**
* Ensure the service is initialized
*/
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initializeBuiltinProviders()
this.initialized = true
}
this.registry.set(providerId, handler)
}
unregister(providerId: string): void {
this.registry.delete(providerId)
/**
* Initialize built-in OCR providers
*/
private async initializeBuiltinProviders(): Promise<void> {
try {
// Ensure built-in providers exist in database
await ocrProviderRepository.initializeBuiltInProviders()
logger.info('OCR service initialized with built-in providers')
} catch (error) {
logger.error('Failed to initialize OCR service', error as Error)
throw error
}
}
public listProviderIds(): string[] {
/**
* Register built-in providers (sync)
*/
private registerBuiltinProviders(): void {
this.register(BuiltinOcrProviderIdMap.tesseract, tesseractService)
if (systemOcrService) {
this.register(BuiltinOcrProviderIdMap.system, systemOcrService)
}
this.register(BuiltinOcrProviderIdMap.paddleocr, ppocrService)
if (ovOcrService) {
this.register(BuiltinOcrProviderIdMap.ovocr, ovOcrService)
}
}
/**
* Register an OCR provider service
*/
private register(providerId: OcrProviderKeyBusiness, service: OcrBaseService): void {
if (this.registry.has(providerId)) {
logger.warn(`Provider ${providerId} already registered. Overwriting.`)
}
this.registry.set(providerId, service)
logger.info(`Registered OCR provider: ${providerId}`)
}
// Not sure when it will be needed.
/**
* Unregister an OCR provider service
*/
// private unregister(providerId: OcrProviderId): void {
// if (this.registry.delete(providerId)) {
// logger.info(`Unregistered OCR provider: ${providerId}`)
// }
// }
/**
* Get all registered provider IDs
*/
public getRegisteredProviderIds(): OcrProviderKeyBusiness[] {
return Array.from(this.registry.keys())
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id)
if (!handler) {
throw new Error(`Provider ${provider.id} is not registered`)
/**
* Check if a provider is registered
*/
public isProviderRegistered(providerId: OcrProviderKeyBusiness): boolean {
return this.registry.has(providerId)
}
/**
* Get list of OCR providers
*/
public async listProviders(query?: ListOcrProvidersQuery): Promise<OcrProviderBusiness[]> {
try {
await this.ensureInitialized()
const providers = await ocrProviderRepository.findAll()
let result = providers
if (query?.registered) {
// Filter by registered providers
const registeredIds = this.getRegisteredProviderIds()
result = providers.filter((provider) => registeredIds.includes(provider.id))
}
logger.debug(`Listed ${result.length} OCR providers`)
return result
} catch (error) {
logger.error('Failed to list OCR providers', error as Error)
throw error
}
return handler(file, provider.config)
}
/**
* Get OCR provider by ID
*/
public async getProvider(providerId: OcrProviderKeyBusiness): Promise<OcrProviderBusiness> {
try {
await this.ensureInitialized()
const provider = await ocrProviderRepository.findById(providerId)
logger.debug(`Retrieved OCR provider: ${providerId}`)
return provider
} catch (error) {
logger.error(`Failed to get OCR provider ${providerId}`, error as Error)
throw error
}
}
/**
* Create new OCR provider
*/
public async createProvider(data: OcrProviderCreateBusiness): Promise<OcrProviderBusiness> {
try {
await this.ensureInitialized()
const result = await ocrProviderRepository.create(data)
logger.info(`Created OCR provider: ${data.id}`)
return result
} catch (error) {
logger.error(`Failed to create OCR provider ${data.id}`, error as Error)
throw error
}
}
/**
* Update OCR provider (partial update)
*/
public async updateProvider(
id: OcrProviderKeyBusiness,
data: OcrProviderUpdateBusiness
): Promise<OcrProviderBusiness> {
try {
await this.ensureInitialized()
const result = await ocrProviderRepository.update(id, data)
logger.info(`Updated OCR provider: ${id}`)
return result
} catch (error) {
logger.error(`Failed to update OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Replace OCR provider (full update)
*/
public async replaceProvider(data: OcrProviderReplaceBusiness): Promise<OcrProviderBusiness> {
try {
await this.ensureInitialized()
const result = await ocrProviderRepository.replace(data)
logger.info(`Replaced OCR provider: ${data.id}`)
return result
} catch (error) {
logger.error(`Failed to replace OCR provider ${data.id}`, error as Error)
throw error
}
}
/**
* Delete OCR provider
*/
public async deleteProvider(id: OcrProviderKeyBusiness): Promise<void> {
try {
await this.ensureInitialized()
await ocrProviderRepository.delete(id)
logger.info(`Deleted OCR provider: ${id}`)
} catch (error) {
logger.error(`Failed to delete OCR provider ${id}`, error as Error)
throw error
}
}
/**
* Perform OCR on a file using the specified provider
*/
public async ocr(file: SupportedOcrFile, params: OcrParams): Promise<OcrResult> {
try {
await this.ensureInitialized()
const service = this.registry.get(params.providerId)
if (!service) {
throw new Error(`Provider ${params.providerId} is not registered`)
}
// Validate that the provider exists in database
const provider = await this.getProvider(params.providerId)
logger.debug(`Performing OCR with provider: ${JSON.stringify(provider, undefined, 2)}`)
const result = await service.ocr(file, provider.config)
logger.info(`OCR completed successfully with provider: ${params.providerId}`)
return result
} catch (error) {
logger.error(`OCR failed with provider ${params.providerId}`, error as Error)
throw error
}
}
/**
* Check if a provider is available and ready
*/
public async isProviderAvailable(providerId: OcrProviderKeyBusiness): Promise<boolean> {
try {
const service = this.registry.get(providerId)
if (!service) {
return false
}
// Check if provider exists in database
await this.getProvider(providerId)
// Additional availability checks can be added here
return true
} catch (error) {
logger.debug(`Provider ${providerId} is not available`, error as Error)
return false
}
}
private async _isProviderAvailable(provider: OcrProvider): Promise<boolean> {
try {
return this.registry.get(provider.id) !== undefined
} catch (error) {
logger.debug(`Provider ${provider.id} is not available`, error as Error)
return false
}
}
/**
* Get available providers
* It's only for image type. May re-designed for a specific file type in the future.
*
*/
public async getAvailableProvidersForFile(): Promise<DbOcrProvider[]> {
try {
const providers = await this.listProviders()
// Filter providers that can handle the file type
// This logic can be extended based on file type and provider capabilities
const availableProviders: DbOcrProvider[] = []
const capFilter = (provider: OcrProvider) => provider.capabilities.image
for (const provider of providers.filter(capFilter)) {
if (await this._isProviderAvailable(provider)) {
availableProviders.push(provider)
}
}
logger.debug(`Found ${availableProviders.length} available providers for file`)
return availableProviders
} catch (error) {
logger.error('Failed to get available providers for file', error as Error)
throw error
}
}
/**
* Cleanup resources
*/
public dispose(): void {
this.registry.clear()
logger.info('OCR service disposed')
}
}
export const ocrService = new OcrService()
// Register built-in providers
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))

View File

@@ -1,7 +1,7 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import type { OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
import { isImageFileMetadata } from '@types'
import type { OcrOvConfig, OcrProviderConfig, OcrResult, SupportedOcrFile } from '@types'
import { isImageFileMetadata, isOcrOvConfig } from '@types'
import { exec } from 'child_process'
import * as fs from 'fs'
import * as os from 'os'
@@ -15,20 +15,17 @@ const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
const isOvAvailable =
isWin &&
os.cpus()[0].model.toLowerCase().includes('intel') &&
os.cpus()[0].model.toLowerCase().includes('ultra') &&
fs.existsSync(PATH_BAT_FILE)
export class OvOcrService extends OcrBaseService {
constructor() {
super()
}
public isAvailable(): boolean {
return (
isWin &&
os.cpus()[0].model.toLowerCase().includes('intel') &&
os.cpus()[0].model.toLowerCase().includes('ultra') &&
fs.existsSync(PATH_BAT_FILE)
)
}
private getOvOcrPath(): string {
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
}
@@ -81,8 +78,8 @@ export class OvOcrService extends OcrBaseService {
}
}
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
private async ocrImage(filePath: string, config?: OcrOvConfig): Promise<OcrResult> {
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(config)}`)
try {
// 1. Clear img directory and output directory
@@ -117,13 +114,16 @@ export class OvOcrService extends OcrBaseService {
}
}
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
public ocr = async (file: SupportedOcrFile, config?: OcrProviderConfig): Promise<OcrResult> => {
if (!isOcrOvConfig(config)) {
throw new Error('Invalid OCR OV config')
}
if (isImageFileMetadata(file)) {
return this.ocrImage(file.path, options)
return this.ocrImage(file.path, config)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const ovOcrService = new OvOcrService()
export const ovOcrService = isOvAvailable ? new OvOcrService() : undefined

View File

@@ -1,6 +1,6 @@
import { loadOcrImage } from '@main/utils/ocr'
import type { ImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
import { isImageFileMetadata } from '@types'
import { isImageFileMetadata, isOcrPpocrConfig } from '@types'
import { net } from 'electron'
import * as z from 'zod'
@@ -40,14 +40,17 @@ const OcrResponseSchema = z.object({
})
export class PpocrService extends OcrBaseService {
public ocr = async (file: SupportedOcrFile, options?: OcrPpocrConfig): Promise<OcrResult> => {
public ocr = async (file: SupportedOcrFile, config?: OcrPpocrConfig): Promise<OcrResult> => {
if (!isOcrPpocrConfig(config)) {
throw new Error('Invalid OCR config')
}
if (!isImageFileMetadata(file)) {
throw new Error('Only image files are supported currently')
}
if (!options) {
if (!config) {
throw new Error('config is required')
}
return this.imageOcr(file, options)
return this.imageOcr(file, config)
}
private async imageOcr(file: ImageFileMetadata, options: OcrPpocrConfig): Promise<OcrResult> {

View File

@@ -1,8 +1,8 @@
import { isLinux, isWin } from '@main/constant'
import { loadOcrImage } from '@main/utils/ocr'
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
import type { ImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types'
import { isImageFileMetadata } from '@types'
import type { ImageFileMetadata, OcrProviderConfig, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types'
import { isImageFileMetadata, isOcrSystemConfig } from '@types'
import { OcrBaseService } from './OcrBaseService'
@@ -12,23 +12,26 @@ export class SystemOcrService extends OcrBaseService {
super()
}
private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise<OcrResult> {
private async ocrImage(file: ImageFileMetadata, config?: OcrSystemConfig): Promise<OcrResult> {
if (isLinux) {
return { text: '' }
}
const buffer = await loadOcrImage(file)
const langs = isWin ? options?.langs : undefined
const langs = isWin ? config?.langs : undefined
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
return { text: result.text }
}
public ocr = async (file: SupportedOcrFile, options?: OcrSystemConfig): Promise<OcrResult> => {
public ocr = async (file: SupportedOcrFile, config?: OcrProviderConfig): Promise<OcrResult> => {
if (!isOcrSystemConfig(config)) {
throw new Error('Invalid OCR configuration')
}
if (isImageFileMetadata(file)) {
return this.ocrImage(file, options)
return this.ocrImage(file, config)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const systemOcrService = new SystemOcrService()
export const systemOcrService = !isLinux ? new SystemOcrService() : undefined

View File

@@ -2,8 +2,8 @@ import { loggerService } from '@logger'
import { getIpCountry } from '@main/utils/ipService'
import { loadOcrImage } from '@main/utils/ocr'
import { MB } from '@shared/config/constant'
import type { ImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types'
import { isImageFileMetadata } from '@types'
import type { ImageFileMetadata, OcrProviderConfig, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types'
import { isImageFileMetadata, isOcrTesseractConfig } from '@types'
import { app } from 'electron'
import fs from 'fs'
import { isEqual } from 'lodash'
@@ -70,8 +70,8 @@ export class TesseractService extends OcrBaseService {
return this.worker
}
private async imageOcr(file: ImageFileMetadata, options?: OcrTesseractConfig): Promise<OcrResult> {
const worker = await this.getWorker(options)
private async imageOcr(file: ImageFileMetadata, config?: OcrTesseractConfig): Promise<OcrResult> {
const worker = await this.getWorker(config)
const stat = await fs.promises.stat(file.path)
if (stat.size > MB_SIZE_THRESHOLD * MB) {
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
@@ -81,11 +81,14 @@ export class TesseractService extends OcrBaseService {
return { text: result.data.text }
}
public ocr = async (file: SupportedOcrFile, options?: OcrTesseractConfig): Promise<OcrResult> => {
public ocr = async (file: SupportedOcrFile, config?: OcrProviderConfig): Promise<OcrResult> => {
if (!isOcrTesseractConfig(config)) {
throw new Error('Invalid Tesseract config')
}
if (!isImageFileMetadata(file)) {
throw new Error('Only image files are supported currently')
}
return this.imageOcr(file, options)
return this.imageOcr(file, config)
}
private async _getLangPath(): Promise<string> {

View File

@@ -1,223 +0,0 @@
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

@@ -1,309 +0,0 @@
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,4 +1,3 @@
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'
@@ -13,7 +12,7 @@ import type {
} from '@shared/data/preference/preferenceTypes'
import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import type { Notification, OcrParams } from '@types'
import type {
AddMemoryOptions,
AssistantMessage,
@@ -28,7 +27,6 @@ import type {
MemoryConfig,
MemoryListOptions,
MemorySearchOptions,
OcrProvider,
OcrResult,
Provider,
RestartApiServerStatusResult,
@@ -43,16 +41,6 @@ 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 }
@@ -237,7 +225,6 @@ const api = {
},
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
shortcuts: {
getAll: () => ipcRenderer.invoke(IpcChannel.Shortcuts_GetAll),
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
},
knowledgeBase: {
@@ -438,15 +425,6 @@ 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),
@@ -497,9 +475,8 @@ const api = {
ipcRenderer.invoke(IpcChannel.CodeTools_RemoveCustomTerminalPath, terminalId)
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
ocr: (file: SupportedOcrFile, params: OcrParams): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_Ocr, file, params)
},
cherryai: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
@@ -569,21 +546,6 @@ 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

@@ -98,8 +98,7 @@ export default class ModernAiProvider {
// 提前构建中间件
const middlewares = buildAiSdkMiddlewares({
...config,
provider: this.actualProvider,
assistant: config.assistant
provider: this.actualProvider
})
logger.debug('Built middlewares in completions', {
middlewareCount: middlewares.length,

View File

@@ -1,18 +1,13 @@
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { loggerService } from '@logger'
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 MCPTool, 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')
@@ -25,7 +20,6 @@ export interface AiSdkMiddlewareConfig {
onChunk?: (chunk: Chunk) => void
model?: Model
provider?: Provider
assistant?: Assistant
enableReasoning: boolean
// 是否开启提示词工具调用
isPromptToolUse: boolean
@@ -134,7 +128,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
const builder = new AiSdkMiddlewareBuilder()
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') {
if (config.knowledgeRecognition === 'off') {
builder.add({
name: 'force-knowledge-first',
middleware: toolChoiceMiddleware('builtin_knowledge_search')
@@ -225,21 +219,6 @@ 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

@@ -1,39 +0,0 @@
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 { Button } from '@cherrystudio/ui'
import { useCallback, useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -20,12 +20,18 @@ const ExpandableText = ({
setIsExpanded((prev) => !prev)
}, [])
return (
<Container ref={ref} style={style} $expanded={isExpanded}>
<TextContainer $expanded={isExpanded}>{text}</TextContainer>
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}
</Container>
)
}
@@ -42,4 +48,4 @@ const TextContainer = styled.div<{ $expanded?: boolean }>`
line-height: ${(props) => (props.$expanded ? 'unset' : '30px')};
`
export default ExpandableText
export default memo(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, Space, Table } from 'antd'
import { Modal, Table } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -221,26 +221,6 @@ 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')}
@@ -249,7 +229,24 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
width={800}
centered
transitionName="animation-move-down"
footer={footerContent}>
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>
]}>
<Table
rowKey="fileName"
columns={columns}

View File

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

@@ -9,15 +9,6 @@ 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

@@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
min-width: 100%;
display: flex;
flex-direction: row;
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
min-height: ${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

@@ -1,182 +1 @@
import type {
BuiltinOcrProvider,
BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider,
OcrProviderCapability,
OcrSystemProvider,
OcrTesseractProvider,
TesseractLangCode,
TranslateLanguageCode
} from '@renderer/types'
import { isMac, isWin } from './constant'
const tesseract: OcrTesseractProvider = {
id: 'tesseract',
name: 'Tesseract',
capabilities: {
image: true
},
config: {
langs: {
chi_sim: true,
chi_tra: true,
eng: true
}
}
} as const
const systemOcr: OcrSystemProvider = {
id: 'system',
name: 'System',
config: {
langs: isWin ? ['en-us'] : undefined
},
capabilities: {
image: true
// pdf: true
}
} as const satisfies OcrSystemProvider
const ppocrOcr: OcrPpocrProvider = {
id: 'paddleocr',
name: 'PaddleOCR',
config: {
apiUrl: ''
},
capabilities: {
image: true
// pdf: true
}
} as const
const ovOcr: OcrOvProvider = {
id: 'ovocr',
name: 'Intel OV(NPU) OCR',
config: {
langs: isWin ? ['en-us', 'zh-cn'] : undefined
},
capabilities: {
image: true
// pdf: true
}
} as const satisfies OcrOvProvider
export const BUILTIN_OCR_PROVIDERS_MAP = {
tesseract,
system: systemOcr,
paddleocr: ppocrOcr,
ovocr: ovOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
export const DEFAULT_OCR_PROVIDER = {
image: isWin || isMac ? systemOcr : tesseract
} as const satisfies Record<OcrProviderCapability, BuiltinOcrProvider>
export const TESSERACT_LANG_MAP: Record<TranslateLanguageCode, TesseractLangCode> = {
'af-za': 'afr',
'am-et': 'amh',
'ar-sa': 'ara',
'as-in': 'asm',
'az-az': 'aze',
'az-cyrl-az': 'aze_cyrl',
'be-by': 'bel',
'bn-bd': 'ben',
'bo-cn': 'bod',
'bs-ba': 'bos',
'bg-bg': 'bul',
'ca-es': 'cat',
'ceb-ph': 'ceb',
'cs-cz': 'ces',
'zh-cn': 'chi_sim',
'zh-tw': 'chi_tra',
'chr-us': 'chr',
'cy-gb': 'cym',
'da-dk': 'dan',
'de-de': 'deu',
'dz-bt': 'dzo',
'el-gr': 'ell',
'en-us': 'eng',
'enm-gb': 'enm',
'eo-world': 'epo',
'et-ee': 'est',
'eu-es': 'eus',
'fa-ir': 'fas',
'fi-fi': 'fin',
'fr-fr': 'fra',
'frk-de': 'frk',
'frm-fr': 'frm',
'ga-ie': 'gle',
'gl-es': 'glg',
'grc-gr': 'grc',
'gu-in': 'guj',
'ht-ht': 'hat',
'he-il': 'heb',
'hi-in': 'hin',
'hr-hr': 'hrv',
'hu-hu': 'hun',
'iu-ca': 'iku',
'id-id': 'ind',
'is-is': 'isl',
'it-it': 'ita',
'ita-it': 'ita_old',
'jv-id': 'jav',
'ja-jp': 'jpn',
'kn-in': 'kan',
'ka-ge': 'kat',
'kat-ge': 'kat_old',
'kk-kz': 'kaz',
'km-kh': 'khm',
'ky-kg': 'kir',
'ko-kr': 'kor',
'ku-tr': 'kur',
'la-la': 'lao',
'la-va': 'lat',
'lv-lv': 'lav',
'lt-lt': 'lit',
'ml-in': 'mal',
'mr-in': 'mar',
'mk-mk': 'mkd',
'mt-mt': 'mlt',
'ms-my': 'msa',
'my-mm': 'mya',
'ne-np': 'nep',
'nl-nl': 'nld',
'no-no': 'nor',
'or-in': 'ori',
'pa-in': 'pan',
'pl-pl': 'pol',
'pt-pt': 'por',
'ps-af': 'pus',
'ro-ro': 'ron',
'ru-ru': 'rus',
'sa-in': 'san',
'si-lk': 'sin',
'sk-sk': 'slk',
'sl-si': 'slv',
'es-es': 'spa',
'spa-es': 'spa_old',
'sq-al': 'sqi',
'sr-rs': 'srp',
'sr-latn-rs': 'srp_latn',
'sw-tz': 'swa',
'sv-se': 'swe',
'syr-sy': 'syr',
'ta-in': 'tam',
'te-in': 'tel',
'tg-tj': 'tgk',
'tl-ph': 'tgl',
'th-th': 'tha',
'ti-er': 'tir',
'tr-tr': 'tur',
'ug-cn': 'uig',
'uk-ua': 'ukr',
'ur-pk': 'urd',
'uz-uz': 'uzb',
'uz-cyrl-uz': 'uzb_cyrl',
'vi-vn': 'vie',
'yi-us': 'yid'
}
// All config are migrated to @shared/config/ocr

View File

@@ -11,8 +11,6 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
return MistralLogo
case 'mineru':
return MinerULogo
case 'open-mineru':
return MinerULogo
default:
return undefined
}
@@ -38,11 +36,5 @@ 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

@@ -412,7 +412,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/apps/anthropic',
anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy',
models: SYSTEM_MODELS.dashscope,
isSystem: true,
enabled: false

View File

@@ -1,6 +1,5 @@
/// <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'
@@ -20,14 +19,5 @@ declare global {
store: any
navigate: NavigateFunction
toast: ToastUtilities
agentTools: {
respondToPermission: (payload: {
requestId: string
behavior: 'allow' | 'deny'
updatedInput?: Record<string, unknown>
message?: string
updatedPermissions?: PermissionUpdate[]
}) => Promise<{ success: boolean }>
}
}
}

View File

@@ -1,4 +1,4 @@
import { useShortcutConfig } from '@renderer/hooks/useShortcuts'
import { useAppSelector } from '@renderer/store'
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
@@ -7,8 +7,9 @@ import { useLocation, useNavigate } from 'react-router-dom'
const NavigationHandler: React.FC = () => {
const location = useLocation()
const navigate = useNavigate()
const showSettingsShortcut = useShortcutConfig('show_settings')
const showSettingsShortcutEnabled = showSettingsShortcut?.enabled ?? false
const showSettingsShortcutEnabled = useAppSelector(
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
)
useHotkeys(
'meta+, ! ctrl+,',

View File

@@ -1,49 +0,0 @@
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

@@ -1,18 +1,21 @@
import { loggerService } from '@logger'
import * as OcrService from '@renderer/services/ocr/OcrService'
import type { ImageFileMetadata, SupportedOcrFile } from '@renderer/types'
import type { ImageFileMetadata, OcrProvider, SupportedOcrFile } from '@renderer/types'
import { isImageFileMetadata } from '@renderer/types'
import { formatErrorMessage } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useOcrProviders } from './useOcrProvider'
import { useOcrImageProvider } from './useOcrImageProvider'
const logger = loggerService.withContext('useOcr')
const isProviderAvailable = (provider: OcrProvider | undefined | null): provider is OcrProvider =>
provider !== undefined
export const useOcr = () => {
const { t } = useTranslation()
const { imageProvider } = useOcrProviders()
const { imageProvider, imageProviderId } = useOcrImageProvider()
/**
* OCR识别
@@ -22,10 +25,16 @@ export const useOcr = () => {
*/
const ocrImage = useCallback(
async (image: ImageFileMetadata) => {
logger.debug('ocrImage', { config: imageProvider.config })
return OcrService.ocr(image, imageProvider)
if (isProviderAvailable(imageProvider)) {
logger.debug('ocrImage', { provider: imageProvider })
return OcrService.ocr(image, {
providerId: imageProvider.id
})
} else {
throw new Error(t('ocr.error.provider.not_availabel', { provider: imageProviderId }))
}
},
[imageProvider]
[imageProvider, imageProviderId, t]
)
/**

View File

@@ -0,0 +1,9 @@
import { usePreference } from '@data/hooks/usePreference'
import { useOcrProvider } from './useOcrProvider'
export const useOcrImageProvider = () => {
const [imageProviderId, setImageProviderId] = usePreference('ocr.settings.image_provider_id')
const { provider: imageProvider, mutating, loading, error, updateConfig } = useOcrProvider(imageProviderId)
return { imageProvider, loading, mutating, error, updateConfig, imageProviderId, setImageProviderId }
}

View File

@@ -0,0 +1,37 @@
import { useMutation, useQuery } from '@data/hooks/useDataApi'
import type { OcrProviderConfig } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import type { ConcreteApiPaths } from '@shared/data/api'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('useOcrProvider')
export const useOcrProvider = (id: string | null) => {
const { t } = useTranslation()
const path: ConcreteApiPaths = `/ocr/providers/${id}`
const { data, loading, error } = useQuery(path)
const { mutate, loading: mutating } = useMutation('PATCH', path)
const updateConfig = useCallback(
async (update: Partial<OcrProviderConfig>) => {
if (!id) return
try {
await mutate({ body: { id, config: update } })
} catch (e) {
window.toast.error({ title: t('ocr.provider.config.patch.error.failed'), description: getErrorMessage(e) })
}
},
[id, mutate, t]
)
return {
/** undefined: loading; null: invalid, id is null */
provider: id ? data?.data : null,
loading,
mutating,
error,
updateConfig
}
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@data/hooks/useDataApi'
import { getBuiltinOcrProviderLabel } from '@renderer/i18n/label'
import type { ListOcrProvidersQuery, OcrProvider } from '@renderer/types'
import { isBuiltinOcrProvider } from '@renderer/types'
export const useOcrProviders = (query?: ListOcrProvidersQuery) => {
const { data, loading, error } = useQuery('/ocr/providers', { query })
const getOcrProviderName = (p: OcrProvider) => {
return isBuiltinOcrProvider(p) ? getBuiltinOcrProviderLabel(p.id) : p.name
}
return {
providers: data?.data,
loading,
error,
getOcrProviderName
}
}

View File

@@ -13,18 +13,12 @@ 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'
@@ -33,7 +27,6 @@ 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')
@@ -155,64 +148,6 @@ 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 !== '' || ['mineru', 'open-mineru'].includes(p.id))
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
.map((p) => ({ value: p.id, label: p.name }))
}
return [preprocessOptions]

View File

@@ -1,148 +0,0 @@
import { Avatar } from '@cherrystudio/ui'
import { loggerService } from '@logger'
import IntelLogo from '@renderer/assets/images/providers/intel.png'
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
import { getBuiltinOcrProviderLabel } from '@renderer/i18n/label'
import { useAppSelector } from '@renderer/store'
import { addOcrProvider, removeOcrProvider, setImageOcrProviderId, updateOcrProviderConfig } from '@renderer/store/ocr'
import type { ImageOcrProvider, OcrProvider, OcrProviderConfig } from '@renderer/types'
import { isBuiltinOcrProvider, isBuiltinOcrProviderId, isImageOcrProvider } from '@renderer/types'
import { FileQuestionMarkIcon, MonitorIcon } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
const logger = loggerService.withContext('useOcrProvider')
export const useOcrProviders = () => {
const providers = useAppSelector((state) => state.ocr.providers)
const imageProviders = providers.filter(isImageOcrProvider)
const imageProviderId = useAppSelector((state) => state.ocr.imageProviderId)
const [imageProvider, setImageProvider] = useState<ImageOcrProvider>(DEFAULT_OCR_PROVIDER.image)
const dispatch = useDispatch()
const { t } = useTranslation()
/**
* 添加一个新的OCR服务提供者
* @param provider - OCR提供者对象包含id和其他配置信息
* @throws {Error} 当尝试添加一个已存在ID的提供者时抛出错误
*/
const addProvider = useCallback(
(provider: OcrProvider) => {
if (providers.some((p) => p.id === provider.id)) {
const msg = `Provider with id ${provider.id} already exists`
logger.error(msg)
window.toast.error(t('ocr.error.provider.existing'))
throw new Error(msg)
}
dispatch(addOcrProvider(provider))
},
[dispatch, providers, t]
)
/**
* 移除一个OCR服务提供者
* @param id - 要移除的OCR提供者ID
* @throws {Error} 当尝试移除一个内置提供商时抛出错误
*/
const removeProvider = (id: string) => {
if (isBuiltinOcrProviderId(id)) {
const msg = `Cannot remove builtin provider ${id}`
logger.error(msg)
window.toast.error(t('ocr.error.provider.cannot_remove_builtin'))
throw new Error(msg)
}
dispatch(removeOcrProvider(id))
}
const setImageProviderId = useCallback(
(id: string) => {
dispatch(setImageOcrProviderId(id))
},
[dispatch]
)
const getOcrProviderName = (p: OcrProvider) => {
return isBuiltinOcrProvider(p) ? getBuiltinOcrProviderLabel(p.id) : p.name
}
const OcrProviderLogo = ({ provider: p, size = 14 }: { provider: OcrProvider; size?: number }) => {
if (isBuiltinOcrProvider(p)) {
switch (p.id) {
case 'tesseract':
return <Avatar src={TesseractLogo} style={{ width: size, height: size }} />
case 'system':
return <MonitorIcon size={size} />
case 'paddleocr':
return <Avatar src={PaddleocrLogo} style={{ width: size, height: size }} />
case 'ovocr':
return <Avatar src={IntelLogo} style={{ width: size, height: size }} />
}
}
return <FileQuestionMarkIcon size={size} />
}
useEffect(() => {
const actualImageProvider = imageProviders.find((p) => p.id === imageProviderId)
if (!actualImageProvider) {
if (isBuiltinOcrProviderId(imageProviderId)) {
logger.warn(`Builtin ocr provider ${imageProviderId} not exist. Will add it to providers.`)
addProvider(BUILTIN_OCR_PROVIDERS_MAP[imageProviderId])
}
setImageProviderId(DEFAULT_OCR_PROVIDER.image.id)
setImageProvider(DEFAULT_OCR_PROVIDER.image)
} else {
setImageProviderId(actualImageProvider.id)
setImageProvider(actualImageProvider)
}
}, [addProvider, imageProviderId, imageProviders, setImageProviderId])
return {
providers,
imageProvider,
addProvider,
removeProvider,
setImageProviderId,
getOcrProviderName,
OcrProviderLogo
}
}
export const useOcrProvider = (id: string) => {
const { t } = useTranslation()
const dispatch = useDispatch()
const { providers, addProvider } = useOcrProviders()
let provider = providers.find((p) => p.id === id)
// safely fallback
if (!provider) {
logger.error(`Ocr Provider ${id} not found`)
window.toast.error(t('ocr.error.provider.not_found'))
if (isBuiltinOcrProviderId(id)) {
try {
addProvider(BUILTIN_OCR_PROVIDERS_MAP[id])
} catch (e) {
logger.warn(`Add ${BUILTIN_OCR_PROVIDERS_MAP[id].name} failed. Just use temp provider from config.`)
window.toast.warning(t('ocr.warning.provider.fallback', { name: BUILTIN_OCR_PROVIDERS_MAP[id].name }))
} finally {
provider = BUILTIN_OCR_PROVIDERS_MAP[id]
}
} else {
logger.warn(`Fallback to tesseract`)
window.toast.warning(t('ocr.warning.provider.fallback', { name: 'Tesseract' }))
provider = BUILTIN_OCR_PROVIDERS_MAP.tesseract
}
}
const updateConfig = (update: Partial<OcrProviderConfig>) => {
dispatch(updateOcrProviderConfig({ id: provider.id, update }))
}
return {
provider,
updateConfig
}
}

View File

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

View File

@@ -1,13 +1,10 @@
import { preferenceService } from '@data/PreferenceService'
import { isMac, isWin } from '@renderer/config/constant'
import { shortcutRendererStore } from '@renderer/services/ShortcutService'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
import type { HydratedShortcut, ShortcutPreferenceEntry, ShortcutPreferenceMap } from '@shared/shortcuts/types'
import { useAppSelector } from '@renderer/store'
import { orderBy } from 'lodash'
import { useMemo, useSyncExternalStore } from 'react'
import { useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
export interface UseShortcutOptions {
interface UseShortcutOptions {
preventDefault?: boolean
enableOnFormTags?: boolean
enabled?: boolean
@@ -20,232 +17,77 @@ const defaultOptions: UseShortcutOptions = {
enabled: true
}
const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
const toHotkeysFormat = (keys: string[]): string => {
return keys
.map((key) => {
switch (key.toLowerCase()) {
case 'commandorcontrol':
case 'command':
case 'cmd':
return 'mod'
case 'control':
case 'ctrl':
return 'ctrl'
case 'alt':
case 'altgraph':
return 'alt'
case 'shift':
return 'shift'
case 'meta':
return 'meta'
case 'arrowup':
return 'up'
case 'arrowdown':
return 'down'
case 'arrowleft':
return 'left'
case 'arrowright':
return 'right'
case 'escape':
return 'escape'
case 'space':
return 'space'
default:
return key.toLowerCase()
}
})
.join('+')
}
const toDisplayFormat = (keys: string[]): string => {
return keys
.map((key) => {
switch (key.toLowerCase()) {
case 'control':
case 'ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'command':
case 'cmd':
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
case 'meta':
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'alt':
case 'altgraph':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'arrowup':
return '↑'
case 'arrowdown':
return '↓'
case 'arrowleft':
return '←'
case 'arrowright':
return '→'
case 'slash':
return '/'
case 'semicolon':
return ';'
case 'bracketleft':
return '['
case 'bracketright':
return ']'
case 'backslash':
return '\\'
case 'quote':
return "'"
case 'comma':
return ','
case 'minus':
return '-'
case 'equal':
return '='
case 'escape':
return isMac ? '⎋' : 'Esc'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
})
.join(isMac ? '' : ' + ')
}
const useShortcutMap = () =>
useSyncExternalStore(
shortcutRendererStore.subscribe,
shortcutRendererStore.getSnapshot,
shortcutRendererStore.getServerSnapshot
)
export const useShortcut = (
name: string,
callback: (event: KeyboardEvent) => void,
shortcutKey: string,
callback: (e: KeyboardEvent) => void,
options: UseShortcutOptions = defaultOptions
) => {
const shortcuts = useShortcutMap()
const shortcutConfig = shortcuts[name]
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const hotkey = useMemo(() => {
if (
!shortcutConfig ||
shortcutConfig.scope !== 'renderer' ||
!shortcutConfig.enabled ||
shortcutConfig.key.length === 0
) {
return null
}
return toHotkeysFormat(shortcutConfig.key)
}, [shortcutConfig])
const formatShortcut = useCallback((shortcut: string[]) => {
return shortcut
.map((key) => {
switch (key.toLowerCase()) {
case 'command':
return 'meta'
case 'commandorcontrol':
return isMac ? 'meta' : 'ctrl'
default:
return key.toLowerCase()
}
})
.join('+')
}, [])
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
useHotkeys(
hotkey ?? 'none',
(event) => {
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
(e) => {
if (options.preventDefault) {
event.preventDefault()
e.preventDefault()
}
if (options.enabled !== false) {
callback(event)
callback(e)
}
},
{
enableOnFormTags: options.enableOnFormTags,
description: options.description ?? shortcutConfig?.description,
enabled: Boolean(hotkey && shortcutConfig?.enabled)
},
[
callback,
hotkey,
shortcutConfig,
options.preventDefault,
options.enableOnFormTags,
options.enabled,
options.description
]
description: options.description || shortcutConfig?.key,
enabled: !!shortcutConfig?.enabled
}
)
}
export function useShortcuts() {
const shortcuts = useShortcutMap()
const list = useMemo(() => {
return orderBy(
Object.values(shortcuts).map((shortcut) => ({
...shortcut,
key: [...shortcut.key]
})),
['system', 'name'],
['desc', 'asc']
)
}, [shortcuts])
return { shortcuts: list }
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
}
export function useShortcutConfig(name: string): HydratedShortcut | undefined {
const shortcuts = useShortcutMap()
return shortcuts[name]
}
export function useShortcutDisplay(name: string) {
const shortcut = useShortcutConfig(name)
return useMemo(() => {
if (!shortcut || !shortcut.enabled || shortcut.key.length === 0) {
return ''
}
return toDisplayFormat(shortcut.key)
}, [shortcut])
}
async function writeShortcutPreferences(updater: (current: ShortcutPreferenceMap) => ShortcutPreferenceMap) {
const current = await preferenceService.get('shortcut.preferences')
const next = updater({ ...current })
await preferenceService.set('shortcut.preferences', next)
}
export async function setShortcutBinding(name: string, keys: string[]) {
await writeShortcutPreferences((current) => {
const entry: ShortcutPreferenceEntry = { ...current[name] }
entry.key = [...keys]
current[name] = entry
return current
})
}
export async function setShortcutEnabled(name: string, enabled: boolean) {
await writeShortcutPreferences((current) => {
const entry: ShortcutPreferenceEntry = { ...current[name] }
entry.enabled = enabled
current[name] = entry
return current
})
}
export async function resetShortcut(name: string) {
const definition = definitionMap.get(name)
if (!definition) {
return
}
await writeShortcutPreferences((current) => {
current[name] = {
key: [...definition.defaultKey],
enabled: definition.defaultEnabled
}
return current
})
}
export async function resetAllShortcuts() {
await writeShortcutPreferences(() => {
return Object.fromEntries(
shortcutDefinitions.map((definition) => [
definition.name,
{
key: [...definition.defaultKey],
enabled: definition.defaultEnabled
export function useShortcutDisplay(key: string) {
const formatShortcut = useCallback((shortcut: string[]) => {
return shortcut
.map((key) => {
switch (key.toLowerCase()) {
case 'control':
return isMac ? '⌃' : 'Ctrl'
case 'ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'command':
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'alt':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
])
)
})
})
.join('+')
}, [])
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const shortcutConfig = shortcuts.find((s) => s.key === key)
return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : ''
}

View File

@@ -200,7 +200,6 @@ const shortcutKeyMap = {
exit_fullscreen: 'settings.shortcuts.exit_fullscreen',
label: 'settings.shortcuts.label',
mini_window: 'settings.shortcuts.mini_window',
show_mini_window: 'settings.shortcuts.show_mini_window',
new_topic: 'settings.shortcuts.new_topic',
press_shortcut: 'settings.shortcuts.press_shortcut',
reset_defaults: 'settings.shortcuts.reset_defaults',

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "Cannot delete built-in provider",
"existing": "The provider already exists",
"get_providers": "Failed to get available providers",
"not_availabel": "Provide {{provider}} is not available",
"not_found": "OCR provider does not exist",
"update_failed": "Failed to update configuration"
},
@@ -2141,6 +2065,40 @@
"not_supported": "Unsupported file type {{type}}"
},
"processing": "OCR processing...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "Failed to update config"
}
}
},
"create": {
"error": {
"failed": "Failed to create provider"
}
},
"delete": {
"error": {
"failed": "Failed to delete provider {{provider}}"
}
},
"get": {
"error": {
"failed": "Failed to get provider {{provider}}"
}
},
"list": {
"error": {
"failed": "Failed to list providers"
}
},
"update": {
"error": {
"failed": "Failed to update the provider"
}
}
},
"warning": {
"provider": {
"fallback": "Reverted to {{name}}, which may cause issues"
@@ -2376,32 +2334,6 @@
"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"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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": "未知类型"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "不能删除内置提供商",
"existing": "提供商已存在",
"get_providers": "获取可用提供商失败",
"not_availabel": "{{provider}} 暂不可用",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失败"
},
@@ -2141,6 +2065,40 @@
"not_supported": "不支持的文件类型 {{type}}"
},
"processing": "OCR 处理中...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "更新配置失败"
}
}
},
"create": {
"error": {
"failed": "创建提供商失败"
}
},
"delete": {
"error": {
"failed": "删除提供商 {{provider}} 失败"
}
},
"get": {
"error": {
"failed": "获取提供商 {{provider}} 失败"
}
},
"list": {
"error": {
"failed": "获取提供商列表失败"
}
},
"update": {
"error": {
"failed": "更新提供商失败"
}
}
},
"warning": {
"provider": {
"fallback": "已回退到 {{name}},这可能导致问题"
@@ -2376,32 +2334,6 @@
"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": "复制为图片"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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": "未知類型"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "不能刪除內建提供者",
"existing": "提供者已存在",
"get_providers": "取得可用提供者失敗",
"not_availabel": "提供 {{provider}} 不可用",
"not_found": "OCR 提供者不存在",
"update_failed": "更新配置失敗"
},
@@ -2141,6 +2065,40 @@
"not_supported": "不支持的文件類型 {{type}}"
},
"processing": "OCR 處理中...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "更新設定失敗"
}
}
},
"create": {
"error": {
"failed": "無法建立提供者"
}
},
"delete": {
"error": {
"failed": "刪除提供者 {{provider}} 失敗"
}
},
"get": {
"error": {
"failed": "無法取得提供者 {{provider}}"
}
},
"list": {
"error": {
"failed": "無法列出提供者"
}
},
"update": {
"error": {
"failed": "無法更新提供者"
}
}
},
"warning": {
"provider": {
"fallback": "已回退到 {{name}},這可能導致問題"
@@ -2376,32 +2334,6 @@
"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": "複製為圖片"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "Eingebauter Anbieter kann nicht entfernt werden",
"existing": "Anbieter existiert bereits",
"get_providers": "Failed to obtain available providers",
"not_availabel": "{{provider}} ist nicht verfügbar",
"not_found": "OCR-Anbieter nicht gefunden",
"update_failed": "Konfiguration aktualisieren fehlgeschlagen"
},
@@ -2141,6 +2065,40 @@
"not_supported": "Nicht unterstützter Dateityp {{type}}"
},
"processing": "OCR wird verarbeitet...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "Fehler beim Aktualisieren der Konfiguration"
}
}
},
"create": {
"error": {
"failed": "Fehler beim Erstellen des Anbieters"
}
},
"delete": {
"error": {
"failed": "Fehler beim Löschen des Anbieters {{provider}}"
}
},
"get": {
"error": {
"failed": "Fehler beim Abrufen des Anbieters {{provider}}"
}
},
"list": {
"error": {
"failed": "Anbieter konnten nicht aufgelistet werden"
}
},
"update": {
"error": {
"failed": "Fehler beim Aktualisieren des Anbieters"
}
}
},
"warning": {
"provider": {
"fallback": "Auf {{name}} zurückgefallen, dies kann zu Problemen führen"
@@ -2376,32 +2334,6 @@
"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"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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": "Άγνωστος Τύπος"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
"get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων",
"not_availabel": "Ο πάροχος {{provider}} δεν είναι διαθέσιμος",
"not_found": "Ο πάροχος OCR δεν υπάρχει",
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
},
@@ -2141,6 +2065,40 @@
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
},
"processing": "Η επεξεργασία OCR βρίσκεται σε εξέλιξη...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "Αποτυχία ενημέρωσης ρυθμίσεων"
}
}
},
"create": {
"error": {
"failed": "Αποτυχία δημιουργίας παρόχου"
}
},
"delete": {
"error": {
"failed": "Αποτυχία διαγραφής του παρόχου {{provider}}"
}
},
"get": {
"error": {
"failed": "Αποτυχία λήψης του παρόχου {{provider}}"
}
},
"list": {
"error": {
"failed": "Αποτυχία καταγραφής παρόχων"
}
},
"update": {
"error": {
"failed": "Αποτυχία ενημέρωσης του παρόχου"
}
}
},
"warning": {
"provider": {
"fallback": "Επαναφέρθηκε στο {{name}}, το οποίο μπορεί να προκαλέσει προβλήματα"
@@ -2376,32 +2334,6 @@
"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": "Αντιγραφή ως εικόνα"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
"existing": "El proveedor ya existe",
"get_providers": "Error al obtener proveedores disponibles",
"not_availabel": "Proporcionar {{provider}} no está disponible",
"not_found": "El proveedor de OCR no existe",
"update_failed": "Actualización de la configuración fallida"
},
@@ -2141,6 +2065,40 @@
"not_supported": "Tipo de archivo no compatible {{type}}"
},
"processing": "Procesando OCR...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "Error al actualizar la configuración"
}
}
},
"create": {
"error": {
"failed": "Error al crear el proveedor"
}
},
"delete": {
"error": {
"failed": "Error al eliminar el proveedor {{provider}}"
}
},
"get": {
"error": {
"failed": "Error al obtener el proveedor {{provider}}"
}
},
"list": {
"error": {
"failed": "Error al listar proveedores"
}
},
"update": {
"error": {
"failed": "Error al actualizar el proveedor"
}
}
},
"warning": {
"provider": {
"fallback": "Se ha revertido a {{name}}, lo que podría causar problemas"
@@ -2376,32 +2334,6 @@
"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"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
"existing": "Le fournisseur existe déjà",
"get_providers": "Échec de l'obtention des fournisseurs disponibles",
"not_availabel": "Fournir {{provider}} nest pas disponible",
"not_found": "Le fournisseur OCR n'existe pas",
"update_failed": "Échec de la mise à jour de la configuration"
},
@@ -2141,6 +2065,40 @@
"not_supported": "Type de fichier non pris en charge {{type}}"
},
"processing": "Traitement OCR en cours...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "Échec de la mise à jour de la configuration"
}
}
},
"create": {
"error": {
"failed": "Échec de la création du fournisseur"
}
},
"delete": {
"error": {
"failed": "Échec de la suppression du fournisseur {{provider}}"
}
},
"get": {
"error": {
"failed": "Échec de l'obtention du fournisseur {{provider}}"
}
},
"list": {
"error": {
"failed": "Échec de la liste des fournisseurs"
}
},
"update": {
"error": {
"failed": "Échec de la mise à jour du fournisseur"
}
}
},
"warning": {
"provider": {
"fallback": "Revenu à {{name}}, ce qui pourrait entraîner des problèmes"
@@ -2376,32 +2334,6 @@
"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"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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": "不明なタイプ"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
"existing": "プロバイダーはすでに存在します",
"get_providers": "利用可能なプロバイダーの取得に失敗しました",
"not_availabel": "{{provider}}が利用できません",
"not_found": "OCRプロバイダーが存在しません",
"update_failed": "更新構成に失敗しました"
},
@@ -2141,6 +2065,40 @@
"not_supported": "サポートされていないファイルタイプ {{type}}"
},
"processing": "OCR処理中...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "設定の更新に失敗しました"
}
}
},
"create": {
"error": {
"failed": "プロバイダーの作成に失敗しました"
}
},
"delete": {
"error": {
"failed": "プロバイダー {{provider}} の削除に失敗しました"
}
},
"get": {
"error": {
"failed": "プロバイダー {{provider}} の取得に失敗しました"
}
},
"list": {
"error": {
"failed": "プロバイダーの一覧取得に失敗しました"
}
},
"update": {
"error": {
"failed": "プロバイダーの更新に失敗しました"
}
}
},
"warning": {
"provider": {
"fallback": "{{name}} に戻されました。これにより問題が発生する可能性があります。"
@@ -2376,32 +2334,6 @@
"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": "画像としてコピー"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
"existing": "O provedor já existe",
"get_providers": "Falha ao obter provedores disponíveis",
"not_availabel": "Fornecedor {{provider}} não está disponível",
"not_found": "O provedor OCR não existe",
"update_failed": "Falha ao atualizar a configuração"
},
@@ -2141,6 +2065,40 @@
"not_supported": "Tipo de arquivo não suportado {{type}}"
},
"processing": "Processamento OCR em andamento...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "Falha ao atualizar a configuração"
}
}
},
"create": {
"error": {
"failed": "Falha ao criar o provedor"
}
},
"delete": {
"error": {
"failed": "Falha ao excluir o provedor {{provider}}"
}
},
"get": {
"error": {
"failed": "Falha ao obter o provedor {{provider}}"
}
},
"list": {
"error": {
"failed": "Falha ao listar provedores"
}
},
"update": {
"error": {
"failed": "Falha ao atualizar o provedor"
}
}
},
"warning": {
"provider": {
"fallback": "Revertido para {{name}}, o que pode causar problemas"
@@ -2376,32 +2334,6 @@
"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"

View File

@@ -107,50 +107,6 @@
"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": {
@@ -235,39 +191,6 @@
"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": "Неизвестный тип"
@@ -2132,6 +2055,7 @@
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
"existing": "Поставщик уже существует",
"get_providers": "Не удалось получить доступных поставщиков",
"not_availabel": "Поставщик {{provider}} недоступен",
"not_found": "Поставщик OCR отсутствует",
"update_failed": "Обновление конфигурации не удалось"
},
@@ -2141,6 +2065,40 @@
"not_supported": "Неподдерживаемый тип файла {{type}}"
},
"processing": "Обработка OCR...",
"provider": {
"config": {
"patch": {
"error": {
"failed": "Не удалось обновить конфигурацию"
}
}
},
"create": {
"error": {
"failed": "Не удалось создать поставщика"
}
},
"delete": {
"error": {
"failed": "Не удалось удалить поставщика {{provider}}"
}
},
"get": {
"error": {
"failed": "Не удалось получить поставщика {{provider}}"
}
},
"list": {
"error": {
"failed": "Не удалось получить список поставщиков"
}
},
"update": {
"error": {
"failed": "Не удалось обновить поставщика"
}
}
},
"warning": {
"provider": {
"fallback": "Возвращено к {{name}}, это может вызвать проблемы"
@@ -2376,32 +2334,6 @@
"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": "Скопировать как изображение"

View File

@@ -3,7 +3,6 @@ import { loggerService } from '@logger'
import { startAutoSync } from './services/BackupService'
import { startNutstoreAutoSync } from './services/NutstoreService'
import { initializeShortcutService } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { webTraceService } from './services/WebTraceService'
loggerService.initWindowSource('mainWindow')
@@ -37,4 +36,3 @@ function initWebTrace() {
initAutoSync()
initStoreSync()
initWebTrace()
initializeShortcutService()

View File

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

View File

@@ -7,7 +7,6 @@ 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'
@@ -57,8 +56,6 @@ 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)
@@ -97,21 +94,6 @@ 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

@@ -3,12 +3,10 @@ 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'
@@ -24,7 +22,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, MessageSquareDiff } from 'lucide-react'
import { CirclePause } from 'lucide-react'
import type { CSSProperties, FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -49,8 +47,6 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
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)
@@ -92,22 +88,6 @@ 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
@@ -307,16 +287,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}}
onBlur={() => setInputFocus(false)}
/>
<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>
<div className="flex justify-end px-1">
<div className="flex items-center gap-1">
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
{canAbort && (
<Tooltip placement="top" content={t('chat.input.pause')}>
@@ -327,8 +299,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
/>
</Tooltip>
)}
</ToolbarGroup>
</Toolbar>
</div>
</div>
</InputBarContainer>
</Container>
</NarrowLayout>
@@ -374,25 +346,6 @@ 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

@@ -6,7 +6,6 @@ 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'
@@ -79,16 +78,12 @@ function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?:
// 统一的组件渲染入口
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
const { arguments: args, response, tool, status } = toolResponse
const { arguments: args, response, tool } = 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

@@ -1,235 +0,0 @@
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 { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
@@ -10,6 +10,7 @@ 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'
@@ -26,11 +27,11 @@ interface SessionsProps {
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { t } = useTranslation()
const { sessions, isLoading, error, deleteSession } = useSessions(agentId)
const { agent } = useAgent(agentId)
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
const { chat } = useRuntime()
const { activeSessionIdMap } = chat
const dispatch = useAppDispatch()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const setActiveSessionId = useCallback(
(agentId: string, sessionId: string | null) => {
@@ -40,6 +41,19 @@ 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) {
@@ -96,7 +110,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
return (
<div className="sessions-tab flex h-full w-full flex-col p-2">
<AddButton onClick={createDefaultSession} className="mb-2" disabled={creatingSession}>
<AddButton onClick={handleCreateSession} className="mb-2">
{t('agent.session.add.title')}
</AddButton>
{/* h-9 */}

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,15 +19,6 @@ 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()
@@ -264,7 +255,7 @@ describe('WebviewSearch', () => {
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
await act(async () => {
@@ -316,7 +307,7 @@ describe('WebviewSearch', () => {
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
findInPageMock.mockClear()

View File

@@ -7,7 +7,6 @@ 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'
@@ -21,7 +20,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
resolve: () => void
}
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps'
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'session-mcps'
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
const [open, setOpen] = useState(true)
@@ -57,10 +56,6 @@ 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')
@@ -80,9 +75,6 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
</div>
)
}
if (!agent) {
return null
}
return (
<div className="flex w-full flex-1">
<LeftMenu>
@@ -98,7 +90,6 @@ 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 { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { isAgentType } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,11 +20,13 @@ 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: {
...parsedConfiguration,
...agent.configuration,
permission_mode: agent.configuration?.permission_mode ?? 'default',
max_turns: agent.configuration?.max_turns ?? 100,
avatar
}
} satisfies UpdateAgentForm

View File

@@ -1,115 +0,0 @@
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,53 +0,0 @@
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

@@ -1,99 +0,0 @@
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

@@ -1,228 +0,0 @@
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

@@ -1,83 +0,0 @@
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

@@ -1,321 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,93 +1,85 @@
import { Alert, Skeleton } from '@heroui/react'
import { loggerService } from '@logger'
import { Skeleton } from '@cherrystudio/ui'
import { Alert } from '@heroui/react'
import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
import { isMac, isWin } from '@renderer/config/constant'
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
import type { ImageOcrProvider, OcrProvider } from '@renderer/types'
import { BuiltinOcrProviderIds, isImageOcrProvider } from '@renderer/types'
import { useOcrImageProvider } from '@renderer/hooks/ocr/useOcrImageProvider'
import { useOcrProviders } from '@renderer/hooks/ocr/useOcrProviders'
import { BuiltinOcrProviderIdMap, isImageOcrProvider } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { Select } from 'antd'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
import { SettingRow, SettingRowTitle } from '..'
const logger = loggerService.withContext('OcrImageSettings')
// const logger = loggerService.withContext('OcrImageSettings')
type Props = {
setProvider: (provider: OcrProvider) => void
}
const OcrImageSettings = ({ setProvider }: Props) => {
const OcrImageSettings = () => {
const { t } = useTranslation()
const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders()
const fetcher = useCallback(() => {
return window.api.ocr.listProviders()
}, [])
const { providers, loading, error, getOcrProviderName } = useOcrProviders({ registered: true })
const { imageProvider, setImageProviderId, imageProviderId } = useOcrImageProvider()
const { data: validProviders, isLoading, error } = useSWRImmutable('ocr/providers', fetcher)
const imageProviders = useMemo(() => providers?.filter((p) => isImageOcrProvider(p)) ?? [], [providers])
const imageProviders = providers.filter((p) => isImageOcrProvider(p))
// 挂载时更新外部状态
// FIXME: Just keep the imageProvider always valid, so we don't need update it in this component.
useEffect(() => {
setProvider(imageProvider)
}, [imageProvider, setProvider])
const setImageProvider = (id: string) => {
const provider = imageProviders.find((p) => p.id === id)
if (!provider) {
logger.error(`Failed to find image provider by id: ${id}`)
window.toast.error(t('settings.tool.ocr.image.error.provider_not_found'))
return
}
setProvider(provider)
setImageProviderId(id)
}
const setImageProvider = useCallback(
(id: string) => {
setImageProviderId(id)
},
[setImageProviderId]
)
const platformSupport = isMac || isWin
const options = useMemo(() => {
if (!validProviders) return []
const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system
const validFilter = (p: ImageOcrProvider) => validProviders.includes(p.id)
return imageProviders
.filter(platformFilter)
.filter(validFilter)
.map((p) => ({
value: p.id,
label: getOcrProviderName(p)
}))
}, [getOcrProviderName, imageProviders, platformSupport, validProviders])
return imageProviders.map((p) => ({
value: p.id,
label: getOcrProviderName(p)
}))
}, [getOcrProviderName, imageProviders])
const isSystem = imageProvider.id === BuiltinOcrProviderIds.system
const isSystem = imageProvider?.id === BuiltinOcrProviderIdMap.system
const content = useMemo(() => {
if (loading) {
return <Skeleton className="h-full w-50" />
}
if (error) {
return (
<Alert
color="danger"
title={t('ocr.provider.get.error.failed', { provider: imageProviderId })}
description={getErrorMessage(error)}
/>
)
}
if (!imageProvider) {
return <Alert color="danger" title={t('ocr.error.provider.not_found')} />
}
return (
<>
{!platformSupport && isSystem && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
{!loading && !error && (
<Select
value={imageProvider.id}
className="w-50"
onChange={(id: string) => setImageProvider(id)}
options={options}
/>
)}
{!loading && error && (
<Alert color="danger" title={t('ocr.error.provider.get_providers')} description={getErrorMessage(error)} />
)}
</>
)
}, [error, imageProvider, imageProviderId, isSystem, loading, options, platformSupport, setImageProvider, t])
return (
<>
<SettingRow>
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{!platformSupport && isSystem && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
<Skeleton isLoaded={!isLoading}>
{!error && (
<Select
value={imageProvider.id}
style={{ width: '200px' }}
onChange={(id: string) => setImageProvider(id)}
options={options}
/>
)}
{error && (
<Alert
color="danger"
title={t('ocr.error.provider.get_providers')}
description={getErrorMessage(error)}
/>
)}
</Skeleton>
</div>
<div className="flex items-center gap-2 self-stretch">{content}</div>
</SettingRow>
</>
)

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