Compare commits

...

232 Commits

Author SHA1 Message Date
kangfenmao
606a80d3ee Merge branch 'main' into feat/sora2
# Conflicts:
#	package.json
#	src/renderer/src/config/models/utils.ts
#	src/renderer/src/store/migrate.ts
2025-10-17 20:58:43 +08:00
defi-failure
8470e252d6 feat: auto-start API server when agents exist (#10772)
* feat: auto-start API server when agents exist

* fix: only display not running alert when enabled
2025-10-17 20:53:08 +08:00
defi-failure
131444ac52 fix: agent supported model filter (#10788)
* Revert "fix: make anthropic model provided by cherryin visible to agent (#10695)"

This reverts commit 7b3b73d390.

* fix: agent supported model filter
2025-10-17 20:52:15 +08:00
defi-failure
ab3083f943 fix: fail to create assistant (#10796) 2025-10-17 20:48:40 +08:00
SuYao
1e1d5c4a14 feat: add Mistral provider configuration to AI Providers (#10795) 2025-10-17 20:34:53 +08:00
beyondkmp
c8ab0b9428 fix: resolve gpt-5-codex streaming response issue (#10781)
* fix: resolve gpt-5-codex streaming response issue

- Add patch for @opeoginni/github-copilot-openai-compatible to fix text part ID mismatch
- Fix text-end event to use currentTextId instead of value.item.id for proper ID matching
- Add COPILOT_DEFAULT_HEADERS to OpenAI client for GitHub Copilot compatibility

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

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

* format code

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-17 20:27:16 +08:00
Phantom
33ce41704d fix(message): adjust layout and overflow properties for better display (#10746)
* style(CodeBlockView): reduce min-width from 45ch to 35ch to fix layout issues

* style(messages): adjust overflow properties and clean up commented code

Remove commented overflow properties and adjust overflow behavior for better scroll handling in message containers

* style: remove commented overflow css properties
2025-10-17 20:24:13 +08:00
Phantom
4eb3aa31ee feat: session settings (#10773)
* fix(home/Tabs): remove redundant isTopicView check in tab rendering

* refactor(runtime): rename activeSessionId to activeSessionIdMap for clarity

Update variable name to better reflect its purpose as a mapping structure

* refactor(agent): add CreateAgentSessionResponse type and schema

Add new type and schema for create session response to better reflect API contract

* fix(useSessions): return null instead of undefined on session creation error

Returning null provides better type safety and aligns with the response type Promise<CreateAgentSessionResponse | null>

* refactor(useSessions): add return type to deleteSession callback

* fix(useSessions): return session data or null from getSession

Ensure getSession callback always returns a value (session data or null) to handle error cases properly and improve type safety

* feat(hooks): add useActiveSession hook and handle null agentId in useSessions

Add new hook to get active session from runtime and sessions data. Update useSessions to handle null agentId cases by returning early and adding null checks.

* feat(hooks): add useActiveAgent hook to get active agent

Expose active agent data by combining useRuntime and useAgent hooks

* fix(agents): remove fake agent id handling and improve null checks

- Replace fake agent id with null in HomePage
- Remove fake id check in useAgent hook and throw error for null id
- Simplify agent session initialization by removing fake id checks

* refactor(hooks): replace useAgent with useActiveAgent for active agent state

* feat(home): add session settings tab component

Replace AgentSettingsTab with SessionSettingsTab to better handle session-specific settings. The new component includes essential and advanced settings sections with a more settings button.

* refactor(settings): consolidate agent and session essential settings into single component

Replace AgentEssentialSettings and SessionEssentialSettings with a unified EssentialSettings component that handles both agent and session types. This reduces code duplication and improves maintainability.

* style(SelectAgentModelButton): improve model name display with truncation

Add overflow-x-hidden to container and truncate to model name span to prevent text overflow

* refactor(AgentSettings): replace Ellipsis with truncate for text overflow

Use CSS truncate instead of Ellipsis component for better performance and consistency

* refactor(chat-navbar): replace useAgent and useSession with useActiveAgent and useActiveSession

Simplify component logic by using dedicated hooks for active agent and session

* feat(ChatNavbar): add session settings button to breadcrumb

Add clickable session label chip that opens session settings popup when active session exists

* refactor(agents): improve session update hook and type definitions

- Extract UpdateAgentBaseOptions type to shared types file
- Update useUpdateSession to return both updateSession and updateModel functions
- Modify components to use destructured updateSession from hook
- Add null check for agentId in useUpdateSession
- Add success toast option to session updates

* refactor(components): rename agent prop to agentBase for clarity

Update component name and prop to better reflect its purpose and improve code readability

* refactor(ChatNavbar): rename SelectAgentModelButton to SelectAgentBaseModelButton and update usage

Update component name to better reflect its purpose and adjust props to use activeSession instead of activeAgent for consistency

* feat(i18n): add null id error message for agent retrieval

Add error message for when agent ID is null across all supported languages

* refactor(hooks): simplify agent and session hooks by returning destructured values

Remove unnecessary intermediate variables and directly return hook results
Update useSession to handle null agentId and sessionId cases

* feat(i18n): add null session ID error message for all locales

* refactor(home): rename SelectAgentModelButton to SelectAgentBaseModelButton

The component was renamed to better reflect its purpose of selecting base models for agents. The functionality remains unchanged.

* refactor(session): rename useUpdateAgent to useUpdateSession for clarity

* refactor(home-tabs): replace useUpdateAgent with useUpdateSession hook

Update session settings tab to use the new useUpdateSession hook which requires activeAgentId

* style(AgentSettings): remove unnecessary gap class from ModelSetting

* refactor(agents): improve error handling and remove duplicate code

- Replace formatErrorMessageWithPrefix with getErrorMessage for better error handling
- Move updateSession logic to useUpdateSession hook to avoid duplication

* fix(ChatNavbar): prevent model update when activeAgent is missing

Add activeAgent to dependency array and check its existence before updating model to avoid potential errors

* feat(home/Tabs): add loading and error states for session settings

Add Skeleton loader and Alert component to handle loading and error states when fetching session data in the settings tab

* fix(home/Tabs): add h-full class to Skeleton for proper height

* fix(AssistantsTab): remove weird effect hook for agent selection

* refactor(chat-navbar): clean up unused code and update session handling

remove commented out code in ChatNavbar.tsx and update ChatNavbarContent to use active agent/session hooks

* style(home): remove negative margin from model name span

* refactor(Agents): mark Agents component as deprecated

* refactor: remove unused Agents and Assistants code

---------

Co-authored-by: dev <verc20.dev@proton.me>
2025-10-17 19:44:47 +08:00
beyondkmp
d1a9dfa3e6 feat: add Greek language option to spell checker options (#10793)
feat: add Greek language option to GeneralSettings component

- Added support for Greek (Ελληνικά) language in the language selection dropdown of the GeneralSettings component.
2025-10-17 17:19:16 +08:00
Kejiang Ma
0e5ebcfd00 feat: new build-in OCR provider -> intel OV(NPU) OCR (#10737)
* new build-in ocr provider intel ov

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>
Signed-off-by: Kejiang Ma <kj.ma@intel.com>

* updated base on PR's commnets

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

* feat(OcrImageSettings): use swr to fetch available providers

Add loading state and error handling when fetching available OCR providers. Display an alert when provider loading fails, showing the error message. Also optimize provider filtering logic using useMemo.

* refactor(ocr): rename providers to listProviders for consistency

Update method name to better reflect its functionality and maintain naming consistency across the codebase

---------

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>
Signed-off-by: Kejiang Ma <kj.ma@intel.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-17 15:18:00 +08:00
beyondkmp
c4e0a6acfe fix: prevent default behavior for Cmd/Ctrl+F in WebviewService (#10783)
fix: prevent default behavior for Cmd/Ctrl+F in WebviewService (#10800)

Updated the keyboard handler in WebviewService to always prevent the default action for the Cmd/Ctrl+F shortcut, ensuring it overrides the guest page's native find dialog. This change allows the renderer to manage the behavior of Escape and Enter keys based on the visibility of the search bar.
2025-10-17 15:07:19 +08:00
Kejiang Ma
2243bb2862 feat: update and download ovms to 2025.3 official release from offici… (#10603)
* feat: update and download ovms to 2025.3 official release from official site.

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

* fix UI text

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

---------

Signed-off-by: Kejiang Ma <kj.ma@intel.com>
2025-10-17 13:40:53 +08:00
defi-failure
1f7d2fa93f feat: notes full text search (#10640)
* feat: notes full text search initial commit

* fix: update highlight overlay when scroll

* fix: reset note search result properly

* refactor: extract scrollToLine logic from CodeEditor into a custom hook

* fix: hide match overlay when overlap

* fix: truncate line with ellipsis around search match for better visibility

* fix: unified note search match highlight style
2025-10-17 10:38:52 +08:00
SongSong
fb680ce764 feat: add built-in DiDi MCP server integration (#10318)
* feat: add built-in DiDi MCP server integration

- Add DiDi MCP server implementation with ride-hailing services
- Support map search, price estimation, order management, and driver tracking
- Add multilingual translations for DiDi MCP server descriptions
- Available only in mainland China, requires DIDI_API_KEY environment variable

* fix: resolve code formatting issues in DiDi MCP server

fixes code formatting issues in the DiDi MCP server implementation to resolve CI format check failures.

---------

Co-authored-by: BillySong <billysongli@didiglobal.com>
2025-10-17 10:37:07 +08:00
亢奋猫
dc5bc64040 fix: update default enableTopP setting to false in AssistantModelSett… (#10754)
fix: update default enableTopP setting to false in AssistantModelSettings and DefaultAssistantSettings

- Changed default value of enableTopP from true to false in AssistantModelSettings and DefaultAssistantSettings components.
- Updated related logic to ensure consistent behavior across settings.
2025-10-17 10:36:36 +08:00
defi-failure
1c2ce7e0aa fix: agents show ChatNavbar in both LeftNavbar and TopNavbar layouts (#10718)
* fix: show ChatNavbar in both LeftNavbar and TopNavbar layouts

* Revert "fix: show ChatNavbar in both LeftNavbar and TopNavbar layouts"

This reverts commit 7f205bf241.

* refactor: extract ChatNavBarContent from ChatNavBar

* fix: add navbar content to top nav in left nav mode

* fix: add nodrag to navbar container

* fix: lint error

* fix: ChatNavbarContainer layout

* fix: adjust NavbarLeftContainer min-width for macOS compatibility

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-10-17 10:19:48 +08:00
Pleasure1234
a290ee7f39 fix: add array checks for knowledge and memories in citations (#10778)
Updated formatCitationsFromBlock to verify that 'knowledge' and 'memories' are arrays before accessing their length and mapping over them. This prevents potential runtime errors if these properties are not arrays.
2025-10-17 09:40:41 +08:00
Shemol
79c697c34d fix: preserve spaces in API keys; update i18n tips to use commas or newlines (#10751)
fix(api): preserve spaces in API keys; i18n: clarify tips

Tips now say "Use commas to separate multiple keys." Full-width commas are auto-normalized.
2025-10-16 17:50:09 +01:00
Pleasure1234
76271cbf77 fix: ensure API key rotation for each request (#10776)
Updated ModernAiProvider to regenerate config on every request, ensuring API key rotation is effective. Refactored BaseApiClient to use an API key getter for dynamic key retrieval, supporting key rotation when multiple keys are configured.
2025-10-17 00:20:33 +08:00
kangfenmao
9e0ee24fd7 fix: update Aihubmix auth URL to use console domain 2025-10-16 22:45:53 +08:00
George·Dong
5eb2772d53 fix(minapps): can't open links in external broswer when using tab navigation (#10669)
* fix(minapps): can't open links in external broswer when using tab navigation

* fix(minapps): stabilize webview navigation and add logging

* fix(minapps): debounce nav updates and robust webview attach
2025-10-16 21:35:37 +08:00
Phantom
f943f05cb1 fix(translate): auto copy failed (#10745)
* fix(translate): auto copy failed

Because translatedContent may be stale

* refactor(translate): improve copy functionality dependency handling

Update copy callback dependencies to include setCopied and ensure proper memoization
Fix onCopy and translateText dependencies to include copy function
2025-10-16 12:23:09 +01:00
Calcium-Ion
96ce645064 feat: support NewAPI as a generic provider type (#10696)
* feat: add support for New API providerType

* feat: support New API as a generic painting provider

* refactor: update styling in painting pages to use Tailwind classes

- Replaced inline styles with Tailwind CSS classes for margin adjustments in AihubmixPage, DmxapiPage, SiliconPage, TokenFluxPage, and ZhipuPage.
- Enhanced consistency and maintainability of the codebase by standardizing styling approach across components.
- Minor refactor in ProviderSelect component to support className prop for better styling flexibility.
2025-10-16 13:07:28 +08:00
Phantom
1a972ac0e0 fix: api server status (#10734)
* refactor(apiServer): move api server types to dedicated module

Restructure api server type definitions by moving them from index.ts to a dedicated apiServer.ts file. This improves code organization and maintainability by grouping related types together.

* feat(api-server): add api server management hooks and integration

Extract api server management logic into reusable hook and integrate with settings page

* feat(api-server): improve api server status handling and error messages

- add new error messages for api server status
- optimize initial state and loading in useApiServer hook
- centralize api server enabled check via useApiServer hook
- update components to use new api server status handling

* fix(agents): update error message key for agent server not running

* fix(i18n): update api server status messages across locales

Remove redundant 'notRunning' message in en-us locale
Add consistent 'not_running' error message in all locales
Add missing 'notEnabled' message in several locales

* refactor: update api server type imports to use @types

Move api server related type imports from renderer/src/types to @types package for better code organization and maintainability

* docs(IpcChannel): add comment about unused api-server:get-config

Add TODO comment about data inconsistency in useApiServer hook

* refactor(assistants): pass apiServerEnabled as prop instead of using hook

Move apiServerEnabled from being fetched via useApiServer hook to being passed as a prop through component hierarchy. This improves maintainability by making dependencies more explicit and reducing hook usage in child components.

* style(AssistantsTab): add consistent margin-bottom to alert components

* feat(useAgent): add api server status checks before fetching agent

Ensure api server is enabled and running before attempting to fetch agent data
2025-10-16 12:49:31 +08:00
kangfenmao
2e173631a0 chore: update release notes for v1.7.0-beta.1
- Major features introduced: Agent System, Agent Management, and Unified UI.
- Added detailed agent features and UI/UX improvements.
- Included bug fixes and technical updates, such as React upgrade and enhanced Claude Code service.
- Updated version in package.json to 1.7.0-beta.1.
2025-10-15 20:39:46 +08:00
ABucket
c457d4a868 fix: Duplicate dialog when clearing messages (#10721) 2025-10-15 18:48:30 +08:00
defi-failure
b74655651d fix: swagger ui can't open (#10732) 2025-10-15 17:24:25 +08:00
SuYao
f27a481c3c Fix/aisdk error (#10563)
* Add syntax highlighting to AI SDK error cause display

- Parse and format error cause as JSON with syntax highlighting
- Use CodeStyleProvider context for consistent code styling
- Maintain plain text fallback for non-JSON content

* fix patch

* chore: yarn lock

* feat: provider-specific-error

* chore

* chore

* fix: handle JSON parsing errors in AiSdkErrorBase component

* fix: improve error message formatting in AiSdkToChunkAdapter

* fix: remove unused MarkdownContainer and update AiSdkErrorBase to use styled div
2025-10-15 16:47:45 +08:00
defi-failure
4028b26c1d fix: remove agent session input trigger placeholder (#10729) 2025-10-15 15:53:30 +08:00
Phantom
011b6f2df1 build: update react and react-dom to v19.2.0 (#10710)
Update dependencies to latest stable versions to benefit from bug fixes and performance improvements
2025-10-15 13:03:12 +08:00
defi-failure
7b3b73d390 fix: make anthropic model provided by cherryin visible to agent (#10695) 2025-10-14 20:59:07 +08:00
defi-failure
004d6d8201 fix: move newly created agent session to top (#10711) 2025-10-14 20:56:22 +08:00
Kejiang Ma
7cf57adceb feat: new middleware to add 'no_think' (#10675)
* new middleware to add 'no_think'

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

* translate comments to English

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

---------

Signed-off-by: Kejiang Ma <kj.ma@intel.com>
2025-10-14 20:01:56 +08:00
beyondkmp
866e8e8734 fix: guard webview search against destroyed webviews (#10704)
* 🐛 fix: guard webview search against destroyed webviews

* delete code

* delete code
2025-10-14 14:04:57 +08:00
George·Dong
80e1784777 chore(ci): switch Claude action to custom endpoint (#10701) 2025-10-14 01:23:34 +08:00
icarus
b5f2c63396 chore: bump version to 1.7.0-sora.3 2025-10-13 23:09:05 +08:00
icarus
4e76806cc3 fix(video): prevent thumbnail update for queued/in_progress videos
When a video is in 'queued' or 'in_progress' status, setting a thumbnail is not meaningful and could cause issues. This change ensures thumbnail remains undefined for these states.
2025-10-13 23:08:44 +08:00
icarus
09ed82eb49 fix(hooks): add missing dependencies to hook dependency arrays
Add providerId to useAddOpenAIVideo and t to useProviderVideos dependency arrays to prevent stale closures
2025-10-13 23:08:31 +08:00
亢奋猫
0d760ffa2e feat: add AgentSettingsTab component and integrate into HomeTabs (#10668)
- Introduced the AgentSettingsTab component for managing agent settings.
- Integrated AgentSettingsTab into HomeTabs, allowing access to agent settings based on the active session or topic.
- Updated AgentEssentialSettings to conditionally render the ModelSetting based on props.
- Adjusted styles in various components for consistency and improved layout.
2025-10-13 22:34:27 +08:00
icarus
b068fc25da feat(i18n): add new translation keys for video features
Add new translation keys for video deletion, download, thumbnail and status messages
2025-10-13 22:23:00 +08:00
icarus
a0627f76d5 feat(video): add thumbnail retrieval functionality
- Add new translations for thumbnail operations
- Extend video types to support thumbnail operations
- Implement thumbnail retrieval hook with error handling
- Add thumbnail get action to video list items
- Update video page to handle thumbnail retrieval
- Enhance provider videos hook with thumbnail support
2025-10-13 22:21:26 +08:00
icarus
85daceb417 feat(video): add video deletion functionality
implement video deletion for openai provider
add i18n strings for deletion states and errors
update video list ui to support deletion
handle pending states during deletion
2025-10-13 21:52:48 +08:00
icarus
2fab33de41 refactor(video): split video hooks into provider-specific and global logic
Move provider-specific video management to useProviderVideos hook
Simplify useVideos to handle global video operations
2025-10-13 21:52:34 +08:00
icarus
e88b4c091d refactor(video): rename useRetrieveThumbnail to useVideoThumbnail and add remove functionality
The hook has been renamed to better reflect its purpose and expanded with a removeThumbnail function to provide complete thumbnail management capabilities
2025-10-13 21:51:44 +08:00
icarus
6c097e6733 feat(video): add delete video interfaces and thumbnail/fileId fields
Add new interfaces for handling video deletion operations and optional thumbnail/fileId fields to VideoBase interface
2025-10-13 21:50:14 +08:00
icarus
c9c859731f feat(hooks): add usePending hook to manage pending state
Add a new custom hook to track pending states with a map in the runtime store. The hook provides a way to set and clear pending flags by id.
2025-10-13 21:49:37 +08:00
icarus
c85fad90b5 feat(toast): add i18n support for loading toast title
Use i18next to translate the loading toast title for better localization support
2025-10-13 21:48:56 +08:00
Pleasure1234
88f7e6a854 fix: add esbuild and update tar-fs dependency (#10671)
Added 'esbuild' with version ^0.25.0 and updated 'tar-fs' to ^2.1.4 in package.json. This also updates related entries in yarn.lock to ensure compatibility and resolve dependency issues.
2025-10-13 21:44:31 +08:00
kangfenmao
de37e2355d chore: update @ai-sdk/google to version 2.0.20 and add corresponding patch
- Updated the @ai-sdk/google dependency to version 2.0.20 in package.json.
- Added a new patch file for the updated version to address specific changes in the library.
2025-10-13 21:00:25 +08:00
icarus
261b79198a style(video): update video player background color for dark mode 2025-10-13 20:04:44 +08:00
icarus
81f186abd6 feat(video): add status label helper function for video items
Add getStatusLabel function to centralize status text display logic and improve consistency. The function handles all status cases including empty states for 'downloaded' status.
2025-10-13 20:04:37 +08:00
icarus
f44a4f7f96 fix(video): add aria-label to progress bar for accessibility 2025-10-13 17:48:00 +08:00
icarus
15b7eb78c1 feat(video): add thumbnail retrieval hook and update video interfaces
Implement useRetrieveThumbnail hook to handle thumbnail fetching and caching
Update video interfaces to clarify thumbnail field types and add missing documentation
Refactor useVideos to use new thumbnail hook instead of direct API calls
2025-10-13 17:46:24 +08:00
Chen Tao
f27b04c5b0 fix: support gemini-2.5-image-flash (#10683) 2025-10-13 17:39:08 +08:00
icarus
efd5e9dcf2 fix(video): reload video element when video id changes
Ensure the video element is properly reloaded when switching between different videos to prevent playback issues
2025-10-13 17:01:22 +08:00
defi-failure
a02b8f4609 chore: update SiliconFlow logo (#10684) 2025-10-13 16:57:20 +08:00
icarus
3b69b2bc49 fix(video): filter openai videos by queued or in_progress status 2025-10-13 16:54:10 +08:00
icarus
c8dfae1d70 refactor(video): extract VideoListItem component from VideoList
Move VideoListItem implementation to a separate file to improve code organization and maintainability
2025-10-13 16:40:58 +08:00
icarus
2ab3ddd804 fix(video): ensure thumbnail is not null before showing it 2025-10-13 16:39:44 +08:00
icarus
7a62418f41 chore: bump version to 1.7.0-sora.2 2025-10-13 16:32:52 +08:00
icarus
58c5df9284 feat(video): implement video download functionality and improve viewer
- Add video download logic with progress tracking in VideoPanel
- Reset load state when video changes in VideoViewer
- Improve video player styling and loading state handling
- Add file upload and metadata handling for downloaded videos
2025-10-13 16:32:42 +08:00
icarus
c20394f460 feat(video): add VideoPlayer component with file loading
Implement VideoPlayer component that fetches video file path using FileManager and displays it with loading skeleton. This improves video loading reliability by handling file existence checks and error states.
2025-10-13 15:23:48 +08:00
icarus
8518734e48 feat(files): add video file type support
Add video file type metadata and UI components to support video files in the files page
2025-10-13 15:11:12 +08:00
icarus
29a01ef49a fix(video): pass onDownload callback to LoadFailedVideo component
Make onRedownload prop required in LoadFailedVideo and directly pass the onDownload callback instead of using optional chaining. This ensures the redownload functionality works consistently when video loading fails.
2025-10-13 15:04:25 +08:00
icarus
5e4b516402 feat(video): add expired state and regenerate button to video viewer
Implement expired video state handling with a regenerate button. The viewer now checks if the video has expired and shows appropriate UI with a regeneration option (currently unimplemented).
2025-10-13 14:58:45 +08:00
icarus
1c89262929 feat(video): add video download functionality
implement video download feature with progress tracking and error handling
update video status and thumbnail types to support null values
add download error message to i18n
2025-10-13 14:44:16 +08:00
icarus
b68a0ffaba feat(video): add name and providerId fields to video types
Add required name and providerId fields to video interfaces and update all related implementations including mock data and hook usage. This ensures consistent video object structure across the application.
2025-10-13 14:24:45 +08:00
icarus
41041fa296 feat(video): add download button for completed videos
Implement download button UI for completed videos. Currently shows a toast notification as the functionality is not yet implemented.
2025-10-13 14:20:07 +08:00
icarus
66b88aec74 refactor(video): add commented mock video code and fix dependency array 2025-10-13 14:05:02 +08:00
icarus
f54e583f34 refactor(video): extract video status components and fix type names
Extract inline video status rendering logic into separate components for better maintainability. Also fix inconsistent type naming (Videodownloaded -> VideoDownloaded, VideoFailed -> VideoFailedBase) and properly type VideoFailed as OpenAIVideoFailed.
2025-10-13 14:03:54 +08:00
icarus
1e1bfafb88 refactor(video): rename VideoProps to VideoViewerProps for clarity 2025-10-13 13:44:56 +08:00
icarus
63459e3ec4 feat(video): add error details modal for failed videos
Implement a modal dialog to display detailed error information when a video processing fails. This provides better visibility into failure reasons compared to just showing a generic error state.
2025-10-13 13:43:40 +08:00
icarus
de10a7fd6c refactor(video): improve video status update handling with ref
- Use useRef to track videos state to avoid stale closures
- Add error handling for video status updates
- Remove hardcoded openai provider check in favor of dynamic provider
2025-10-13 13:33:12 +08:00
icarus
dced99ce57 refactor(video): clean up unused imports and hooks in video components
Remove unused imports and hooks from VideoPage and useOpenAIVideo
Simplify useOpenAIVideo by removing unnecessary effect and dependencies
2025-10-13 13:33:00 +08:00
icarus
0cafdeb540 refactor(video): remove mock data and use real videos from provider 2025-10-13 13:24:43 +08:00
icarus
258666e382 refactor(video): remove unused useVideos hook import 2025-10-13 13:23:06 +08:00
icarus
8a45fe70d0 refactor(video): update video handling and type definitions
- Rename RetrieveVideoParams to RetrieveVideoContentParams for consistency
- Move video list management to parent component
- Add setVideo action and improve video state updates
- Implement video status polling and thumbnail fetching
2025-10-13 13:22:49 +08:00
icarus
d8363b5591 docs(video): add jsdoc comments for VideoStatus enum values
Explain the meaning of each VideoStatus value in the interface documentation
2025-10-12 21:33:53 +08:00
icarus
397a24b833 Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/sora2 2025-10-12 21:18:49 +08:00
beyondkmp
7b90dfb46c fix: intercept webview keyboard shortcuts for search functionality (#10641)
* feat: intercept webview keyboard shortcuts for search functionality

Implemented keyboard shortcut interception in webview to enable search functionality (Ctrl/Cmd+F) and navigation (Enter/Escape) within mini app pages. Previously, these shortcuts were consumed by the webview content and not propagated to the host application.

Changes:
- Added Webview_SearchHotkey IPC channel for forwarding keyboard events
- Implemented before-input-event handler in WebviewService to intercept Ctrl/Cmd+F, Escape, and Enter
- Extended preload API with onFindShortcut callback for webview shortcut events
- Updated WebviewSearch component to handle shortcuts from both window and webview
- Added comprehensive test coverage for webview shortcut handling

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

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

* fix lint

* refactor: improve webview hotkey initialization and error handling

Refactored webview keyboard shortcut handler for better code organization and reliability.

Changes:
- Extracted keyboard handler logic into reusable attachKeyboardHandler function
- Added initWebviewHotkeys() to initialize handlers for existing webviews on startup
- Integrated initialization in main app entry point
- Added explanatory comment for event.preventDefault() behavior
- Added warning log when webContentsId is unavailable in WebviewSearch

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

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

* feat: add WebviewKeyEvent type and update related components

- Introduced WebviewKeyEvent type to standardize keyboard event handling for webviews.
- Updated preload index to utilize the new WebviewKeyEvent type in the onFindShortcut callback.
- Refactored WebviewSearch component and its tests to accommodate the new type, enhancing type safety and clarity.

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

* fix lint

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 18:45:37 +08:00
Pleasure1234
26a9dba01a fix: claude-translator.yml (#10588)
* Update claude-translator.yml

* Update claude-translator.yml
2025-10-12 18:20:34 +08:00
icarus
ca53e5f0c7 build(deps): update openai dependency to forked version
Replace openai npm package with forked version @cherrystudio/openai@6.3.0-fork.1
Update package version to 1.7.0-sora.1
2025-10-12 18:13:48 +08:00
icarus
c50a574982 fix(video): handle queued status in video progress updates
Add 'queued' status to the SWR refresh conditions and restructure progress update logic to prevent potential race conditions when video status changes from queued to in_progress
2025-10-12 17:52:48 +08:00
icarus
c3c125f3a3 feat(video): add video status tracking and thumbnail handling
- Implement useVideo hook for single video retrieval
- Make thumbnail optional in VideoCompleted interface
- Add prompt parameter to addOpenAIVideo and handle progress updates
- Add auto-refresh for in-progress videos and update progress
2025-10-12 17:37:56 +08:00
icarus
eba370210f feat(video): add useOpenAIVideo hook for fetching video data
Implement a custom hook using SWR to fetch and manage OpenAI video data with revalidation capabilities
2025-10-12 17:17:35 +08:00
icarus
697ef22ab6 refactor(video): replace mock videos with real data from useVideos hook 2025-10-12 17:16:00 +08:00
SuYao
a176814ad1 fix: update ESLint configuration and dependencies, replace zod import… (#10645)
fix: update ESLint configuration and dependencies, replace zod import style
2025-10-12 17:15:52 +08:00
George·Dong
ea51439aac feat(reasoning): add special handling for Grok 4 fast models & qwen3-omni/qwen3-vl (#10367)
* feat(reasoning): add special handling for Grok 4 fast models

* feat(models): add grok4_fast model and refine grok reasoning

* feat(reasoning): unify Grok reasoning handling and XAI params

* feat(models): Grok/qwen handling and XAI

* feat(models): recognize qwen3-vl thinking models and add sizes

* fix(reasoning): reasoning enabled for QwenAlwaysThink models

* feat(reasoning): enable reasoning for Grok 4 Fast models

* fix(reasoning): rename and correct Grok 4 Fast model checks

* fix: adjust Grok-4 Fast reasoning detection for OpenRouter

* fix(reasoning): exclude non-reasoning models from reasoning detection
2025-10-12 11:34:16 +08:00
icarus
33582a460b fix(video): set empty string as default prompt instead of undefined 2025-10-12 08:14:10 +08:00
icarus
d5078baa20 refactor(VideoViewer): remove unused imports and radio group component
Clean up code by removing unused imports and commented out radio group component
2025-10-12 08:11:23 +08:00
icarus
ae54d5d9b9 fix(video): handle undefined video case in VideoPanel
Add conditional check to handle undefined video case and show toast for unimplemented remix video feature.
2025-10-12 08:09:19 +08:00
icarus
7bde37680e fix(video): handle undefined video case and add new video button
Add fallback for undefined video case in VideoPanel to clear prompt params
Add PlusIcon button in VideoList to allow creating new videos by setting activeVideoId to undefined
2025-10-12 08:05:02 +08:00
icarus
942c239d14 feat(video): add mock data and improve video panel handling
- Add mock video data for testing purposes
- Improve video panel state management with useEffect
- Export video types from index file
2025-10-12 07:56:45 +08:00
icarus
83114ee0c1 feat(video): add active video selection to VideoList
- Introduce activeVideoId state to track selected video
- Update VideoList to highlight active video with border
- Pass click handler to set active video
2025-10-12 07:44:43 +08:00
icarus
0dd894c911 feat(i18n): update video status messages and add thumbnail placeholder
- Simplify "failed" status message across languages
- Add thumbnail placeholder text for all locales
- Add error messages for image reference uploads in zh-cn
2025-10-12 07:35:26 +08:00
icarus
e0cb39d00d feat(video): implement video list UI with status indicators and thumbnails
- Add mock data for testing video list display
- Implement status icons, progress bars, and thumbnail display
- Add hover effects and styling for video items
- Update video types to include thumbnail and prompt fields
2025-10-12 07:35:06 +08:00
icarus
12323375a5 feat(video): add image reference upload with validation
implement image reference upload functionality in video panel
add validation for file format and size (max 5MB)
replace lodash merge with custom deepUpdate utility
add error messages for invalid uploads
2025-10-12 07:00:23 +08:00
icarus
788b170f98 feat(video): pass params to VideoPanel for state management
Move prompt state management to parent component to maintain consistency across video creation flow
2025-10-12 06:00:42 +08:00
icarus
42015b51e3 feat(i18n): add new translations for video features and common actions
- Add complete Chinese translations for video-related terms and statuses
- Add new common action translations (redownload, retry, send) in multiple languages
- Mark video-related terms for translation in other languages
2025-10-12 05:52:53 +08:00
icarus
9997188f5e refactor(video): extract size update logic into separate callback
Improve code maintainability by separating size update logic into its own useCallback hook
2025-10-12 05:48:53 +08:00
icarus
1fd7b0b667 feat(video): add settings component for OpenAI video params
Add OpenAIParamSettings component to handle video duration and size selection
Include new i18n translations for seconds and size labels
2025-10-12 05:45:58 +08:00
icarus
1467493e1d refactor(video-settings): improve settings layout and remove unused components
- Remove SettingTitle component and move label to Select component
- Update SettingsGroup styling for better spacing and borders
- Clean up unused imports in shared components
2025-10-12 05:32:41 +08:00
icarus
f61cadd5b5 feat(video): add video model validation and settings improvements
- Introduce new utility and config files for video model validation
- Refactor ModelSetting component to use centralized video models config
- Update VideoPage to handle video params with proper model validation
2025-10-12 05:32:26 +08:00
icarus
377b2b796f feat(video-settings): add SettingsGroup component and update SettingItem divider default
Update SettingItem to have divider=false by default and introduce new SettingsGroup component for better organization
2025-10-12 04:54:17 +08:00
icarus
36df06db75 refactor(video): update import path for useAddOpenAIVideo hook 2025-10-12 04:53:59 +08:00
icarus
a901943675 refactor(video): rename useOpenAIVideos to useAddOpenAIVideo 2025-10-12 04:53:45 +08:00
icarus
953f0f4a2f fix(video-viewer): handle undefined video status in radio group 2025-10-12 04:33:23 +08:00
icarus
8b875935d0 feat(video): add onPress handler to video send button
Handle video creation when the send button is pressed
2025-10-12 04:32:31 +08:00
icarus
2f9b174095 feat(video): add image reference button and send tooltip
Add button for image reference with tooltip in video panel
Include send button tooltip using i18n translation
2025-10-12 04:31:20 +08:00
icarus
d80eac2fbe feat(video): add error handling and loading state for video creation
handle video creation errors by showing toast notifications and prevent multiple submissions by adding a loading state
2025-10-12 04:21:54 +08:00
icarus
5776512bf6 fix(ToastPortal): prevent text overflow by adjusting toast width styles 2025-10-12 04:13:32 +08:00
icarus
fd1a3faa69 feat(video): add video list component to display videos
Implement VideoList component to show videos from a provider. Replace placeholder div with the new component in VideoPage.
2025-10-12 03:49:25 +08:00
icarus
82ad9e15e2 feat(video): enhance video error handling with retry options
Add retry and redownload buttons for failed video loading states
Improve error message display with detailed failure reason
2025-10-12 03:44:16 +08:00
icarus
46221985bd feat(video): add video loading error handling and status display
- Add new error message for video loading failure in i18n
- Implement video loading state and error handling in VideoViewer
- Display appropriate UI for loading errors when video fails to load
2025-10-12 03:34:22 +08:00
icarus
d982c659d3 refactor(video): rename VideoPlayer to VideoViewer and improve layout
Move status radio group to absolute position and update background colors
2025-10-12 03:22:19 +08:00
icarus
dad9425b44 feat(video): pass provider prop to VideoPanel component
Add useProvider hook to fetch provider data and pass it to VideoPanel to enable provider-specific functionality
2025-10-12 03:15:22 +08:00
icarus
dc19c17526 feat(video): add status handling and error messages to video player
- Add new i18n strings for video status and error messages
- Implement status-based UI rendering with progress indicators
- Include test radio group for status simulation
2025-10-12 03:13:23 +08:00
icarus
85c8d5fca2 refactor(video): rename Video component to VideoPlayer and pass video prop
Update VideoPanel to use VideoPlayer component instead of Video and accept video as a prop
2025-10-12 02:49:15 +08:00
icarus
4cf4c1e946 feat(video): add OpenAI video creation support in VideoPanel
- Integrate OpenAI video creation API with proper provider handling
- Add keyboard event to trigger video creation on Enter key
2025-10-12 02:44:20 +08:00
icarus
00221471b8 feat(video): add hook for handling OpenAI video status updates 2025-10-12 02:40:12 +08:00
icarus
6d22a635f2 feat(video): add downloading status and metadata to video types
Add new video status types for downloading state and include metadata in OpenAIVideoBase. This allows better tracking of video processing stages and provides access to video metadata.
2025-10-12 02:40:04 +08:00
icarus
014247f983 refactor(videos): move useVideos to videos folder 2025-10-12 02:39:41 +08:00
icarus
7fe4524415 feat(hooks): add useVideos hook for video management
Implement custom hook to handle video operations including add, update, remove and set videos
2025-10-12 02:10:00 +08:00
icarus
0ada5656ad fix(video): make videoMap entries optional and handle undefined cases
Fix potential runtime errors by properly handling undefined videoMap entries. Update type definition and add null checks for videoMap operations.
2025-10-12 02:01:54 +08:00
icarus
c7c6561b77 Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/sora2 2025-10-12 01:51:51 +08:00
icarus
590d69cfba feat(video): add video store module and migration
- Initialize video store module with state management for video operations
- Add video state to root reducer
- Extend video types with id field and specific OpenAIVideo types
- Include video store in migration to initialize videoMap
2025-10-12 01:50:33 +08:00
icarus
9487eaf091 feat(video): add video status types for different processing states 2025-10-12 01:31:52 +08:00
icarus
1235362c82 feat(video): add support for retrieving video content from OpenAI
Implement new interfaces and methods to handle video content retrieval from OpenAI, including type definitions and API client integration
2025-10-12 01:12:01 +08:00
icarus
5db5d69cec feat(video): add retrieve video functionality for OpenAI
Implement video retrieval endpoint and integrate it through the API client stack. This enables fetching existing video resources from OpenAI's API.
2025-10-12 00:52:09 +08:00
icarus
9931856a1f refactor(video): restructure video types and add createVideo service
- Split video types into base interfaces and OpenAI-specific implementations
- Add new createVideo service function to handle video creation
2025-10-12 00:37:23 +08:00
icarus
833d2d9276 refactor(openai): remove unnecessary await in createVideo method 2025-10-12 00:16:15 +08:00
Chen Tao
162e33f478 fix: remove LRU for websearch rag (#10631) 2025-10-12 00:01:35 +08:00
icarus
a1fde0db38 feat(video): implement OpenAI video creation support
Add video creation functionality using OpenAI SDK. Update types to match OpenAI's video API and implement the actual creation method in the OpenAI client.
2025-10-11 19:19:54 +08:00
icarus
612d3756cf feat(i18n): add video translation keys for multiple locales
Add new translation keys for video feature in zh-cn locale and placeholder keys in other locales
2025-10-11 19:11:57 +08:00
icarus
05ad98bb20 build: replace openai package with @cherrystudio/openai fork
Update all imports from 'openai' to '@cherrystudio/openai' across the codebase
Remove openai patch from package.json and add @cherrystudio/openai dependency
2025-10-11 19:11:37 +08:00
icarus
1c53222582 feat(video): add video creation types and stubs for future implementation 2025-10-11 17:57:19 +08:00
icarus
c6a0ad3fc0 feat(video): add model selection to video settings
Add ModelSetting component to allow selecting video generation models
2025-10-11 17:40:15 +08:00
icarus
ab2aa8380f feat(video): add video panel component with error handling
Implement video panel with placeholder prompt input and video display area
Add error states for invalid and undefined video cases
Update i18n strings for video related messages
2025-10-11 17:22:14 +08:00
icarus
45bdea5301 feat(video): add provider settings component and layout
Implement provider selection dropdown in video settings panel
Add shared setting components for consistent styling
Update video page layout to accommodate settings sidebar
2025-10-11 16:37:39 +08:00
kangfenmao
ee4c310725 feat: update migration logic and increment version for store
- Incremented version in the store configuration from 161 to 162.
- Updated migration logic to handle new provider integration and state adjustments.
- Removed deprecated migration logic for version 161.
2025-10-11 16:17:00 +08:00
kangfenmao
a000ff2a1a fix: adjust overflow properties in MessageGroup component
- Changed overflow properties in the GridContainer styled component to improve layout handling. Overflow is now set to hidden for vertical alignment.
2025-10-11 16:07:49 +08:00
kangfenmao
2f9576b2ae feat: remove some minapp and update related configurations
- Introduced new app icon for Stepfun.
- Updated minapps configuration to include Stepfun with its logo and URL.
- Removed Yuewen app from configurations and translations.
- Updated translations for multiple languages to reflect the addition of Stepfun and removal of Yuewen.
- Incremented version in the store configuration and added migration logic for new provider integration.
2025-10-11 16:07:49 +08:00
kangfenmao
92554dd398 chore: update @ai-sdk/google to version 2.0.17 and add corresponding patch 2025-10-11 16:07:49 +08:00
defi-failure
9473ddc762 feature: unified assistant tab (#10590)
* feature: unified assistant tab

* refactor(TagGroup): make TagsContainer component internal by removing export

* refactor(components): migrate styled-components to cn utility classes

Replace styled-components with cn utility classes from @heroui/react for better maintainability and performance

* refactor(AssistantsTab): split AssistantsTab into smaller hooks and components

* fix: click agent item should jump to topic tab

* feat: add AddButton component and refactor usage across tabs

- Introduced a new AddButton component for consistent UI across different tabs.
- Replaced existing button implementations with AddButton in Sessions, Topics, and UnifiedAddButton components.
- Removed unnecessary margin from AssistantsTab's container for improved layout.

---------

Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-10-11 16:07:35 +08:00
SuYao
5f469a71f3 fix: update ai-sdk dependencies to latest versions (#10643) 2025-10-11 15:53:30 +08:00
icarus
0f14b1625f feat(video): add video page and sidebar integration
- Add new video page component with basic structure
- Include video icon in sidebar and launchpad
- Update i18n labels for video feature
- Increment store version and add migration for video icon
2025-10-11 15:39:38 +08:00
defi-failure
87bac60afc fix: long dir breaks edit agent layout (#10644) 2025-10-11 14:47:45 +08:00
SuYao
704339e835 fix: increase tool call maxCount (#10642) 2025-10-11 14:21:18 +08:00
ABucket
c8ab7180ba fix: Provider icons are not displayed after selecting SiliconFlow in the "images" page (#10620) 2025-10-11 12:48:26 +08:00
ABucket
11757546c3 fix: Quick Assistant fails to correctly inject variables in prompts (#10617) 2025-10-11 12:45:25 +08:00
ABucket
420b9ec2f2 fix: AI_TypeValidationError when calling Ling-1T model (#10622) 2025-10-11 12:45:00 +08:00
beyondkmp
1c73271e33 fix: support gpt-5-codex for github copilot (#10587)
* fix: support gpt-5-codex for github copilot

- Added patch for @ai-sdk/openai to version 2.0.42 in package.json and yarn.lock.
- Updated editor version for Copilot from v1.97.2 to v1.104.1 in OpenAIBaseClient and providerConfig.
- Enhanced provider configuration to support new model options for Copilot.

* fix: streamline Copilot header management

- Replaced individual header assignments for Copilot with centralized constants in OpenAIBaseClient and providerConfig.
- Enhanced provider configuration to conditionally set response mode for Copilot models, improving routing logic.

* update aisdk

* delete patch

* 🤖 chore: integrate Copilot SDK provider

* use a plugin

* udpate dependency

* fix: remove unused Copilot default headers from OpenAIBaseClient

- Eliminated the import and usage of COPILOT_DEFAULT_HEADERS to streamline header management in the OpenAIBaseClient class.

* update yarn

* fix lint

* format code

* feat: enhance web search tool types in webSearchPlugin

- Added type normalization for web search tools to improve type safety and clarity.
- Updated WebSearchToolInputSchema and WebSearchToolOutputSchema to use normalized types for better consistency across the plugin.
2025-10-11 10:18:09 +08:00
ABucket
acdbe6b9ed feat: allow right click to create note and folder (#10523)
* feat: allow right click to create note and folder

* fix: duplicate menu for notes or folder

* fix: create notes in folder when a folder is selected
2025-10-10 16:58:14 +01:00
beyondkmp
6c201228d9 feat: support search in mini app page (#10609)
*  feat: add webview find-in-page overlay

* 🐛 fix: reset webview search on tab change

* fix clear search issue

* 🐛 fix: rebind webview search events

* 🐛 fix: disable spellcheck in search input

* fix spellcheck

* 🐛 fix: webview search can now reopen after closing

Fixed an issue where the search overlay couldn't be reopened after closing.
The openSearch callback was unnecessarily depending on webviewRef.current,
causing event listener rebinding issues. Removed the redundant webviewRef
check as isWebviewReady is sufficient to ensure webview readiness.

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

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

---------

Co-authored-by: Payne Fu <payne@Paynes-Mac-mini.rcoffice.ringcentral.com>
Co-authored-by: Payne Fu <payne@Paynes-MBP.rcoffice.ringcentral.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-10 07:00:45 -07:00
Tristan Zhang
73b2a375ad fix: insert reasoning block before the content block (#10545)
fix: always insert reasoning block before the content block
2025-10-09 22:32:13 +08:00
Chen Tao
89bb830b60 fix: knowledge base not delete and websearch rag error (#10595)
* fix: knowledge base not  delete

* fix: websearch rag error

* chore: add comment
2025-10-09 22:29:52 +08:00
Tristan Zhang
2399db4944 fix: adding multiple keys to the zhipu model service is not detected properly (#10583) 2025-10-09 20:40:46 +08:00
beyondkmp
62774b34d3 feat: add updating dialog in render (#10569)
* feat: replace update dialog handling with quit and install functionality

* refactor: remove App_ShowUpdateDialog and implement App_QuitAndInstall in IpcChannel
* update ipc.ts to handle quit and install action
* modify AppUpdater to include quitAndInstall method
* adjust preload index to invoke new quit and install action
* enhance AboutSettings to manage update dialog state and trigger quit and install

* fix(AboutSettings): handle null update info in update dialog state management

* fix(UpdateDialog): improve error handling during update installation and enhance release notes processing

* fix(AppUpdater): remove redundant assignment of releaseInfo after update download

* fix(IpcChannel): remove UpdateDownloadedCancelled enum value

* format code

* fix(UpdateDialog): enhance installation process with loading state and error handling

* update i18n

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

* feat(UpdateAppButton): integrate UpdateDialog and update button functionality for better user experience

* fix(UpdateDialog): update installation handler to support async operation and ensure modal closes after installation

* refactor(AppUpdater.test): remove deprecated formatReleaseNotes tests to streamline test suite

* refactor(update-dialog): simplify dialog close handling

Replace onOpenChange with onClose prop to directly handle dialog closing
Remove redundant handleClose function and simplify button onPress handler

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-09 15:58:24 +08:00
Tristan Zhang
654f19eaa9 fix: change the url for qwen (#10584) 2025-10-09 13:37:07 +08:00
Tristan Zhang
ce642f17d9 fix: layout for antrophic api tips (#10579)
* fix: layout for antrophic api tips

* lint
2025-10-09 13:20:40 +08:00
fullex
d7bcd5a20e Merge pull request #10096 from CherryHQ/feat/agents-new
feat: agents implemention
2025-10-09 10:07:18 +08:00
suyao
27903e7d9d fix 2025-10-09 09:42:04 +08:00
suyao
a8c0d0a684 fix 2025-10-09 09:10:04 +08:00
suyao
5e33c89fe7 Merge branch 'main' into feat/agents-new 2025-10-09 09:06:06 +08:00
Tristan Zhang
42849e4586 feat: support export image for notes (#10559)
* feat: support export image for notes

* feat: extract functions
2025-10-08 23:32:32 +08:00
kangfenmao
6a8544fb0e chore: bump version to 1.6.3 2025-10-08 22:08:08 +08:00
kangfenmao
37f7042f0f refactor: update styling and layout in Message component and NotesSidebar
- Adjusted class names in Message component for better layout management.
- Modified margin in DropHintNode of NotesSidebar for improved spacing.
- Enhanced BackupService to remove 'notes_tree' from indexedDB during data restoration.
2025-10-08 21:42:50 +08:00
亢奋猫
65d066cbef fix: migration for missing providers … (#10438)
chore: bump version to 1.6.3 and add migration for missing providers #10425

fix: #10425

- Updated the version from 158 to 159 in the persisted reducer configuration.
- Implemented a migration function to ensure missing system providers are added to the state during the migration to version 159, enhancing state consistency.
2025-10-08 19:28:08 +08:00
George·Dong
504531d4d5 feat(notes): add spell-check control (#10507)
* feat(notes): add spell-check control

* feat(notes): add spell-check toggle to preview mode toolbar

* feat(settings): move spellcheck to global and use hook
2025-10-08 17:48:26 +08:00
Tristan Zhang
d4b3428160 feat: Support automatic line wrapping for tables in notes (#10503)
* feat: add table auto-wrap feature for notes

* chore: lint

* feat: remove settings for auto wrap
2025-10-08 01:57:00 +08:00
Daniel Hofheinz
cd881ceb34 fix(ui): remove redundant scrollbar in side-by-side view & fix message menubar overflow (#10543)
* fix(ui): remove redundant scrollbar in side-by-side view

Changed GridContainer from styled(Scrollbar) to styled.div to
eliminate redundant horizontal scrollbar in multi-model horizontal
layout mode. The Scrollbar component is designed for vertical
scrolling and conflicts with horizontal layouts.

Fixes #10520

* fix(ui): restore vertical scrollbar for grid mode while preserving horizontal fix

Optimal solution: Use Scrollbar component as base to preserve auto-hide
behavior for vertical modes (grid, vertical, fold) while overriding its
overflow-y behavior for horizontal mode only.

This approach:
- Preserves the June 2025 UX optimization (auto-hide scrollbars)
- Fixes horizontal scrollbar issue from #10520
- Restores vertical scrolling for grid mode
- Maintains auto-hide behavior for all vertical scrolling modes
- Minimal change with no code duplication

The Scrollbar component provides scrollbar thumb auto-hide after 1.5s,
which enhances UX for vertical scrolling. By using CSS overrides only
for horizontal mode, we get the best of both worlds.

* chore: fix import sorting in MessageGroup.tsx

Unrelated to PR scope - fixing to unblock CI.
Auto-fixed via eslint --fix (moved Scrollbar import to correct position).
Also updated yarn.lock to resolve dependency sync.

* fix(ui): add explicit overflow declarations for all grid modes

Previous fix relied on CSS inheritance from Scrollbar base component,
but display: grid interferes with overflow property inheritance.

This iteration adds explicit overflow-y: auto and overflow-x: hidden
to grid, fold, vertical, and multi-select modes to ensure vertical
scrolling works reliably across all layouts.

- horizontal mode: overflow-y visible, overflow-x auto (unchanged)
- grid/fold/vertical modes: explicit overflow-y auto, overflow-x hidden
- multi-select mode: explicit overflow-y auto, overflow-x hidden

Fixes vertical scrollbar missing in grid mode reported by @EurFelux

* fix(Messages): adjust overflow behavior in message groups

Fix scrollbar issues by hiding vertical overflow in horizontal layout and simplifying overflow handling in grid layout

* feat(HorizontalScrollContainer): add classNames prop for container and content styling

allow custom styling of container and content via classNames prop

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-08 01:55:21 +08:00
Vaayne
68b37e66e9 ⬆️ chore: upgrade electron from 37.4.0 to 37.6.0 2025-10-07 14:36:03 +08:00
Vaayne
d6e7ed81ee Merge remote-tracking branch 'origin/main' into feat/agents-new
# Conflicts:
#	src/renderer/src/pages/home/Tabs/TopicsTab.tsx
#	yarn.lock
2025-10-07 14:33:41 +08:00
Phantom
a9843b4128 feat: expand clickable area of topic in-place renaming (#10548)
* chore: update electron dependency from 37.4.0 to 37.6.0

* feat(TopicsTab): add double click to edit topic name

Move double click handler from TopicName component to parent div to improve UX

* fix(TopicsTab): prevent topic edit on double click when already editing
2025-10-07 14:24:29 +08:00
Vaayne
d4c6131fa3 Merge remote-tracking branch 'origin/main' into feat/agents-new
# Conflicts:
#	package.json
#	src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts
2025-10-07 12:30:18 +08:00
Murphy
d2d5064eed fix: forked topic and rename modal retaining old name after rename (#10528)
fix: sync active topic metadata after rename
2025-10-07 00:02:48 +08:00
rebecca554owen
8bec7640fa fix(metrics): restore first token latency reporting (#10538) 2025-10-06 22:19:09 +08:00
沿途风浪
fcf53f06ef fix(models vision) (#10530) 2025-10-05 20:39:32 +08:00
one
2048f210e7 feat(CodeEditor): add a prop to enable the readOnly extension (#10516)
* feat(CodeEditor): add a prop to enable the readOnly extension

* feat: enable keymap for TextFilePreview
2025-10-04 23:24:50 +08:00
PP Kun
78eacccf6e chore(build): Upgrade electron (#10525)
- 将 electron 从 37.4.0 升级到 37.6.0
- 解决旧版本导致macOS 26卡顿问题
2025-10-04 12:42:07 +08:00
Tristan Zhang
a436ab1d78 fix(TextFilePreview): make editor read-only but can be copied (#10499)
* fix(TextFilePreview): make editor read-only but can be copied

* feat: add table auto-wrap feature for notes

* Revert "feat: add table auto-wrap feature for notes"

This reverts commit 7785f480b1.
2025-10-03 19:23:49 +08:00
Phantom
2aedbf5702 fix(reasoning): support deepseek v3.2, claude 4.5, glm 4.6 (#10475)
* fix(reasoning): update deepseek model id regex pattern to match more variants

The previous regex pattern was too restrictive and didn't account for all possible deepseek model id formats. This change expands the pattern to support more variants while maintaining the same functionality.

* fix(reasoning): update deepseek model id regex pattern to match more variants

* fix(reasoning): improve regex pattern for deepseek model matching

Update the regex pattern to be more precise in matching deepseek model versions.
Add detailed comments explaining the pattern and note future improvements.

* feat(models): add GLM-4.6 model to supported list

Update model configuration to include new GLM-4.6 model and add it to the supported models for thinking token functionality

* feat(models): add claude sonnet 4.5 model to anthropic provider
2025-10-03 14:36:18 +08:00
Tristan Zhang
b7e7174f3d feat: add middle-click tab closing (#10498) 2025-10-02 20:56:53 +08:00
Tristan Zhang
e7e5c0456f feat: allowing notes to be renamed using LLM (#10487)
* feat: implement auto-renaming feature for notes

* feat: motion effects for auto renaming in notes

* feat: add i18n for zh-tw for auto renaming in notes

* chore: lint
2025-10-02 20:45:46 +08:00
purefkh
53e38ed1aa feat(models): update Gemini regex (#10463)
* feat(models): update Gemini regex

* fix: lint

* fix format
2025-10-02 17:45:42 +08:00
Tristan Zhang
f91e7da0a1 feat: add notes export (#10488)
* feat: add notes export

* chore: fix lint error

* feat: unified export interface for notes

* fix: hide export reasoning when exporting notes

* chore: fix lint error

* chore: remove debug log
2025-10-02 08:09:11 +01:00
dependabot[bot]
74db4c4646 ci(deps): bump actions/github-script from 7 to 8 (#10480)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:16:02 +08:00
dependabot[bot]
1e4902b267 ci(deps): bump actions/checkout from 4 to 5 (#10479)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:15:38 +08:00
dependabot[bot]
932b1d529a ci(deps): bump actions/setup-node from 4 to 5 (#10478)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:15:14 +08:00
Vaayne
53046460ec fix(ClaudeCodeService): update environment variables to use modelInfo provider details 2025-09-30 23:53:10 +08:00
LeaderOnePro
38ac42af8c feat: add GitHub Copilot CLI integration to coding tools (#10403)
* feat: add GitHub Copilot CLI integration to coding tools

- Add githubCopilotCli to codeTools enum
- Support @github/copilot package installation
- Add 'copilot' executable command mapping
- Update Redux store to include GitHub Copilot CLI state
- Add GitHub Copilot CLI option to UI with proper provider mapping
- Implement environment variable handling for GitHub authentication
- Fix model selection logic to disable model choice for GitHub Copilot CLI
- Update launch validation to not require model selection for GitHub Copilot CLI
- Fix prepareLaunchEnvironment and executeLaunch to handle no-model scenario

This enables users to launch GitHub Copilot CLI directly from Cherry Studio's
code tools interface without needing to select a model, as GitHub Copilot CLI
uses GitHub's built-in models and authentication.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* style: apply code formatting for GitHub Copilot CLI integration

Auto-fix code style inconsistencies using project's Biome formatter.
Resolves semicolon, comma, and quote style issues to match project standards.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* feat: conditionally render model selector for GitHub Copilot CLI

- Hide model selector component when GitHub Copilot CLI is selected
- Maintain validation logic to allow GitHub Copilot CLI without model selection
- Improve UX by removing empty model dropdown for GitHub Copilot CLI

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

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

---------

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-30 23:43:19 +08:00
Vaayne
538291c03f ♻️ refactor: consolidate Claude Code system message handling and streaming logic
- Unify buildClaudeCodeSystemMessage implementation in shared package
- Refactor MessagesService to provide comprehensive message processing API
- Extract streaming logic, error handling, and header preparation into service methods
- Remove duplicate anthropic config from renderer, use shared implementation
- Update ClaudeCodeService to use append mode for custom instructions
- Improve type safety and request validation in message processing
2025-09-30 23:33:41 +08:00
icarus
142ad9e41e refactor(Assistants): move add assistant button inside container div
Improve layout structure by moving the button inside the same container as other elements for better visual grouping
2025-09-30 21:14:48 +08:00
Vaayne
7250ce3514 fix(CodeToolsService): update package name for Claude Code SDK 2025-09-30 18:55:17 +08:00
Vaayne
02cf012671 fix(ProcessTransport): replace spawn with fork for Node.js process handling 2025-09-30 18:50:02 +08:00
MyPrototypeWhat
d11a2cd95c chore: update dependencies and versioning across packages (#10471)
- Bumped versions for several @ai-sdk packages in package.json and yarn.lock to their latest releases, including @ai-sdk/amazon-bedrock, @ai-sdk/google-vertex, @ai-sdk/mistral, and @ai-sdk/perplexity.
- Updated ai package version from 5.0.44 to 5.0.59.
- Updated aiCore package version from 1.0.0-alpha.18 to 1.0.1 and adjusted dependencies accordingly.
- Ensured compatibility with the latest zod version in multiple packages.
2025-09-30 18:39:48 +08:00
Vaayne
65ac3181a8 feat: add anthropicApiHost to CherryAI and New-API providers 2025-09-30 18:25:51 +08:00
Vaayne
998e54246f 🔖 chore: bump version to 1.7.0-alpha.4 2025-09-30 18:16:15 +08:00
Vaayne
fcd8f7a26e 🐛 fix: update i18n translations, UI components, and provider configuration
- Add error.open_path translation across multiple locales (zh-tw, el-gr, es-es, fr-fr, ja-jp, pt-pt, ru-ru)
- Remove avatarProps from AgentLabel in AgentSettingsPopup
- Add ovms provider to SystemProviderIds
2025-09-30 18:15:08 +08:00
Vaayne
b991afd69a fix(claude): streamline systemPrompt and settingSources initialization 2025-09-30 18:05:32 +08:00
Vaayne
d9d8bae2d6 Merge remote-tracking branch 'origin/main' into feat/agents-new 2025-09-30 18:03:35 +08:00
Vaayne
422ba52093 ⬆️ chore: migrate from Claude Code SDK to Claude Agent SDK v0.1.1
- Replace @anthropic-ai/claude-code with @anthropic-ai/claude-agent-sdk@0.1.1
- Update all import statements across 4 files
- Migrate patch for Electron compatibility (fork vs spawn)
- Handle breaking changes: replace appendSystemPrompt with systemPrompt preset
- Add settingSources configuration for filesystem settings
- Update vendor path in build scripts
- Update package name mapping in CodeToolsService
2025-09-30 17:54:02 +08:00
Vaayne
51630f95fd ♻️ refactor(agents): improve error handling and logging in agent services 2025-09-30 17:17:56 +08:00
Vaayne
ac1cab60a3 fix(cache): reduce cache TTL from 1 minute to 10 seconds for quicker updates 2025-09-29 23:24:36 +08:00
Phantom
23f61b0d62 feat: support gpt-5-codex (#10448)
* feat(models): add gpt5_codex model support

Add support for gpt5_codex model type in model configuration and type definitions. Update getThinkModelType to handle codex variant of gpt5 models.

* feat(models): add gpt-5-codex model logo and update logo mapping

Add new GPT-5-Codex model logo image and include it in the logo mapping configuration
2025-09-29 23:22:25 +08:00
Vaayne
759f8518b2 fix(logger): enhance cache check for available providers 2025-09-29 23:02:05 +08:00
icarus
7bd6c92f43 refactor(agent): rename getAgentDefaultAvatar to getAgentTypeAvatar for clarity
Simplify avatar handling by removing default avatar logic and always using emoji icons
2025-09-29 21:04:31 +08:00
icarus
ff705d99b3 refactor(AvatarSetting): simplify avatar selection by removing radio options
Remove radio group selection for avatar type and only keep emoji picker
Clean up unused imports and code related to the removed functionality
2025-09-29 21:00:42 +08:00
icarus
7ec17dc771 style(ChatNavbar): adjust AgentLabel styling for better consistency 2025-09-29 20:17:41 +08:00
icarus
35883e8601 fix(i18n): update error message for failed path opening 2025-09-29 19:59:51 +08:00
icarus
48b7bdb9ba feat(file): improve error handling for openPath operation
Add error message translation and proper error propagation when opening a file path fails
2025-09-29 19:53:32 +08:00
icarus
d2d5b4370c feat(ChatNavbar): make path info tag clickable to open file location
Add onClick handler to InfoTag component to open file location when clicked
2025-09-29 19:36:13 +08:00
icarus
27c31d6e0c feat(file): add showInFolder IPC channel to reveal files in explorer
Implement functionality to show files/folders in system explorer through IPC. Includes channel definition, preload API, main handler, and error handling for non-existent paths.
2025-09-29 19:36:05 +08:00
Kejiang Ma
961ee22327 feat: add new provider intel OVMS(openvino model server) (#9853)
* add new provider: OVMS(openvino model server)

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* remove useless comments

* add note: support windows only

* fix eslint error; add migrate for ovms provider

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix ci error after rebase

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* modifications base on reviewers' comments

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* show intel-ovms provider only on windows and intel cpu

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* complete i18n for intel ovms

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* update ovms 2025.3; apply patch for model qwen3-8b on local

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix lint issues

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix issues for format, type checking

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* remove test code

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

* fix issues after rebase

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>

---------

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>
2025-09-29 18:36:54 +08:00
Vaayne
37b3c08baa ♻️ refactor: improve async processing and error handling in ClaudeCodeService 2025-09-29 17:14:13 +08:00
Vaayne
d8c3f601df ♻️ refactor: correct include filter path for Claude code ripgrep 2025-09-29 16:18:12 +08:00
Vaayne
cff9068359 ♻️ refactor: standardize string quotes and improve logging in Anthropic integration 2025-09-29 14:42:50 +08:00
Vaayne
cc871b7a72 ♻️ refactor: enhance logging and provider handling for Anthropic integration 2025-09-29 14:38:41 +08:00
Vaayne
5b98ef5b3d ♻️ refactor: update import paths for message handling module 2025-09-29 13:12:06 +08:00
Vaayne
3428d15299 ♻️ refactor: centralize agent stream timeouts 2025-09-29 13:06:47 +08:00
Vaayne
9ea3f0842c 📝 docs: refresh AI assistant guidelines 2025-09-29 11:12:56 +08:00
Vaayne
90242e2285 fix: exclude proxy environment variables from login shell environment 2025-09-29 11:05:05 +08:00
Vaayne
1616345261 Bump version to 1.7.0-alpha.3 2025-09-29 09:51:40 +08:00
GitHub Action
0b818477ac fix(i18n): Auto update translations for PR #10096 2025-09-28 15:35:27 +00:00
Vaayne
027d6ea2b2 Merge remote-tracking branch 'origin/main' into feat/agents-new 2025-09-28 23:34:33 +08:00
LeaderOnePro
c7d2588f1a feat: add LongCat provider support (#10365)
* feat: add LongCat provider support

- Add LongCat to SystemProviderIds enum
- Add LongCat provider logo and configuration
- Configure API endpoints and URLs based on official docs
- Add two models: LongCat-Flash-Chat and LongCat-Flash-Thinking
- Update provider mappings for proper integration

The LongCat provider uses OpenAI-compatible API format and supports
up to 8K tokens output with daily free quota of 500K tokens.

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

* feat: add migration for LongCat provider

- Add migration version 158 for LongCat provider
- Ensure existing users get LongCat provider on app update
- Follow standard migration pattern for simple provider additions

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>

---------

Signed-off-by: LeaderOnePro <leaderonepro@outlook.com>
2025-09-28 20:54:42 +08:00
MyPrototypeWhat
06ab2822be Refactor/reasoning time (#10393) 2025-09-28 19:38:44 +08:00
kangfenmao
bb0ec0a3ec chore: update @ai-sdk/google patch and refine getModelPath function
- Updated the resolution and checksum for the @ai-sdk/google patch in yarn.lock.
- Enhanced the getModelPath function to check for "models/" in the modelId before returning the path, improving its robustness.
2025-09-28 16:32:51 +08:00
MyPrototypeWhat
483b4e090e feat(toolUsePlugin): separate provider-defined tools from prompt tool (#10428)
* feat(toolUsePlugin): separate provider-defined tools from prompt tools in context

- Enhanced the `createPromptToolUsePlugin` function to distinguish between provider-defined tools and other tools, ensuring only non-provider-defined tools are saved in the context.
- Updated the handling of tools in the transformed parameters to retain provider-defined tools while removing others.
- Improved error handling in `ToolExecutor` by logging tool and tool use details for better debugging.
- Refactored various components to use `NormalToolResponse` instead of `MCPToolResponse`, aligning with the new response structure across multiple message components.

* refactor(toolUsePlugin): streamline tool handling in createPromptToolUsePlugin

- Updated the `createPromptToolUsePlugin` function to improve type handling for tools, ensuring proper type inference and reducing the use of type assertions.
- Enhanced clarity in the separation of provider-defined tools and prompt tools, maintaining functionality while improving code readability.

* refactor(ToolExecutor): remove debug logging for tool and tool use

- Removed console logging for tool and tool use details in the ToolExecutor class to clean up the code and improve performance. This change enhances the clarity of the code without affecting functionality.
2025-09-28 16:27:26 +08:00
kangfenmao
4975c2d9e8 chore: update build configurations to use secrets for sensitive environment variables
- Modified GitHub Actions workflows to replace environment variable references with secrets for MAIN_VITE_MINERU_API_KEY, RENDERER_VITE_AIHUBMIX_SECRET, and RENDERER_VITE_PPIO_APP_SECRET.
- Added onwarn handler in electron.vite.config.ts to suppress specific warnings related to CommonJS variables in ESM.
2025-09-28 16:12:48 +08:00
kangfenmao
5365fddec9 chore: bump version to 1.6.2
- Updated release notes to reflect recent optimizations and bug fixes, including improvements to the note-taking feature and resolution of issues with CherryAI and VertexAI.
- Bumped version number from 1.6.1 to 1.6.2 in package.json.
2025-09-28 15:07:21 +08:00
kangfenmao
e401685449 lint: fix code format 2025-09-28 14:57:01 +08:00
320 changed files with 13333 additions and 3885 deletions

View File

@@ -2,8 +2,8 @@ name: Auto I18N
env:
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
on:
pull_request:
@@ -26,7 +26,7 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
- name: 📦 Setting Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

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

View File

@@ -16,10 +16,13 @@ on:
jobs:
translate:
if: |
(github.event_name == 'issues') ||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
(github.event_name == 'issues')
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|| (
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
&& github.event.sender.type != 'Bot'
&& github.event.pull_request.head.repo.fork == false
)
runs-on: ubuntu-latest
permissions:
contents: read
@@ -29,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -42,7 +45,7 @@ jobs:
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
allowed_non_write_users: "*"
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
prompt: |
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
@@ -105,3 +108,5 @@ jobs:
使用以下命令获取完整信息:
gh issue view ${{ github.event.issue.number }} --json title,body,comments
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}

View File

@@ -37,7 +37,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1

View File

@@ -12,7 +12,7 @@ jobs:
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Delete merged branch
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.git.deleteRef({

View File

@@ -56,7 +56,7 @@ jobs:
ref: main
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
@@ -99,9 +99,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -110,15 +110,15 @@ jobs:
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -128,9 +128,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Rename artifacts with nightly format
shell: bash

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -47,7 +47,7 @@ jobs:
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
@@ -86,9 +86,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -98,15 +98,15 @@ jobs:
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -116,9 +116,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Release
uses: ncipollo/release-action@v1

View File

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

View File

@@ -0,0 +1,31 @@
diff --git a/sdk.mjs b/sdk.mjs
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -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
@@ -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 ? 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, {
+ this.child = fork(pathToClaudeCodeExecutable, args, {
cwd,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal: this.abortController.signal,
env
});

View File

@@ -1,31 +0,0 @@
diff --git a/sdk.mjs b/sdk.mjs
index e2dbafb4e2faa1bf2b6b02f0009a2b9bbf57c757..ea333ae8c69fcd27a9f2d89b3dbfc0a3e4e4dec4 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
}
// src/transport/ProcessTransport.ts
-import { spawn } from "child_process";
+import { fork } from "child_process";
import { createInterface } from "readline";
// src/utils/fsOperations.ts
@@ -6452,13 +6452,12 @@ class ProcessTransport {
throw new ReferenceError(errorMessage);
}
const isNative = isNativeBinary(pathToClaudeCodeExecutable);
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
- this.logDebug(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
- this.child = spawn(spawnCommand, spawnArgs, {
+
+ this.logDebug(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
+ this.child = fork(pathToClaudeCodeExecutable, args, {
cwd,
- stdio: ["pipe", "pipe", stderrMode],
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
signal: this.abortController.signal,
env
});

View File

@@ -0,0 +1,44 @@
diff --git a/dist/index.js b/dist/index.js
index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class {
}
});
} else if (value.item.type === "message") {
- controller.enqueue({
- type: "text-end",
- id: value.item.id
- });
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
+ if (currentTextId) {
+ controller.enqueue({
+ type: "text-end",
+ id: currentTextId
+ });
+ }
currentTextId = null;
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id];
diff --git a/dist/index.mjs b/dist/index.mjs
index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class {
}
});
} else if (value.item.type === "message") {
- controller.enqueue({
- type: "text-end",
- id: value.item.id
- });
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
+ if (currentTextId) {
+ controller.enqueue({
+ type: "text-end",
+ id: currentTextId
+ });
+ }
currentTextId = null;
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id];

Binary file not shown.

View File

@@ -2,22 +2,16 @@
This file provides guidance to AI coding assistants when working with code in this repository. Adherence to these guidelines is crucial for maintaining code quality and consistency.
## Guiding Principles
## Guiding Principles (MUST FOLLOW)
- **Clarity and Simplicity**: Write code that is easy to understand and maintain.
- **Consistency**: Follow existing patterns and conventions in the codebase.
- **Correctness**: Ensure code is correct, well-tested, and robust.
- **Efficiency**: Write performant code and use resources judiciously.
## MUST Follow Rules
1. **Code Search**: Use `ast-grep` for semantic code pattern searches when available. Fallback to `rg` (ripgrep) or `grep` for text-based searches.
2. **UI Framework**: Exclusively use **HeroUI** for all new UI components. The use of `antd` or `styled-components` is strictly **PROHIBITED**.
3. **Quality Assurance**: **Always** run `yarn build:check` before finalizing your work or making any commits. This ensures code quality (linting, testing, and type checking).
4. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`.
5. **External Research**: Leverage `subagent` for gathering external information, including latest documentation, API references, news, or web-based research. This keeps the main conversation focused on the task at hand.
6. **Code Reviews**: Always seek a code review from a human developer before merging significant changes. This ensures adherence to project standards and catches potential issues.
7. **Documentation**: Update or create documentation for any new features, modules, or significant changes to existing functionality.
- **Keep it clear**: Write code that is easy to read, maintain, and explain.
- **Match the house style**: Reuse existing patterns, naming, and conventions.
- **Search smart**: Prefer `ast-grep` for semantic queries; fall back to `rg`/`grep` when needed.
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Seek review**: Ask a human developer to review substantial changes before merging.
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
## Development Commands

View File

@@ -126,58 +126,112 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
🚀 New Features:
- Refactored AI core engine for more efficient and stable content generation
- Added support for multiple AI model providers: CherryIN, AiOnly
- Added API server functionality for external application integration
- Added PaddleOCR document recognition for enhanced document processing
- Added Anthropic OAuth authentication support
- Added data storage space limit notifications
- Added font settings for global and code fonts customization
- Added auto-copy feature after translation completion
- Added keyboard shortcuts: rename topic, edit last message, etc.
- Added text attachment preview for viewing file contents in messages
- Added custom window control buttons (minimize, maximize, close)
- Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads
- Support for Qwen image recognition models (Qwen-Image)
- Added iFlow CLI support
- Converted knowledge base and web search to tool-calling approach for better flexibility
What's New in v1.7.0-beta.1
Major Features:
- Agent System: Introducing intelligent Agent capabilities alongside Assistants. Agents can autonomously solve complex problems using Claude Code SDK with tool calling, file operations, and multi-turn reasoning
- Agent Management: Create, configure, and manage agents with custom settings including model selection, tool permissions, accessible paths, and MCP server integrations
- Agent Sessions: Dedicated session management for agent interactions with persistent message history and context tracking
- Unified UI: Streamlined interface combining Assistants and Agents tabs with improved navigation and settings management
Agent Features:
- Tool Support: Web search, file operations, bash commands, and custom MCP tools
- Advanced Configuration: Max turns, temperature, token limits
- Permission Control: Configurable tool approval modes (manual, automatic, none)
- Session Persistence: Automatic message saving with optimized streaming and database integration
- Model Selection: API-based model filtering with provider-specific support
UI/UX Improvements:
- Unified assistant/agent tabs with smooth animations
- In-place session name editing
- Virtual list rendering for improved performance
- Session count indicators for active agents
- Enhanced settings popup with tabbed interface
- Webview keyboard shortcut interception for search functionality
API & Infrastructure:
- RESTful API for agent and session management
- Drizzle ORM integration for agent database
- OAuth support for Claude Code authentication
- Express validator for request validation
- Comprehensive error handling with Zod schemas
Model Updates:
- Gemini 2.5 Image Flash support
- Grok 4 Fast with reasoning capabilities
- Qwen3-omni and Qwen3-vl thinking models
- DeepSeek, Claude 4.5, GLM 4.6 support
- GitHub Copilot CLI integration with gpt-5-codex
Bug Fixes:
- Fix Swagger UI accessibility issues
- Fix AI SDK error display with syntax highlighting
- Fix webview search shortcut handling
- Fix agent model visibility for CherryIn provider
- Fix session message ordering and persistence
- Fix anthropic model visibility in agent configuration
- Fix knowledge base deletion and web search RAG errors
- Fix migration for missing providers
Technical Updates:
- React 19.2.0 upgrade
- Enhanced Claude Code service with streaming support
- Improved message transformation and streaming lifecycle
- Database migration system with automatic schema sync
- Optimized bundle size and dependency management
🎨 UI Improvements & Bug Fixes:
- Integrated HeroUI and Tailwind CSS framework
- Optimized message notification styles with unified toast component
- Moved free models to bottom with fixed position for easier access
- Refactored quick panel and input bar tools for smoother operation
- Optimized responsive design for navbar and sidebar
- Improved scrollbar component with horizontal scrolling support
- Fixed multiple translation issues: paste handling, file processing, state management
- Various UI optimizations and bug fixes
<!--LANG:zh-CN-->
🚀 新功能:
- 重构 AI 核心引擎,提供更高效稳定的内容生成
- 新增多个 AI 模型提供商支持CherryIN、AiOnly
- 新增 API 服务器功能,支持外部应用集成
- 新增 PaddleOCR 文档识别,增强文档处理能力
- 新增 Anthropic OAuth 认证支持
- 新增数据存储空间限制提醒
- 新增字体设置,支持全局字体和代码字体自定义
- 新增翻译完成后自动复制功能
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
- 新增文本附件预览,可查看消息中的文件内容
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
- 支持通义千问长文本qwen-long和文档分析qwen-doc模型原生文件上传
- 支持通义千问图像识别模型Qwen-Image
- 新增 iFlow CLI 支持
- 知识库和网页搜索转换为工具调用方式,提升灵活性
v1.7.0-beta.1 新特性
🎨 界面改进与问题修复:
- 集成 HeroUI 和 Tailwind CSS 框架
- 优化消息通知样式,统一 toast 组件
- 免费模型移至底部固定位置,便于访问
- 重构快捷面板和输入栏工具,操作更流畅
- 优化导航栏和侧边栏响应式设计
- 改进滚动条组件,支持水平滚动
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
- 各种界面优化和问题修复
核心功能:
- Agent 系统:引入智能 Agent 能力,与助手(Assistant)并存。Agent 基于 Claude Code SDK 构建,具备工具调用、文件操作和多轮推理能力,可自主解决复杂问题
- Agent 管理:创建、配置和管理 Agent,支持自定义模型选择、工具权限、可访问路径和 MCP 服务器集成
- Agent 会话:专属会话管理系统,支持持久化消息历史和上下文追踪
- 统一界面:精简的助手和 Agent 标签页界面,改进导航和设置管理体验
Agent 功能特性:
- 工具支持网页搜索、文件操作、Bash 命令执行和自定义 MCP 工具
- 高级配置最大轮次、温度、Token 限制
- 权限控制:可配置的工具批准模式(手动、自动、无需批准)
- 会话持久化:自动消息保存,优化的流式传输和数据库集成
- 模型选择:基于 API 的模型过滤,支持特定提供商
界面与交互优化:
- 统一的助手/Agent 标签页,带有流畅动画效果
- 会话名称原地编辑功能
- 虚拟列表渲染,提升性能表现
- 活跃 Agent 的会话计数指示器
- 增强的设置弹窗,采用标签页界面
- Webview 键盘快捷键拦截,支持搜索功能
API 与基础设施:
- RESTful API 用于 Agent 和会话管理
- 集成 Drizzle ORM 管理 Agent 数据库
- Claude Code OAuth 认证支持
- Express validator 请求验证
- 基于 Zod 模式的完善错误处理
模型更新:
- 支持 Gemini 2.5 Image Flash
- Grok 4 Fast 推理能力
- Qwen3-omni 和 Qwen3-vl 思考模型
- DeepSeek、Claude 4.5、GLM 4.6 支持
- GitHub Copilot CLI 集成 gpt-5-codex
问题修复:
- 修复 Swagger UI 无法打开
- 修复 AI SDK 错误显示,添加语法高亮
- 修复 Webview 搜索快捷键处理
- 修复 CherryIn 提供商的 Agent 模型可见性
- 修复会话消息排序和持久化
- 修复 Anthropic 模型在 Agent 配置中的可见性
- 修复知识库删除和网页搜索 RAG 错误
- 修复缺失提供商的迁移问题
技术更新:
- 升级至 React 19.2.0
- 增强 Claude Code 服务流式传输支持
- 改进消息转换和流式生命周期
- 数据库迁移系统,支持自动模式同步
- 优化打包大小和依赖管理
<!--LANG:END-->

View File

@@ -34,6 +34,10 @@ export default defineConfig({
output: {
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
},
onwarn(warning, warn) {
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
warn(warning)
}
},
sourcemap: isDev
@@ -112,6 +116,10 @@ export default defineConfig({
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
},
onwarn(warning, warn) {
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
warn(warning)
}
}
},

View File

@@ -2,6 +2,7 @@ import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import importZod from 'eslint-plugin-import-zod'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
@@ -15,7 +16,8 @@ export default defineConfig([
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports
'unused-imports': unusedImports,
'import-zod': importZod
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
@@ -25,6 +27,7 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'import-zod/prefer-zod-namespace': 'error'
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-alpha.2",
"version": "1.7.0-sora.3",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -78,11 +78,12 @@
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-code": "patch:@anthropic-ai/claude-code@npm%3A1.0.118#~/.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.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",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"jsdom": "26.1.0",
@@ -92,6 +93,7 @@
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0"
},
@@ -99,10 +101,10 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.21",
"@ai-sdk/google-vertex": "^3.0.27",
"@ai-sdk/mistral": "^2.0.14",
"@ai-sdk/perplexity": "^2.0.9",
"@ai-sdk/amazon-bedrock": "^3.0.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
@@ -124,6 +126,7 @@
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/openai": "6.3.0-fork.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -152,6 +155,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch",
"@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
@@ -219,7 +223,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.44",
"ai": "^5.0.68",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -244,7 +248,7 @@
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"electron": "37.4.0",
"electron": "37.6.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1",
@@ -256,11 +260,11 @@
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
@@ -293,16 +297,15 @@
"motion": "^12.10.5",
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.15.0",
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"playwright": "^1.52.0",
"proxy-agent": "^6.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
@@ -334,7 +337,6 @@
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swagger-ui-express": "^5.0.1",
"swr": "^2.3.6",
"tailwindcss": "^4.1.13",
"tar": "^7.4.3",
@@ -370,17 +372,19 @@
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"esbuild": "^0.25.0",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.3.0-fork.1",
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.3.0-fork.1",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"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.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.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"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.0-alpha.18",
"version": "1.0.1",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -36,14 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.17",
"@ai-sdk/azure": "^2.0.30",
"@ai-sdk/deepseek": "^1.0.17",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.9",
"@ai-sdk/xai": "^2.0.18",
"@ai-sdk/provider-utils": "^3.0.12",
"@ai-sdk/xai": "^2.0.26",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
return params
}
context.mcpTools = params.tools
// 分离 provider-defined 和其他类型的工具
const providerDefinedTools: ToolSet = {}
const promptTools: ToolSet = {}
// 构建系统提示符
for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) {
if (tool.type === 'provider-defined') {
// provider-defined 类型的工具保留在 tools 参数中
providerDefinedTools[toolName] = tool
} else {
// 其他工具转换为 prompt 模式
promptTools[toolName] = tool
}
}
// 只有当有非 provider-defined 工具时才保存到 context
if (Object.keys(promptTools).length > 0) {
context.mcpTools = promptTools
}
// 构建系统提示符(只包含非 provider-defined 工具)
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
let systemMessage: string | null = systemPrompt
if (config.createSystemMessage) {
// 🎯 如果用户提供了自定义处理函数,使用它
systemMessage = config.createSystemMessage(systemPrompt, params, context)
}
// 移除 tools改为 prompt 模式
// 保留 provider-defined tools移除其他 tools
const transformedParams = {
...params,
...(systemMessage ? { system: systemMessage } : {}),
tools: undefined
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
}
context.originalParams = transformedParams
return transformedParams
@@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
let textBuffer = ''
// let stepId = ''
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
if (!context.mcpTools) {
throw new Error('No tools available')
return new TransformStream()
}
// 从 context 中获取或初始化 usage 累加器

View File

@@ -1,6 +1,7 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { InferToolInput, InferToolOutput, type Tool } from 'ai'
import { ProviderOptionsMap } from '../../../options/types'
import { OpenRouterSearchConfig } from './openrouter'
@@ -14,6 +15,13 @@ export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tool
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
type NormalizeTool<T> = T extends Tool<infer INPUT, infer OUTPUT> ? Tool<INPUT, OUTPUT> : Tool<any, any>
type AnthropicWebSearchTool = NormalizeTool<ReturnType<typeof anthropic.tools.webSearch_20250305>>
type OpenAIWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearch>>
type OpenAIChatWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearchPreview>>
type GoogleWebSearchTool = NormalizeTool<ReturnType<typeof google.tools.googleSearch>>
/**
* 插件初始化时接收的完整配置对象
*
@@ -58,24 +66,31 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
export type WebSearchToolOutputSchema = {
// Anthropic 工具 - 手动定义
anthropicWebSearch: Array<{
url: string
title: string
pageAge: string | null
encryptedContent: string
type: string
}>
anthropic: InferToolOutput<AnthropicWebSearchTool>
// OpenAI 工具 - 基于实际输出
openaiWebSearch: {
// TODO: 上游定义不规范,是unknown
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
openai: {
status: 'completed' | 'failed'
}
'openai-chat': {
status: 'completed' | 'failed'
}
// Google 工具
googleSearch: {
// TODO: 上游定义不规范,是unknown
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
google: {
webSearchQueries?: string[]
groundingChunks?: Array<{
web?: { uri: string; title: string }
}>
}
}
export type WebSearchToolInputSchema = {
anthropic: InferToolInput<AnthropicWebSearchTool>
openai: InferToolInput<OpenAIWebSearchTool>
google: InferToolInput<GoogleWebSearchTool>
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
}

View File

@@ -13,7 +13,7 @@ import { LanguageModelV2 } from '@ai-sdk/provider'
import { createXai } from '@ai-sdk/xai'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { customProvider, Provider } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
/**
* 基础 Provider IDs

View File

@@ -5,8 +5,8 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
@@ -34,6 +34,7 @@ export enum IpcChannel {
App_GetBinaryPath = 'app:get-binary-path',
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
App_InstallOvmsBinary = 'app:install-ovms-binary',
App_LogToMain = 'app:log-to-main',
App_SaveData = 'app:save-data',
App_GetDiskInfo = 'app:get-disk-info',
@@ -52,6 +53,7 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
// Open
Open_Path = 'open:path',
@@ -187,6 +189,7 @@ export enum IpcChannel {
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
File_StartWatcher = 'file:startWatcher',
File_StopWatcher = 'file:stopWatcher',
File_ShowInFolder = 'file:showInFolder',
// file service
FileService_Upload = 'file-service:upload',
@@ -224,6 +227,7 @@ export enum IpcChannel {
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName',
// DevTools
System_ToggleDevTools = 'system:toggleDevTools',
@@ -231,7 +235,6 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -314,6 +317,7 @@ export enum IpcChannel {
ApiServer_Stop = 'api-server:stop',
ApiServer_Restart = 'api-server:restart',
ApiServer_GetStatus = 'api-server:get-status',
// NOTE: This api is not be used.
ApiServer_GetConfig = 'api-server:get-config',
// Anthropic OAuth
@@ -333,6 +337,16 @@ export enum IpcChannel {
// OCR
OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_AddModel = 'ovms:add-model',
Ovms_StopAddModel = 'ovms:stop-addmodel',
Ovms_GetModels = 'ovms:get-models',
Ovms_IsRunning = 'ovms:is-running',
Ovms_GetStatus = 'ovms:get-status',
Ovms_RunOVMS = 'ovms:run-ovms',
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
Cherryai_GetSignature = 'cherryai:get-signature'

View File

@@ -1,4 +1,4 @@
import type { SDKMessage } from '@anthropic-ai/claude-code'
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages'
export type ClaudeCodeRawValue =

View File

@@ -12,8 +12,19 @@ import Anthropic from '@anthropic-ai/sdk'
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import { Provider } from '@types'
import type { ModelMessage } from 'ai'
const logger = loggerService.withContext('anthropic-sdk')
const defaultClaudeCodeSystemPrompt = `You are Claude Code, Anthropic's official CLI for Claude.`
const defaultClaudeCodeSystem: Array<TextBlockParam> = [
{
type: 'text',
text: defaultClaudeCodeSystemPrompt
}
]
/**
* Creates and configures an Anthropic SDK client based on the provider configuration.
*
@@ -44,7 +55,11 @@ const logger = loggerService.withContext('anthropic-sdk')
* const apiKeyClient = getSdkClient(apiKeyProvider);
* ```
*/
export function getSdkClient(provider: Provider, oauthToken?: string | null): Anthropic {
export function getSdkClient(
provider: Provider,
oauthToken?: string | null,
extraHeaders?: Record<string, string | string[]>
): Anthropic {
if (provider.authType === 'oauth') {
if (!oauthToken) {
throw new Error('OAuth token is not available')
@@ -68,7 +83,8 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
'x-stainless-os': 'MacOS',
'x-stainless-arch': 'arm64',
'x-stainless-runtime': 'node',
'x-stainless-runtime-version': 'v22.18.0'
'x-stainless-runtime-version': 'v22.18.0',
...extraHeaders
}
})
}
@@ -77,7 +93,22 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
? provider.apiHost
: (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost
logger.debug('Anthropic API baseURL', { baseURL })
logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id })
if (provider.id === 'aihubmix') {
return new Anthropic({
apiKey: provider.apiKey,
baseURL,
dangerouslyAllowBrowser: true,
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19',
'APP-Code': 'MLTG2087',
...provider.extra_headers,
...extraHeaders
}
})
}
return new Anthropic({
apiKey: provider.apiKey,
authToken: provider.apiKey,
@@ -104,53 +135,36 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
* @param system - Optional user-provided system message (string or TextBlockParam array)
* @returns Combined system message with Claude Code prompt prepended
*
* @example
* ```typescript
* // No system message
* const result1 = buildClaudeCodeSystemMessage();
* // Returns: "You are Claude Code, Anthropic's official CLI for Claude."
*
* // String system message
* const result2 = buildClaudeCodeSystemMessage("You are a helpful assistant.");
* // Returns: [
* // { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
* // { type: 'text', text: "You are a helpful assistant." }
* // ]
*
* // Array system message
* const systemArray = [{ type: 'text', text: 'Custom instructions' }];
* const result3 = buildClaudeCodeSystemMessage(systemArray);
* // Returns: Array with Claude Code message prepended
* ```
*/
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): string | Array<TextBlockParam> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): Array<TextBlockParam> {
if (!system) {
return defaultClaudeCodeSystem
}
if (typeof system === 'string') {
if (system.trim() === defaultClaudeCodeSystem) {
return system
if (system.trim() === defaultClaudeCodeSystemPrompt || system.trim() === '') {
return defaultClaudeCodeSystem
} else {
return [...defaultClaudeCodeSystem, { type: 'text', text: system }]
}
}
if (Array.isArray(system)) {
const firstSystem = system[0]
if (firstSystem.type === 'text' && firstSystem.text.trim() === defaultClaudeCodeSystemPrompt) {
return system
} else {
return [...defaultClaudeCodeSystem, ...system]
}
return [
{
type: 'text',
text: defaultClaudeCodeSystem
},
{
type: 'text',
text: system
}
]
}
if (system[0].text.trim() != defaultClaudeCodeSystem) {
system.unshift({
type: 'text',
text: defaultClaudeCodeSystem
})
}
return system
return defaultClaudeCodeSystem
}
export function buildClaudeCodeSystemModelMessage(system?: string | Array<TextBlockParam>): Array<ModelMessage> {
const textBlocks = buildClaudeCodeSystemMessage(system)
return textBlocks.map((block) => ({
role: 'system',
content: block.text
}))
}

View File

@@ -217,7 +217,8 @@ export enum codeTools {
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex',
iFlowCli = 'iflow-cli'
iFlowCli = 'iflow-cli',
githubCopilotCli = 'github-copilot-cli'
}
export enum terminalApps {

View File

@@ -22,3 +22,12 @@ export type MCPProgressEvent = {
callId: string
progress: number // 0-1 range
}
export type WebviewKeyEvent = {
webviewId: number
key: string
control: boolean
meta: boolean
shift: boolean
alt: boolean
}

View File

@@ -1,5 +1,7 @@
const https = require('https')
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
/**
* Downloads a file from a URL with redirect handling
@@ -32,4 +34,39 @@ async function downloadWithRedirects(url, destinationPath) {
})
}
module.exports = { downloadWithRedirects }
/**
* Downloads a file using PowerShell Invoke-WebRequest command
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<boolean>} Promise that resolves to true if download succeeds
*/
async function downloadWithPowerShell(url, destinationPath) {
return new Promise((resolve, reject) => {
try {
// Only support windows platform for PowerShell download
if (process.platform !== 'win32') {
return reject(new Error('PowerShell download is only supported on Windows'))
}
const outputDir = path.dirname(destinationPath)
fs.mkdirSync(outputDir, { recursive: true })
// PowerShell command to download the file with progress disabled for faster download
const psCommand = `powershell -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest '${url}' -OutFile '${destinationPath}'"`
console.log(`Downloading with PowerShell: ${url}`)
execSync(psCommand, { stdio: 'inherit' })
if (fs.existsSync(destinationPath)) {
console.log(`Download completed: ${destinationPath}`)
resolve(true)
} else {
reject(new Error('Download failed: File not found after download'))
}
} catch (error) {
reject(new Error(`PowerShell download failed: ${error.message}`))
}
})
}
module.exports = { downloadWithRedirects, downloadWithPowerShell }

View File

@@ -0,0 +1,263 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_RELEASE_BASE_URL =
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip'
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
/**
* error code:
* 101: Unsupported CPU (not Intel Ultra)
* 102: Unsupported platform (not Windows)
* 103: Download failed
* 104: Installation failed
* 105: Failed to create ovdnd.exe
* 106: Failed to create run.bat
* 110: Cleanup of old installation failed
*/
/**
* Clean old OVMS installation if it exists
*/
function cleanOldOvmsInstallation() {
console.log('Cleaning the existing OVMS installation...')
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
if (fs.existsSync(csOvmsDir)) {
try {
fs.rmSync(csOvmsDir, { recursive: true })
} catch (error) {
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
return 110
}
}
return 0
}
/**
* Install OVMS Base package
*/
async function installOvmsBase() {
// Download the base package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
try {
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`)
} catch (error) {
console.error(`Download OVMS Base failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
// unzip the base package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
fs.mkdirSync(csOvmsDir, { recursive: true })
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS: ${error.message}`)
fs.unlinkSync(tempFilename)
return 104
}
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
// copy ovms.exe to ovdnd.exe
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
console.log('Copied ovms.exe to ovdnd.exe')
} catch (error) {
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
return 105
}
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
// del %USERPROFILE%\.cherrystudio\ovms_log.log
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
fs.appendFileSync(runBatPath, '\r\n')
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
fs.appendFileSync(
runBatPath,
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
)
console.log(`Created run.bat at: ${runBatPath}`)
} catch (error) {
console.error(`Error creating run.bat: ${error.message}`)
return 106
}
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
console.log(`Created config file: ${configJsonPath}`)
return 0
}
/**
* Install OVMS Extra package
*/
async function installOvmsExtra() {
// Download the extra package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
try {
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
} catch (error) {
console.error(`Download OVMS Extra failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
// unzip the extra package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS Extra: ${error.message}`)
fs.unlinkSync(tempFilename)
return 104
}
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
try {
const files = fs.readdirSync(patchDir)
files.forEach((file) => {
const srcPath = path.join(patchDir, file)
const destPath = path.join(csOvmsBinDir, file)
fs.copyFileSync(srcPath, destPath)
console.log(`Applied patch file: ${file}`)
})
} catch (error) {
console.error(`Error applying OVMS patch: ${error.message}`)
}
return 0
}
/**
* Get the CPU Name and ID
*/
function getCpuInfo() {
const cpuInfo = {
name: '',
id: ''
}
// Use PowerShell to get CPU information
try {
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
const psOutput = execSync(psCommand).toString()
const cpuData = JSON.parse(psOutput)
if (Array.isArray(cpuData)) {
cpuInfo.name = cpuData[0].Name || ''
cpuInfo.id = cpuData[0].DeviceID || ''
} else {
cpuInfo.name = cpuData.Name || ''
cpuInfo.id = cpuData.DeviceID || ''
}
} catch (error) {
console.error(`Failed to get CPU info: ${error.message}`)
}
return cpuInfo
}
/**
* Main function to install OVMS
*/
async function installOvms() {
const platform = os.platform()
console.log(`Detected platform: ${platform}`)
const cpuName = getCpuInfo().name
console.log(`CPU Name: ${cpuName}`)
// Check if CPU name contains "Ultra"
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
return 101
}
// only support windows
if (platform !== 'win32') {
console.error('OVMS installation is only supported on Windows.')
return 102
}
// Clean old installation if it exists
const cleanupCode = cleanOldOvmsInstallation()
if (cleanupCode !== 0) {
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
return cleanupCode
}
const installBaseCode = await installOvmsBase()
if (installBaseCode !== 0) {
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
cleanOldOvmsInstallation()
return installBaseCode
}
const installExtraCode = await installOvmsExtra()
if (installExtraCode !== 0) {
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
return installExtraCode
}
return 0
}
// Run the installation
installOvms()
.then((retcode) => {
if (retcode === 0) {
console.log('OVMS installation successful')
} else {
console.error('OVMS installation failed')
}
process.exit(retcode)
})
.catch((error) => {
console.error('OVMS installation failed:', error)
process.exit(100)
})

View File

@@ -2,9 +2,9 @@
* 该脚本用于少量自动翻译所有baseLocale以外的文本。待翻译文案必须以[to be translated]开头
*
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import * as fs from 'fs'
import OpenAI from 'openai'
import * as path from 'path'
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')

View File

@@ -35,7 +35,7 @@ const allX64 = {
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
}
const claudeCodeVenderPath = '@anthropic-ai/claude-code/vendor'
const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor'
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
const platformToArch = {
@@ -88,7 +88,7 @@ exports.default = async function (context) {
const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin']
const includeClaudeCodeFilters = [
'!node_modules/' + claudeCodeVenderPath + '/' + `${archType}-${platformToArch[platform]}/**`
'!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + `${archType}-${platformToArch[platform]}/**`
]
if (arch === Arch.arm64) {

View File

@@ -4,9 +4,9 @@
* API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import fs from 'fs'
import OpenAI from 'openai'
type I18NValue = string | { [key: string]: I18NValue }
type I18N = { [key: string]: I18NValue }

View File

@@ -3,6 +3,7 @@ import cors from 'cors'
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { LONG_POLL_TIMEOUT_MS } from './config/timeouts'
import { authMiddleware } from './middleware/auth'
import { errorHandler } from './middleware/error'
import { setupOpenAPIDocumentation } from './middleware/openapi'
@@ -14,7 +15,6 @@ import { modelsRoutes } from './routes/models'
const logger = loggerService.withContext('ApiServer')
const LONG_POLL_TIMEOUT_MS = 120 * 60_000 // 120 minutes
const extendMessagesTimeout: express.RequestHandler = (req, res, next) => {
req.setTimeout(LONG_POLL_TIMEOUT_MS)
res.setTimeout(LONG_POLL_TIMEOUT_MS)

View File

@@ -0,0 +1,3 @@
export const LONG_POLL_TIMEOUT_MS = 120 * 60_000 // 120 minutes
export const MESSAGE_STREAM_TIMEOUT_MS = LONG_POLL_TIMEOUT_MS

View File

@@ -1,8 +1,9 @@
import { loggerService } from '@logger'
import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts'
import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController'
import { agentService, sessionMessageService, sessionService } from '@main/services/agents'
import { Request, Response } from 'express'
import { agentService, sessionMessageService, sessionService } from '../../../../services/agents'
const logger = loggerService.withContext('ApiServerMessagesHandlers')
// Helper function to verify agent and session exist and belong together
@@ -25,6 +26,8 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
}
export const createMessage = async (req: Request, res: Response): Promise<void> => {
let clearAbortTimeout: (() => void) | undefined
try {
const { agentId, sessionId } = req.params
@@ -42,7 +45,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
const abortController = new AbortController()
const {
abortController,
registerAbortHandler,
clearAbortTimeout: helperClearAbortTimeout
} = createStreamAbortController({
timeoutMs: MESSAGE_STREAM_TIMEOUT_MS
})
clearAbortTimeout = helperClearAbortTimeout
const { stream, completion } = await sessionMessageService.createSessionMessage(
session,
messageData,
@@ -54,6 +64,10 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
let responseEnded = false
let streamFinished = false
const cleanupAbortTimeout = () => {
clearAbortTimeout?.()
}
const finalizeResponse = () => {
if (responseEnded) {
return
@@ -64,6 +78,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
}
responseEnded = true
cleanupAbortTimeout()
try {
// res.write('data: {"type":"finish"}\n\n')
res.write('data: [DONE]\n\n')
@@ -92,12 +107,51 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
* - Clean up event listeners to prevent memory leaks
* - Mark the response as ended to prevent further writes
*/
const handleDisconnect = () => {
registerAbortHandler((abortReason) => {
cleanupAbortTimeout()
if (responseEnded) return
logger.info('Streaming client disconnected', { agentId, sessionId })
responseEnded = true
if (abortReason === STREAM_TIMEOUT_REASON) {
logger.error('Streaming message timeout', { agentId, sessionId })
try {
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
message: 'Stream timeout',
type: 'timeout_error',
code: 'stream_timeout'
}
})}\n\n`
)
} catch (writeError) {
logger.error('Error writing timeout to SSE stream', { error: writeError })
}
} else if (abortReason === 'Client disconnected') {
logger.info('Streaming client disconnected', { agentId, sessionId })
} else {
logger.warn('Streaming aborted', { agentId, sessionId, reason: abortReason })
}
reader.cancel(abortReason ?? 'stream aborted').catch(() => {})
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
}
if (!res.writableEnded) {
res.end()
}
})
const handleDisconnect = () => {
if (abortController.signal.aborted) return
abortController.abort('Client disconnected')
reader.cancel('Client disconnected').catch(() => {})
}
req.on('close', handleDisconnect)
@@ -135,6 +189,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing stream error to SSE', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
res.end()
}
}
@@ -166,41 +221,14 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
logger.error('Error writing completion error to SSE stream', { error: writeError })
}
responseEnded = true
cleanupAbortTimeout()
res.end()
})
// Set a timeout to prevent hanging indefinitely
const timeout = setTimeout(
() => {
if (!responseEnded) {
logger.error('Streaming message timeout', { agentId, sessionId })
try {
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
message: 'Stream timeout',
type: 'timeout_error',
code: 'stream_timeout'
}
})}\n\n`
)
} catch (writeError) {
logger.error('Error writing timeout to SSE stream', { error: writeError })
}
abortController.abort('stream timeout')
reader.cancel('stream timeout').catch(() => {})
responseEnded = true
res.end()
}
},
10 * 60 * 1000
) // 10 minutes timeout
// Clear timeout when response ends
res.on('close', () => clearTimeout(timeout))
res.on('finish', () => clearTimeout(timeout))
res.on('close', cleanupAbortTimeout)
res.on('finish', cleanupAbortTimeout)
} catch (error: any) {
clearAbortTimeout?.()
logger.error('Error in streaming message handler', {
error,
agentId: req.params.agentId,

View File

@@ -1,5 +1,5 @@
import { ChatCompletionCreateParams } from '@cherrystudio/openai/resources'
import express, { Request, Response } from 'express'
import { ChatCompletionCreateParams } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import {

View File

@@ -1,9 +1,9 @@
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import { Provider } from '@types'
import express, { Request, Response } from 'express'
import { Provider } from '../../../renderer/src/types/provider'
import { MessagesService, messagesService } from '../services/messages'
import { messagesService } from '../services/messages'
import { getProviderById, validateModelId } from '../utils'
const logger = loggerService.withContext('ApiServerMessagesRoutes')
@@ -11,7 +11,7 @@ const logger = loggerService.withContext('ApiServerMessagesRoutes')
const router = express.Router()
const providerRouter = express.Router({ mergeParams: true })
// Helper functions for shared logic
// Helper function for basic request validation
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
const request: MessageCreateParams = req.body
@@ -31,147 +31,53 @@ async function validateRequestBody(req: Request): Promise<{ valid: boolean; erro
return { valid: true }
}
async function handleStreamingResponse(
res: Response,
request: MessageCreateParams,
provider: Provider,
messagesService: MessagesService
): Promise<void> {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
const flushableResponse = res as Response & { flush?: () => void }
const flushStream = () => {
if (typeof flushableResponse.flush !== 'function') {
return
}
try {
flushableResponse.flush()
} catch (flushError: unknown) {
logger.warn('Failed to flush streaming response', {
error: flushError
})
}
}
try {
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
res.write(`event: ${chunk.type}\n`)
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
flushStream()
}
res.write('data: [DONE]\n\n')
flushStream()
} catch (streamError: any) {
logger.error('Stream error', { error: streamError })
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
type: 'api_error',
message: 'Stream processing error'
}
})}\n\n`
)
} finally {
res.end()
}
}
function handleErrorResponse(res: Response, error: any): Response {
logger.error('Message processing error', { error })
let statusCode = 500
let errorType = 'api_error'
let errorMessage = 'Internal server error'
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
const anthropicError = error?.error
if (anthropicStatus) {
statusCode = anthropicStatus
}
if (anthropicError?.type) {
errorType = anthropicError.type
}
if (anthropicError?.message) {
errorMessage = anthropicError.message
} else if (error instanceof Error && error.message) {
errorMessage = error.message
}
if (!anthropicStatus && error instanceof Error) {
if (error.message.includes('API key') || error.message.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
statusCode = 502
errorType = 'api_error'
} else if (error.message.includes('validation') || error.message.includes('invalid')) {
statusCode = 400
errorType = 'invalid_request_error'
}
}
return res.status(statusCode).json({
type: 'error',
error: {
type: errorType,
message: errorMessage,
requestId: error?.request_id
}
})
}
async function processMessageRequest(
req: Request,
res: Response,
provider: Provider,
interface HandleMessageProcessingOptions {
req: Request
res: Response
provider: Provider
request: MessageCreateParams
modelId?: string
): Promise<Response | void> {
}
async function handleMessageProcessing({
req,
res,
provider,
request,
modelId
}: HandleMessageProcessingOptions): Promise<void> {
try {
const request: MessageCreateParams = req.body
// Use provided modelId or keep original model
if (modelId) {
request.model = modelId
}
// Validate request
const validation = messagesService.validateRequest(request)
if (!validation.isValid) {
return res.status(400).json({
res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: validation.errors.join('; ')
}
})
}
logger.silly('Processing message request', {
request,
provider: provider.id
})
// Handle streaming
if (request.stream) {
await handleStreamingResponse(res, request, provider, messagesService)
return
}
// Handle non-streaming
const response = await messagesService.processMessage(request, provider)
return res.json(response)
const extraHeaders = messagesService.prepareHeaders(req.headers)
const { client, anthropicRequest } = await messagesService.processMessage({
provider,
request,
extraHeaders,
modelId
})
if (request.stream) {
await messagesService.handleStreaming(client, anthropicRequest, { response: res }, provider)
return
}
const response = await client.messages.create(anthropicRequest)
res.json(response)
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
res.status(statusCode).json(errorResponse)
}
}
@@ -328,10 +234,11 @@ router.post('/', async (req: Request, res: Response) => {
const provider = modelValidation.provider!
const modelId = modelValidation.modelId!
// Use shared processing function
return await processMessageRequest(req, res, provider, modelId)
return handleMessageProcessing({ req, res, provider, request, modelId })
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
return res.status(statusCode).json(errorResponse)
}
})
@@ -483,10 +390,13 @@ providerRouter.post('/', async (req: Request, res: Response) => {
})
}
// Use shared processing function (no modelId override needed)
return await processMessageRequest(req, res, provider)
const request: MessageCreateParams = req.body
return handleMessageProcessing({ req, res, provider, request })
} catch (error: any) {
return handleErrorResponse(res, error)
logger.error('Message processing error', { error })
const { statusCode, errorResponse } = messagesService.transformError(error)
return res.status(statusCode).json(errorResponse)
}
})

View File

@@ -104,9 +104,7 @@ const router = express
logger.info('Models response ready', {
filter,
total: response.total
})
logger.debug('Model IDs returned', {
total: response.total,
modelIds: response.data.map((m) => m.id)
})

View File

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources'
import { Provider } from '@types'
import OpenAI from 'openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import { ModelValidationError, validateModelId } from '../utils'

View File

@@ -1,33 +1,93 @@
import Anthropic from '@anthropic-ai/sdk'
import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources'
import { loggerService } from '@logger'
import anthropicService from '@main/services/AnthropicService'
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
import { Provider } from '@types'
import { Response } from 'express'
const logger = loggerService.withContext('MessagesService')
const EXCLUDED_FORWARD_HEADERS: ReadonlySet<string> = new Set([
'host',
'x-api-key',
'authorization',
'sentry-trace',
'baggage',
'content-length',
'connection'
])
export interface ValidationResult {
isValid: boolean
errors: string[]
}
export interface ErrorResponse {
type: 'error'
error: {
type: string
message: string
requestId?: string
}
}
export interface StreamConfig {
response: Response
onChunk?: (chunk: MessageStreamEvent) => void
onError?: (error: any) => void
onComplete?: () => void
}
export interface ProcessMessageOptions {
provider: Provider
request: MessageCreateParams
extraHeaders?: Record<string, string | string[]>
modelId?: string
}
export interface ProcessMessageResult {
client: Anthropic
anthropicRequest: MessageCreateParams
}
export class MessagesService {
// oxlint-disable-next-line no-unused-vars
validateRequest(request: MessageCreateParams): ValidationResult {
// TODO: Implement comprehensive request validation
const errors: string[] = []
if (!request.model) {
if (!request.model || typeof request.model !== 'string') {
errors.push('Model is required')
}
if (!request.max_tokens || request.max_tokens < 1) {
errors.push('max_tokens is required and must be at least 1')
if (typeof request.max_tokens !== 'number' || !Number.isFinite(request.max_tokens) || request.max_tokens < 1) {
errors.push('max_tokens is required and must be a positive number')
}
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
errors.push('messages is required and must be a non-empty array')
} else {
request.messages.forEach((message, index) => {
if (!message || typeof message !== 'object') {
errors.push(`messages[${index}] must be an object`)
return
}
if (!('role' in message) || typeof message.role !== 'string' || message.role.trim().length === 0) {
errors.push(`messages[${index}].role is required`)
}
const content: unknown = message.content
if (content === undefined || content === null) {
errors.push(`messages[${index}].content is required`)
return
}
if (typeof content === 'string' && content.trim().length === 0) {
errors.push(`messages[${index}].content cannot be empty`)
} else if (Array.isArray(content) && content.length === 0) {
errors.push(`messages[${index}].content must include at least one item when using an array`)
}
})
}
return {
@@ -36,79 +96,224 @@ export class MessagesService {
}
}
async getClient(provider: Provider): Promise<Anthropic> {
async getClient(provider: Provider, extraHeaders?: Record<string, string | string[]>): Promise<Anthropic> {
// Create Anthropic client for the provider
if (provider.authType === 'oauth') {
const oauthToken = await anthropicService.getValidAccessToken()
return getSdkClient(provider, oauthToken)
return getSdkClient(provider, oauthToken, extraHeaders)
}
return getSdkClient(provider)
return getSdkClient(provider, null, extraHeaders)
}
async processMessage(request: MessageCreateParams, provider: Provider): Promise<Message> {
logger.debug('Preparing Anthropic message request', {
model: request.model,
messageCount: request.messages.length,
stream: request.stream,
maxTokens: request.max_tokens,
provider: provider.id
})
prepareHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[]> {
const extraHeaders: Record<string, string | string[]> = {}
// Create Anthropic client for the provider
const client = await this.getClient(provider)
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
// Prepare request with the actual model ID
const normalizedKey = key.toLowerCase()
if (EXCLUDED_FORWARD_HEADERS.has(normalizedKey)) {
continue
}
extraHeaders[normalizedKey] = value
}
return extraHeaders
}
createAnthropicRequest(request: MessageCreateParams, provider: Provider, modelId?: string): MessageCreateParams {
const anthropicRequest: MessageCreateParams = {
...request,
stream: false
stream: !!request.stream
}
if (provider.authType === 'oauth') {
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system || '')
// Override model if provided
if (modelId) {
anthropicRequest.model = modelId
}
const response = await client.messages.create(anthropicRequest)
// Add Claude Code system message for OAuth providers
if (provider.type === 'anthropic' && provider.authType === 'oauth') {
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system)
}
logger.info('Anthropic message completed', {
model: request.model,
provider: provider.id
})
return response
return anthropicRequest
}
async *processStreamingMessage(
async handleStreaming(
client: Anthropic,
request: MessageCreateParams,
config: StreamConfig,
provider: Provider
): AsyncIterable<RawMessageStreamEvent> {
logger.debug('Preparing streaming Anthropic message request', {
model: request.model,
messageCount: request.messages.length,
provider: provider.id
): Promise<void> {
const { response, onChunk, onError, onComplete } = config
// Set streaming headers
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
response.setHeader('Cache-Control', 'no-cache, no-transform')
response.setHeader('Connection', 'keep-alive')
response.setHeader('X-Accel-Buffering', 'no')
response.flushHeaders()
const flushableResponse = response as Response & { flush?: () => void }
const flushStream = () => {
if (typeof flushableResponse.flush !== 'function') {
return
}
try {
flushableResponse.flush()
} catch (flushError: unknown) {
logger.warn('Failed to flush streaming response', { error: flushError })
}
}
const writeSse = (eventType: string | undefined, payload: unknown) => {
if (response.writableEnded || response.destroyed) {
return
}
if (eventType) {
response.write(`event: ${eventType}\n`)
}
const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
response.write(`data: ${data}\n\n`)
flushStream()
}
try {
const stream = client.messages.stream(request)
for await (const chunk of stream) {
if (response.writableEnded || response.destroyed) {
logger.warn('Streaming response ended before stream completion', {
provider: provider.id,
model: request.model
})
break
}
writeSse(chunk.type, chunk)
if (onChunk) {
onChunk(chunk)
}
}
writeSse(undefined, '[DONE]')
if (onComplete) {
onComplete()
}
} catch (streamError: any) {
logger.error('Stream error', {
error: streamError,
provider: provider.id,
model: request.model,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost
})
writeSse(undefined, {
type: 'error',
error: {
type: 'api_error',
message: 'Stream processing error'
}
})
if (onError) {
onError(streamError)
}
} finally {
if (!response.writableEnded) {
response.end()
}
}
}
transformError(error: any): { statusCode: number; errorResponse: ErrorResponse } {
let statusCode = 500
let errorType = 'api_error'
let errorMessage = 'Internal server error'
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
const anthropicError = error?.error
if (anthropicStatus) {
statusCode = anthropicStatus
}
if (anthropicError?.type) {
errorType = anthropicError.type
}
if (anthropicError?.message) {
errorMessage = anthropicError.message
} else if (error instanceof Error && error.message) {
errorMessage = error.message
}
// Infer error type from message if not from Anthropic API
if (!anthropicStatus && error instanceof Error) {
const errorMessageText = error.message ?? ''
if (errorMessageText.includes('API key') || errorMessageText.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
} else if (errorMessageText.includes('rate limit') || errorMessageText.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
} else if (errorMessageText.includes('timeout') || errorMessageText.includes('connection')) {
statusCode = 502
errorType = 'api_error'
} else if (errorMessageText.includes('validation') || errorMessageText.includes('invalid')) {
statusCode = 400
errorType = 'invalid_request_error'
}
}
const safeErrorMessage =
typeof errorMessage === 'string' && errorMessage.length > 0 ? errorMessage : 'Internal server error'
return {
statusCode,
errorResponse: {
type: 'error',
error: {
type: errorType,
message: safeErrorMessage,
requestId: error?.request_id
}
}
}
}
async processMessage(options: ProcessMessageOptions): Promise<ProcessMessageResult> {
const { provider, request, extraHeaders, modelId } = options
const client = await this.getClient(provider, extraHeaders)
const anthropicRequest = this.createAnthropicRequest(request, provider, modelId)
const messageCount = Array.isArray(request.messages) ? request.messages.length : 0
logger.info('Processing anthropic messages request', {
provider: provider.id,
apiHost: provider.apiHost,
anthropicApiHost: provider.anthropicApiHost,
model: anthropicRequest.model,
stream: !!anthropicRequest.stream,
// systemPrompt: JSON.stringify(!!request.system),
// messages: JSON.stringify(request.messages),
messageCount,
toolCount: Array.isArray(request.tools) ? request.tools.length : 0
})
// Create Anthropic client for the provider
const client = await this.getClient(provider)
// Prepare streaming request
const streamingRequest: MessageCreateParams = {
...request,
stream: true
// Return client and request for route layer to handle streaming/non-streaming
return {
client,
anthropicRequest
}
if (provider.authType === 'oauth') {
streamingRequest.system = buildClaudeCodeSystemMessage(request.system || '')
}
const stream = client.messages.stream(streamingRequest)
for await (const chunk of stream) {
yield chunk
}
logger.info('Completed streaming Anthropic message', {
model: request.model,
provider: provider.id
})
}
}

View File

@@ -1,6 +1,13 @@
import { isEmpty } from 'lodash'
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
import { loggerService } from '../../services/LoggerService'
import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils'
import {
getAvailableProviders,
getProviderAnthropicModelChecker,
listAllAvailableModels,
transformModelToOpenAI
} from '../utils'
const logger = loggerService.withContext('ModelsService')
@@ -16,9 +23,7 @@ export class ModelsService {
let providers = await getAvailableProviders()
if (filter.providerType === 'anthropic') {
providers = providers.filter(
(p) => p.type === 'anthropic' || (p.anthropicApiHost !== undefined && p.anthropicApiHost.trim() !== '')
)
providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim()))
}
const models = await listAllAvailableModels(providers)
@@ -27,9 +32,20 @@ export class ModelsService {
for (const model of models) {
const provider = providers.find((p) => p.id === model.provider)
if (!provider || (provider.isAnthropicModel && !provider.isAnthropicModel(model))) {
logger.debug(`Processing model ${model.id}`)
if (!provider) {
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
continue
}
if (filter.providerType === 'anthropic') {
const checker = getProviderAnthropicModelChecker(provider.id)
if (!checker(model)) {
logger.debug(`Skipping model ${model.id} from ${model.provider}. Reason: Not an Anthropic model.`)
continue
}
}
const openAIModel = transformModelToOpenAI(model, provider)
const fullModelId = openAIModel.id // This is already in format "provider:model_id"

View File

@@ -0,0 +1,64 @@
export type StreamAbortHandler = (reason: unknown) => void
export interface StreamAbortController {
abortController: AbortController
registerAbortHandler: (handler: StreamAbortHandler) => void
clearAbortTimeout: () => void
}
export const STREAM_TIMEOUT_REASON = 'stream timeout'
interface CreateStreamAbortControllerOptions {
timeoutMs: number
}
export const createStreamAbortController = (options: CreateStreamAbortControllerOptions): StreamAbortController => {
const { timeoutMs } = options
const abortController = new AbortController()
const signal = abortController.signal
let timeoutId: NodeJS.Timeout | undefined
let abortHandler: StreamAbortHandler | undefined
const clearAbortTimeout = () => {
if (!timeoutId) {
return
}
clearTimeout(timeoutId)
timeoutId = undefined
}
const handleAbort = () => {
clearAbortTimeout()
if (!abortHandler) {
return
}
abortHandler(signal.reason)
}
signal.addEventListener('abort', handleAbort, { once: true })
const registerAbortHandler = (handler: StreamAbortHandler) => {
abortHandler = handler
if (signal.aborted) {
abortHandler(signal.reason)
}
}
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
if (!signal.aborted) {
abortController.abort(STREAM_TIMEOUT_REASON)
}
}, timeoutMs)
}
return {
abortController,
registerAbortHandler,
clearAbortTimeout
}
}

View File

@@ -7,13 +7,13 @@ const logger = loggerService.withContext('ApiServerUtils')
// Cache configuration
const PROVIDERS_CACHE_KEY = 'api-server:providers'
const PROVIDERS_CACHE_TTL = 1 * 60 * 1000 // 1 minutes
const PROVIDERS_CACHE_TTL = 10 * 1000 // 10 seconds
export async function getAvailableProviders(): Promise<Provider[]> {
try {
// Try to get from cache first (faster)
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
if (cachedSupportedProviders) {
if (cachedSupportedProviders && cachedSupportedProviders.length > 0) {
logger.debug('Providers resolved from cache', {
count: cachedSupportedProviders.length
})
@@ -279,3 +279,16 @@ export function validateProvider(provider: Provider): boolean {
return false
}
}
export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model) => boolean) => {
switch (providerId) {
case 'cherryin':
case 'new-api':
return (m: Model) => m.endpoint_type === 'anthropic'
case 'aihubmix':
return (m: Model) => m.id.includes('claude')
default:
// allow all models when checker not configured
return () => true
}
}

View File

@@ -30,6 +30,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
@@ -108,6 +109,7 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
@@ -157,11 +159,26 @@ if (!app.requestSingleInstanceLock()) {
logger.error('Failed to initialize Agent service:', error)
}
// Start API server if enabled
// Start API server if enabled or if agents exist
try {
const config = await apiServerService.getCurrentConfig()
logger.info('API server config:', config)
if (config.enabled) {
// Check if there are any agents
let shouldStart = config.enabled
if (!shouldStart) {
try {
const { total } = await agentService.listAgents({ limit: 1 })
if (total > 0) {
shouldStart = true
logger.info(`Detected ${total} agent(s), auto-starting API server`)
}
} catch (error: any) {
logger.warn('Failed to check agent count:', error)
}
}
if (shouldStart) {
await apiServerService.start()
}
} catch (error: any) {

View File

@@ -45,6 +45,7 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -91,6 +92,7 @@ const obsidianVaultService = new ObsidianVaultService()
const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance()
const dxtService = new DxtService()
const ovmsManager = new OvmsManager()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
@@ -140,7 +142,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -463,6 +465,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// system
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()
@@ -526,6 +529,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@@ -741,6 +745,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
//copilot
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
@@ -781,7 +786,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
@@ -871,6 +875,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider)
)
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
ovmsManager.addModel(modelName, modelId, modelSource, task)
)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))

View File

@@ -0,0 +1,473 @@
/**
* DiDi MCP Server Implementation
*
* Based on official DiDi MCP API capabilities.
* API Documentation: https://mcp.didichuxing.com/api?tap=api
*
* Provides ride-hailing services including map search, price estimation,
* order management, and driver tracking.
*
* Note: Only available in Mainland China.
*/
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const logger = loggerService.withContext('DiDiMCPServer')
export class DiDiMcpServer {
private _server: Server
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
private apiKey: string
constructor(apiKey?: string) {
this._server = new Server(
{
name: 'didi-mcp-server',
version: '0.1.0'
},
{
capabilities: {
tools: {}
}
}
)
// Get API key from parameter or environment variables
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
if (!this.apiKey) {
logger.warn('DIDI_API_KEY environment variable is not set')
}
this.setupRequestHandlers()
}
get server(): Server {
return this._server
}
private setupRequestHandlers() {
// List available tools
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'maps_textsearch',
description: 'Search for POI locations based on keywords and city',
inputSchema: {
type: 'object',
properties: {
city: {
type: 'string',
description: 'Query city'
},
keywords: {
type: 'string',
description: 'Search keywords'
},
location: {
type: 'string',
description: 'Location coordinates, format: longitude,latitude'
}
},
required: ['keywords', 'city']
}
},
{
name: 'taxi_cancel_order',
description: 'Cancel a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation or query results'
},
reason: {
type: 'string',
description:
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
}
},
required: ['order_id']
}
},
{
name: 'taxi_create_order',
description: 'Create taxi order directly via API without opening any app interface',
inputSchema: {
type: 'object',
properties: {
caller_car_phone: {
type: 'string',
description: 'Caller phone number (optional)'
},
estimate_trace_id: {
type: 'string',
description: 'Estimation trace ID from estimation results'
},
product_category: {
type: 'string',
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
}
},
required: ['product_category', 'estimate_trace_id']
}
},
{
name: 'taxi_estimate',
description: 'Get available ride-hailing vehicle types and fare estimates',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
from_name: {
type: 'string',
description: 'Departure location name'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
},
to_name: {
type: 'string',
description: 'Destination name'
}
},
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
}
},
{
name: 'taxi_generate_ride_app_link',
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
product_category: {
type: 'string',
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
}
},
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
}
},
{
name: 'taxi_get_driver_location',
description: 'Get real-time driver location for a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Taxi order ID'
}
},
required: ['order_id']
}
},
{
name: 'taxi_query_order',
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
}
}
}
}
]
}
})
// Handle tool calls
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'maps_textsearch':
return await this.handleMapsTextSearch(args)
case 'taxi_cancel_order':
return await this.handleTaxiCancelOrder(args)
case 'taxi_create_order':
return await this.handleTaxiCreateOrder(args)
case 'taxi_estimate':
return await this.handleTaxiEstimate(args)
case 'taxi_generate_ride_app_link':
return await this.handleTaxiGenerateRideAppLink(args)
case 'taxi_get_driver_location':
return await this.handleTaxiGetDriverLocation(args)
case 'taxi_query_order':
return await this.handleTaxiQueryOrder(args)
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
logger.error(`Error calling tool ${name}:`, error as Error)
throw error
}
})
}
private async handleMapsTextSearch(args: any) {
const { city, keywords, location } = args
const params = {
name: 'maps_textsearch',
arguments: {
keywords,
city,
...(location && { location })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Maps text search error:', error as Error)
throw error
}
}
private async handleTaxiCancelOrder(args: any) {
const { order_id, reason } = args
const params = {
name: 'taxi_cancel_order',
arguments: {
order_id,
...(reason && { reason })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi cancel order error:', error as Error)
throw error
}
}
private async handleTaxiCreateOrder(args: any) {
const { caller_car_phone, estimate_trace_id, product_category } = args
const params = {
name: 'taxi_create_order',
arguments: {
product_category,
estimate_trace_id,
...(caller_car_phone && { caller_car_phone })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi create order error:', error as Error)
throw error
}
}
private async handleTaxiEstimate(args: any) {
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
const params = {
name: 'taxi_estimate',
arguments: {
from_lng,
from_lat,
from_name,
to_lng,
to_lat,
to_name
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi estimate error:', error as Error)
throw error
}
}
private async handleTaxiGenerateRideAppLink(args: any) {
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
const params = {
name: 'taxi_generate_ride_app_link',
arguments: {
from_lng,
from_lat,
to_lng,
to_lat,
...(product_category && { product_category })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi generate ride app link error:', error as Error)
throw error
}
}
private async handleTaxiGetDriverLocation(args: any) {
const { order_id } = args
const params = {
name: 'taxi_get_driver_location',
arguments: {
order_id
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi get driver location error:', error as Error)
throw error
}
}
private async handleTaxiQueryOrder(args: any) {
const { order_id } = args
const params = {
name: 'taxi_query_order',
arguments: {
...(order_id && { order_id })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi query order error:', error as Error)
throw error
}
}
private async makeRequest(method: string, params: any): Promise<any> {
const requestData = {
jsonrpc: '2.0',
method: method,
id: Date.now(),
...(Object.keys(params).length > 0 && { params })
}
// API key is passed as URL parameter
const url = `${this.baseUrl}?key=${this.apiKey}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
}
return data.result
}
}
export default DiDiMcpServer

View File

@@ -3,7 +3,7 @@ import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import { z } from 'zod'
import * as z from 'zod'
const logger = loggerService.withContext('DifyKnowledgeServer')

View File

@@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search'
import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
@@ -42,6 +43,10 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
case BuiltinMCPServerNames.didiMCP: {
const apiKey = envs.DIDI_API_KEY
return new DiDiMcpServer(apiKey).server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import { net } from 'electron'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { z } from 'zod'
import * as z from 'zod'
export const RequestPayloadSchema = z.object({
url: z.url(),

View File

@@ -8,7 +8,7 @@ import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
import { z } from 'zod'
import * as z from 'zod'
const logger = loggerService.withContext('MCP:FileSystemServer')

View File

@@ -1,5 +1,11 @@
import { IpcChannel } from '@shared/IpcChannel'
import { ApiServerConfig } from '@types'
import {
ApiServerConfig,
GetApiServerStatusResult,
RestartApiServerStatusResult,
StartApiServerStatusResult,
StopApiServerStatusResult
} from '@types'
import { ipcMain } from 'electron'
import { apiServer } from '../apiServer'
@@ -52,7 +58,7 @@ export class ApiServerService {
registerIpcHandlers(): void {
// API Server
ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise<StartApiServerStatusResult> => {
try {
await this.start()
return { success: true }
@@ -61,7 +67,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise<StopApiServerStatusResult> => {
try {
await this.stop()
return { success: true }
@@ -70,7 +76,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise<RestartApiServerStatusResult> => {
try {
await this.restart()
return { success: true }
@@ -79,7 +85,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => {
try {
const config = await this.getCurrentConfig()
return {

View File

@@ -1,17 +1,15 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog, net } from 'electron'
import { app, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -66,7 +63,6 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
@@ -247,37 +243,9 @@ export default class AppUpdater {
}
}
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
}
/**
@@ -349,38 +317,9 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@@ -31,7 +31,10 @@ interface VersionInfo {
class CodeToolsService {
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null
private terminalsCache: {
terminals: TerminalConfig[]
timestamp: number
} | null = null
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
@@ -82,6 +85,8 @@ class CodeToolsService {
return '@qwen-code/qwen-code'
case codeTools.iFlowCli:
return '@iflow-ai/iflow-cli'
case codeTools.githubCopilotCli:
return '@github/copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -99,6 +104,8 @@ class CodeToolsService {
return 'qwen'
case codeTools.iFlowCli:
return 'iflow'
case codeTools.githubCopilotCli:
return 'copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -144,7 +151,9 @@ class CodeToolsService {
case terminalApps.powershell:
// Check for PowerShell in PATH
try {
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
await execAsync('powershell -Command "Get-Host"', {
timeout: 3000
})
return terminal
} catch {
try {
@@ -384,7 +393,9 @@ class CodeToolsService {
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
const { stdout } = await execAsync(`"${executablePath}" --version`, {
timeout: 10000
})
// Extract version number from output (format may vary by tool)
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
@@ -425,7 +436,10 @@ class CodeToolsService {
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
this.versionCache.set(cacheKey, {
version: latestVersion!,
timestamp: now
})
logger.debug(`Cached latest version for ${packageName}`)
} catch (error) {
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)

View File

@@ -725,7 +725,10 @@ class FileStorage {
}
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
const resolved = await shell.openPath(path)
if (resolved !== '') {
throw new Error(resolved)
}
}
/**
@@ -1229,6 +1232,19 @@ class FileStorage {
return false
}
}
public showInFolder = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
if (!fs.existsSync(path)) {
const msg = `File or folder does not exist: ${path}`
logger.error(msg)
throw new Error(msg)
}
try {
shell.showItemInFolder(path)
} catch (error) {
logger.error('Failed to show item in folder:', error as Error)
}
}
}
export const fileStorage = new FileStorage()

View File

@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file'
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
@@ -147,11 +147,16 @@ class KnowledgeService {
}
}
private getDbPath = (id: string): string => {
// 消除网络搜索requestI d中的特殊字符
return path.join(this.storageDir, sanitizeFilename(id, '_'))
}
/**
* Delete knowledge base file
*/
private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = path.join(this.storageDir, id)
const dbPath = this.getDbPath(id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -244,7 +249,8 @@ class KnowledgeService {
dimensions
})
try {
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)

View File

@@ -0,0 +1,586 @@
import { exec } from 'node:child_process'
import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { loggerService } from '@logger'
import * as fs from 'fs-extra'
import * as path from 'path'
const logger = loggerService.withContext('OvmsManager')
const execAsync = promisify(exec)
interface OvmsProcess {
pid: number
path: string
workingDirectory: string
}
interface ModelConfig {
name: string
base_path: string
}
interface OvmsConfig {
mediapipe_config_list: ModelConfig[]
}
class OvmsManager {
private ovms: OvmsProcess | null = null
/**
* Recursively terminate a process and all its child processes
* @param pid Process ID to terminate
* @returns Promise<{ success: boolean; message?: string }>
*/
private async terminalProcess(pid: number): Promise<{ success: boolean; message?: string }> {
try {
// Check if the process is running
const processCheckCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
const { stdout: processStdout } = await execAsync(`powershell -Command "${processCheckCommand}"`)
if (!processStdout.trim()) {
logger.info(`Process with PID ${pid} is not running`)
return { success: true, message: `Process with PID ${pid} is not running` }
}
// Find child processes
const childProcessCommand = `Get-WmiObject -Class Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | Select-Object ProcessId | ConvertTo-Json`
const { stdout: childStdout } = await execAsync(`powershell -Command "${childProcessCommand}"`)
// If there are child processes, terminate them first
if (childStdout.trim()) {
const childProcesses = JSON.parse(childStdout)
const childList = Array.isArray(childProcesses) ? childProcesses : [childProcesses]
logger.info(`Found ${childList.length} child processes for PID ${pid}`)
// Recursively terminate each child process
for (const childProcess of childList) {
const childPid = childProcess.ProcessId
logger.info(`Terminating child process PID: ${childPid}`)
await this.terminalProcess(childPid)
}
} else {
logger.info(`No child processes found for PID ${pid}`)
}
// Finally, terminate the parent process
const killCommand = `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`
await execAsync(`powershell -Command "${killCommand}"`)
logger.info(`Terminated process with PID: ${pid}`)
// Wait for the process to disappear with 5-second timeout
const timeout = 5000 // 5 seconds
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
const checkCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
const { stdout: checkStdout } = await execAsync(`powershell -Command "${checkCommand}"`)
if (!checkStdout.trim()) {
logger.info(`Process with PID ${pid} has disappeared`)
return { success: true, message: `Process ${pid} and all child processes terminated successfully` }
}
// Wait 300ms before checking again
await new Promise((resolve) => setTimeout(resolve, 300))
}
logger.warn(`Process with PID ${pid} did not disappear within timeout`)
return { success: false, message: `Process ${pid} did not disappear within 5 seconds` }
} catch (error) {
logger.error(`Failed to terminate process ${pid}:`, error as Error)
return { success: false, message: `Failed to terminate process ${pid}` }
}
}
/**
* Stop OVMS process if it's running
* @returns Promise<{ success: boolean; message?: string }>
*/
public async stopOvms(): Promise<{ success: boolean; message?: string }> {
try {
// Check if OVMS process is running
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('OVMS process is not running')
return { success: true, message: 'OVMS process is not running' }
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length === 0) {
logger.info('OVMS process is not running')
return { success: true, message: 'OVMS process is not running' }
}
// Terminate all OVMS processes using terminalProcess
for (const process of processList) {
const result = await this.terminalProcess(process.Id)
if (!result.success) {
logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`)
return { success: false, message: `Failed to terminate OVMS process: ${result.message}` }
}
logger.info(`Terminated OVMS process with PID: ${process.Id}`)
}
// Reset the ovms instance
this.ovms = null
logger.info('OVMS process stopped successfully')
return { success: true, message: 'OVMS process stopped successfully' }
} catch (error) {
logger.error(`Failed to stop OVMS process: ${error}`)
return { success: false, message: 'Failed to stop OVMS process' }
}
}
/**
* Run OVMS by ensuring config.json exists and executing run.bat
* @returns Promise<{ success: boolean; message?: string }>
*/
public async runOvms(): Promise<{ success: boolean; message?: string }> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
const runBatPath = path.join(ovmsDir, 'run.bat')
try {
// Check if config.json exists, if not create it with default content
if (!(await fs.pathExists(configPath))) {
logger.info(`Config file does not exist, creating: ${configPath}`)
// Ensure the models directory exists
await fs.ensureDir(path.dirname(configPath))
// Create config.json with default content
const defaultConfig = {
mediapipe_config_list: [],
model_config_list: []
}
await fs.writeJson(configPath, defaultConfig, { spaces: 2 })
logger.info(`Config file created: ${configPath}`)
}
// Check if run.bat exists
if (!(await fs.pathExists(runBatPath))) {
logger.error(`run.bat not found at: ${runBatPath}`)
return { success: false, message: 'run.bat not found' }
}
// Run run.bat without waiting for it to complete
logger.info(`Starting OVMS with run.bat: ${runBatPath}`)
exec(`"${runBatPath}"`, { cwd: ovmsDir }, (error) => {
if (error) {
logger.error(`Error running run.bat: ${error}`)
}
})
logger.info('OVMS started successfully')
return { success: true }
} catch (error) {
logger.error(`Failed to run OVMS: ${error}`)
return { success: false, message: 'Failed to run OVMS' }
}
}
/**
* Get OVMS status - checks installation and running status
* @returns 'not-installed' | 'not-running' | 'running'
*/
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
const homeDir = homedir()
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
try {
// Check if OVMS executable exists
if (!(await fs.pathExists(ovmsPath))) {
logger.info(`OVMS executable not found at: ${ovmsPath}`)
return 'not-installed'
}
// Check if OVMS process is running
//const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq "${ovmsPath.replace(/\\/g, '\\\\')}" } | Select-Object Id | ConvertTo-Json`;
//const { stdout } = await execAsync(`powershell -Command "${psCommand}"`);
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('OVMS process not running')
return 'not-running'
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length > 0) {
logger.info('OVMS process is running')
return 'running'
} else {
logger.info('OVMS process not running')
return 'not-running'
}
} catch (error) {
logger.info(`Failed to check OVMS status: ${error}`)
return 'not-running'
}
}
/**
* Initialize OVMS by finding the executable path and working directory
*/
public async initializeOvms(): Promise<boolean> {
// Use PowerShell to find ovms.exe processes with their paths
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.error('Command to find OVMS process returned no output')
return false
}
logger.debug(`OVMS process output: ${stdout}`)
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
// Find the first process with a valid path
for (const process of processList) {
this.ovms = {
pid: process.Id,
path: process.Path,
workingDirectory: path.dirname(process.Path)
}
return true
}
return this.ovms !== null
}
/**
* Check if the Model Name and ID are valid, they are valid only if they are not used in the config.json
* @param modelName Name of the model to check
* @param modelId ID of the model to check
*/
public async isNameAndIDAvalid(modelName: string, modelId: string): Promise<boolean> {
if (!modelName || !modelId) {
logger.error('Model name and ID cannot be empty')
return false
}
const homeDir = homedir()
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return false
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn(`No mediapipe_config_list found in config: ${configPath}`)
return false
}
// Check if the model name or ID already exists in the config
const exists = config.mediapipe_config_list.some(
(model) => model.name === modelName || model.base_path === modelId
)
if (exists) {
logger.warn(`Model with name "${modelName}" or ID "${modelId}" already exists in the config`)
return false
}
} catch (error) {
logger.error(`Failed to check model existence: ${error}`)
return false
}
return true
}
private async applyModelPath(modelDirPath: string): Promise<boolean> {
const homeDir = homedir()
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
if (!(await fs.pathExists(patchDir))) {
return true
}
const modelId = path.basename(modelDirPath)
// get all sub directories in patchDir
const patchs = await fs.readdir(patchDir)
for (const patch of patchs) {
const fullPatchPath = path.join(patchDir, patch)
if (fs.lstatSync(fullPatchPath).isDirectory()) {
if (modelId.toLowerCase().includes(patch.toLowerCase())) {
// copy all files from fullPath to modelDirPath
try {
const files = await fs.readdir(fullPatchPath)
for (const file of files) {
const srcFile = path.join(fullPatchPath, file)
const destFile = path.join(modelDirPath, file)
await fs.copyFile(srcFile, destFile)
}
} catch (error) {
logger.error(`Failed to copy files from ${fullPatchPath} to ${modelDirPath}: ${error}`)
return false
}
logger.info(`Applied patchs for model ${modelId}`)
return true
}
}
}
return true
}
/**
* Add a model to OVMS by downloading it
* @param modelName Name of the model to add
* @param modelId ID of the model to download
* @param modelSource Model Source: huggingface, hf-mirror and modelscope, default is huggingface
* @param task Task type: text_generation, embedding, rerank, image_generation
*/
public async addModel(
modelName: string,
modelId: string,
modelSource: string,
task: string = 'text_generation'
): Promise<{ success: boolean; message?: string }> {
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
const homeDir = homedir()
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const pathModel = path.join(ovdndDir, 'models', modelId)
try {
// check the ovdnDir+'models'+modelId exist or not
if (await fs.pathExists(pathModel)) {
logger.error(`Model with ID ${modelId} already exists`)
return { success: false, message: 'Model ID already exists!' }
}
// remove the model directory if it exists
if (await fs.pathExists(pathModel)) {
logger.info(`Removing existing model directory: ${pathModel}`)
await fs.remove(pathModel)
}
// Use ovdnd.exe for downloading instead of ovms.exe
const ovdndPath = path.join(ovdndDir, 'ovdnd.exe')
const command =
`"${ovdndPath}" --pull ` +
`--model_repository_path "${ovdndDir}/models" ` +
`--source_model "${modelId}" ` +
`--model_name "${modelName}" ` +
`--target_device GPU ` +
`--task ${task} ` +
`--overwrite_models`
const env: Record<string, string | undefined> = {
...process.env,
OVMS_DIR: ovdndDir,
PYTHONHOME: path.join(ovdndDir, 'python'),
PATH: `${process.env.PATH};${ovdndDir};${path.join(ovdndDir, 'python')}`
}
if (modelSource) {
env.HF_ENDPOINT = modelSource
}
logger.info(`Running command: ${command} from ${modelSource}`)
const { stdout } = await execAsync(command, { env: env, cwd: ovdndDir })
logger.info('Model download completed')
logger.debug(`Command output: ${stdout}`)
} catch (error) {
// remove ovdnDir+'models'+modelId if it exists
if (await fs.pathExists(pathModel)) {
logger.info(`Removing failed model directory: ${pathModel}`)
await fs.remove(pathModel)
}
logger.error(`Failed to add model: ${error}`)
return {
success: false,
message: `Download model ${modelId} failed, please check following items and try it again:<p>- the model id</p><p>- network connection and proxy</p>`
}
}
// Update config file
if (!(await this.updateModelConfig(modelName, modelId))) {
logger.error('Failed to update model config')
return { success: false, message: 'Failed to update model config' }
}
if (!(await this.applyModelPath(pathModel))) {
logger.error('Failed to apply model patchs')
return { success: false, message: 'Failed to apply model patchs' }
}
logger.info(`Model ${modelName} added successfully with ID ${modelId}`)
return { success: true }
}
/**
* Stop the model download process if it's running
* @returns Promise<{ success: boolean; message?: string }>
*/
public async stopAddModel(): Promise<{ success: boolean; message?: string }> {
try {
// Check if ovdnd.exe process is running
const psCommand = `Get-Process -Name "ovdnd" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('ovdnd process is not running')
return { success: true, message: 'Model download process is not running' }
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length === 0) {
logger.info('ovdnd process is not running')
return { success: true, message: 'Model download process is not running' }
}
// Terminate all ovdnd processes
for (const process of processList) {
this.terminalProcess(process.Id)
}
logger.info('Model download process stopped successfully')
return { success: true, message: 'Model download process stopped successfully' }
} catch (error) {
logger.error(`Failed to stop model download process: ${error}`)
return { success: false, message: 'Failed to stop model download process' }
}
}
/**
* check if the model id exists in the OVMS configuration
* @param modelId ID of the model to check
*/
public async checkModelExists(modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return false
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn('No mediapipe_config_list found in config')
return false
}
return config.mediapipe_config_list.some((model) => model.base_path === modelId)
} catch (error) {
logger.error(`Failed to check model existence: ${error}`)
return false
}
}
/**
* Update the model configuration file
*/
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
// Ensure the models directory exists
await fs.ensureDir(path.dirname(configPath))
let config: OvmsConfig
// Read existing config or create new one
if (await fs.pathExists(configPath)) {
config = await fs.readJson(configPath)
} else {
config = { mediapipe_config_list: [] }
}
// Ensure mediapipe_config_list exists
if (!config.mediapipe_config_list) {
config.mediapipe_config_list = []
}
// Add new model config
const newModelConfig: ModelConfig = {
name: modelName,
base_path: modelId
}
// Check if model already exists, if so, update it
const existingIndex = config.mediapipe_config_list.findIndex((model) => model.base_path === modelId)
if (existingIndex >= 0) {
config.mediapipe_config_list[existingIndex] = newModelConfig
logger.info(`Updated existing model config: ${modelName}`)
} else {
config.mediapipe_config_list.push(newModelConfig)
logger.info(`Added new model config: ${modelName}`)
}
// Write config back to file
await fs.writeJson(configPath, config, { spaces: 2 })
logger.info(`Config file updated: ${configPath}`)
} catch (error) {
logger.error(`Failed to update model config: ${error}`)
return false
}
return true
}
/**
* Get all models from OVMS config, filtered for image generation models
* @returns Array of model configurations
*/
public async getModels(): Promise<ModelConfig[]> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return []
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn('No mediapipe_config_list found in config')
return []
}
// Filter models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
const imageGenerationModels = config.mediapipe_config_list.filter((model) => {
const modelName = model.name.toLowerCase()
return (
modelName.startsWith('sd') ||
modelName.startsWith('stable-diffusion') ||
modelName.startsWith('stable diffusion') ||
modelName.startsWith('flux')
)
})
logger.info(`Found ${imageGenerationModels.length} image generation models`)
return imageGenerationModels
} catch (error) {
logger.error(`Failed to get models: ${error}`)
return []
}
}
}
export default OvmsManager

View File

@@ -1,4 +1,5 @@
import { session, shell, webContents } from 'electron'
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
@@ -36,3 +37,66 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
}
})
}
const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (contents.getType?.() !== 'webview') {
return
}
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
if (!input) {
return
}
const key = input.key?.toLowerCase()
if (!key) {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
if (!isFindShortcut && !isEscape && !isEnter) {
return
}
const host = contents.hostWebContents
if (!host || host.isDestroyed()) {
return
}
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
if (isFindShortcut) {
event.preventDefault()
}
// Send the hotkey event to the renderer
// The renderer will decide whether to preventDefault for Escape and Enter
// based on whether the search bar is visible
host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id,
key,
control: Boolean(input.control),
meta: Boolean(input.meta),
shift: Boolean(input.shift),
alt: Boolean(input.alt)
})
}
contents.on('before-input-event', handleBeforeInput)
contents.once('destroyed', () => {
contents.removeListener('before-input-event', handleBeforeInput)
})
}
export function initWebviewHotkeys() {
webContents.getAllWebContents().forEach((contents) => {
if (contents.isDestroyed()) return
attachKeyboardHandler(contents)
})
app.on('web-contents-created', (_, contents) => {
attachKeyboardHandler(contents)
})
}

View File

@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})

View File

@@ -1,341 +0,0 @@
# Agent Message Architecture Design Document
## Overview
This document describes the architecture for handling agent messages in Cherry Studio, including how agent-specific messages are generated, transformed to AI SDK format, stored, and sent to the UI. The system is designed to be agent-agnostic, allowing multiple agent types (Claude Code, OpenAI, etc.) to integrate seamlessly.
## Core Design Principles
1. **Agent Agnosticism**: The core message handling system should work with any agent type without modification
2. **Data Preservation**: All raw agent data must be preserved alongside transformed UI-friendly formats
3. **Streaming First**: Support real-time streaming of agent responses to the UI
4. **Type Safety**: Strong TypeScript interfaces ensure consistency across the pipeline
## Architecture Components
### 1. Agent Service Layer
Each agent (e.g., ClaudeCodeService) implements the `AgentServiceInterface`:
```typescript
interface AgentServiceInterface {
invoke(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream
}
```
#### Responsibilities:
- Spawn and manage agent-specific processes (e.g., Claude Code CLI)
- Parse agent-specific output formats (e.g., SDKMessage for Claude Code)
- Transform agent messages to AI SDK format
- Emit standardized `AgentStreamEvent` objects
### 2. Agent Stream Events
The standardized event interface that all agents emit:
```typescript
interface AgentStreamEvent {
type: 'chunk' | 'error' | 'complete'
chunk?: UIMessageChunk // AI SDK format for UI
rawAgentMessage?: any // Agent-specific raw message
error?: Error
agentResult?: any // Complete agent-specific result
}
```
### 3. Session Message Service
The `SessionMessageService` acts as the orchestration layer:
#### Responsibilities:
- Manages session lifecycle and persistence
- Collects streaming chunks and raw agent messages
- Stores structured data in the database
- Forwards events to the API layer
### 4. Database Storage
Session messages are stored with complete structured data:
```typescript
interface SessionMessageContent {
aiSDKChunks: UIMessageChunk[] // UI-friendly format
rawAgentMessages: any[] // Original agent messages
agentResult?: any // Complete agent result
agentType: string // Agent identifier
}
```
## Data Flow
```mermaid
graph TD
A[User Input] --> B[API Handler]
B --> C[SessionMessageService]
C --> D[Agent Service]
D --> E[Agent Process]
E --> F[Raw Agent Output]
F --> G[Transform to AI SDK]
G --> H[Emit AgentStreamEvent]
H --> I[SessionMessageService]
I --> J[Store in Database]
I --> K[Forward to Client]
K --> L[UI Rendering]
```
## Message Transformation Process
### Step 1: Raw Agent Message Generation
Each agent generates messages in its native format:
**Claude Code Example:**
```typescript
// SDKMessage from Claude Code CLI
{
type: 'assistant',
uuid: 'msg_123',
session_id: 'session_456',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Hello, I can help...' },
{ type: 'tool_use', id: 'tool_1', name: 'read_file', input: {...} }
]
}
}
```
### Step 2: Transformation to AI SDK Format
The agent service transforms native messages to AI SDK `UIMessageChunk`:
```typescript
// In ClaudeCodeService
const emitChunks = (sdkMessage: SDKMessage) => {
// Transform to AI SDK format
const chunks = transformSDKMessageToUIChunk(sdkMessage)
for (const chunk of chunks) {
stream.emit('data', {
type: 'chunk',
chunk, // AI SDK format
rawAgentMessage: sdkMessage // Preserve original
})
}
}
```
**Transformed AI SDK Chunk:**
```typescript
{
type: 'text-delta',
id: 'msg_123',
delta: 'Hello, I can help...',
providerMetadata: {
claudeCode: {
originalSDKMessage: {...},
uuid: 'msg_123',
session_id: 'session_456'
}
}
}
```
### Step 3: Session Message Processing
The SessionMessageService collects and processes events:
```typescript
// Collect streaming data
const streamedChunks: UIMessageChunk[] = []
const rawAgentMessages: any[] = []
claudeStream.on('data', async (event: AgentStreamEvent) => {
switch (event.type) {
case 'chunk':
streamedChunks.push(event.chunk)
if (event.rawAgentMessage) {
rawAgentMessages.push(event.rawAgentMessage)
}
// Forward to client
sessionStream.emit('data', { type: 'chunk', chunk: event.chunk })
break
case 'complete':
// Store complete structured data
const content = {
aiSDKChunks: streamedChunks,
rawAgentMessages: rawAgentMessages,
agentResult: event.agentResult,
agentType: event.agentResult?.agentType || 'unknown'
}
// Save to database...
break
}
})
```
### Step 4: Client Streaming
The API handler converts events to Server-Sent Events (SSE):
```typescript
// In API handler
messageStream.on('data', (event: any) => {
switch (event.type) {
case 'chunk':
// Send AI SDK chunk as SSE
res.write(`data: ${JSON.stringify(event.chunk)}\n\n`)
break
case 'complete':
res.write('data: [DONE]\n\n')
res.end()
break
}
})
```
## Adding New Agent Types
To add support for a new agent (e.g., OpenAI):
### 1. Create Agent Service
```typescript
class OpenAIService implements AgentServiceInterface {
invokeStream(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream {
const stream = new OpenAIStream()
// Call OpenAI API
const openaiResponse = await openai.chat.completions.create({
messages: [{ role: 'user', content: prompt }],
stream: true
})
// Transform OpenAI format to AI SDK
for await (const chunk of openaiResponse) {
const aiSDKChunk = transformOpenAIToAISDK(chunk)
stream.emit('data', {
type: 'chunk',
chunk: aiSDKChunk,
rawAgentMessage: chunk // Preserve OpenAI format
})
}
return stream
}
}
```
### 2. Create Transform Function
```typescript
function transformOpenAIToAISDK(openaiChunk: OpenAIChunk): UIMessageChunk {
return {
type: 'text-delta',
id: openaiChunk.id,
delta: openaiChunk.choices[0].delta.content,
providerMetadata: {
openai: {
original: openaiChunk,
model: openaiChunk.model
}
}
}
}
```
### 3. Register Agent Type
Update the agent type enum and factory:
```typescript
export type AgentType = 'claude-code' | 'openai' | 'anthropic-api'
function createAgentService(type: AgentType): AgentServiceInterface {
switch (type) {
case 'claude-code':
return new ClaudeCodeService()
case 'openai':
return new OpenAIService()
// ...
}
}
```
## Benefits of This Architecture
1. **Extensibility**: Easy to add new agent types without modifying core logic
2. **Data Integrity**: Raw agent data is never lost during transformation
3. **Debugging**: Complete message history available for troubleshooting
4. **Performance**: Streaming support for real-time responses
5. **Type Safety**: Strong interfaces prevent runtime errors
6. **UI Consistency**: All agents provide data in standard AI SDK format
## Key Interfaces Reference
### AgentStreamEvent
```typescript
interface AgentStreamEvent {
type: 'chunk' | 'error' | 'complete'
chunk?: UIMessageChunk
rawAgentMessage?: any
error?: Error
agentResult?: any
}
```
### SessionMessageEntity
```typescript
interface SessionMessageEntity {
id: number
session_id: string
parent_id?: number
role: 'user' | 'assistant' | 'system' | 'tool'
type: string
content: string | SessionMessageContent
metadata?: Record<string, any>
created_at: string
updated_at: string
}
```
### SessionMessageContent
```typescript
interface SessionMessageContent {
aiSDKChunks: UIMessageChunk[]
rawAgentMessages: any[]
agentResult?: any
agentType: string
}
```
## Testing Strategy
### Unit Tests
- Test each transform function independently
- Verify event emission sequences
- Validate data structure preservation
### Integration Tests
- Test complete flow from input to database
- Verify streaming behavior
- Test error handling and recovery
### Agent-Specific Tests
- Validate agent-specific transformations
- Test edge cases for each agent type
- Verify metadata preservation
## Future Enhancements
1. **Message Replay**: Ability to replay sessions from stored raw messages
2. **Format Migration**: Tools to migrate between agent formats
3. **Analytics**: Aggregate metrics from raw agent data
4. **Caching**: Cache transformed chunks for performance
5. **Compression**: Compress raw messages for storage efficiency
## Conclusion
This architecture provides a robust, extensible foundation for handling messages from multiple AI agents while maintaining data integrity and providing a consistent interface for the UI. The separation of concerns between agent-specific logic and core message handling ensures the system can evolve to support new agents and features without breaking existing functionality.

View File

@@ -59,16 +59,23 @@ export abstract class BaseService {
}
if (ids && ids.length > 0) {
for (const id of ids) {
const server = await mcpApiService.getServerInfo(id)
if (server) {
server.tools.forEach((tool: MCPTool) => {
tools.push({
id: `mcp_${id}_${tool.name}`,
name: tool.name,
type: 'mcp',
description: tool.description || '',
requirePermissions: true
try {
const server = await mcpApiService.getServerInfo(id)
if (server) {
server.tools.forEach((tool: MCPTool) => {
tools.push({
id: `mcp_${id}_${tool.name}`,
name: tool.name,
type: 'mcp',
description: tool.description || '',
requirePermissions: true
})
})
}
} catch (error) {
logger.warn('Failed to list MCP tools', {
id,
error: error as Error
})
}
}

View File

@@ -6,7 +6,7 @@ import type {
ListOptions
} from '@types'
import { TextStreamPart } from 'ai'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, not } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { sessionMessagesTable } from '../database/schema'
@@ -276,7 +276,7 @@ export class SessionMessageService extends BaseService {
const result = await this.database
.select({ agent_session_id: sessionMessagesTable.agent_session_id })
.from(sessionMessagesTable)
.where(eq(sessionMessagesTable.session_id, sessionId))
.where(and(eq(sessionMessagesTable.session_id, sessionId), not(eq(sessionMessagesTable.agent_session_id, ''))))
.orderBy(desc(sessionMessagesTable.created_at))
.limit(1)

View File

@@ -1,4 +1,4 @@
import type { SDKMessage } from '@anthropic-ai/claude-code'
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { describe, expect, it } from 'vitest'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'

View File

@@ -2,7 +2,7 @@
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-code'
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
import { validateModelId } from '@main/apiServer/utils'
@@ -27,7 +27,7 @@ class ClaudeCodeService implements AgentServiceInterface {
constructor() {
// Resolve Claude Code CLI robustly (works in dev and in asar)
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-code/cli.js')
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-agent-sdk/cli.js')
if (app.isPackaged) {
this.claudeExecutablePath = this.claudeExecutablePath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
}
@@ -78,10 +78,21 @@ class ClaudeCodeService implements AgentServiceInterface {
const apiConfig = await apiConfigService.get()
const loginShellEnv = await getLoginShellEnvironment()
const loginShellEnvWithoutProxies = Object.fromEntries(
Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy'))
) as Record<string, string>
const env = {
...loginShellEnv,
ANTHROPIC_API_KEY: apiConfig.apiKey,
ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
...loginShellEnvWithoutProxies,
// TODO: fix the proxy api server
// ANTHROPIC_API_KEY: apiConfig.apiKey,
// ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
// ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
ANTHROPIC_API_KEY: modelInfo.provider.apiKey,
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
ANTHROPIC_MODEL: modelInfo.modelId,
ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId,
ELECTRON_RUN_AS_NODE: '1',
ELECTRON_NO_ATTACH_CONSOLE: '1'
}
@@ -93,13 +104,20 @@ class ClaudeCodeService implements AgentServiceInterface {
abortController,
cwd,
env,
model: modelInfo.modelId,
// model: modelInfo.modelId,
pathToClaudeCodeExecutable: this.claudeExecutablePath,
stderr: (chunk: string) => {
logger.warn('claude stderr', { chunk })
errorChunks.push(chunk)
},
appendSystemPrompt: session.instructions,
systemPrompt: session.instructions
? {
type: 'preset',
preset: 'claude_code',
append: session.instructions
}
: { type: 'preset', preset: 'claude_code' },
settingSources: ['project'],
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,
maxTurns: session.configuration?.max_turns,
@@ -128,6 +146,8 @@ class ClaudeCodeService implements AgentServiceInterface {
if (lastAgentSessionId) {
options.resume = lastAgentSessionId
// TODO: use fork session when we support branching sessions
// options.forkSession = true
}
logger.info('Starting Claude Code SDK query', {
@@ -140,8 +160,18 @@ class ClaudeCodeService implements AgentServiceInterface {
resume: options.resume
})
// Start async processing
this.processSDKQuery(prompt, options, aiStream, errorChunks)
// Start async processing on the next tick so listeners can subscribe first
setImmediate(() => {
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)
})
aiStream.emit('data', {
type: 'error',
error: error instanceof Error ? error : new Error(String(error))
})
})
})
return aiStream
}
@@ -194,6 +224,11 @@ class ClaudeCodeService implements AgentServiceInterface {
message,
event: JSON.stringify(message.event)
})
} else {
logger.silly('Claude response', {
message,
event: JSON.stringify(message)
})
}
// Transform SDKMessage to UIMessageChunks
@@ -244,6 +279,11 @@ class ClaudeCodeService implements AgentServiceInterface {
errorChunks.push(errorObj instanceof Error ? errorObj.message : String(errorObj))
const errorMessage = errorChunks.join('\n\n')
logger.error('SDK query failed', {
duration,
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
stderr: errorChunks
})
// Emit error event
stream.emit('data', {
type: 'error',

View File

@@ -20,7 +20,7 @@
* emitting `text-*` parts and a synthetic `finish-step`.
*/
import { SDKMessage } from '@anthropic-ai/claude-code'
import { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import type { BetaStopReason } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { loggerService } from '@logger'
import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai'
@@ -591,6 +591,15 @@ function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>):
raw: message
}
})
} else if (message.subtype === 'compact_boundary') {
chunks.push({
type: 'raw',
rawValue: {
type: 'compact',
session_id: message.session_id,
raw: message
}
})
}
return chunks
}

View File

@@ -4,7 +4,7 @@ import {
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import EventEmitter from 'events'
import { z } from 'zod'
import * as z from 'zod'
export interface OAuthStorageData {
clientInfo?: OAuthClientInformation

View File

@@ -2,6 +2,7 @@ import { loggerService } from '@logger'
import { isLinux } from '@main/constant'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { ovOcrService } from './builtin/OvOcrService'
import { ppocrService } from './builtin/PpocrService'
import { systemOcrService } from './builtin/SystemOcrService'
import { tesseractService } from './builtin/TesseractService'
@@ -22,6 +23,10 @@ export class OcrService {
this.registry.delete(providerId)
}
public listProviderIds(): string[] {
return Array.from(this.registry.keys())
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id)
if (!handler) {
@@ -39,3 +44,5 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
!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

@@ -0,0 +1,128 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
import { exec } from 'child_process'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { promisify } from 'util'
import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('OvOcrService')
const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
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')
}
private getImgDir(): string {
return path.join(this.getOvOcrPath(), 'img')
}
private getOutputDir(): string {
return path.join(this.getOvOcrPath(), 'output')
}
private async clearDirectory(dirPath: string): Promise<void> {
if (fs.existsSync(dirPath)) {
const files = await fs.promises.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) {
await this.clearDirectory(filePath)
await fs.promises.rmdir(filePath)
} else {
await fs.promises.unlink(filePath)
}
}
} else {
// If the directory does not exist, create it
await fs.promises.mkdir(dirPath, { recursive: true })
}
}
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
const imgDir = this.getImgDir()
const targetFilePath = path.join(imgDir, targetFileName)
await fs.promises.copyFile(sourceFilePath, targetFilePath)
}
private async runOcrBatch(): Promise<void> {
const ovOcrPath = this.getOvOcrPath()
try {
// Execute run.bat in the ov-ocr directory
await execAsync(`"${PATH_BAT_FILE}"`, {
cwd: ovOcrPath,
timeout: 60000 // 60 second timeout
})
} catch (error) {
logger.error(`Error running ovocr batch: ${error}`)
throw new Error(`Failed to run OCR batch: ${error}`)
}
}
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
try {
// 1. Clear img directory and output directory
await this.clearDirectory(this.getImgDir())
await this.clearDirectory(this.getOutputDir())
// 2. Copy file to img directory
const fileName = path.basename(filePath)
await this.copyFileToImgDir(filePath, fileName)
logger.info(`File copied to img directory: ${fileName}`)
// 3. Run run.bat
logger.info('Running OV OCR batch process...')
await this.runOcrBatch()
// 4. Check that output/[basename].txt file exists
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
if (!fs.existsSync(outputFilePath)) {
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
}
// 5. Read output/[basename].txt file content
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
// 6. Return result
return { text: ocrText }
} catch (error) {
logger.error(`Error during OV OCR process: ${error}`)
throw error
}
}
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
if (isImageFileMetadata(file)) {
return this.ocrImage(file.path, options)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const ovOcrService = new OvOcrService()

View File

@@ -1,7 +1,7 @@
import { loadOcrImage } from '@main/utils/ocr'
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
import { net } from 'electron'
import { z } from 'zod'
import * as z from 'zod'
import { OcrBaseService } from './OcrBaseService'

View File

@@ -1,8 +1,8 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
import * as fs from 'fs'
import OpenAI from 'openai'
import { CacheService } from '../CacheService'
import { BaseFileService } from './BaseFileService'

View File

@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
@@ -12,6 +12,7 @@ import {
FileListResponse,
FileMetadata,
FileUploadResponse,
GetApiServerStatusResult,
KnowledgeBaseParams,
KnowledgeItem,
KnowledgeSearchResult,
@@ -22,8 +23,11 @@ import {
OcrProvider,
OcrResult,
Provider,
RestartApiServerStatusResult,
S3Config,
Shortcut,
StartApiServerStatusResult,
StopApiServerStatusResult,
SupportedOcrFile,
ThemeMode,
WebDavConfig
@@ -51,7 +55,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -95,7 +99,8 @@ const api = {
},
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
},
devTools: {
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
@@ -199,7 +204,8 @@ const api = {
}
ipcRenderer.on('file-change', listener)
return () => ipcRenderer.off('file-change', listener)
}
},
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
@@ -221,7 +227,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
add: ({
base,
item,
@@ -286,6 +292,16 @@ const api = {
clearAuthCache: (projectId: string, clientEmail?: string) =>
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
},
ovms: {
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),
getModels: () => ipcRenderer.invoke(IpcChannel.Ovms_GetModels),
isRunning: () => ipcRenderer.invoke(IpcChannel.Ovms_IsRunning),
getStatus: () => ipcRenderer.invoke(IpcChannel.Ovms_GetStatus),
runOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_RunOVMS),
stopOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_StopOVMS)
},
config: {
set: (key: string, value: any, isNotify: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
@@ -351,6 +367,7 @@ const api = {
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
installOvmsBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallOvmsBinary),
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
@@ -377,7 +394,16 @@ const api = {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
@@ -454,7 +480,8 @@ const api = {
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
},
cherryai: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
@@ -474,6 +501,12 @@ const api = {
ipcRenderer.removeListener(channel, listener)
}
}
},
apiServer: {
getStatus: (): Promise<GetApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus),
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
}
}

View File

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

View File

@@ -6,6 +6,8 @@
import { loggerService } from '@logger'
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage } from '@renderer/utils/error'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import type { TextStreamPart, ToolSet } from 'ai'
@@ -24,6 +26,8 @@ export class AiSdkToChunkAdapter {
private isFirstChunk = true
private enableWebSearch: boolean = false
private onSessionUpdate?: (sessionId: string) => void
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
constructor(
private onChunk: (chunk: Chunk) => void,
@@ -38,6 +42,17 @@ export class AiSdkToChunkAdapter {
this.onSessionUpdate = onSessionUpdate
}
private markFirstTokenIfNeeded() {
if (this.firstTokenTimestamp === null && this.responseStartTimestamp !== null) {
this.firstTokenTimestamp = Date.now()
}
}
private resetTimingState() {
this.responseStartTimestamp = null
this.firstTokenTimestamp = null
}
/**
* 处理 AI SDK 流结果
* @param aiSdkResult AI SDK 的流结果对象
@@ -65,6 +80,8 @@ export class AiSdkToChunkAdapter {
webSearchResults: [],
reasoningId: ''
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset link converter state at the start of stream
this.isFirstChunk = true
@@ -77,6 +94,7 @@ export class AiSdkToChunkAdapter {
if (this.enableWebSearch) {
const remainingText = flushLinkConverterBuffer()
if (remainingText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: remainingText
@@ -91,6 +109,7 @@ export class AiSdkToChunkAdapter {
}
} finally {
reader.releaseLock()
this.resetTimingState()
}
}
@@ -152,6 +171,7 @@ export class AiSdkToChunkAdapter {
// Only emit chunk if there's text to send
if (finalText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: this.accumulate ? final.text : finalText
@@ -176,16 +196,18 @@ export class AiSdkToChunkAdapter {
break
case 'reasoning-delta':
final.reasoningContent += chunk.text || ''
if (chunk.text) {
this.markFirstTokenIfNeeded()
}
this.onChunk({
type: ChunkType.THINKING_DELTA,
text: final.reasoningContent || '',
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
text: final.reasoningContent || ''
})
break
case 'reasoning-end':
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent
text: final.reasoningContent || ''
})
final.reasoningContent = ''
break
@@ -276,44 +298,37 @@ export class AiSdkToChunkAdapter {
break
}
case 'finish':
case 'finish': {
const usage = {
completion_tokens: chunk.totalUsage?.outputTokens || 0,
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
total_tokens: chunk.totalUsage?.totalTokens || 0
}
const metrics = this.buildMetrics(chunk.totalUsage)
const baseResponse = {
text: final.text || '',
reasoning_content: final.reasoningContent || ''
}
this.onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
}
})
this.onChunk({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
}
})
this.resetTimingState()
break
}
// === 源和文件相关事件 ===
case 'source':
@@ -342,13 +357,45 @@ export class AiSdkToChunkAdapter {
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error: chunk.error as Record<string, any>
error: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
})
break
default:
}
}
private buildMetrics(totalUsage?: {
inputTokens?: number | null
outputTokens?: number | null
totalTokens?: number | null
}) {
if (!totalUsage) {
return undefined
}
const completionTokens = totalUsage.outputTokens ?? 0
const now = Date.now()
const start = this.responseStartTimestamp ?? now
const firstToken = this.firstTokenTimestamp
const timeFirstToken = Math.max(firstToken != null ? firstToken - start : 0, 0)
const baseForCompletion = firstToken ?? start
let timeCompletion = Math.max(now - baseForCompletion, 0)
if (timeCompletion === 0 && completionTokens > 0) {
timeCompletion = 1
}
return {
completion_tokens: completionTokens,
time_first_token_millsec: timeFirstToken,
time_completion_millsec: timeCompletion
}
}
}
export default AiSdkToChunkAdapter

View File

@@ -12,8 +12,24 @@ import { loggerService } from '@logger'
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type {
Assistant,
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams
} from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
@@ -21,7 +37,6 @@ import LegacyAiProvider from './legacy/index'
import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas'
import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
import { buildPlugins } from './plugins/PluginBuilder'
import { buildClaudeCodeSystemMessage } from './provider/config/anthropic'
import { createAiSdkProvider } from './provider/factory'
import {
getActualProvider,
@@ -83,10 +98,8 @@ export default class ModernAiProvider {
throw new Error('Model is required for completions. Please use constructor with model parameter.')
}
// 确保配置存在
if (!this.config) {
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
}
// 每次请求时重新生成配置以确保API key轮换生效
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
// 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config)
@@ -122,13 +135,9 @@ export default class ModernAiProvider {
}
if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') {
const claudeCodeSystemMessage = buildClaudeCodeSystemMessage(params.system)
const claudeCodeSystemMessage = buildClaudeCodeSystemModelMessage(params.system)
params.system = undefined // 清除原有system避免重复
if (Array.isArray(params.messages)) {
params.messages = [...claudeCodeSystemMessage, ...params.messages]
} else {
params.messages = claudeCodeSystemMessage
}
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
}
if (config.topicId && getEnableDeveloperMode()) {
@@ -504,6 +513,34 @@ export default class ModernAiProvider {
return images
}
/**
* We manually implement this method before aisdk supports it well
*/
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
return this.legacyProvider.createVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
return this.legacyProvider.retrieveVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
return this.legacyProvider.retrieveVideoContent(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
return this.legacyProvider.deleteVideo(params)
}
public getBaseURL(): string {
return this.legacyProvider.getBaseURL()
}

View File

@@ -12,6 +12,7 @@ import { VertexAPIClient } from './gemini/VertexAPIClient'
import { NewAPIClient } from './newapi/NewAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { OVMSClient } from './ovms/OVMSClient'
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
@@ -63,6 +64,12 @@ export class ApiClientFactory {
return instance
}
if (provider.id === 'ovms') {
logger.debug(`Creating OVMSClient for provider: ${provider.id}`)
instance = new OVMSClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的 Provider Type
switch (provider.type) {
case 'openai':

View File

@@ -70,13 +70,19 @@ export abstract class BaseApiClient<
{
public provider: Provider
protected host: string
protected apiKey: string
protected sdkInstance?: TSdkInstance
constructor(provider: Provider) {
this.provider = provider
this.host = this.getBaseURL()
this.apiKey = this.getApiKey()
}
/**
* Get the current API key with rotation support
* This getter ensures API keys rotate on each access when multiple keys are configured
*/
protected get apiKey(): string {
return this.getApiKey()
}
/**

View File

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { Provider } from '@renderer/types'
import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,3 +1,9 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import {
ChatCompletionContentPart,
ChatCompletionContentPartRefusal,
ChatCompletionTool
} from '@cherrystudio/openai/resources'
import { loggerService } from '@logger'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import {
@@ -78,8 +84,6 @@ import {
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { t } from 'i18next'
import OpenAI, { AzureOpenAI } from 'openai'
import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool } from 'openai/resources'
import { GenericChunk } from '../../middleware/schemas'
import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types'

View File

@@ -1,4 +1,6 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants'
import {
isClaudeReasoningModel,
isOpenAIReasoningModel,
@@ -24,7 +26,6 @@ import {
ReasoningEffortOptionalParams
} from '@renderer/types/sdk'
import { formatApiHost } from '@renderer/utils/api'
import OpenAI, { AzureOpenAI } from 'openai'
import { BaseApiClient } from '../BaseApiClient'
@@ -167,8 +168,7 @@ export abstract class OpenAIBaseClient<
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {})
}
}) as TSdkInstance
}

View File

@@ -1,3 +1,5 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { ResponseInput } from '@cherrystudio/openai/resources/responses/responses'
import { loggerService } from '@logger'
import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas'
import { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types'
@@ -34,6 +36,12 @@ import {
OpenAIResponseSdkTool,
OpenAIResponseSdkToolCall
} from '@renderer/types/sdk'
import {
CreateVideoParams,
DeleteVideoParams,
RetrieveVideoContentParams,
RetrieveVideoParams
} from '@renderer/types/video'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
isSupportedToolUse,
@@ -45,8 +53,6 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
import { MB } from '@shared/config/constant'
import { t } from 'i18next'
import { isEmpty } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import { ResponseInput } from 'openai/resources/responses/responses'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
import { OpenAIAPIClient } from './OpenAIApiClient'
@@ -152,6 +158,26 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return await sdk.responses.create(payload, options)
}
public async createVideo(params: CreateVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.create(params.params, params.options)
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.retrieve(params.videoId, params.options)
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<Response> {
const sdk = await this.getSdkInstance()
return sdk.videos.downloadContent(params.videoId, params.query, params.options)
}
public async deleteVideo(params: DeleteVideoParams): Promise<OpenAI.Videos.VideoDeleteResponse> {
const sdk = await this.getSdkInstance()
return sdk.videos.delete(params.videoId, params.options)
}
private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
if (file.size > 32 * MB) return undefined
try {
@@ -343,7 +369,14 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
switch (message.type) {
case 'function_call_output':
sum += estimateTextTokens(message.output)
if (typeof message.output === 'string') {
sum += estimateTextTokens(message.output)
} else {
sum += message.output
.filter((item) => item.type === 'input_text')
.map((item) => estimateTextTokens(item.text))
.reduce((prev, cur) => prev + cur, 0)
}
break
case 'function_call':
sum += estimateTextTokens(message.arguments)

View File

@@ -0,0 +1,56 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { objectKeys, Provider } from '@renderer/types'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
const logger = loggerService.withContext('OVMSClient')
export class OVMSClient extends OpenAIAPIClient {
constructor(provider: Provider) {
super(provider)
}
override async listModels(): Promise<OpenAI.Models.Model[]> {
try {
const sdk = await this.getSdkInstance()
const chatModelsResponse = await sdk.request({
method: 'get',
path: '../v1/config'
})
logger.debug(`Chat models response: ${JSON.stringify(chatModelsResponse)}`)
// Parse the config response to extract model information
const config = chatModelsResponse as Record<string, any>
const models = objectKeys(config)
.map((modelName) => {
const modelInfo = config[modelName]
// Check if model has at least one version with "AVAILABLE" state
const hasAvailableVersion = modelInfo?.model_version_status?.some(
(versionStatus: any) => versionStatus?.state === 'AVAILABLE'
)
if (hasAvailableVersion) {
return {
id: modelName,
object: 'model' as const,
owned_by: 'ovms',
created: Date.now()
}
}
return null // Skip models without available versions
})
.filter(Boolean) // Remove null entries
logger.debug(`Processed models: ${JSON.stringify(models)}`)
// Filter out unsupported models
return models.filter((model): model is OpenAI.Models.Model => model !== null && isSupportedModel(model))
} catch (error) {
logger.error(`Error listing OVMS models: ${error}`)
return []
}
}
}

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,4 +1,5 @@
import Anthropic from '@anthropic-ai/sdk'
import OpenAI from '@cherrystudio/openai'
import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@renderer/types'
import { Provider } from '@renderer/types'
import {
@@ -13,7 +14,6 @@ import {
SdkTool,
SdkToolCall
} from '@renderer/types/sdk'
import OpenAI from 'openai'
import { CompletionsParams, GenericChunk } from '../middleware/schemas'
import { CompletionsContext } from '../middleware/types'

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { GenerateImageParams } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -5,8 +5,22 @@ import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@render
import { getProviderByModel } from '@renderer/services/AssistantService'
import { withSpanResult } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import type {
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams
} from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { AihubmixAPIClient } from './clients/aihubmix/AihubmixAPIClient'
@@ -179,6 +193,54 @@ export default class AiProvider {
return this.apiClient.generateImage(params)
}
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.createVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.retrieveVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const response = await this.apiClient.retrieveVideoContent(params)
return {
type: 'openai',
response
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const result = await this.apiClient.deleteVideo(params)
return {
type: 'openai',
result
}
} else {
throw new Error('Video deletion is not supported by this provider')
}
}
public getBaseURL(): string {
return this.apiClient.getBaseURL()
}

View File

@@ -1,10 +1,10 @@
import OpenAI from '@cherrystudio/openai'
import { toFile } from '@cherrystudio/openai/uploads'
import { isDedicatedImageGenerationModel } from '@renderer/config/models'
import FileManager from '@renderer/services/FileManager'
import { ChunkType } from '@renderer/types/chunk'
import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
import OpenAI from 'openai'
import { toFile } from 'openai/uploads'
import { BaseApiClient } from '../../clients/BaseApiClient'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'

View File

@@ -4,6 +4,8 @@ import type { MCPTool, Message, Model, Provider } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
import { noThinkMiddleware } from './noThinkMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
/**
@@ -186,6 +188,14 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
// 其他provider的通用处理
break
}
// OVMS+MCP's specific middleware
if (config.provider.id === 'ovms' && config.mcpTools && config.mcpTools.length > 0) {
builder.add({
name: 'no-think',
middleware: noThinkMiddleware()
})
}
}
/**

View File

@@ -0,0 +1,52 @@
import { loggerService } from '@logger'
import { LanguageModelMiddleware } from 'ai'
const logger = loggerService.withContext('noThinkMiddleware')
/**
* No Think Middleware
* Automatically appends ' /no_think' string to the end of user messages for the provider
* This prevents the model from generating unnecessary thinking process and returns results directly
* @returns LanguageModelMiddleware
*/
export function noThinkMiddleware(): LanguageModelMiddleware {
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)) {
const lastContent = message.content[message.content.length - 1]
// If the last content is text type, append ' /no_think'
if (lastContent && lastContent.type === 'text' && typeof lastContent.text === 'string') {
// Avoid duplicate additions
if (!lastContent.text.endsWith('/no_think')) {
logger.debug('Adding /no_think to user message')
return {
...message,
content: [
...message.content.slice(0, -1),
{
...lastContent,
text: lastContent.text + ' /no_think'
}
]
}
}
}
}
}
return message
})
}
return transformedParams
}
}
}

View File

@@ -5,7 +5,6 @@ import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { Assistant } from '@renderer/types'
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
import reasoningTimePlugin from './reasoningTimePlugin'
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
import { createTelemetryPlugin } from './telemetryPlugin'
@@ -39,9 +38,9 @@ export function buildPlugins(
}
// 3. 推理模型时添加推理插件
if (middlewareConfig.enableReasoning) {
plugins.push(reasoningTimePlugin)
}
// if (middlewareConfig.enableReasoning) {
// plugins.push(reasoningTimePlugin)
// }
// 4. 启用Prompt工具调用时添加工具插件
if (middlewareConfig.isPromptToolUse) {

View File

@@ -3,6 +3,7 @@
* 处理文件内容提取、文件格式转换、文件上传等逻辑
*/
import type OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { getProviderByModel } from '@renderer/services/AssistantService'
import type { FileMetadata, Message, Model } from '@renderer/types'
@@ -10,7 +11,6 @@ import { FileTypes } from '@renderer/types'
import { FileMessageBlock } from '@renderer/types/newMessage'
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
import type { FilePart, TextPart } from 'ai'
import type OpenAI from 'openai'
import { getAiSdkProviderId } from '../provider/factory'
import { getFileSizeLimit, supportsImageInput, supportsLargeFileUpload, supportsPdfInput } from './modelCapabilities'

View File

@@ -23,6 +23,7 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
@@ -159,14 +160,14 @@ export async function buildStreamTextParams(
abortSignal: options.requestOptions?.signal,
headers: options.requestOptions?.headers,
providerOptions,
stopWhen: stepCountIs(10),
stopWhen: stepCountIs(20),
maxRetries: 0
}
if (tools) {
params.tools = tools
}
if (assistant.prompt) {
params.system = assistant.prompt
params.system = await replacePromptVariables(assistant.prompt, model.name)
}
logger.debug('params', params)
return {

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn()
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({ copilot: { defaultHeaders: {} } })
}
}))
import type { Model, Provider } from '@renderer/types'
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
import { providerToAiSdkConfig } from '../providerConfig'
const createWindowKeyv = () => {
const store = new Map<string, string>()
return {
get: (key: string) => store.get(key),
set: (key: string, value: string) => {
store.set(key, value)
}
}
}
const createCopilotProvider = (): Provider => ({
id: 'copilot',
type: 'openai',
name: 'GitHub Copilot',
apiKey: 'test-key',
apiHost: 'https://api.githubcopilot.com',
models: [],
isSystem: true
})
const createModel = (id: string, name = id): Model => ({
id,
name,
provider: 'copilot',
group: 'copilot'
})
describe('Copilot responses routing', () => {
beforeEach(() => {
;(globalThis as any).window = {
...(globalThis as any).window,
keyv: createWindowKeyv()
}
})
it('detects official GPT-5 Codex identifiers case-insensitively', () => {
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'gpt-5-codex'))).toBe(true)
expect(isCopilotResponsesModel(createModel('GPT-5-CODEX', 'GPT-5-CODEX'))).toBe(true)
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'custom-name'))).toBe(true)
expect(isCopilotResponsesModel(createModel('custom-id', 'custom-name'))).toBe(false)
})
it('configures gpt-5-codex with the Copilot provider', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION)
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
expect(config.options.headers?.['copilot-vision-request']).toBe('true')
})
it('uses the Copilot provider for other models and keeps headers', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-4'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
})
})

View File

@@ -1,24 +0,0 @@
import { SystemModelMessage } from 'ai'
export function buildClaudeCodeSystemMessage(system?: string): Array<SystemModelMessage> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
if (!system || system.trim() === defaultClaudeCodeSystem) {
return [
{
role: 'system',
content: defaultClaudeCodeSystem
}
]
}
return [
{
role: 'system',
content: defaultClaudeCodeSystem
},
{
role: 'system',
content: system
}
]
}

View File

@@ -0,0 +1,25 @@
import type { Model } from '@renderer/types'
export const COPILOT_EDITOR_VERSION = 'vscode/1.104.1'
export const COPILOT_PLUGIN_VERSION = 'copilot-chat/0.26.7'
export const COPILOT_INTEGRATION_ID = 'vscode-chat'
export const COPILOT_USER_AGENT = 'GitHubCopilotChat/0.26.7'
export const COPILOT_DEFAULT_HEADERS = {
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
'User-Agent': COPILOT_USER_AGENT,
'Editor-Version': COPILOT_EDITOR_VERSION,
'Editor-Plugin-Version': COPILOT_PLUGIN_VERSION,
'editor-version': COPILOT_EDITOR_VERSION,
'editor-plugin-version': COPILOT_PLUGIN_VERSION,
'copilot-vision-request': 'true'
} as const
// Models that require the OpenAI Responses endpoint when routed through GitHub Copilot (#10560)
const COPILOT_RESPONSES_MODEL_IDS = ['gpt-5-codex']
export function isCopilotResponsesModel(model: Model): boolean {
const normalizedId = model.id?.trim().toLowerCase()
const normalizedName = model.name?.trim().toLowerCase()
return COPILOT_RESPONSES_MODEL_IDS.some((target) => normalizedId === target || normalizedName === target)
}

View File

@@ -28,7 +28,8 @@ const STATIC_PROVIDER_MAPPING: Record<string, ProviderId> = {
gemini: 'google', // Google Gemini -> google
'azure-openai': 'azure', // Azure OpenAI -> azure
'openai-response': 'openai', // OpenAI Responses -> openai
grok: 'xai' // Grok -> xai
grok: 'xai', // Grok -> xai
copilot: 'github-copilot-openai-compatible'
}
/**

View File

@@ -21,6 +21,7 @@ import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
@@ -62,13 +63,14 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider)
}
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider)
}
@@ -109,6 +111,9 @@ function formatProviderApiHost(provider: Provider): Provider {
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
@@ -151,6 +156,26 @@ export function providerToAiSdkConfig(
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === 'copilot'
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
headers: {
...COPILOT_DEFAULT_HEADERS,
...storedHeaders,
...actualProvider.extra_headers
},
name: actualProvider.id,
includeUsage: true
})
return {
providerId: 'github-copilot-openai-compatible',
options
}
}
// 处理OpenAI模式
const extraOptions: any = {}
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
@@ -172,15 +197,6 @@ export function providerToAiSdkConfig(
}
}
}
// copilot
if (actualProvider.id === 'copilot') {
extraOptions.headers = {
...extraOptions.headers,
'editor-version': 'vscode/1.97.2',
'copilot-vision-request': 'true'
}
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
extraOptions.apiVersion = actualProvider.apiVersion
@@ -229,7 +245,6 @@ export function providerToAiSdkConfig(
}
}
// 如果AI SDK支持该provider使用原生配置
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
return {
@@ -277,9 +292,17 @@ export async function prepareSpecialProviderConfig(
) {
switch (provider.id) {
case 'copilot': {
const defaultHeaders = store.getState().copilot.defaultHeaders
const { token } = await window.api.copilot.getToken(defaultHeaders)
const defaultHeaders = store.getState().copilot.defaultHeaders ?? {}
const headers = {
...COPILOT_DEFAULT_HEADERS,
...defaultHeaders
}
const { token } = await window.api.copilot.getToken(headers)
config.options.apiKey = token
config.options.headers = {
...headers,
...config.options.headers
}
break
}
case 'cherryai': {

View File

@@ -32,6 +32,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
supportsImageGeneration: true,
aliases: ['vertexai-anthropic']
},
{
id: 'github-copilot-openai-compatible',
name: 'GitHub Copilot OpenAI Compatible',
import: () => import('@opeoginni/github-copilot-openai-compatible'),
creatorFunctionName: 'createGitHubCopilotOpenAICompatible',
supportsImageGeneration: false,
aliases: ['copilot', 'github-copilot']
},
{
id: 'bedrock',
name: 'Amazon Bedrock',
@@ -47,6 +55,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createPerplexity',
supportsImageGeneration: false,
aliases: ['perplexity']
},
{
id: 'mistral',
name: 'Mistral',
import: () => import('@ai-sdk/mistral'),
creatorFunctionName: 'createMistral',
supportsImageGeneration: false,
aliases: ['mistral']
}
] as const

View File

@@ -4,7 +4,7 @@ import type { Assistant, KnowledgeReference } from '@renderer/types'
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { isEmpty } from 'lodash'
import { z } from 'zod'
import * as z from 'zod'
/**
* 知识库搜索工具

View File

@@ -1,7 +1,7 @@
import store from '@renderer/store'
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
import { MemoryProcessor } from '../../services/MemoryProcessor'

View File

@@ -3,7 +3,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
import * as z from 'zod'
/**
* 使用预提取关键词的网络搜索工具

View File

@@ -6,6 +6,7 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -52,7 +53,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
// Don't disable reasoning for models that require it
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
if (
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -100,6 +106,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// reasoningEffort有效的情况
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(provider)) {
switch (provider.id) {
@@ -142,6 +149,16 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
// Grok 4 Fast doesn't support effort levels, always use enabled: true
if (isGrok4FastReasoningModel(model)) {
return {
reasoning: {
enabled: true // Ignore effort level, just enable reasoning
}
}
}
// Other OpenRouter models that support effort levels
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -412,6 +429,13 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
/**
* Get XAI-specific reasoning parameters
* This function should only be called for XAI provider models
* @param assistant - The assistant configuration
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
@@ -419,6 +443,11 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort) {
return {}
}
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}

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