Compare commits

...

140 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
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
218 changed files with 7345 additions and 2059 deletions

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

@@ -1,8 +1,8 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -452,7 +452,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {

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

@@ -125,21 +125,113 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
What's New in v1.6.3
<!--LANG:en-->
What's New in v1.7.0-beta.1
Features:
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
- Code Tools: Add GitHub Copilot CLI integration
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
- Fix forked topic retaining old name after rename
- Restore first token latency reporting in metrics
- Fix UI scrollbar and overflow issues
Technical Updates:
- Upgrade to Electron 37.6.0
- Update dependencies across packages
- 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
<!--LANG:zh-CN-->
v1.7.0-beta.1 新特性
核心功能:
- 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

@@ -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.5",
"version": "1.7.0-sora.3",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -83,6 +83,7 @@
"@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"
},
@@ -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,7 +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": "0.1.18",
"@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",
@@ -257,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",
@@ -294,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",
@@ -335,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",
@@ -371,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.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.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

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

@@ -53,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',
@@ -316,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
@@ -335,6 +337,7 @@ export enum IpcChannel {
// OCR
OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_AddModel = 'ovms:add-model',

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

@@ -5,105 +5,171 @@ const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_PKG_NAME = 'ovms250911.zip'
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
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'
/**
* Downloads and extracts the OVMS binary for the specified platform
* 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
*/
async function downloadOvmsBinary() {
// Create output directory structure - OVMS goes into its own subdirectory
/**
* Clean old OVMS installation if it exists
*/
function cleanOldOvmsInstallation() {
console.log('Cleaning the existing OVMS installation...')
const csDir = path.join(os.homedir(), '.cherrystudio')
// Ensure directories exist
fs.mkdirSync(csDir, { recursive: true })
const csOvmsDir = path.join(csDir, 'ovms')
// Delete existing OVMS directory if it exists
if (fs.existsSync(csOvmsDir)) {
fs.rmSync(csOvmsDir, { recursive: true })
}
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
// Try each URL until one succeeds
let downloadSuccess = false
let lastError = null
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
try {
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(downloadUrl, tempFilename)
// If we get here, download was successful
downloadSuccess = true
console.log(`Successfully downloaded from: ${downloadUrl}`)
break
fs.rmSync(csOvmsDir, { recursive: true })
} catch (error) {
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
lastError = error
// Clean up failed download file if it exists
if (fs.existsSync(tempFilename)) {
try {
fs.unlinkSync(tempFilename)
} catch (cleanupError) {
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
}
}
// Continue to next URL if this one failed
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
console.log(`Trying next URL...`)
}
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
return 110
}
}
// Check if any download succeeded
if (!downloadSuccess) {
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
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
}
try {
console.log(`Extracting to ${csDir}...`)
// 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 to ${csDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csDir}`)
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}`)
if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
// Check if ovmsDir is empty and remove it if so
try {
const ovmsDir = path.join(csDir, 'ovms')
const files = fs.readdirSync(ovmsDir)
if (files.length === 0) {
fs.rmSync(ovmsDir, { recursive: true })
console.log(`Removed empty directory: ${ovmsDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
return 105
}
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
}
@@ -158,7 +224,27 @@ async function installOvms() {
return 102
}
return await downloadOvmsBinary()
// 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

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

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

@@ -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,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,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,18 +32,18 @@ export class ModelsService {
for (const model of models) {
const provider = providers.find((p) => p.id === model.provider)
logger.debug(`Processing model ${model.id} from provider ${model.provider}`, {
isAnthropicModel: provider?.isAnthropicModel
})
if (
!provider ||
(filter.providerType === 'anthropic' && provider.isAnthropicModel && !provider.isAnthropicModel(model))
) {
logger.debug(`Processing model ${model.id}`)
if (!provider) {
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
continue
}
// Special case: For "aihubmix", it should be covered by above condition, but just in case
if (provider.id === 'aihubmix' && filter.providerType === 'anthropic' && !model.id.includes('claude')) {
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)

View File

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

@@ -786,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
@@ -876,6 +875,7 @@ 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) =>

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

@@ -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
@@ -390,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),
@@ -467,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> }) =>
@@ -487,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'
@@ -355,7 +357,11 @@ 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

View File

@@ -12,8 +12,23 @@ 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'
@@ -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)
@@ -500,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

@@ -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'
@@ -166,7 +167,8 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers
...this.provider.extra_headers,
...(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

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

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

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

@@ -63,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)
}

View File

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

View File

@@ -9,6 +9,8 @@ import {
CreateAgentRequest,
CreateAgentResponse,
CreateAgentResponseSchema,
CreateAgentSessionResponse,
CreateAgentSessionResponseSchema,
CreateSessionForm,
CreateSessionRequest,
GetAgentResponse,
@@ -171,12 +173,12 @@ export class AgentApiClient {
}
}
public async createSession(agentId: string, session: CreateSessionForm): Promise<GetAgentSessionResponse> {
public async createSession(agentId: string, session: CreateSessionForm): Promise<CreateAgentSessionResponse> {
const url = this.getSessionPaths(agentId).base
try {
const payload = session satisfies CreateSessionRequest
const response = await this.axios.post(url, payload)
const data = GetAgentSessionResponseSchema.parse(response.data)
const data = CreateAgentSessionResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to add session.')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
*/
min-width: 45ch;
min-width: 35ch;
.code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};

View File

@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger'
import { Extension, keymap } from '@uiw/react-codemirror'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
@@ -203,3 +203,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
})
}, [onHeightChange])
}
interface UseScrollToLineOptions {
highlight?: boolean
}
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
const domAtPos = view.domAtPos(position)
let node: Node | null = domAtPos.node
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement
}
while (node) {
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
return node
}
node = node.parentElement
}
return null
}, [])
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
if (previousHighlight) {
previousHighlight.classList.remove('animation-locate-highlight')
}
element.classList.add('animation-locate-highlight')
const handleAnimationEnd = () => {
element.classList.remove('animation-locate-highlight')
element.removeEventListener('animationend', handleAnimationEnd)
}
element.addEventListener('animationend', handleAnimationEnd)
}, [])
return useCallback(
(lineNumber: number, options?: UseScrollToLineOptions) => {
const view = editorViewRef.current
if (!view) return
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
const lineElement = findLineElement(view, targetLine.from)
if (lineElement) {
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
if (options?.highlight) {
requestAnimationFrame(() => highlightLine(view, lineElement))
}
return
}
view.dispatch({
effects: EditorView.scrollIntoView(targetLine.from, {
y: 'start'
})
})
if (!options?.highlight) {
return
}
setTimeout(() => {
const fallbackElement = findLineElement(view, targetLine.from)
if (fallbackElement) {
highlightLine(view, fallbackElement)
}
}, 200)
},
[editorViewRef, findLineElement, highlightLine]
)
}

View File

@@ -5,13 +5,14 @@ import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
export interface CodeEditorHandles {
save?: () => void
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
}
export interface CodeEditorProps {
@@ -181,8 +182,11 @@ const CodeEditor = ({
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
const scrollToLine = useScrollToLine(editorViewRef)
useImperativeHandle(ref, () => ({
save: handleSave
save: handleSave,
scrollToLine
}))
return (

View File

@@ -0,0 +1,48 @@
import { FC, memo, useMemo } from 'react'
interface HighlightTextProps {
text: string
keyword: string
caseSensitive?: boolean
className?: string
}
/**
* Text highlighting component that marks keyword matches
*/
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
const highlightedText = useMemo(() => {
if (!keyword || !text) {
return <span>{text}</span>
}
// Escape regex special characters
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(`(${escapedKeyword})`, flags)
// Split text by keyword matches
const parts = text.split(regex)
return (
<>
{parts.map((part, index) => {
// Check if part matches keyword
const isMatch = regex.test(part)
regex.lastIndex = 0 // Reset regex state
if (isMatch) {
return <mark key={index}>{part}</mark>
}
return <span key={index}>{part}</span>
})}
</>
)
}, [text, keyword, caseSensitive])
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
return <span className={combinedClassName}>{highlightedText}</span>
}
export default memo(HighlightText)

View File

@@ -25,7 +25,7 @@ const WebviewContainer = memo(
onNavigateCallback: (appid: string, url: string) => void
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck } = useSettings()
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@@ -76,6 +76,8 @@ const WebviewContainer = memo(
const webviewId = webviewRef.current?.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
// Set link opening behavior for this webview
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
}
@@ -104,6 +106,22 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
// Update webview settings when they change
useEffect(() => {
if (!webviewRef.current) return
try {
const webviewId = webviewRef.current.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView may not be ready yet, settings will be applied in dom-ready event
logger.debug(`WebView ${appid} not ready for settings update`)
}
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
const WebviewStyle: React.CSSProperties = {
width: '100%',
height: '100%',

View File

@@ -17,7 +17,7 @@ import {
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { getModelLogo } from '@renderer/config/models'
import { agentModelFilter, getModelLogo } from '@renderer/config/models'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
@@ -245,14 +245,23 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
const modelOptions = useMemo(() => {
// mocked data. not final version
return (models ?? []).map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
return (models ?? [])
.filter((m) =>
agentModelFilter({
id: m.id,
provider: m.provider || '',
name: m.name,
group: ''
})
)
.map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {

View File

@@ -98,7 +98,7 @@ export const SessionModal: React.FC<Props> = ({
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { createSession } = useSessions(agentId)
const updateSession = useUpdateSession(agentId)
const { updateSession } = useUpdateSession(agentId)
const { agent } = useAgent(agentId)
const isEditing = (session?: AgentSessionEntity) => session !== undefined

View File

@@ -0,0 +1,2 @@
// Attribute used to store the original source line number in markdown editors
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'

View File

@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent } from '@tiptap/react'
import { Tooltip } from 'antd'
@@ -29,6 +30,156 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
import { useRichEditor } from './useRichEditor'
const logger = loggerService.withContext('RichEditor')
/**
* Find element by line number with fallback strategies:
* 1. Exact line + content match
* 2. Exact line match
* 3. Closest line <= target
*/
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
if (allElements.length === 0) {
logger.warn('No elements with data-source-line attribute found')
return null
}
const exactMatches = editorDom.querySelectorAll(
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
) as NodeListOf<HTMLElement>
// Strategy 1: Exact line + content match
if (exactMatches.length > 1 && lineContent) {
for (const match of Array.from(exactMatches)) {
if (match.textContent?.includes(lineContent)) {
return match
}
}
}
// Strategy 2: Exact line match
if (exactMatches.length > 0) {
return exactMatches[0]
}
// Strategy 3: Closest line <= target
let closestElement: HTMLElement | null = null
let closestLine = 0
for (const el of allElements) {
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
if (sourceLine <= lineNumber && sourceLine > closestLine) {
closestLine = sourceLine
closestElement = el
}
}
return closestElement
}
/**
* Create fixed-position highlight overlay at element location
* with boundary detection to prevent overflow and toolbar overlap
*/
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
try {
// Remove previous overlay
const previousOverlay = document.body.querySelector('.highlight-overlay')
if (previousOverlay) {
previousOverlay.remove()
}
const editorWrapper = container.closest('.rich-editor-wrapper')
// Create overlay at element position
const rect = element.getBoundingClientRect()
const overlay = document.createElement('div')
overlay.className = 'highlight-overlay animation-locate-highlight'
overlay.style.position = 'fixed'
overlay.style.left = `${rect.left}px`
overlay.style.top = `${rect.top}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
overlay.style.pointerEvents = 'none'
overlay.style.zIndex = '9999'
overlay.style.borderRadius = '4px'
document.body.appendChild(overlay)
// Update overlay position and visibility on scroll
const updatePosition = () => {
const newRect = element.getBoundingClientRect()
const newContainerRect = container.getBoundingClientRect()
// Update position
overlay.style.left = `${newRect.left}px`
overlay.style.top = `${newRect.top}px`
overlay.style.width = `${newRect.width}px`
overlay.style.height = `${newRect.height}px`
// Get current toolbar bottom (it might change)
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
// Check if overlay is within visible bounds
const overlayTop = newRect.top
const overlayBottom = newRect.bottom
const visibleTop = currentToolbarBottom // Don't overlap toolbar
const visibleBottom = newContainerRect.bottom
// Hide overlay if any part is outside the visible container area
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
overlay.style.opacity = '0'
overlay.style.visibility = 'hidden'
} else {
overlay.style.opacity = '1'
overlay.style.visibility = 'visible'
}
}
container.addEventListener('scroll', updatePosition)
// Auto-remove after animation
const handleAnimationEnd = () => {
overlay.remove()
container.removeEventListener('scroll', updatePosition)
overlay.removeEventListener('animationend', handleAnimationEnd)
}
overlay.addEventListener('animationend', handleAnimationEnd)
} catch (error) {
logger.error('Failed to create highlight overlay:', error as Error)
}
}
/**
* Scroll to element and show highlight after scroll completes
*/
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
let scrollTimeout: NodeJS.Timeout
const handleScroll = () => {
clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(() => {
container.removeEventListener('scroll', handleScroll)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}, 150)
}
container.addEventListener('scroll', handleScroll)
// Fallback: if element already in view (no scroll happens)
setTimeout(() => {
const initialScrollTop = container.scrollTop
setTimeout(() => {
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
container.removeEventListener('scroll', handleScroll)
clearTimeout(scrollTimeout)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}
}, 200)
}, 50)
}
const RichEditor = ({
ref,
initialContent = '',
@@ -372,6 +523,22 @@ const RichEditor = ({
scrollContainerRef.current.scrollTop = value
}
},
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
if (!editor || !scrollContainerRef.current) return
try {
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
if (!element) return
if (options?.highlight) {
scrollAndHighlight(element, scrollContainerRef.current)
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
}
} catch (error) {
logger.error('Failed in scrollToLine:', error as Error)
}
},
// Dynamic command management
registerCommand,
registerToolbarCommand,

View File

@@ -111,6 +111,8 @@ export interface RichEditorRef {
getScrollTop: () => number
/** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void
/** Scroll to specific line number in markdown */
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
// Dynamic command management
/** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void

View File

@@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css'
import { TableKit } from '@cherrystudio/extension-table-plus'
import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import {
@@ -11,6 +12,7 @@ import {
markdownToPreviewText
} from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { migrateMathStrings } from '@tiptap/extension-mathematics'
import Mention from '@tiptap/extension-mention'
@@ -36,6 +38,31 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
const logger = loggerService.withContext('useRichEditor')
// Create extension to preserve data-source-line attribute
const SourceLineAttribute = Extension.create({
name: 'sourceLineAttribute',
addGlobalAttributes() {
return [
{
types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'],
attributes: {
dataSourceLine: {
default: null,
parseHTML: (element) => {
const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR)
return value
},
renderHTML: (attributes) => {
if (!attributes.dataSourceLine) return {}
return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine }
}
}
}
}
]
}
})
export interface UseRichEditorOptions {
/** Initial markdown content */
initialContent?: string
@@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions
const extensions = useMemo(
() => [
SourceLineAttribute,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6]

View File

@@ -32,6 +32,7 @@ import {
Sparkle,
Sun,
Terminal,
Video,
X
} from 'lucide-react'
import { useCallback, useEffect, useMemo } from 'react'
@@ -106,6 +107,8 @@ const getTabIcon = (
return <Settings size={14} />
case 'code':
return <Terminal size={14} />
case 'video':
return <Video size={14} />
default:
return null
}

View File

@@ -23,7 +23,8 @@ export const ToastPortal = () => {
timeout: 3000,
classNames: {
// This setting causes the 'hero-toast' class to be applied twice to the toast element. This is weird and I don't know why, but it works.
base: 'hero-toast'
// `w-auto` would not overwrite default style, which set the width to a fixed value and causes text overflow.
base: 'hero-toast w-auto! max-w-[50vw]'
}
}}
/>,

View File

@@ -1,5 +1,6 @@
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import { RequireSome } from '@renderer/types'
import { t } from 'i18next'
type AddToastProps = Parameters<typeof addToast>[0]
type ToastPropsColored = Omit<AddToastProps, 'color'>
@@ -54,7 +55,7 @@ export const loading = (args: RequireSome<AddToastProps, 'promise'>) => {
if (args.timeout === undefined) {
args.timeout = 1
}
return addToast(args)
return addToast({ title: t('common.loading'), ...args })
}
export const getToastUtilities = () =>

View File

@@ -74,7 +74,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
`
const NavbarLeftContainer = styled.div`
min-width: var(--assistants-width);
min-width: ${isMac ? 'calc(var(--assistants-width) - 20px)' : 'var(--assistants-width)'};
padding: 0 10px;
display: flex;
flex-direction: row;

View File

@@ -26,7 +26,8 @@ import {
Palette,
Settings,
Sparkle,
Sun
Sun,
Video
} from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -139,7 +140,8 @@ const MainMenus: FC = () => {
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={18} className="icon" />,
notes: <NotepadText size={18} className="icon" />,
code_tools: <Code size={18} className="icon" />
code_tools: <Code size={18} className="icon" />,
video: <Video size={18} className="icon" />
}
const pathMap = {
@@ -151,7 +153,8 @@ const MainMenus: FC = () => {
knowledge: '/knowledge',
files: '/files',
code_tools: '/code',
notes: '/notes'
notes: '/notes',
video: '/video'
}
return sidebarIcons.visible.map((icon) => {

View File

@@ -14,7 +14,7 @@ import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
// Reasoning models
export const REASONING_REGEX =
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
@@ -24,6 +24,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
gpt5_codex: ['low', 'medium', 'high'] as const,
grok: ['low', 'high'] as const,
grok4_fast: ['auto'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
qwen: ['low', 'medium', 'high'] as const,
@@ -43,6 +44,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
@@ -66,6 +68,8 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
}
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isGrok4FastReasoningModel(model)) {
thinkingModelType = 'grok4_fast'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
thinkingModelType = 'gemini'
@@ -142,19 +146,46 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
}
const modelId = getLowerBaseModelName(model.id)
const providerId = model.provider.toLowerCase()
if (modelId.includes('grok-3-mini')) {
return true
}
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
return true
}
return false
}
/**
* Checks if the model is Grok 4 Fast reasoning version
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
*
* Note: XAI official uses different model IDs for reasoning vs non-reasoning
* Third-party providers like OpenRouter expose a single ID with reasoning parameters, while first-party providers require separate IDs. Only the OpenRouter variant supports toggling.
*
* @param model - The model to check
* @returns true if the model is a reasoning-enabled Grok 4 Fast model
*/
export function isGrok4FastReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('grok-4-fast') && !modelId.includes('non-reasoning')
}
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
if (
isSupportedReasoningEffortGrokModel(model) ||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
) {
return true
}
@@ -265,7 +296,11 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
return modelId.startsWith('qwen3') && modelId.includes('thinking')
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
return (
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
)
}
// Doubao 支持思考模式的模型正则
@@ -329,7 +364,10 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
return (
isSupportedReasoningEffortPerplexityModel(model) ||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
)
}
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
@@ -443,6 +481,8 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-vl-235b-a22b-thinking$': { min: 0, max: 81_920 },
'qwen3-vl-30b-a3b-thinking$': { min: 0, max: 81_920 },
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
'qwen3-1\\.7b$': { min: 0, max: 30_720 },

View File

@@ -1,11 +1,12 @@
import OpenAI from '@cherrystudio/openai'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding'
import { Model } from '@renderer/types'
import { getLowerBaseModelName } from '@renderer/utils'
import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts'
import { getWebSearchTools } from '../tools'
import { isOpenAIReasoningModel } from './reasoning'
import { isGenerateImageModel, isVisionModel } from './vision'
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
import { isOpenAIWebSearchChatCompletionOnlyModel } from './websearch'
export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i
@@ -246,3 +247,7 @@ export const isOpenAIOpenWeightModel = (model: Model) => {
// zhipu 视觉推理模型用这组 special token 标记推理结果
export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const
export const agentModelFilter = (model: Model): boolean => {
return !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model)
}

View File

@@ -0,0 +1,149 @@
import { SystemProviderId, Video } from '@renderer/types'
// Hard-encoded for now. We may implement a function to filter video generation model from provider.models.
export const videoModelsMap = {
openai: ['sora-2', 'sora-2-pro'] as const
} as const satisfies Partial<Record<SystemProviderId, string[]>>
// Mock data for testing
export const mockVideos: Video[] = [
{
id: '1',
type: 'openai',
status: 'downloaded',
prompt: 'A beautiful sunset over the ocean with waves crashing',
thumbnail: 'https://picsum.photos/200/200?random=1',
fileId: 'file-001',
providerId: 'openai',
name: 'video-001',
metadata: {
id: 'video-001',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2',
progress: 100,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'completed'
}
},
{
id: '2',
type: 'openai',
status: 'in_progress',
prompt: 'A cat playing with a ball of yarn in slow motion',
progress: 65,
providerId: 'openai',
name: 'video-002',
metadata: {
id: 'video-002',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 65,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'in_progress'
}
},
{
id: '3',
type: 'openai',
status: 'queued',
prompt: 'Time-lapse of flowers blooming in a garden',
providerId: 'openai',
name: 'video-003',
metadata: {
id: 'video-003',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '12',
size: '1280x720',
status: 'queued'
}
},
{
id: '4',
type: 'openai',
prompt: 'Birds flying in formation against blue sky',
status: 'downloading',
progress: 80,
thumbnail: 'https://picsum.photos/200/200?random=4',
providerId: 'openai',
name: 'video-004',
metadata: {
id: 'video-004',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'completed'
}
},
{
id: '5',
type: 'openai',
status: 'failed',
error: { code: '400', message: 'Video generation failed' },
prompt: 'Mountain landscape with snow peaks and forest',
providerId: 'openai',
name: 'video-005',
metadata: {
id: 'video-005',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: { code: '400', message: 'Video generation failed' },
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'failed'
}
},
{
id: '6',
type: 'openai',
status: 'completed',
thumbnail: 'https://picsum.photos/200/200?random=6',
prompt: 'City street at night with neon lights reflecting on wet pavement',
providerId: 'openai',
name: 'video-006',
metadata: {
id: 'video-006',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '12',
size: '1024x1792',
status: 'completed'
}
}
]

View File

@@ -24,7 +24,7 @@ const visionAllowedModels = [
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni',
'qwen3-omni(?:-[\\w-]+)?',
'qvq',
'internvl2',
'grok-vision-beta',
@@ -82,14 +82,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image-preview',
'gemini-2.5-flash-image',
'gemini-2.0-flash-preview-image-generation'
]
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
// Models that should auto-enable image generation button when selected
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
@@ -107,7 +107,7 @@ export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation',
'gemini-2.5-flash-image-preview',
'gemini-2.5-flash-image',
...DEDICATED_IMAGE_MODELS
]

View File

@@ -1,6 +1,7 @@
import {
BuiltinOcrProvider,
BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider,
OcrProviderCapability,
OcrSystemProvider,
@@ -50,10 +51,23 @@ const ppocrOcr: OcrPpocrProvider = {
}
} as const
const ovOcr: OcrOvProvider = {
id: 'ovocr',
name: 'Intel OV(NPU) OCR',
config: {
langs: isWin ? ['en-us', 'zh-cn'] : undefined
},
capabilities: {
image: true
// pdf: true
}
} as const satisfies OcrOvProvider
export const BUILTIN_OCR_PROVIDERS_MAP = {
tesseract,
system: systemOcr,
paddleocr: ppocrOcr
paddleocr: ppocrOcr,
ovocr: ovOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)

View File

@@ -58,7 +58,6 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import {
AtLeast,
isSystemProvider,
Model,
OpenAIServiceTiers,
Provider,
ProviderType,
@@ -88,6 +87,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.net',
anthropicApiHost: 'https://open.cherryin.net',
models: [],
isSystem: true,
enabled: true
@@ -109,7 +109,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
apiKey: '',
apiHost: 'https://aihubmix.com',
anthropicApiHost: 'https://aihubmix.com/anthropic',
isAnthropicModel: (m: Model) => m.id.includes('claude'),
models: SYSTEM_MODELS.aihubmix,
isSystem: true,
enabled: false
@@ -289,7 +288,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': {
id: 'new-api',
name: 'New API',
type: 'openai',
type: 'new-api',
apiKey: '',
apiHost: 'http://localhost:3000',
anthropicApiHost: 'http://localhost:3000',
@@ -1432,5 +1431,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
}
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id)
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
}

View File

@@ -1,5 +1,5 @@
import { ChatCompletionTool } from '@cherrystudio/openai/resources'
import { Model } from '@renderer/types'
import { ChatCompletionTool } from 'openai/resources'
import { WEB_SEARCH_PROMPT_FOR_ZHIPU } from './prompts'

View File

@@ -0,0 +1,4 @@
export type UpdateAgentBaseOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}

View File

@@ -0,0 +1,8 @@
import { useRuntime } from '../useRuntime'
import { useAgent } from './useAgent'
export const useActiveAgent = () => {
const { chat } = useRuntime()
const { activeAgentId } = chat
return useAgent(activeAgentId)
}

View File

@@ -0,0 +1,9 @@
import { useRuntime } from '../useRuntime'
import { useSession } from './useSession'
export const useActiveSession = () => {
const { chat } = useRuntime()
const { activeSessionIdMap, activeAgentId } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
return useSession(activeAgentId, activeSessionId)
}

View File

@@ -1,18 +1,28 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useApiServer } from '../useApiServer'
import { useAgentClient } from './useAgentClient'
export const useAgent = (id: string | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = id ? client.agentPaths.withId(id) : null
const { apiServerConfig, apiServerRunning } = useApiServer()
const fetcher = useCallback(async () => {
if (!id || id === 'fake') {
return null
if (!id) {
throw new Error(t('agent.get.error.null_id'))
}
if (!apiServerConfig.enabled) {
throw new Error(t('apiServer.messages.notEnabled'))
}
if (!apiServerRunning) {
throw new Error(t('agent.server.error.not_running'))
}
const result = await client.getAgent(id)
return result
}, [client, id])
}, [apiServerConfig.enabled, apiServerRunning, client, id, t])
const { data, error, isLoading } = useSWR(key, id ? fetcher : null)
return {

View File

@@ -17,7 +17,7 @@ export const useAgentSessionInitializer = () => {
const dispatch = useAppDispatch()
const client = useAgentClient()
const { chat } = useRuntime()
const { activeAgentId, activeSessionId } = chat
const { activeAgentId, activeSessionIdMap } = chat
/**
* Initialize session for the given agent by loading its sessions
@@ -25,11 +25,11 @@ export const useAgentSessionInitializer = () => {
*/
const initializeAgentSession = useCallback(
async (agentId: string) => {
if (!agentId || agentId === 'fake') return
if (!agentId) return
try {
// Check if this agent already has an active session
const currentSessionId = activeSessionId[agentId]
const currentSessionId = activeSessionIdMap[agentId]
if (currentSessionId) {
// Session already exists, just switch to session view
dispatch(setActiveTopicOrSessionAction('session'))
@@ -58,21 +58,21 @@ export const useAgentSessionInitializer = () => {
dispatch(setActiveTopicOrSessionAction('session'))
}
},
[client, dispatch, activeSessionId]
[client, dispatch, activeSessionIdMap]
)
/**
* Auto-initialize when activeAgentId changes
*/
useEffect(() => {
if (activeAgentId && activeAgentId !== 'fake') {
if (activeAgentId) {
// Check if we need to initialize this agent's session
const hasActiveSession = activeSessionId[activeAgentId]
const hasActiveSession = activeSessionIdMap[activeAgentId]
if (!hasActiveSession) {
initializeAgentSession(activeAgentId)
}
}
}, [activeAgentId, activeSessionId, initializeAgentSession])
}, [activeAgentId, activeSessionIdMap, initializeAgentSession])
return {
initializeAgentSession

View File

@@ -6,6 +6,7 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useApiServer } from '../useApiServer'
import { useRuntime } from '../useRuntime'
import { useAgentClient } from './useAgentClient'
@@ -23,11 +24,19 @@ export const useAgents = () => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.agentPaths.base
const { apiServerConfig, apiServerRunning } = useApiServer()
const fetcher = useCallback(async () => {
// API server will start on startup if enabled OR there are agents
if (!apiServerConfig.enabled && !apiServerRunning) {
throw new Error(t('apiServer.messages.notEnabled'))
}
if (!apiServerRunning) {
throw new Error(t('agent.server.error.not_running'))
}
const result = await client.listAgents()
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data
}, [client])
}, [apiServerConfig.enabled, apiServerRunning, client, t])
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const { chat } = useRuntime()
const { activeAgentId } = chat

View File

@@ -1,21 +1,24 @@
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { UpdateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { useCallback, useEffect, useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
import { useUpdateSession } from './useUpdateSession'
export const useSession = (agentId: string, sessionId: string) => {
export const useSession = (agentId: string | null, sessionId: string | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.getSessionPaths(agentId).withId(sessionId)
const key = agentId && sessionId ? client.getSessionPaths(agentId).withId(sessionId) : null
const dispatch = useAppDispatch()
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
const sessionTopicId = useMemo(() => (sessionId ? buildAgentSessionTopicId(sessionId) : null), [sessionId])
const { updateSession } = useUpdateSession(agentId)
const fetcher = async () => {
if (!agentId) throw new Error(t('agent.get.error.null_id'))
if (!sessionId) throw new Error(t('agent.session.get.error.null_id'))
const data = await client.getSession(agentId, sessionId)
return data
}
@@ -24,26 +27,13 @@ export const useSession = (agentId: string, sessionId: string) => {
// Use loadTopicMessagesThunk to load messages (with caching mechanism)
// This ensures messages are preserved when switching between sessions/tabs
useEffect(() => {
if (sessionId) {
if (sessionTopicId) {
// loadTopicMessagesThunk will check if messages already exist in Redux
// and skip loading if they do (unless forceReload is true)
dispatch(loadTopicMessagesThunk(sessionTopicId))
}
}, [dispatch, sessionId, sessionTopicId])
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
if (!agentId) return
try {
const result = await client.updateSession(agentId, form)
mutate(result)
} catch (error) {
window.toast.error(t('agent.session.update.error.failed'))
}
},
[agentId, client, mutate, t]
)
return {
session: data,
error,

View File

@@ -1,4 +1,4 @@
import { CreateSessionForm } from '@renderer/types'
import { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -6,46 +6,50 @@ import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useSessions = (agentId: string) => {
export const useSessions = (agentId: string | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.getSessionPaths(agentId).base
const key = agentId ? client.getSessionPaths(agentId).base : null
const fetcher = async () => {
if (!agentId) throw new Error('No active agent.')
const data = await client.listSessions(agentId)
return data.data
}
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const createSession = useCallback(
async (form: CreateSessionForm) => {
async (form: CreateSessionForm): Promise<CreateAgentSessionResponse | null> => {
if (!agentId) return null
try {
const result = await client.createSession(agentId, form)
await mutate((prev) => [...(prev ?? []), result], { revalidate: false })
await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false })
return result
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed')))
return undefined
return null
}
},
[agentId, client, mutate, t]
)
// TODO: including messages field
const getSession = useCallback(
async (id: string) => {
async (id: string): Promise<GetAgentSessionResponse | null> => {
if (!agentId) return null
try {
const result = await client.getSession(agentId, id)
mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session)))
return result
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed')))
return null
}
},
[agentId, client, mutate, t]
)
const deleteSession = useCallback(
async (id: string) => {
async (id: string): Promise<boolean> => {
if (!agentId) return false
try {
await client.deleteSession(agentId, id)

View File

@@ -4,20 +4,16 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { UpdateAgentBaseOptions } from './types'
import { useAgentClient } from './useAgentClient'
export type UpdateAgentOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}
export const useUpdateAgent = () => {
const { t } = useTranslation()
const client = useAgentClient()
const listKey = client.agentPaths.base
const updateAgent = useCallback(
async (form: UpdateAgentForm, options?: UpdateAgentOptions) => {
async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions) => {
try {
const itemKey = client.agentPaths.withId(form.id)
// may change to optimistic update
@@ -35,7 +31,7 @@ export const useUpdateAgent = () => {
)
const updateModel = useCallback(
async (agentId: string, modelId: string, options?: UpdateAgentOptions) => {
async (agentId: string, modelId: string, options?: UpdateAgentBaseOptions) => {
updateAgent({ id: agentId, model: modelId }, options)
},
[updateAgent]

View File

@@ -1,19 +1,21 @@
import { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { getErrorMessage } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { UpdateAgentBaseOptions } from './types'
import { useAgentClient } from './useAgentClient'
export const useUpdateSession = (agentId: string) => {
export const useUpdateSession = (agentId: string | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions) => {
if (!agentId) return
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
const sessionId = form.id
try {
const itemKey = paths.withId(sessionId)
@@ -24,13 +26,29 @@ export const useUpdateSession = (agentId: string) => {
(prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? []
)
mutate(itemKey, result)
window.toast.success(t('common.update_success'))
if (options?.showSuccessToast ?? true) {
window.toast.success(t('common.update_success'))
}
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
window.toast.error({ title: t('agent.session.update.error.failed'), description: getErrorMessage(error) })
}
},
[agentId, client, listKey, paths, t]
[agentId, client, t]
)
return updateSession
const updateModel = useCallback(
async (sessionId: string, modelId: string, options?: UpdateAgentBaseOptions) => {
if (!agentId) return
return updateSession(
{
id: sessionId,
model: modelId
},
options
)
},
[agentId, updateSession]
)
return { updateSession, updateModel }
}

View File

@@ -0,0 +1,112 @@
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useApiServer')
export const useApiServer = () => {
const { t } = useTranslation()
// FIXME: We currently store two copies of the config data in both the renderer and the main processes,
// which carries the risk of data inconsistency. This should be modified so that the main process stores
// the data, and the renderer retrieves it.
const apiServerConfig = useAppSelector((state) => state.settings.apiServer)
const dispatch = useAppDispatch()
// Optimistic initial state.
const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled)
const [apiServerLoading, setApiServerLoading] = useState(true)
const setApiServerEnabled = useCallback(
(enabled: boolean) => {
dispatch(setApiServerEnabledAction(enabled))
},
[dispatch]
)
// API Server functions
const checkApiServerStatus = useCallback(async () => {
setApiServerLoading(true)
try {
const status = await window.api.apiServer.getStatus()
setApiServerRunning(status.running)
} catch (error: any) {
logger.error('Failed to check API server status:', error)
} finally {
setApiServerLoading(false)
}
}, [])
const startApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.start()
if (result.success) {
setApiServerRunning(true)
window.toast.success(t('apiServer.messages.startSuccess'))
} else {
window.toast.error(t('apiServer.messages.startError') + result.error)
}
} catch (error: any) {
window.toast.error(t('apiServer.messages.startError') + (error.message || error))
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
const stopApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.stop()
if (result.success) {
setApiServerRunning(false)
window.toast.success(t('apiServer.messages.stopSuccess'))
} else {
window.toast.error(t('apiServer.messages.stopError') + result.error)
}
} catch (error: any) {
window.toast.error(t('apiServer.messages.stopError') + (error.message || error))
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
const restartApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.restart()
if (result.success) {
await checkApiServerStatus()
window.toast.success(t('apiServer.messages.restartSuccess'))
} else {
window.toast.error(t('apiServer.messages.restartError') + result.error)
}
} catch (error) {
window.toast.error(t('apiServer.messages.restartFailed') + (error as Error).message)
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, checkApiServerStatus, t])
useEffect(() => {
checkApiServerStatus()
}, [checkApiServerStatus])
return {
apiServerConfig,
apiServerRunning,
apiServerLoading,
startApiServer,
stopApiServer,
restartApiServer,
checkApiServerStatus,
setApiServerEnabled
}
}

View File

@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import IntelLogo from '@renderer/assets/images/providers/intel.png'
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
@@ -83,6 +84,8 @@ export const useOcrProviders = () => {
return <MonitorIcon size={size} />
case 'paddleocr':
return <Avatar size={size} src={PaddleocrLogo} />
case 'ovocr':
return <Avatar size={size} src={IntelLogo} />
}
}
return <FileQuestionMarkIcon size={size} />

View File

@@ -0,0 +1,17 @@
import { useAppDispatch } from '@renderer/store'
import { setPendingAction } from '@renderer/store/runtime'
import { useCallback } from 'react'
import { useRuntime } from './useRuntime'
export const usePending = () => {
const { pendingMap } = useRuntime()
const dispatch = useAppDispatch()
const setPending = useCallback(
(id: string, value: boolean | undefined) => {
dispatch(setPendingAction({ id, value }))
},
[dispatch]
)
return { pendingMap, setPending }
}

View File

@@ -0,0 +1,65 @@
import OpenAI from '@cherrystudio/openai'
import { useCallback } from 'react'
import { useProviderVideos } from './useProviderVideos'
export const useAddOpenAIVideo = (providerId: string) => {
const { addVideo } = useProviderVideos(providerId)
const addOpenAIVideo = useCallback(
(video: OpenAI.Videos.Video, prompt: string) => {
switch (video.status) {
case 'queued':
addVideo({
id: video.id,
name: video.id,
providerId,
status: video.status,
type: 'openai',
metadata: video,
prompt
})
break
case 'in_progress':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'in_progress',
type: 'openai',
progress: video.progress,
metadata: video,
prompt
})
break
case 'completed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'completed',
type: 'openai',
metadata: video,
prompt,
thumbnail: null
})
break
case 'failed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'failed',
type: 'openai',
error: video.error,
metadata: video,
prompt
})
break
}
},
[addVideo, providerId]
)
return addOpenAIVideo
}

View File

@@ -0,0 +1,47 @@
import { retrieveVideo } from '@renderer/services/ApiService'
import useSWR, { SWRConfiguration, useSWRConfig } from 'swr'
import { useProvider } from '../useProvider'
import { useVideo } from './useVideo'
export const useOpenAIVideo = (providerId: string, id: string) => {
const { provider } = useProvider(providerId)
const fetcher = async () => {
switch (provider.type) {
case 'openai-response':
return retrieveVideo({
type: 'openai',
videoId: id,
provider
})
default:
throw new Error(`Unsupported provider type: ${provider.type}`)
}
}
const video = useVideo(providerId, id)
let options: SWRConfiguration = {}
switch (video?.status) {
case 'queued':
case 'in_progress':
options = {
refreshInterval: 3000
}
break
default:
options = {
revalidateOnFocus: false,
revalidateOnMount: true
}
}
const { data, isLoading, error } = useSWR(`video/openai/${id}`, fetcher, options)
const { mutate } = useSWRConfig()
const revalidate = () => mutate(`video/openai/${id}`)
return {
video: data,
isLoading,
error,
revalidate
}
}

View File

@@ -0,0 +1,174 @@
import { loggerService } from '@logger'
import { retrieveVideo } from '@renderer/services/ApiService'
import { getProviderById } from '@renderer/services/ProviderService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addVideoAction, setVideoAction, setVideosAction, updateVideoAction } from '@renderer/store/video'
import { Video } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useVideos } from './useVideos'
import { useVideoThumbnail } from './useVideoThumbnail'
const logger = loggerService.withContext('useVideo')
export const useProviderVideos = (providerId: string) => {
const { removeVideo } = useVideos()
const videos = useAppSelector((state) => state.video.videoMap[providerId])
const videosRef = useRef(videos)
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
videosRef.current = videos
}, [videos])
const getVideo = useCallback(
(id: string) => {
return videos?.find((v) => v.id === id)
},
[videos]
)
const addVideo = useCallback(
(video: Video) => {
if (videos && videos.every((v) => v.id !== video.id)) {
dispatch(addVideoAction({ providerId, video }))
}
},
[dispatch, providerId, videos]
)
const updateVideo = useCallback(
(update: Partial<Omit<Video, 'status'>> & { id: string }) => {
dispatch(updateVideoAction({ providerId, update }))
},
[dispatch, providerId]
)
const setVideo = useCallback(
(video: Video) => {
dispatch(setVideoAction({ providerId, video }))
},
[dispatch, providerId]
)
const setVideos = useCallback(
(newVideos: Video[]) => {
dispatch(setVideosAction({ providerId, videos: newVideos }))
},
[dispatch, providerId]
)
const removeProviderVideo = useCallback(
(videoId: string) => {
removeVideo(videoId, providerId)
},
[providerId, removeVideo]
)
useEffect(() => {
if (!videos) {
setVideos([])
}
}, [setVideos, videos])
// update videos from api
// NOTE: This provider should support openai videos endpoint. No runtime check here.
const provider = getProviderById(providerId)
const fetcher = async () => {
if (!videos || !provider) return []
if (provider.type === 'openai-response') {
const openaiVideos = videos
.filter((v) => v.type === 'openai')
.filter((v) => v.status === 'queued' || v.status === 'in_progress')
const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider }))
const result = await Promise.allSettled(jobs)
return result.filter((p) => p.status === 'fulfilled').map((p) => p.value)
} else {
throw new Error(`Provider type ${provider.type} is not supported for video status polling`)
}
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const { retrieveThumbnail } = useVideoThumbnail()
useEffect(() => {
if (error) {
logger.error('Failed to fetch video status updates', error)
return
}
if (!provider) {
logger.warn(`Provider ${providerId} not found.`)
return
}
const videos = videosRef.current
if (!data || !videos) return
data.forEach((v) => {
const retrievedVideo = v.video
const storeVideo = videos.find((v) => v.id === retrievedVideo.id)
if (!storeVideo) {
logger.warn(`Try to update video ${retrievedVideo.id}, but it's not in the store.`)
return
}
switch (retrievedVideo.status) {
case 'in_progress':
if (storeVideo.status === 'queued' || storeVideo.status === 'in_progress') {
setVideo({
...storeVideo,
status: 'in_progress',
progress: retrievedVideo.progress,
metadata: retrievedVideo
})
}
break
case 'completed': {
if (storeVideo.status === 'in_progress' || storeVideo.status === 'queued') {
const newVideo = { ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo } as const
setVideo(newVideo)
// Try to get thumbnail
retrieveThumbnail(newVideo)
.then((thumbnail) => {
const latestVideo = videosRef.current?.find((v) => v.id === newVideo.id)
if (
thumbnail !== null &&
latestVideo &&
latestVideo.status !== 'queued' &&
latestVideo.status !== 'in_progress' &&
latestVideo.status !== 'failed'
) {
setVideo({
...latestVideo,
thumbnail
})
}
})
.catch((e) => {
logger.error('Failed to get thumbnail', e as Error)
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
})
}
break
}
case 'failed':
setVideo({
...storeVideo,
status: 'failed',
error: retrievedVideo.error,
metadata: retrievedVideo
})
}
})
}, [data, error, provider, providerId, retrieveThumbnail, setVideo, t])
return {
videos: videos ?? [],
getVideo,
addVideo,
updateVideo,
setVideos,
setVideo,
removeVideo: removeProviderVideo
}
}

View File

@@ -0,0 +1,7 @@
import { useProviderVideos } from './useProviderVideos'
export const useVideo = (providerId: string, id: string) => {
const { videos } = useProviderVideos(providerId)
const video = videos.find((v) => v.id === id)
return video
}

View File

@@ -0,0 +1,86 @@
import { loggerService } from '@logger'
import { retrieveVideoContent } from '@renderer/services/ApiService'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderById } from '@renderer/services/ProviderService'
import { Video } from '@renderer/types'
import { useCallback } from 'react'
const logger = loggerService.withContext('useRetrieveThumbnail')
const pendingSet = new Set<string>()
export const useVideoThumbnail = () => {
const getThumbnailKey = useCallback((id: string) => {
return `video-thumbnail-${id}`
}, [])
const retrieveThumbnail = useCallback(
async (video: Video): Promise<string> => {
const provider = getProviderById(video.providerId)
if (!provider) {
throw new Error(`Provider not found for id ${video.providerId}`)
}
const thumbnailKey = getThumbnailKey(video.id)
if (pendingSet.has(thumbnailKey)) {
throw new Error('Thumbnail retrieval already pending')
}
pendingSet.add(thumbnailKey)
try {
const cachedThumbnail = await ImageStorage.get(thumbnailKey)
if (cachedThumbnail) {
return cachedThumbnail
}
const result = await retrieveVideoContent({
type: 'openai',
provider,
videoId: video.id,
query: { variant: 'thumbnail' }
})
const { response } = result
if (!response.ok) {
throw new Error(`Unexpected thumbnail status: ${response.status}`)
}
const blob = await response.blob()
if (!blob || blob.size === 0) {
throw new Error('Thumbnail response body is empty')
}
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert thumbnail to base64'))
}
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read thumbnail blob'))
reader.readAsDataURL(blob)
})
await ImageStorage.set(thumbnailKey, base64)
return base64
} catch (e) {
logger.error(`Failed to get thumbnail for video ${video.id}`, e as Error)
throw e
} finally {
pendingSet.delete(thumbnailKey)
}
},
[getThumbnailKey]
)
const removeThumbnail = useCallback(
async (id: string) => {
const key = getThumbnailKey(id)
return ImageStorage.remove(key)
},
[getThumbnailKey]
)
return { getThumbnailKey, retrieveThumbnail, removeThumbnail }
}

View File

@@ -0,0 +1,48 @@
import FileManager from '@renderer/services/FileManager'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { removeVideoAction } from '@renderer/store/video'
import { objectValues } from '@renderer/types'
import { useCallback } from 'react'
import { useVideoThumbnail } from './useVideoThumbnail'
export const useVideos = () => {
const videoMap = useAppSelector((state) => state.video.videoMap)
const dispatch = useAppDispatch()
const { removeThumbnail } = useVideoThumbnail()
const videos = objectValues(videoMap)
.flat()
.filter((v) => v !== undefined)
const getVideo = useCallback(
(videoId: string) => {
return videos.find((v) => v.id === videoId)
},
[videos]
)
const removeVideo = useCallback(
(videoId: string, providerId?: string) => {
const video = getVideo(videoId)
if (!video) {
return
}
if (!providerId) {
providerId = video.providerId
}
// should delete from redux state, and related thumbnail image, video file
if (video.thumbnail) {
removeThumbnail(videoId)
}
if (video.fileId) {
FileManager.deleteFile(video.fileId)
}
dispatch(removeVideoAction({ providerId, videoId }))
},
[dispatch, getVideo, removeThumbnail]
)
return { videos, getVideo, removeVideo }
}

View File

@@ -146,7 +146,8 @@ const titleKeyMap = {
notes: 'title.notes',
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate'
translate: 'title.translate',
video: 'title.video'
} as const
export const getTitleLabel = (key: string): string => {
@@ -172,7 +173,8 @@ const sidebarIconKeyMap = {
knowledge: 'knowledge.title',
files: 'files.title',
code_tools: 'code.title',
notes: 'notes.title'
notes: 'notes.title',
video: 'video.title'
} as const
export const getSidebarIconLabel = (key: string): string => {
@@ -329,7 +331,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
[BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp'
} as const
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
@@ -339,12 +342,14 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
const builtinOcrProviderKeyMap = {
system: 'ocr.builtin.system',
tesseract: '',
paddleocr: ''
paddleocr: '',
ovocr: ''
} as const satisfies Record<BuiltinOcrProviderId, string>
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
if (key === 'tesseract') return 'Tesseract'
else if (key == 'paddleocr') return 'PaddleOCR'
else if (key == 'ovocr') return 'Intel OV(NPU) OCR'
else return getLabel(builtinOcrProviderKeyMap, key)
}

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