Compare commits

..

239 Commits

Author SHA1 Message Date
suyao
86a2780e2c Add initial chunk emission for agent response processing
- Emit `ChunkType.LLM_RESPONSE_CREATED` event to mirror assistant behavior
- Ensure UI reflects pending state immediately during response processing
- Maintain consistency with existing stream processing callback structure
2025-09-20 16:58:24 +08:00
suyao
d960a42d6e Set default active agent and session on load
- Automatically select first agent if none active after loading
- Automatically select first session per agent if none active after loading
- Prevent empty selection states in UI components
2025-09-20 16:41:15 +08:00
suyao
3ae1b3d4cb Improve model name resolution for agent sessions
- Extract actual model ID from session model string and resolve model details
- Use resolved model name, provider, and group when available instead of defaults
- Remove redundant API model handling in getModelName function
2025-09-20 16:33:56 +08:00
suyao
1c045231c8 Extract model name from session model string
- Use split and pop to isolate the model name after the colon
- Fall back to the full model string if no colon is present
- Maintain provider and group identifiers for model object consistency
2025-09-20 16:28:16 +08:00
suyao
282aa6e81a Simplify provider display name logic and add debug logging
- Replace complex fallback chain for provider display name with direct provider name access
- Add console.log for model debugging in getModelName function
2025-09-20 16:23:37 +08:00
suyao
117e390cf1 Support API models with provider_name field in getModelName
- Add ApiModel type import and update function signature to accept ApiModel
- Return formatted name using provider_name field for API models
- Maintain backward compatibility for legacy models by looking up provider in store
2025-09-20 16:05:18 +08:00
suyao
34b05a138b Add loading state with spinner and i18n support to SessionsTab
- Replace static "No active agent" message with a spinner and loading text
- Integrate react-i18next for translation of loading message
- Adjust animation timing and styling for smoother loading state transition
2025-09-20 15:58:33 +08:00
suyao
6c233fef9f Add smooth animations to SessionsTab and Sessions components
- Replace static conditional rendering with Framer Motion animations for no-agent and session states
- Animate session list items with staggered entrance and exit transitions
- Add loading spinner animation with fade effect
- Apply motion to session creation button with delayed entrance
2025-09-20 15:55:42 +08:00
suyao
1c813aa6c3 Merge branch 'refactor/agent-align' of https://github.com/CherryHQ/cherry-studio into refactor/agent-align 2025-09-20 15:52:32 +08:00
suyao
dd5592ddbb Optimize chat components with memoization and shared layout
- Wrap `SessionMessages` and `SessionInputBar` in `useMemo` to prevent unnecessary re-renders
- Refactor `AgentSessionMessages` to use shared layout components and message grouping
- Extract common styled components to `shared.tsx` for reuse across message components
2025-09-20 15:49:40 +08:00
GitHub Action
6e9d8a1747 fix(i18n): Auto update translations for PR #10276 2025-09-20 07:24:06 +00:00
suyao
cee78c6610 Add provider_name field to model objects and improve display
- Add provider_name field to ApiModel schema and transformation logic
- Update model options to include providerName for better display
- Improve provider label fallback chain in model transformation
- Fix agent hook to use proper SWR key and conditional fetching
- Enhance option rendering with better truncation and provider display
2025-09-20 15:19:11 +08:00
suyao
0b2dfbb88f Add accessible paths management to agent configuration
Move accessible paths functionality from session modal to agent modal,
add validation requiring at least one path, and update form handling to
inherit agent paths in sessions.
2025-09-20 14:53:08 +08:00
suyao
1fd44a68b0 Refactor agent streaming from EventEmitter to ReadableStream
Replaced EventEmitter-based agent streaming with ReadableStream for
better compatibility with AI SDK patterns. Modified
SessionMessageService to return stream/completion pair instead of event
emitter, updated HTTP handlers to use stream pumping, and added IPC
contract for renderer-side message persistence.
2025-09-20 13:31:29 +08:00
icarus
fcacc50fdc Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-20 00:53:55 +08:00
icarus
009b58c9c3 feat(i18n): add error message for failed session retrieval 2025-09-20 00:53:49 +08:00
icarus
77c64cf868 refactor(hooks): improve error handling in useSessions hook
- Remove redundant agentId checks as they're handled by the API client
- Add consistent error formatting using formatErrorMessageWithPrefix
- Update error messages for all session operations
2025-09-20 00:52:39 +08:00
icarus
f5acddbfeb refactor(agent): extract shared components and improve model handling
- Extract common option components to shared.tsx for reuse
- Make useModels filter parameter optional
- Update SessionModal to use real model data from API
2025-09-20 00:48:17 +08:00
icarus
ae35d689ec feat(AgentModal): add dynamic model options from API
Fetch model options from API instead of using mocked data
Display provider label for model options
2025-09-20 00:32:51 +08:00
icarus
825b5e1be4 refactor(error-handling): move error formatting functions to error utils
Consolidate error formatting functions (formatAgentServerError and formatAxiosError) into error.ts utility file to improve code organization and maintainability
2025-09-20 00:32:43 +08:00
Vaayne
17df1db120 ♻️ refactor: simplify streaming message lifecycle management
- Remove unused persistence tracking variables in message handler
- Simplify finalizeResponse logic by removing unnecessary checks
- Change 'finish' event type to 'complete' for consistency
- Add debug logging for streaming events
- Clean up dead code and improve readability
2025-09-20 00:14:02 +08:00
Vaayne
d56521260c ️ perf: add caching layer for MCP servers and providers data access
- Extract getServersFromRedux to shared utility getMCPServersFromRedux
- Implement 5-minute TTL cache for MCP servers and providers
- Reduce redundant Redux store queries in API server
- Improve response times for frequently accessed data
2025-09-20 00:13:26 +08:00
icarus
8efafc6ba9 feat(hooks): add useModels hook for fetching agent models 2025-09-19 23:51:46 +08:00
icarus
f35987a9a9 refactor(api): rename modelsPath to getModelsPath for consistency
Align method naming with other path getter methods in the class for better code maintainability and consistency
2025-09-19 23:50:04 +08:00
icarus
c7ec55c69a refactor(types): rename ApiModelsRequest to ApiModelsFilter for clarity
Update type name and related imports to better reflect its purpose as a filter type rather than a request type
2025-09-19 23:48:58 +08:00
icarus
c77d7dff78 feat(api): add models endpoint to agent client and type response
Add new models endpoint to AgentApiClient with query parameter support
Ensure type safety by adding ApiModelsResponse type and schema validation
2025-09-19 23:43:20 +08:00
Vaayne
b282e4d729 feat: implement robust AbortController for Claude Code agent streams
- Add AbortController support to agent service interface and implementations
- Enhance client disconnect detection with multiple HTTP event listeners (req.close, req.aborted, res.close)
- Implement proper abort error handling in ClaudeCodeService with 'cancelled' event type
- Add comprehensive documentation explaining SSE disconnect detection behavior
- Clean up stream interfaces by removing unused properties and simplifying event structure
- Extend timeout from 5 to 10 minutes for longer-running agent tasks
- Ensure proper resource cleanup and prevent orphaned processes on client disconnect

This enables reliable cancellation of long-running Claude Code processes when clients
disconnect unexpectedly (browser tab close, curl Ctrl+C, network issues, etc.)
2025-09-19 23:42:46 +08:00
Vaayne
c426876d0d feat(transform): refactor message handling to unify user and assistant processing 2025-09-19 22:36:19 +08:00
Vaayne
027ef17a2e feat(agents): enhance ClaudeCodeService to support MCP configurations and additional directories 2025-09-19 21:51:33 +08:00
Vaayne
f0ac74dccf feat(agents): enhance error messages for agent and session operations; update accessible paths handling 2025-09-19 20:20:20 +08:00
Vaayne
d6468f33c5 feat(agents): implement model validation for agent and session creation/updating 2025-09-19 20:20:20 +08:00
Vaayne
1515f511a1 feat(agents): refactor agent invocation to use session object and enhance error handling 2025-09-19 20:20:20 +08:00
Vaayne
1c2211aefb feat(agents): add ensurePathsExist method to validate and create accessible paths 2025-09-19 20:20:20 +08:00
Vaayne
49f9dff9da feat(models): update models filtering to use providerType and enhance API schemas 2025-09-19 20:20:19 +08:00
icarus
92ba1e4fc3 fix(SessionMessageService): stringify user message content to ensure proper formatting 2025-09-19 17:54:25 +08:00
icarus
7060aab33d fix: reverse message order and fix mutation revalidation
Reverse message display order for better UX and prevent unnecessary revalidation during message creation
2025-09-19 17:52:17 +08:00
icarus
0cce8220ce Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-19 17:46:32 +08:00
icarus
1722d9f435 feat(i18n): add error message for failed message creation
Add "create_message" error translation in multiple language files
2025-09-19 17:44:58 +08:00
icarus
078fd57eb5 fix(agent): update message id and improve content handling
- Change default message id from -1 to 77777 in useSession
- Remove schema validation for session response temporarily
- Add proper content parsing for agent session messages
2025-09-19 17:44:08 +08:00
icarus
4bd6087dc0 feat(chat): add agent session inputbar component
Implement input bar for agent sessions with message sending functionality
2025-09-19 17:39:24 +08:00
icarus
e45231376c feat(useSession): add optimistic updates for message creation
Implement optimistic UI updates when creating new messages to improve perceived performance. The changes include cloning the current session data, adding a draft message immediately, and handling rollback on error.
2025-09-19 17:39:04 +08:00
icarus
01c7e509fd feat(hooks): add createSessionMessage to useSession hook
Expose new function to create messages for agent sessions and automatically refresh session data
2025-09-19 17:01:47 +08:00
icarus
5ddf9683b4 feat(chat): add session messages view and active state tracking
Implement agent session messages display component and track active topic/session state
Add AgentSessionMessages component and integrate with chat view
Update topic and session selection to set active state in store
2025-09-19 16:39:02 +08:00
Vaayne
d91df12dbc feat(models): refactor models service to use new API models schema and types 2025-09-19 16:36:11 +08:00
icarus
64e3de9ada feat(chat): add activeTopicOrSession state to track current view
Add new state field and action to track whether the user is viewing topics or sessions in the chat interface. This enables proper UI state management when switching between views.
2025-09-19 16:27:50 +08:00
Vaayne
2cf2f04a70 feat(chat): enhance chat completion error handling and streaming support
feat(messages): improve message validation and add streaming support
feat(tests): add HTTP tests for chat and message endpoints
2025-09-19 16:16:33 +08:00
Vaayne
73380d76df feat(models): enhance models endpoint with filtering and pagination support 2025-09-19 16:16:33 +08:00
Vaayne
38076babcf feat(messages): add messages route and service for handling Anthropic messages 2025-09-19 16:16:33 +08:00
icarus
00cc410dcc refactor(SessionItem): remove confirmation modal for delete action
Simplify delete flow by removing the confirmation modal and directly calling onDelete
2025-09-19 15:59:18 +08:00
icarus
7fc676bbc3 fix(useSession): provide default empty array for messages when undefined
Make messages field optional in GetAgentSessionResponseSchema to match actual API behavior and prevent potential undefined access
2025-09-19 15:56:09 +08:00
icarus
798126d39c feat(session): add session modal component to session item
Implement session modal for editing session details, using existing disclosure hooks
2025-09-19 15:46:52 +08:00
icarus
874d74cf6e feat(chat): add tab state management to redux store
Move tab state from component local state to redux store for better state management across components
2025-09-19 15:43:39 +08:00
icarus
d73834e7f6 feat(i18n): add session add/edit translation keys
Add new translation keys for session add/edit functionality in multiple languages
2025-09-19 15:28:11 +08:00
icarus
cb3afaceab refactor(agent): consolidate agent update logic into useAgents hook 2025-09-19 15:27:23 +08:00
icarus
fc0ba5d0d5 feat(hooks): add useSession hook for managing agent sessions 2025-09-19 15:27:10 +08:00
icarus
7ce4fc50ea refactor(hooks): remove unused useUpdateAgent hook 2025-09-19 15:24:39 +08:00
icarus
b5ef8a93ca refactor(useAgent): simplify mutate call in agent update
The previous implementation unnecessarily spread the previous state when only the result is needed. This simplifies the mutation logic while maintaining the same behavior.
2025-09-19 15:24:33 +08:00
icarus
8ead4e9c0f feat(sessions): add session creation modal and improve session item styling
- Implement SessionModal component for creating/editing sessions
- Replace Button wrapper with fragment in SessionItem for cleaner styling
- Add translation support and proper form handling for session creation
2025-09-19 15:21:27 +08:00
icarus
432d84cda5 refactor(sessions): simplify session update API by using form object
Remove redundant sessionId parameter from updateSession methods since it's already included in the UpdateSessionForm. This makes the API more consistent and reduces potential for mismatched IDs.
2025-09-19 15:08:53 +08:00
icarus
d3378dcf78 refactor(types): use inferred type for CreateSessionRequest 2025-09-19 14:56:50 +08:00
icarus
f0724af2aa refactor(agent-modal): move AgentModal component to agent subdirectory
Restructure the component organization by moving AgentModal.tsx into a dedicated agent subdirectory under Popups. This improves maintainability by grouping related agent components together.

Update all import paths to reflect the new location. The component functionality remains unchanged.
2025-09-19 14:55:26 +08:00
icarus
f127150ea1 feat(sessions): implement session management ui and state
- Rename Container to ButtonContainer for consistency
- Add activeSessionId state to track active sessions per agent
- Implement Sessions and SessionItem components with loading state
- Add session selection and deletion functionality
2025-09-19 14:54:04 +08:00
icarus
b3ef6d4534 feat(i18n): add error messages for session operations
Add error messages for session deletion and update failures in all supported languages. Also include a generic delete failure message.
2025-09-19 14:09:05 +08:00
icarus
1ce791d517 feat(AgentsTab): add loading spinner for agents list
Show spinner while agents are loading to improve user experience
2025-09-19 14:07:56 +08:00
icarus
3d561ad8e3 refactor(sessions): extract sessions component and update hook to use agentId
Move sessions rendering logic to a separate component and modify useSessions hook to work with agentId instead of full agent entity. This improves code organization and simplifies the hook interface.
2025-09-19 14:07:23 +08:00
icarus
14509d1077 feat(hooks): add useAgent hook for managing agent state and updates 2025-09-19 14:07:03 +08:00
icarus
a424e3a039 feat(agents): add success toast notifications for agent operations 2025-09-19 13:32:16 +08:00
icarus
eaa5ec5545 refactor(agents): remove unused useRemoveAgent hook and use deleteAgent from useAgents 2025-09-19 13:30:50 +08:00
icarus
5850e5da66 refactor(AgentItem): simplify component structure and remove unused logger
- Remove Button wrapper from AgentLabel component
- Replace div container with Button component for better semantics
- Clean up unused logger service and related click handler
2025-09-19 13:28:36 +08:00
icarus
eb3ff6f570 fix(agents): convert null values to undefined in database responses
Ensure type consistency by converting null values from database to undefined as specified in type definitions
2025-09-19 13:14:20 +08:00
icarus
ae9c78e643 Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-19 12:58:21 +08:00
icarus
445528aff7 refactor(agent): replace interface with zod schema for message request
Add createMessage method to AgentApiClient to handle posting messages to sessions
2025-09-19 12:58:14 +08:00
Vaayne
d13c25444c feat(agents, sessions): enhance agent and session schemas with detailed properties and CRUD API documentation 2025-09-19 12:56:20 +08:00
Vaayne
5386716ebe feat(session messages): enhance session message persistence with improved error handling and completion notifications 2025-09-19 12:56:20 +08:00
Vaayne
da3cd62486 feat(sessions): update session creation and update requests with new session details 2025-09-19 12:56:20 +08:00
Vaayne
d8b47e30c4 feat(session messages): implement user message persistence and retrieve last agent session ID 2025-09-19 12:56:20 +08:00
icarus
1c19e529ac Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-19 12:51:08 +08:00
Vaayne
514b60f704 feat(agents, sessions): implement replace functionality for agent and session updates 2025-09-19 11:13:05 +08:00
Vaayne
df1d4cd62b refactor(sessions): update getSession and related methods to include agentId parameter 2025-09-19 10:41:26 +08:00
Vaayne
4839b91cef chore(docs): remove session tracking protocol from AI Assistant Guide 2025-09-19 10:27:09 +08:00
Vaayne
5048d6987d refactor(validators): migrate validation logic to Zod for agents and sessions 2025-09-19 10:27:02 +08:00
icarus
809736dd33 feat(sessions): add update session functionality
Introduce UpdateSessionResponse type and schema to support session updates. Implement update session methods across client, service, and handler layers to enable session modifications.
2025-09-18 22:58:49 +08:00
icarus
369cc37071 feat(sessions): add delete session functionality to agent api and hook
Implement session deletion by adding deleteSession method to AgentApiClient and corresponding hook in useSessions. This enables removing sessions from the UI with proper error handling and cache invalidation.
2025-09-18 22:52:46 +08:00
icarus
d0b64dabc2 fix(agents): correct agent update and retrieval logic
Fix mutation logic to use result.id instead of form.id for consistency
Make getAgent async and update cache with fetched agent data
2025-09-18 22:51:02 +08:00
icarus
02d2838424 fix(agent): add response validation for ID mismatches
Add checks to ensure response IDs match expected values in agent API calls
2025-09-18 22:50:05 +08:00
icarus
4c4039283f fix(useSessions): correct session fetching logic to use API call
Previously the getSession hook was only searching local data. Now it properly fetches from the API and updates the cache. This ensures data consistency when sessions are modified elsewhere.
2025-09-18 22:46:24 +08:00
icarus
77df6fd58e feat(sessions): add getSession method to retrieve specific session
Implement session retrieval functionality in both hook and API client to enable fetching individual sessions by ID
2025-09-18 22:37:02 +08:00
icarus
100801821f fix(agents): update agents list response structure to match API
Align frontend and backend types for agents list response. The API now returns paginated data with limit/offset and renamed 'agents' field to 'data' for consistency. Update related type definitions and usage across the codebase.
2025-09-18 22:26:54 +08:00
icarus
2201ebbb88 feat(i18n): add error messages for agent and session operations
Add error messages for agent deletion, update, and session creation operations
Add "no response" error message for all supported languages
2025-09-18 22:21:27 +08:00
icarus
9810f01330 feat(sessions): add create session functionality to agent api and hook
Implement session creation in the agent API client and expose it through the useSessions hook. The hook now provides a createSession method that updates the session list upon successful creation.
2025-09-18 22:19:52 +08:00
icarus
7b428be93d feat(types): add session form interfaces for agent operations
Add BaseSessionForm, CreateSessionForm and UpdateSessionForm interfaces to support session management functionality
2025-09-18 22:10:53 +08:00
icarus
a4c2ed5328 fix(api): update error handling to match new error structure
The AgentServerError schema was updated to nest error properties under an 'error' object. This commit aligns the error formatting function with the new schema structure.
2025-09-18 22:02:42 +08:00
icarus
934cc0dd33 refactor(useAgents): simplify agent data structure and mutations
Remove unnecessary nesting of agents array in SWR response and simplify mutation logic to work directly with the array
2025-09-18 22:00:25 +08:00
icarus
da61500e34 feat(hooks): add useSessions hook for agent session management 2025-09-18 21:57:18 +08:00
icarus
db2042800b feat(agent): add listSessions method to AgentApiClient
Implement session listing functionality for agents by adding the listSessions method. This enables retrieving all sessions associated with a specific agent.
2025-09-18 21:50:14 +08:00
icarus
08772741e6 feat(agent-sessions): add schema and type for listing agent sessions
Add ListAgentSessionsResponseSchema and type to support paginated session listing
2025-09-18 21:49:02 +08:00
icarus
f5f542911f feat(sessions): add CreateSessionResponse type for better type safety
Introduce CreateSessionResponse type and schema to clearly define the return type of session creation operations. This improves type safety and consistency across the codebase when handling session responses.
2025-09-18 21:43:06 +08:00
icarus
3b5b1986e6 feat(hooks): add getAgent function to useAgents hook 2025-09-18 21:34:25 +08:00
icarus
3b0995c8ef feat(agents): add deleteAgent hook to useAgents
Implement agent deletion functionality with optimistic updates and error handling
2025-09-18 21:22:20 +08:00
icarus
34c95ca787 feat(agents): add update agent functionality and error message
Add updateAgent method to useAgents hook and update agent API client
2025-09-18 21:20:58 +08:00
icarus
a4c2a1d435 feat(i18n): add new translation keys for agent errors and session management
Add "failed" error message for agent addition and implement session-related translations including delete confirmation and labels
2025-09-18 20:49:04 +08:00
icarus
e4f0743e2f fix(api): replace Axios constructor with axios.create for better defaults
Using axios.create() provides better default configuration and error handling compared to the Axios constructor
2025-09-18 20:47:53 +08:00
icarus
7632efda88 fix(api): handle ZodError in processError and formatErrorMessage
Add explicit handling of ZodError in processError to return the error directly and in formatErrorMessage to use formatZodError for better error reporting
2025-09-18 20:40:30 +08:00
icarus
ab90eb2aab refactor(AgentModal): replace useAddAgent with useAgents hook 2025-09-18 20:29:04 +08:00
icarus
4c5bed0b1f fix(agents): add http protocol to agent client base url 2025-09-18 20:28:08 +08:00
icarus
302331043a refactor(agent): add error logging in agent api client
Use logger service to log errors when processing API requests
2025-09-18 20:25:40 +08:00
icarus
09f5e7af8c feat(agents): implement add agent functionality in useAgents hook
Add createAgent method to useAgents hook and remove unused useAddAgent hook
Use formatErrorMessageWithPrefix for better error handling
2025-09-18 20:25:28 +08:00
icarus
664304241a feat(i18n): make base locale configurable via env var
Add support for BASE_LOCALE environment variable to override default locale
Add file existence check for base locale file in auto-translate script
Update npm scripts to load .env for i18n commands
2025-09-18 19:31:24 +08:00
icarus
27f98b02a6 feat(sessions): add sessions tab with basic functionality and translations
- Create new SessionsTab component with mock data
- Add session item component with context menu for edit/delete
- Include session tab in main navigation
- Add English translations for session-related strings
2025-09-18 19:26:25 +08:00
icarus
af6a3c87d6 fix(AgentItem): add missing onPress dependency to useCallback
Ensure the callback is updated when onPress prop changes to avoid stale closures
2025-09-18 19:17:10 +08:00
icarus
d1819274bb Revert "feat(agent): add name field to AgentSessionEntitySchema"
This reverts commit 8058ed21b3.
2025-09-18 19:13:24 +08:00
icarus
8058ed21b3 feat(agent): add name field to AgentSessionEntitySchema
Add name field to agent session schema to support session naming functionality
2025-09-18 19:11:52 +08:00
icarus
eaf302bb40 Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-18 19:04:14 +08:00
icarus
3405b7e429 refactor(ui): rename agents to assistantPresets for consistency
Update route paths, i18n keys and tab identifiers to use 'assistantPresets' instead of 'agents'
Add new agents tab component while maintaining backward compatibility
2025-09-18 19:04:05 +08:00
icarus
2fc1df8793 refactor(ui): remove unused UI elements and commented code
clean up UI by removing unused text spans and commented out code
remove unused timer functionality from AgentModal
2025-09-18 19:00:41 +08:00
icarus
ec82eb2881 build: add @types alias to electron vite config 2025-09-18 18:52:36 +08:00
icarus
1c978e0684 feat(agents): add agent selection functionality
- Replace onTagClick with onPress handler in AgentItem
- Add active agent state management in AgentsTab
- Wrap AgentItem content in Button for better interaction
2025-09-18 18:47:37 +08:00
icarus
e938e1572c feat(chat): add activeAgentId to track current agent state
Add activeAgentId field to chat state to track which agent is currently active. This enables UI to reflect the active agent state and handle agent-specific interactions.
2025-09-18 18:43:10 +08:00
icarus
9a7681c5c8 refactor(agent): update agent form types and mutation hooks
- Add proper type definitions for AddAgentForm and UpdateAgentForm
- Split agent mutation hooks into separate files
- Update AgentModal to use new form types and hooks
- Remove redundant fields from form submissions
2025-09-18 18:39:02 +08:00
icarus
259f2157f6 refactor(agent): split AgentForm into BaseAgentForm and specific types
Improve type safety by separating AgentForm into BaseAgentForm for shared fields and specific types for add/update operations. This better reflects the actual usage patterns in the API client and modal components.
2025-09-18 18:21:52 +08:00
icarus
21ce139df0 refactor(agents): replace unimplemented mutation with simple function
Replace the placeholder mutation implementation in useUpdateAgent hook with a simpler function that shows a toast notification. This removes the unnecessary query client usage for an unimplemented feature.
2025-09-18 18:10:53 +08:00
icarus
71536d6ef5 refactor(agents): replace useMutation with simple function in useAddAgent
The mutation logic was removed and replaced with a simple function that shows a toast message. This is a temporary solution until the actual API implementation is ready.
2025-09-18 18:09:58 +08:00
icarus
ef1a035701 feat: add AgentsTab component for managing agents 2025-09-18 18:08:59 +08:00
icarus
2b76c326ee refactor(agents): replace useMutation with simpler implementation in useRemoveAgent 2025-09-18 18:08:45 +08:00
icarus
64ee5c528b fix(useAgents): use right agents data 2025-09-18 18:08:40 +08:00
icarus
136d343c18 refactor(hooks): replace redux with swr in useAgents hook
Simplify agents management by using SWR for data fetching instead of redux store operations
2025-09-18 18:05:55 +08:00
icarus
0b1b9a913f feat(hooks): add useAgentClient hook for agent API client initialization 2025-09-18 18:02:05 +08:00
icarus
cb0833a915 refactor(api): centralize agent and session paths for better maintainability
Add path builder methods to avoid hardcoding URLs and improve consistency
2025-09-18 17:58:53 +08:00
Vaayne
984c28d4be refactor(SessionMessageService): remove unused PermissionMode import 2025-09-18 17:51:45 +08:00
Vaayne
49add96dc0 feat(database): add agent_session_id to session_messages table and update related services 2025-09-18 17:46:05 +08:00
icarus
db58762a13 refactor(AssistantsTab): remove agent-related code and imports 2025-09-18 17:40:12 +08:00
icarus
6e89d0037f Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-18 17:12:10 +08:00
icarus
e1ab17387c build: add swr dependency for data fetching 2025-09-18 17:11:56 +08:00
icarus
54de2341bd refactor(agents): remove draggable functionality and simplify agent list rendering
Remove unused agent management hooks and replace DraggableList with simple map
Add cursor-pointer style to AgentItem for better UX
2025-09-18 16:24:27 +08:00
beyondkmp
b131f0c48c pack optimization 2025-09-18 15:25:16 +08:00
Vaayne
9e4b792fc3 🐛 fix: resolve duplicate migration key '156' to '157' 2025-09-18 14:54:45 +08:00
Vaayne
7abd5da57d ♻️ refactor: replace ClaudeCodeService child process with SDK query
- Replace process spawning with @anthropic-ai/claude-code SDK query function
- Remove complex process management, stdout/stderr parsing, and JSON buffering
- Directly iterate over typed SDKMessages from AsyncGenerator
- Simplify error handling and completion logic
- Maintain full compatibility with existing SessionMessageService interface
- Eliminate ~130 lines of process management code
- Improve reliability by removing JSON parsing edge cases
2025-09-18 14:54:45 +08:00
icarus
be7399b3c4 Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-18 14:49:18 +08:00
icarus
8d92b515ab feat(agent): return updated agent data from updateAgent method
Add response parsing and return type to updateAgent to provide updated agent data to callers
2025-09-18 14:35:57 +08:00
icarus
524098d6d3 feat(agent): add error processing utility and update agent method
Introduce processError utility to handle axios errors consistently
Add updateAgent method to support partial updates of agent data
2025-09-18 14:34:05 +08:00
icarus
42fa2d94be feat(api): add formatAxiosError utility for handling axios errors 2025-09-18 14:33:45 +08:00
icarus
a65b30f3a1 chore: update vscode settings for i18n configuration
Remove display language setting and clean up comments
2025-09-18 14:33:10 +08:00
icarus
f1991b356b feat(agent): add get and delete agent methods with error handling
Implement getAgent and deleteAgent methods in AgentClient with proper error handling
Add formatAgentServerError utility for consistent error messages
2025-09-18 14:20:48 +08:00
beyondkmp
352c23180a add patch for sdk to run cli in electron 2025-09-18 14:11:05 +08:00
icarus
825c376c5c feat(agent): add create agent endpoint to agent client 2025-09-18 14:08:31 +08:00
icarus
231a923c9d feat(types): add AgentForm type and move from component to types file
Centralize the AgentForm type definition in the types file for better maintainability and reuse across components
2025-09-18 14:03:10 +08:00
icarus
dbf01652f8 feat(agent): add avatar support for agent items
Implement getAgentAvatar function to provide avatar images based on agent type. Update AgentItem component to display agent-specific avatars instead of generic ones.
2025-09-18 13:59:37 +08:00
icarus
842a6cb178 style(AssistantsTab): reorder tailwind classes for consistency 2025-09-18 13:54:59 +08:00
icarus
d56c526709 feat(api): add AgentClient class for listing agents
Implement a new API client class to handle agent listing operations with proper error handling and validation
2025-09-18 13:48:57 +08:00
icarus
70a68bef27 Revert "feat(types): add DeleteAgentResponse type and update AgentService"
This reverts commit 219844cb74.
2025-09-18 13:31:23 +08:00
icarus
f9b49ffde6 Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-18 13:28:36 +08:00
icarus
a264fd42e4 feat(types): add AgentServerError schema for error handling 2025-09-18 13:26:51 +08:00
icarus
219844cb74 feat(types): add DeleteAgentResponse type and update AgentService
Update AgentService to use DeleteAgentResponse type instead of boolean for better type safety
2025-09-18 13:24:00 +08:00
icarus
230205d210 feat(types): add UpdateAgentResponse type and schema
Update agent service to use new response type for consistency
2025-09-18 13:21:37 +08:00
icarus
f9fb0f9125 feat(agent): add ListAgentsResponse type and update service
Add ListAgentsResponse schema and type to handle agent listing responses
Update AgentService to use the new type for listAgents method
2025-09-18 13:19:57 +08:00
icarus
0f777e357d refactor(types): convert GetAgentSessionResponse interface to zod schema
Improve type safety by using zod schema definition and inference instead of manual interface
2025-09-18 13:17:32 +08:00
icarus
5c578c191b refactor(types): convert AgentSessionMessageEntity interface to zod schema
Replace interface with zod schema for better type safety and validation
2025-09-18 13:15:58 +08:00
icarus
7a4952f773 refactor(types): convert AgentSessionEntity interface to zod schema
Use zod schema for better type safety and validation capabilities
2025-09-18 13:01:57 +08:00
icarus
71a1daddef refactor(types): reorder CreateAgentResponse and its schema
Place schema definition before type alias for better readability and consistency
2025-09-18 13:00:41 +08:00
icarus
0a82955e91 refactor(types): replace GetAgentResponse interface with zod schema
Use zod schema for better type safety and validation
2025-09-18 12:59:49 +08:00
icarus
62d2da3815 refactor(types): convert Tool interface to zod schema
Use zod schema for better type safety and validation capabilities
2025-09-18 12:58:56 +08:00
icarus
84aab66aa6 refactor(types): add CreateAgentResponseSchema for consistency 2025-09-18 12:56:29 +08:00
icarus
2d0d599ac8 refactor(types): convert AgentEntity interface to zod schema
Use zod schema for better runtime validation and type safety
2025-09-18 12:55:01 +08:00
icarus
dca6be45b0 refactor(types): replace hardcoded agent type check with zod schema
Use zod schema validation for type safety and maintainability instead of manual array checking
2025-09-18 12:52:58 +08:00
icarus
5a71807cc9 feat(types): add CreateAgentResponse type and update AgentService return type 2025-09-18 12:50:42 +08:00
icarus
0d0ab4dcf5 refactor(types): convert AgentBase interface to zod schema
This change improves type safety and validation by replacing the TypeScript interface with a zod schema definition. The schema provides runtime validation while maintaining the same type inference capabilities.
2025-09-18 12:49:17 +08:00
icarus
ac3da51890 refactor(types): replace AgentConfiguration interface with zod schema
Use zod schema for better type safety and validation capabilities
2025-09-18 12:46:45 +08:00
icarus
9ea361f7e8 refactor(types): migrate PermissionMode to zod schema for type safety 2025-09-18 12:45:00 +08:00
icarus
cc6160892a refactor(types): reorganize agent type interfaces for better clarity
Move API DTO interfaces to a dedicated section at the bottom of the file to improve code organization and maintainability
2025-09-18 12:42:01 +08:00
Vaayne
49eed449c3 refactor: Remove outdated validation and planning documents for agents service 2025-09-18 12:02:11 +08:00
Vaayne
8ada7ffaf6 Merge remote-tracking branch 'origin/main' into feat/agents-new 2025-09-18 11:51:34 +08:00
Vaayne
e7c37231e0 feat: Enhance message handling with user message persistence and improved stream management 2025-09-18 00:30:43 +08:00
Vaayne
c196a02c95 feat: Implement database migration system and update agent/session handling 2025-09-17 14:59:08 +08:00
Vaayne
d1ff8591a6 refactor: Rename message stream handler and update session creation logic 2025-09-17 14:10:10 +08:00
LiuVaayne
219d162e1a feat: Add automatic database migration system for agents service (#10215)
* feat: Add automatic database migration system for agents service

- Add migrations tracking schema with version, tag, and timestamp
- Implement MigrationService to automatically run pending migrations
- Integrate migration check into BaseService initialization
- Read migration files from drizzle/ directory and journal.json
- Track applied migrations to prevent re-execution
- Ensure database is always at latest version on service startup

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

* refactor: Improve migration logging and enhance database path configuration

* chore: harden migration bootstrap flow

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
2025-09-17 12:08:59 +08:00
Vaayne
669f60273c rewrite agents schema and types 2025-09-16 23:55:31 +08:00
Vaayne
697f7d1946 refactor: Update session tracking documentation for clarity and consistency 2025-09-16 22:06:41 +08:00
Vaayne
e4d04f8346 refactor: Remove unused variable linting directive in useUpdateAgent hook 2025-09-16 18:13:37 +08:00
Vaayne
c37af25525 Merge remote-tracking branch 'origin/main' into feat/agents-new 2025-09-16 18:08:14 +08:00
Vaayne
ea90c6c9cb feat: Enhance message handling by adding raw message metadata to assistant, stream, system, and result handlers 2025-09-16 17:57:40 +08:00
Vaayne
58dbb514e0 feat: Implement Claude Code service with streaming support and tool integration
- Added `aisdk-stream-protocel.md` to document text and data stream protocols.
- Created `ClaudeCodeService` for invoking and streaming responses from the Claude Code CLI.
- Introduced built-in tools for Claude Code, including Bash, Edit, and WebFetch.
- Developed transformation functions to convert Claude Code messages to AI SDK format.
- Enhanced OCR utility with delayed loading of the Sharp module.
- Updated agent types and session message structures to accommodate new features.
- Modified API tests to reflect changes in session creation and message streaming.
- Upgraded `uuid` package to version 13.0.0 for improved UUID generation.
2025-09-16 15:12:03 +08:00
Vaayne
a8e2df6bed feat: add electron-reload for development auto-restart
- Install electron-reload package for automatic app reloading during development
- Configure electron-reload in main process with development-only activation
- Enable automatic restart when source files change during yarn dev
- Use hardResetMethod: 'exit' for clean app restarts
2025-09-15 21:25:42 +08:00
Vaayne
2f74becb31 feat(dependencies): add @anthropic-ai/claude-code package for enhanced functionality 2025-09-15 19:59:47 +08:00
Vaayne
b31ac74f96 refactor: remove obsolete claude-code files and update logger imports 2025-09-15 19:59:15 +08:00
Vaayne
54b4e6a80b feat(agents): implement Drizzle ORM for database management and schema synchronization 2025-09-15 12:01:29 +08:00
Vaayne
079d2c3cb3 feat(docs): update CLAUDE.md to enhance AI assistant guidelines and add session tracking details 2025-09-15 11:34:19 +08:00
Vaayne
911f9d8bc9 feat(agents): update README for migration commands and remove obsolete database module documentation 2025-09-15 09:39:47 +08:00
Vaayne
f90bda861f feat(agents): add automatic database schema synchronization
- Add schemaSyncer.ts with Drizzle Kit push integration
- Integrate auto schema sync into BaseService.initialize()
- Database schema now automatically updates on agent service startup
- Users no longer need manual migration commands
- Ensures schema consistency across app updates

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2025-09-14 23:07:54 +08:00
Vaayne
71ed94de31 docs: streamline CLAUDE.md and add session tracking
- Reduce file size by 53% while keeping essential info
- Add session tracking requirements for plan mode
- Add Must Follow Rules section with conditional ast-grep usage
- Consolidate architecture to multi-file concepts only

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2025-09-14 22:30:04 +08:00
GitHub Action
dc16cf2aa7 fix(i18n): Auto update translations for PR #10096 2025-09-14 09:12:20 +00:00
Vaayne
be12898b7b feat(claude-code): initialize project with Bun setup, including configuration files and example code 2025-09-14 17:09:37 +08:00
Vaayne
3fc92e093b feat(sessions): add session message creation endpoint and update session details 2025-09-14 17:09:32 +08:00
Vaayne
b6187ad637 feat(sessions): include session messages in session retrieval response 2025-09-14 17:09:19 +08:00
Vaayne
ca8ac9911e feat(i18n): add translations for agent and assistant features in multiple languages 2025-09-14 16:31:52 +08:00
Vaayne
95a1e210b6 Refactor: Remove sessions route and related validation logic
- Deleted the sessions route file, consolidating session management logic.
- Removed validation middleware and error handling for session operations.
- Updated the SessionMessageService to improve query formatting for retrieving session messages.
2025-09-14 16:31:15 +08:00
icarus
b55f419a95 Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-14 12:58:21 +08:00
icarus
8953961a51 feat(i18n): add plural forms for agent and assistant labels
Add singular and plural forms for "agent" and "assistant" labels in both English and Chinese locales
Display plural labels in AssistantsTab component
2025-09-14 12:53:57 +08:00
Vaayne
8836663c35 🧪 test: add HTTP test files for agents and sessions APIs
- Add agents.http with comprehensive API endpoint tests
- Add sessions.http for session management testing
- Include authentication setup and request examples
- Support testing of CRUD operations for both agents and sessions

These files enable easy API testing and validation during development.
2025-09-14 10:17:45 +08:00
Vaayne
aaba77c360 feat: add comprehensive PUT/PATCH support for agents and sessions APIs
- Add PATCH method to agents API for partial updates alongside existing PUT
- Add PUT method to sessions API for complete replacement alongside existing PATCH
- Update API documentation with clear PUT vs PATCH usage examples
- Refactor session status updates to use standard PATCH endpoint
- Ensure both methods use same validation middleware for consistency
- Add comprehensive Swagger documentation for new endpoints

This provides REST-compliant update operations where:
- PUT: Complete resource replacement (idempotent)
- PATCH: Partial resource updates (only specified fields)

Both agents and sessions now support flexible update patterns for different use cases.
2025-09-14 10:17:45 +08:00
icarus
532bad8eb7 refactor(agents): migrate agent hooks to react-query and reorganize structure
- Replace redux-based agent management with react-query hooks
- Move agent-related hooks to dedicated agents directory
- Add useAddAgent, useRemoveAgent, and useUpdateAgent hooks
- Update imports to reflect new hook locations
- Keep redux store temporarily for backward compatibility
2025-09-14 09:51:00 +08:00
icarus
e5b43c8176 Merge branch 'feat/agents-new' of github.com:CherryHQ/cherry-studio into feat/agents-new 2025-09-14 09:21:18 +08:00
icarus
5f9c2d7f6a feat(agents): add edit functionality and update translations
- Refactor AddAgentModal into AgentModal to support both add and edit operations
- Add edit button to AgentItem with corresponding modal functionality
- Update translations for edit and update success messages
2025-09-14 09:16:04 +08:00
Vaayne
568257e7b6 docs(agents): add comprehensive UI integration guide for Agent API
- Document migration from Redux to database-backed API architecture
- Provide complete API reference for agents, sessions, and messages
- Include TypeScript interfaces and practical implementation examples
- Cover message streaming integration with AI SDK compatibility
- Add error handling patterns and best practices
- Include step-by-step migration guide from existing Redux implementation

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2025-09-14 08:25:51 +08:00
icarus
df31629c5f refactor(AgentItem): replace antd dropdown with custom context menu component
The antd dropdown component was replaced with a custom context menu implementation to improve consistency with the application's UI components and remove dependency on antd. The functionality remains the same but is now implemented using the custom context menu component.
2025-09-14 07:24:57 +08:00
icarus
e6dc8619d9 feat(ui): add context menu component using radix-ui
Implement a comprehensive context menu component with submenus, checkboxes, radio items, and separators. The component is built using @radix-ui/react-context-menu and includes proper styling and accessibility features.

Add required dependencies and update yarn.lock accordingly.
2025-09-14 07:17:53 +08:00
icarus
9a71a01b66 feat(agents): add agent management with delete confirmation
- Add agent deletion confirmation dialog with translations
- Implement agent list display and drag-and-drop functionality
- Include avatar support for agent types
- Add success message for agent deletion
2025-09-14 06:43:24 +08:00
icarus
941a6666e6 feat(agents): implement add agent functionality and migration
- Add migration for new agents structure (version 155)
- Enable addAgent call in AddAgentModal
- Update dependencies array with addAgent
- Replace TODO comment with FIXME for model type issue
2025-09-14 05:59:08 +08:00
icarus
1bf63865a8 Merge branch 'main' into feat/agents-new 2025-09-14 05:57:24 +08:00
icarus
ba41d8021f docs(AddAgentModal): add comment explaining modal button pattern
Add explanatory comment for combining Button and Modal components in Hero UI pattern to clarify focus management requirements
2025-09-14 04:44:53 +08:00
icarus
1e919a908f refactor(agent-popup): replace AddAgentPopup with AddAgentModal component
- Remove deprecated AddAgentPopup implementation
- Implement new AddAgentModal component with improved UI using Button component
- Update related imports and usage in AssistantsTab
- Clean up unused styles and code
2025-09-14 04:39:14 +08:00
icarus
702612e3f9 feat(agent): implement AddAgentPopup
- Add isAgentType function to validate agent type strings
- Update i18n files with new agent type validation messages
- Add common validation error messages and success notifications
2025-09-14 03:59:21 +08:00
icarus
fa380619ce chore: update vscode i18n settings and comments
Clean up i18n-related settings in vscode config by commenting out display language and fixing comment alignment
2025-09-14 03:56:02 +08:00
icarus
a30b2e2cb2 refactor(utils): move getClaudeSupportedProviders to utils/provider
Improve code organization by moving provider-related utility function to dedicated utils file
2025-09-14 03:18:44 +08:00
icarus
ae0cee9ef4 style: change outline property to outline-style for better clarity 2025-09-14 01:50:38 +08:00
icarus
dc9fb381f7 feat(agent): implement add agent popup functionality
add new AddAgentPopup component with type selection
move agent-related translations to dedicated section
update UI to use new popup instead of placeholder toast
2025-09-14 01:39:35 +08:00
icarus
943fccf655 refactor(useAgents): simplify addAgent_ by accepting complete agent entity 2025-09-14 00:15:45 +08:00
icarus
e0d2d44f35 feat(config): add default agent configuration constants
Add DEFAULT_AGENT_CONFIG and DEFAULT_CLAUDE_CODE_CONFIG constants to define base agent configurations. These will serve as templates for future agent configurations.
2025-09-14 00:13:22 +08:00
icarus
5a6413f356 feat(hooks): add useAgents hook for managing agent state
Implement a custom hook to handle agent CRUD operations in the store
2025-09-14 00:12:50 +08:00
icarus
f3ef4c77f5 feat(agents): add CRUD operations for agents in store
Add setAgents, addAgent, removeAgent and updateAgent actions to manage agents in the store. The updateAgent action uses lodash's mergeWith to handle array references properly and includes error logging when agent is not found.
2025-09-14 00:00:07 +08:00
icarus
751e391db6 refactor(assistant-presets): rename agents to assistant presets and update related components
- Rename agents to assistant presets across the codebase
- Update components, hooks, and pages to reflect the new naming
- Add new components for managing assistant presets
- Improve localization and grouping of presets
- Maintain existing functionality while updating the UI
2025-09-13 23:16:22 +08:00
icarus
3e04c9493f refactor(agents): rename agents to presets and add new agents array
The old 'agents' array was actually storing presets, so it's renamed for clarity. Added new 'agentsNew' array for actual agent entities in preparation for autonomous agent feature.
2025-09-13 22:52:10 +08:00
icarus
6b0a1a42ad refactor(types): rename Agent type to AssistantPreset for clarity
The type was renamed to better reflect its purpose as a preset configuration for assistants rather than representing an active agent. This change improves code readability and maintainability by using more accurate terminology throughout the codebase.
2025-09-13 22:43:26 +08:00
icarus
ee82b23886 feat(chat): add agent creation button with placeholder implementation
Add a new button for creating agents in the chat interface. The button is currently a placeholder with a "Not implemented" toast message. Includes necessary i18n translations and component props.
2025-09-13 21:49:24 +08:00
Vaayne
0d2dc2c257 🏗️ refactor: migrate agents service from custom migrations to Drizzle ORM
- Replace custom migration system with modern Drizzle ORM implementation
- Add drizzle-orm and drizzle-kit dependencies for type-safe database operations
- Refactor BaseService to use Drizzle client with full type safety
- Create schema definitions in /database/schema/ using Drizzle patterns
- Remove legacy migration files, queries, and migrator classes
- Add comprehensive documentation for new Drizzle-based architecture
- Maintain backward compatibility in service layer APIs
- Simplify database operations with modern ORM patterns

This migration eliminates custom SQL generation in favor of a proven,
type-safe ORM solution that provides better developer experience and
maintainability.
2025-09-13 19:51:16 +08:00
Vaayne
c785be82dd ♻️ refactor: rename SessionLog to SessionMessage for semantic clarity
- Rename SessionLogEntity → SessionMessageEntity type definition
- Rename SessionLogService → SessionMessageService with all methods
- Rename API routes /logs → /messages for better REST semantics
- Update database queries and service layer naming
- Update all Swagger documentation and validation middleware
- Maintain backward compatibility in database schema

This improves code readability by using more accurate terminology
for conversational message data rather than generic "log" naming.
2025-09-13 12:06:02 +08:00
Vaayne
a4bb82a02d 📝 docs: update validation and refactoring documentation for agents service 2025-09-12 18:00:33 +08:00
Vaayne
e8c94f3584 Merge remote-tracking branch 'origin/main' into feat/agents-new 2025-09-12 17:56:10 +08:00
Vaayne
d123eec476 ♻️ refactor: eliminate database schema redundancy + add comprehensive documentation
Refactor database to migration-only approach and add complete documentation

### Database Architecture Improvements:
- **Remove redundant schema files**: Eliminated duplicate table/index definitions
- **Single source of truth**: Migration files now exclusively define database schema
- **Simplified maintenance**: No more sync issues between schema files and migrations

### Files Removed:
- `database/schema/tables.ts` - Redundant table definitions
- `database/schema/indexes.ts` - Redundant index definitions

### Files Updated:
- `database/schema/index.ts` - Now only exports migration utilities
- `database/index.ts` - Simplified exports, removed redundant schema references
- `BaseService.ts` - Updated documentation for migration-only approach
- `migrator.ts` - Enhanced documentation and clarity

### Documentation Added:
- **`database/README.md`** - Comprehensive 400+ line guide covering:
  - Architecture overview and migration-only approach
  - Complete directory structure explanation
  - Migration system lifecycle with diagrams
  - Query organization and API reference
  - Development workflow and best practices
  - Troubleshooting guide and examples

### Benefits:
-  Eliminated redundancy between schema and migration files
-  Reduced maintenance overhead and potential sync issues
-  Established single source of truth for database schema
-  Added comprehensive documentation for team development
-  Maintained full backward compatibility
-  All tests continue to pass (1420/1420)

The database system now follows industry best practices with migrations as the sole
schema definition method, while providing complete documentation for developers.
2025-09-12 17:54:12 +08:00
Vaayne
002a443281 🏗️ refactor: restructure agents service with migration system and modular architecture
BREAKING CHANGE: Major refactoring of agents service structure
- Split monolithic db.ts into focused query modules (agent, session, sessionLog)
- Implement comprehensive migration system with transaction support
- Reorganize services into dedicated services/ subdirectory
- Add production-ready schema versioning with rollback capability

### New Architecture:
- database/migrations/: Version-controlled schema evolution
- database/queries/: Entity-specific CRUD operations
- database/schema/: Table and index definitions
- services/: Business logic layer (AgentService, SessionService, SessionLogService)

### Key Features:
-  Migration system with atomic transactions and checksums
-  Modular query organization by entity type
-  Backward compatibility maintained for existing code
-  Production-ready rollback support
-  Comprehensive validation and testing

### Benefits:
- Single responsibility: Each file handles one specific concern
- Better maintainability: Easy to locate and modify entity-specific code
- Team-friendly: Reduced merge conflicts with smaller focused files
- Scalable: Simple to add new entities without cluttering existing code
- Production-ready: Safe schema evolution with migration tracking

All existing functionality preserved. Comprehensive testing completed (1420 tests pass).
2025-09-12 17:31:30 +08:00
Vaayne
64f3d08d4e ♻️ refactor: split AgentService into focused service modules
- **BaseService**: Shared database connection and JSON serialization utilities
- **AgentService**: Agent management operations (CRUD for agents)
- **SessionService**: Session management operations (CRUD for sessions)
- **SessionLogService**: Session log management operations (CRUD for session logs)

Updated API routes to use appropriate services:
- sessions.ts now uses SessionService for session operations
- session-logs.ts now uses SessionLogService and SessionService as needed
- Maintains backward compatibility with existing API endpoints

Benefits:
- Single Responsibility Principle - each service has a clear focus
- Better code organization and maintainability
- Easier testing and debugging
- Improved separation of concerns
- Shared database infrastructure via BaseService

All TypeScript compilation and build checks pass.
2025-09-12 16:25:50 +08:00
Vaayne
9c956a30ea feat: initialize AgentService in ApiServer and improve ID generation logic 2025-09-12 16:04:04 +08:00
Vaayne
5eaa90a7a2 feat: implement comprehensive CRUD APIs for agent management with type support 2025-09-12 15:27:36 +08:00
Vaayne
e3f5033bc4 chore: add express-validator dependency
Add express-validator for API request validation and data sanitization
in agent management endpoints.
2025-09-11 14:35:54 +08:00
Vaayne
2ec3b20b23 🚀 feat: add comprehensive REST API for agent management
Implement full REST API with Express routes for agents, sessions, and logs:
- CRUD operations for agents with validation and OpenAPI documentation
- Session management with nested resource endpoints
- Hierarchical logging system with bulk operations support
- Request validation using express-validator
- Proper error handling and structured responses
2025-09-11 14:35:34 +08:00
Vaayne
d26d02babc feat: implement AgentService for autonomous agent management
Add comprehensive agent service with full CRUD operations, session management,
and structured logging capabilities. Includes database operations for agents,
sessions, and hierarchical log entries with proper type definitions.
2025-09-11 14:35:14 +08:00
Vaayne
675671688b 📝 docs: add ast-grep tool guidance to CLAUDE.md
Add guidance to use ast-grep for code pattern searches when available,
improving development workflow documentation.
2025-09-11 14:34:55 +08:00
Vaayne
bcdd48615d 🗃️ feat: implement comprehensive agent database schema and queries
- Add complete SQL schema for agents, sessions, and session_logs tables
- Implement CRUD operations for all agent-related entities
- Add SessionLogEntity type with hierarchical logging support
- Include proper indexes and foreign key constraints for performance
- Support agent configuration inheritance in sessions via COALESCE
- Add metadata field for extensible session log tracking
2025-09-11 13:10:31 +08:00
Vaayne
1f974558f8 feat: enhance agent and session entity types with additional properties 2025-09-11 12:28:43 +08:00
Vaayne
0f1ad59e58 feat: add agent and session entity types 2025-09-11 10:00:29 +08:00
317 changed files with 15403 additions and 9005 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
name: Delete merged branch
on:
pull_request:
types:
- closed
jobs:
delete-branch:
runs-on: ubuntu-latest
permissions:
contents: write
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Delete merged branch
uses: actions/github-script@v8
with:
script: |
github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${context.payload.pull_request.head.ref}`,
})

View File

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

View File

@@ -10,21 +10,19 @@ on:
- main
- develop
- v2
types: [ready_for_review, synchronize, opened]
jobs:
build:
runs-on: ubuntu-latest
env:
PRCI: true
if: github.event.pull_request.draft == false
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20

View File

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

4
.gitignore vendored
View File

@@ -54,8 +54,6 @@ local
.qwen/*
.trae/*
.claude-code-router/*
.codebuddy/*
.zed/*
CLAUDE.local.md
# vitest
@@ -71,3 +69,5 @@ playwright-report
test-results
YOUR_MEMORY_FILE_PATH
.sessions/

View File

@@ -15,7 +15,7 @@
".gitignore",
"scripts/cloudflare-worker.js",
"src/main/integration/nutstore/sso/lib/**",
"src/main/integration/cherryai/index.js",
"src/main/integration/cherryin/index.js",
"src/main/integration/nutstore/sso/lib/**",
"src/renderer/src/ui/**",
"packages/**/dist",

View File

@@ -34,10 +34,10 @@
"*.css": "tailwindcss"
},
"files.eol": "\n",
"i18n-ally.displayLanguage": "zh-cn",
// "i18n-ally.displayLanguage": "zh-cn", // 界面显示语言
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
"i18n-ally.fullReloadOnChanged": true, // 界面显示语言
"i18n-ally.fullReloadOnChanged": true,
"i18n-ally.keystyle": "nested", // 翻译路径格式
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
// "i18n-ally.namespace": true, // 开启命名空间

View File

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

View File

@@ -0,0 +1,18 @@
diff --git a/sdk.mjs b/sdk.mjs
index e2dbafb4e2faa1bf2b6b02f0009a2b9bbf57c757..3f07a1d5c2949a246fe5414e69ab45942fa605a2 100644
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6355,11 +6355,11 @@ class ProcessTransport {
prompt,
additionalDirectories = [],
cwd,
- executable = isRunningWithBun() ? "bun" : "node",
+ executable = process.execPath,
executableArgs = [],
extraArgs = {},
pathToClaudeCodeExecutable,
- env = { ...process.env },
+ env = { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stderr,
customSystemPrompt,
appendSystemPrompt,

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

153
CLAUDE.md
View File

@@ -1,127 +1,52 @@
# CLAUDE.md
# AI Assistant Guide
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to AI coding assistants when working with code in this repository. Adherence to these guidelines is crucial for maintaining code quality and consistency.
## Guiding Principles
- **Clarity and Simplicity**: Write code that is easy to understand and maintain.
- **Consistency**: Follow existing patterns and conventions in the codebase.
- **Correctness**: Ensure code is correct, well-tested, and robust.
- **Efficiency**: Write performant code and use resources judiciously.
## MUST Follow Rules
1. **Code Search**: Use `ast-grep` for semantic code pattern searches when available. Fallback to `rg` (ripgrep) or `grep` for text-based searches.
2. **UI Framework**: Exclusively use **HeroUI** for all new UI components. The use of `antd` or `styled-components` is strictly **PROHIBITED**.
3. **Quality Assurance**: **Always** run `yarn build:check` before finalizing your work or making any commits. This ensures code quality (linting, testing, and type checking).
4. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`.
5. **External Research**: Leverage `subagent` for gathering external information, including latest documentation, API references, news, or web-based research. This keeps the main conversation focused on the task at hand.
6. **Code Reviews**: Always seek a code review from a human developer before merging significant changes. This ensures adherence to project standards and catches potential issues.
7. **Documentation**: Update or create documentation for any new features, modules, or significant changes to existing functionality.
## Development Commands
### Environment Setup
- **Install**: `yarn install`
- **Development**: `yarn dev` - Runs Electron app in development mode
- **Debug**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
- **Build Check**: `yarn build:check` - REQUIRED before commits (lint + test + typecheck)
- **Test**: `yarn test` - Run all tests (Vitest)
- **Single Test**: `yarn test:main` or `yarn test:renderer`
- **Lint**: `yarn lint` - Fix linting issues and run typecheck
- **Prerequisites**: Node.js v22.x.x or higher, Yarn 4.9.1
- **Setup Yarn**: `corepack enable && corepack prepare yarn@4.9.1 --activate`
- **Install Dependencies**: `yarn install`
- **Add New Dependencies**: `yarn add -D` for renderer-specific dependencies, `yarn add` for others.
## Project Architecture
### Development
### Electron Structure
- **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.)
- **Renderer Process** (`src/renderer/`): React UI with Redux state management
- **Preload Scripts** (`src/preload/`): Secure IPC bridge
- **Start Development**: `yarn dev` - Runs Electron app in development mode
- **Debug Mode**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
### Testing & Quality
- **Run Tests**: `yarn test` - Runs all tests (Vitest)
- **Run E2E Tests**: `yarn test:e2e` - Playwright end-to-end tests
- **Type Check**: `yarn typecheck` - Checks TypeScript for both node and web
- **Lint**: `yarn lint` - ESLint with auto-fix
- **Format**: `yarn format` - Biome formatting
### Build & Release
- **Build**: `yarn build` - Builds for production (includes typecheck)
- **Platform-specific builds**:
- Windows: `yarn build:win`
- macOS: `yarn build:mac`
- Linux: `yarn build:linux`
## Architecture Overview
### Electron Multi-Process Architecture
- **Main Process** (`src/main/`): Node.js backend handling system integration, file operations, and services
- **Renderer Process** (`src/renderer/`): React-based UI running in Chromium
- **Preload Scripts** (`src/preload/`): Secure bridge between main and renderer processes
### Key Architectural Components
#### Main Process Services (`src/main/services/`)
- **MCPService**: Model Context Protocol server management
- **KnowledgeService**: Document processing and knowledge base management
- **FileStorage/S3Storage/WebDav**: Multiple storage backends
- **WindowService**: Multi-window management (main, mini, selection windows)
- **ProxyManager**: Network proxy handling
- **SearchService**: Full-text search capabilities
#### AI Core (`src/renderer/src/aiCore/`)
- **Middleware System**: Composable pipeline for AI request processing
- **Client Factory**: Supports multiple AI providers (OpenAI, Anthropic, Gemini, etc.)
- **Stream Processing**: Real-time response handling
#### State Management (`src/renderer/src/store/`)
- **Redux Toolkit**: Centralized state management
- **Persistent Storage**: Redux-persist for data persistence
- **Thunks**: Async actions for complex operations
#### Knowledge Management
- **Embeddings**: Vector search with multiple providers (OpenAI, Voyage, etc.)
- **OCR**: Document text extraction (system OCR, Doc2x, Mineru)
- **Preprocessing**: Document preparation pipeline
- **Loaders**: Support for various file formats (PDF, DOCX, EPUB, etc.)
### Build System
- **Electron-Vite**: Development and build tooling (v4.0.0)
- **Rolldown-Vite**: Using experimental rolldown-vite instead of standard vite
- **Workspaces**: Monorepo structure with `packages/` directory
- **Multiple Entry Points**: Main app, mini window, selection toolbar
- **Styled Components**: CSS-in-JS styling with SWC optimization
### Testing Strategy
- **Vitest**: Unit and integration testing
- **Playwright**: End-to-end testing
- **Component Testing**: React Testing Library
- **Coverage**: Available via `yarn test:coverage`
### Key Patterns
- **IPC Communication**: Secure main-renderer communication via preload scripts
- **Service Layer**: Clear separation between UI and business logic
- **Plugin Architecture**: Extensible via MCP servers and middleware
- **Multi-language Support**: i18n with dynamic loading
- **Theme System**: Light/dark themes with custom CSS variables
### UI Design
The project is in the process of migrating from antd & styled-components to HeroUI. Please use HeroUI to build UI components. The use of antd and styled-components is prohibited.
HeroUI Docs: https://www.heroui.com/docs/guide/introduction
## Logging Standards
### Usage
### Key Components
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
### Logging
```typescript
// Main process
import { loggerService } from '@logger'
const logger = loggerService.withContext('moduleName')
// Renderer process (set window source first)
loggerService.initWindowSource('windowName')
const logger = loggerService.withContext('moduleName')
// Logging
// Renderer: loggerService.initWindowSource('windowName') first
logger.info('message', CONTEXT)
logger.error('message', new Error('error'), CONTEXT)
```
### Log Levels (highest to lowest)
- `error` - Critical errors causing crash/unusable functionality
- `warn` - Potential issues that don't affect core functionality
- `info` - Application lifecycle and key user actions
- `verbose` - Detailed flow information for feature tracing
- `debug` - Development diagnostic info (not for production)
- `silly` - Extreme debugging, low-level information

View File

@@ -125,17 +125,16 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
What's New in v1.6.4
🐛 问题修复:
- 修复 Anthropic API URL 处理,移除尾部斜杠并添加端点路径处理
- 修复 MessageEditor 缺少 QuickPanelProvider 包装的问题
- 修复 MiniWindow 高度问题
Features:
- Providers: add CherryIN provider
- Notes: Add right-click context menu to create notes and folders
- Mini App: Add search functionality in mini app page
- Update Dialog: Add updating dialog in renderer process
- Mini App: Remove some mini apps
Bug Fixes:
- Fix reasoning block insertion order - now inserts before content block
- Fix knowledge base deletion and web search RAG errors
- Fix Qwen model URL configuration
🚀 性能优化:
- 优化输入栏提及模型状态缓存,在渲染间保持状态
- 重构网络搜索参数支持模型内置搜索,新增 OpenAI Chat 和 OpenRouter 支持
🔧 重构改进:
- 更新 HeroUIProvider 导入路径,改善上下文管理
- 更新依赖项和 VSCode 开发环境配置
- 升级 @cherrystudio/ai-core 到 v1.0.0-alpha.17

View File

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

View File

@@ -48,27 +48,6 @@ export default defineConfig([
'@eslint-react/no-children-to-array': 'off'
}
},
{
ignores: [
'node_modules/**',
'build/**',
'dist/**',
'out/**',
'local/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**',
'src/main/integration/cherryai/index.js',
'src/main/integration/nutstore/sso/lib/**',
'src/renderer/src/ui/**',
'packages/**/dist'
]
},
// turn off oxlint supported rules.
...oxlint.configs['flat/eslint'],
...oxlint.configs['flat/typescript'],
...oxlint.configs['flat/unicorn'],
{
// LoggerService Custom Rules - only apply to src directory
files: ['src/**/*.{ts,tsx,js,jsx}'],
@@ -131,4 +110,25 @@ export default defineConfig([
'i18n/no-template-in-t': 'warn'
}
},
{
ignores: [
'node_modules/**',
'build/**',
'dist/**',
'out/**',
'local/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**',
'src/main/integration/cherryin/index.js',
'src/main/integration/nutstore/sso/lib/**',
'src/renderer/src/ui/**',
'packages/**/dist'
]
},
// turn off oxlint supported rules.
...oxlint.configs['flat/eslint'],
...oxlint.configs['flat/typescript'],
...oxlint.configs['flat/unicorn']
])

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.6.4",
"version": "1.6.0-rc.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -27,6 +27,7 @@
"scripts": {
"start": "electron-vite preview",
"dev": "dotenv electron-vite dev",
"dev:main": "dotenv electron-vite dev --watch",
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"build": "npm run typecheck && electron-vite build",
"build:check": "yarn lint && yarn test",
@@ -43,15 +44,18 @@
"release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
"generate:agents": "yarn workspace @cherry-studio/database agents",
"agents:generate": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts",
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "tsx scripts/check-i18n.ts",
"sync:i18n": "tsx scripts/sync-i18n.ts",
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"update:languages": "tsx scripts/update-languages.ts",
@@ -75,11 +79,15 @@
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-code": "patch:@anthropic-ai/claude-code@npm%3A1.0.118#~/.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.patch",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"@types/uuid": "^10.0.0",
"drizzle-orm": "^0.44.5",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"jsdom": "26.1.0",
@@ -97,10 +105,10 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.33",
"@ai-sdk/mistral": "^2.0.17",
"@ai-sdk/perplexity": "^2.0.11",
"@ai-sdk/amazon-bedrock": "^3.0.21",
"@ai-sdk/google-vertex": "^3.0.27",
"@ai-sdk/mistral": "^2.0.14",
"@ai-sdk/perplexity": "^2.0.9",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
@@ -151,6 +159,7 @@
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.12.0",
"@swc/plugin-styled-components": "^8.0.4",
@@ -215,7 +224,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.59",
"ai": "^5.0.44",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -238,9 +247,11 @@
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"electron": "37.6.0",
"drizzle-kit": "^0.31.4",
"electron": "37.4.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1",
"electron-store": "^8.2.0",
"electron-updater": "6.6.4",
"electron-vite": "4.0.0",
@@ -325,6 +336,7 @@
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swr": "^2.3.6",
"tailwindcss": "^4.1.13",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
@@ -335,7 +347,7 @@
"typescript": "~5.8.2",
"undici": "6.21.2",
"unified": "^11.0.5",
"uuid": "^10.0.0",
"uuid": "^13.0.0",
"vite": "npm:rolldown-vite@latest",
"vitest": "^3.2.4",
"webdav": "^5.8.0",
@@ -368,8 +380,7 @@
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"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"
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.1",
"version": "1.0.0-alpha.18",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -36,14 +36,15 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.22",
"@ai-sdk/azure": "^2.0.42",
"@ai-sdk/deepseek": "^1.0.20",
"@ai-sdk/openai": "^2.0.42",
"@ai-sdk/openai-compatible": "^1.0.19",
"@ai-sdk/anthropic": "^2.0.17",
"@ai-sdk/azure": "^2.0.30",
"@ai-sdk/deepseek": "^1.0.17",
"@ai-sdk/google": "^2.0.14",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.10",
"@ai-sdk/xai": "^2.0.23",
"@ai-sdk/provider-utils": "^3.0.9",
"@ai-sdk/xai": "^2.0.18",
"zod": "^4.1.5"
},
"devDependencies": {

View File

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

View File

@@ -1,7 +1,6 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { InferToolInput, InferToolOutput } from 'ai'
import { ProviderOptionsMap } from '../../../options/types'
import { OpenRouterSearchConfig } from './openrouter'
@@ -59,31 +58,24 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
export type WebSearchToolOutputSchema = {
// Anthropic 工具 - 手动定义
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
anthropicWebSearch: Array<{
url: string
title: string
pageAge: string | null
encryptedContent: string
type: string
}>
// OpenAI 工具 - 基于实际输出
// TODO: 上游定义不规范,是unknown
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
openai: {
status: 'completed' | 'failed'
}
'openai-chat': {
openaiWebSearch: {
status: 'completed' | 'failed'
}
// Google 工具
// TODO: 上游定义不规范,是unknown
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
google: {
googleSearch: {
webSearchQueries?: string[]
groundingChunks?: Array<{
web?: { uri: string; title: string }
}>
}
}
export type WebSearchToolInputSchema = {
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
}

View File

@@ -5,10 +5,9 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
App_Proxy = 'app:proxy',
App_SetLaunchToTray = 'app:set-launch-to-tray',
@@ -34,7 +33,6 @@ export enum IpcChannel {
App_GetBinaryPath = 'app:get-binary-path',
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
App_InstallOvmsBinary = 'app:install-ovms-binary',
App_LogToMain = 'app:log-to-main',
App_SaveData = 'app:save-data',
App_GetDiskInfo = 'app:get-disk-info',
@@ -91,6 +89,9 @@ export enum IpcChannel {
// Python
Python_Execute = 'python:execute',
// agent messages
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -221,7 +222,6 @@ export enum IpcChannel {
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName',
// DevTools
System_ToggleDevTools = 'system:toggleDevTools',
@@ -229,6 +229,7 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -323,23 +324,10 @@ export enum IpcChannel {
// CodeTools
CodeTools_Run = 'code-tools:run',
CodeTools_GetAvailableTerminals = 'code-tools:get-available-terminals',
CodeTools_SetCustomTerminalPath = 'code-tools:set-custom-terminal-path',
CodeTools_GetCustomTerminalPath = 'code-tools:get-custom-terminal-path',
CodeTools_RemoveCustomTerminalPath = 'code-tools:remove-custom-terminal-path',
// OCR
OCR_ocr = 'ocr:ocr',
// OVMS
Ovms_AddModel = 'ovms:add-model',
Ovms_StopAddModel = 'ovms:stop-addmodel',
Ovms_GetModels = 'ovms:get-models',
Ovms_IsRunning = 'ovms:is-running',
Ovms_GetStatus = 'ovms:get-status',
Ovms_RunOVMS = 'ovms:run-ovms',
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
Cherryai_GetSignature = 'cherryai:get-signature'
// Cherryin
Cherryin_GetSignature = 'cherryin:get-signature'
}

View File

@@ -217,256 +217,5 @@ export enum codeTools {
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex',
iFlowCli = 'iflow-cli',
githubCopilotCli = 'github-copilot-cli'
iFlowCli = 'iflow-cli'
}
export enum terminalApps {
systemDefault = 'Terminal',
iterm2 = 'iTerm2',
kitty = 'kitty',
alacritty = 'Alacritty',
wezterm = 'WezTerm',
ghostty = 'Ghostty',
tabby = 'Tabby',
// Windows terminals
windowsTerminal = 'WindowsTerminal',
powershell = 'PowerShell',
cmd = 'CMD',
wsl = 'WSL'
}
export interface TerminalConfig {
id: string
name: string
bundleId?: string
customPath?: string // For user-configured terminal paths on Windows
}
export interface TerminalConfigWithCommand extends TerminalConfig {
command: (directory: string, fullCommand: string) => { command: string; args: string[] }
}
export const MACOS_TERMINALS: TerminalConfig[] = [
{
id: terminalApps.systemDefault,
name: 'Terminal',
bundleId: 'com.apple.Terminal'
},
{
id: terminalApps.iterm2,
name: 'iTerm2',
bundleId: 'com.googlecode.iterm2'
},
{
id: terminalApps.kitty,
name: 'kitty',
bundleId: 'net.kovidgoyal.kitty'
},
{
id: terminalApps.alacritty,
name: 'Alacritty',
bundleId: 'org.alacritty'
},
{
id: terminalApps.wezterm,
name: 'WezTerm',
bundleId: 'com.github.wez.wezterm'
},
{
id: terminalApps.ghostty,
name: 'Ghostty',
bundleId: 'com.mitchellh.ghostty'
},
{
id: terminalApps.tabby,
name: 'Tabby',
bundleId: 'org.tabby'
}
]
export const WINDOWS_TERMINALS: TerminalConfig[] = [
{
id: terminalApps.cmd,
name: 'Command Prompt'
},
{
id: terminalApps.powershell,
name: 'PowerShell'
},
{
id: terminalApps.windowsTerminal,
name: 'Windows Terminal'
},
{
id: terminalApps.wsl,
name: 'WSL (Ubuntu/Debian)'
},
{
id: terminalApps.alacritty,
name: 'Alacritty'
},
{
id: terminalApps.wezterm,
name: 'WezTerm'
}
]
export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
{
id: terminalApps.cmd,
name: 'Command Prompt',
command: (_: string, fullCommand: string) => ({
command: 'cmd',
args: ['/c', 'start', 'cmd', '/k', fullCommand]
})
},
{
id: terminalApps.powershell,
name: 'PowerShell',
command: (_: string, fullCommand: string) => ({
command: 'cmd',
args: ['/c', 'start', 'powershell', '-NoExit', '-Command', `& '${fullCommand}'`]
})
},
{
id: terminalApps.windowsTerminal,
name: 'Windows Terminal',
command: (_: string, fullCommand: string) => ({
command: 'wt',
args: ['cmd', '/k', fullCommand]
})
},
{
id: terminalApps.wsl,
name: 'WSL (Ubuntu/Debian)',
command: (_: string, fullCommand: string) => {
// Start WSL in a new window and execute the batch file from within WSL using cmd.exe
// The batch file will run in Windows context but output will be in WSL terminal
return {
command: 'cmd',
args: ['/c', 'start', 'wsl', '-e', 'bash', '-c', `cmd.exe /c '${fullCommand}' ; exec bash`]
}
}
},
{
id: terminalApps.alacritty,
name: 'Alacritty',
customPath: '', // Will be set by user in settings
command: (_: string, fullCommand: string) => ({
command: 'alacritty', // Will be replaced with customPath if set
args: ['-e', 'cmd', '/k', fullCommand]
})
},
{
id: terminalApps.wezterm,
name: 'WezTerm',
customPath: '', // Will be set by user in settings
command: (_: string, fullCommand: string) => ({
command: 'wezterm', // Will be replaced with customPath if set
args: ['start', 'cmd', '/k', fullCommand]
})
}
]
// Helper function to escape strings for AppleScript
const escapeForAppleScript = (str: string): string => {
// In AppleScript strings, backslashes and double quotes need to be escaped
// When passed through osascript -e with single quotes, we need:
// 1. Backslash: \ -> \\
// 2. Double quote: " -> \"
return str
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Then escape double quotes
}
export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
{
id: terminalApps.systemDefault,
name: 'Terminal',
bundleId: 'com.apple.Terminal',
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapeForAppleScript(fullCommand)}" in front window'`
]
})
},
{
id: terminalApps.iterm2,
name: 'iTerm2',
bundleId: 'com.googlecode.iterm2',
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "${escapeForAppleScript(fullCommand)}"\n activate\nend tell'`
]
})
},
{
id: terminalApps.kitty,
name: 'kitty',
bundleId: 'net.kovidgoyal.kitty',
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`cd "${_directory}" && open -na kitty --args --directory="${_directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
]
})
},
{
id: terminalApps.alacritty,
name: 'Alacritty',
bundleId: 'org.alacritty',
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na Alacritty --args --working-directory "${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
]
})
},
{
id: terminalApps.wezterm,
name: 'WezTerm',
bundleId: 'com.github.wez.wezterm',
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na WezTerm --args start --new-tab --cwd "${_directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
]
})
},
{
id: terminalApps.ghostty,
name: 'Ghostty',
bundleId: 'com.mitchellh.ghostty',
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`cd "${_directory}" && open -na Ghostty --args --working-directory="${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
]
})
},
{
id: terminalApps.tabby,
name: 'Tabby',
bundleId: 'org.tabby',
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`if pgrep -x "Tabby" > /dev/null; then
open -na Tabby --args open && sleep 0.3
else
open -na Tabby --args open && sleep 2
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "${escapeForAppleScript(fullCommand)}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
]
})
}
]

View File

@@ -1,252 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: transparent;
margin: 0 auto;
}
body.dark {
background: transparent;
color: rgba(255, 255, 255, 0.85);
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 20px;
color: #1a1a1a;
}
body.dark h1 {
color: rgba(255, 255, 255, 0.95);
}
h2 {
font-size: 18px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
color: #2c2c2c;
}
body.dark h2 {
color: rgba(255, 255, 255, 0.9);
}
p {
margin: 12px 0;
line-height: 1.8;
}
body.dark p {
color: rgba(255, 255, 255, 0.8);
}
ul {
margin: 12px 0;
padding-left: 24px;
}
li {
margin: 6px 0;
line-height: 1.6;
}
body.dark li {
color: rgba(255, 255, 255, 0.75);
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
body.dark a {
color: #4da6ff;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
font-size: 13px;
color: #666;
}
body.dark .footer {
border-top-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5);
}
.content-wrapper {
max-height: calc(100vh - 40px);
overflow-y: auto;
padding-right: 10px;
background: transparent;
}
/* Scrollbar styles - Light mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Scrollbar styles - Dark mode */
body.dark ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
body.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
body.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
<script>
// Detect theme
document.addEventListener('DOMContentLoaded', function () {
const urlParams = new URLSearchParams(window.location.search);
const theme = urlParams.get('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
}
});
</script>
</head>
<body>
<div class="content-wrapper">
<h1>Privacy Policy</h1>
<p>
Welcome to Cherry Studio (hereinafter referred to as "the Software" or "we"). We highly value your privacy
protection. This Privacy Policy explains how we process and protect your personal information and data.
Please read and understand this policy carefully before using the Software:
</p>
<h2>1. Information We Collect</h2>
<p>To optimize user experience and improve software quality, we may only collect the following anonymous,
non-personal information:</p>
<ul>
<li>Software version information</li>
<li>Activity and usage frequency of software features</li>
<li>Anonymous crash and error log information</li>
</ul>
<p>The above information is completely anonymous, does not involve any personal identity data, and cannot be
linked to your personal information.</p>
<h2>2. Information We Do Not Collect</h2>
<p>To maximize the protection of your privacy and security, we explicitly commit that we:</p>
<ul>
<li>Will not collect, save, transmit, or process model service API Key information you enter into the
Software</li>
<li>Will not collect, save, transmit, or process any conversation data generated during your use of the
Software, including but not limited to chat content, instruction information, knowledge base
information, vector data, and other custom content</li>
<li>Will not collect, save, transmit, or process any sensitive information that can identify personal
identity</li>
</ul>
<h2>3. Data Interaction Description</h2>
<p>
The Software uses API Keys from third-party model service providers that you apply for and configure
yourself to complete model calls and conversation functions. The model services you use (such as large
models, API interfaces, etc.) are directly provided by third-party providers of your choice. We do not
intervene, monitor, or interfere with the data transmission process.
</p>
<p>
Data interactions between you and third-party model services are governed by the privacy policies and user
agreements of third-party service providers. We recommend that you fully understand the privacy terms of
relevant service providers before use.
</p>
<h2>4. Local Data Security Protection</h2>
<p>The Software is a localized application, and all data is stored on your local device by default. We have
taken the following measures to ensure data security:</p>
<ul>
<li>Conversation records, configuration information, and other data are only saved on your local device</li>
<li>Data import/export functions are provided to facilitate your independent management and backup of data
</li>
<li>Your local data will not be uploaded to any server or cloud storage</li>
</ul>
<h2>5. Third-Party Services</h2>
<p>
When using the Software, you may access third-party services (such as AI model APIs, translation services,
etc.). The use of these third-party services is governed by their respective terms of service and privacy
policies. We strongly recommend that you carefully read and understand the relevant terms before use.
</p>
<h2>6. User Rights</h2>
<p>You have complete control over your data:</p>
<ul>
<li>You can view, modify, and delete all locally stored data at any time</li>
<li>You can choose whether to enable specific features or services</li>
<li>You can stop using the Software and delete all related data at any time</li>
</ul>
<h2>7. Children's Privacy Protection</h2>
<p>The Software is not intended for minors under 18 years of age. If you are a minor, please use the Software
under the guidance of a guardian.</p>
<h2>8. Privacy Policy Updates</h2>
<p>
We may update this Privacy Policy based on legal requirements or changes in product features. The updated
policy will be published in the Software and you will be notified before it takes effect. If you do not
agree with the updated terms, you can choose to stop using the Software.
</p>
<h2>9. Contact Us</h2>
<p>If you have any questions, suggestions, or complaints about this Privacy Policy, please contact us through
the following methods:</p>
<ul>
<li>
GitHub: <a href="https://github.com/CherryHQ/cherry-studio" target="_blank"
rel="noopener noreferrer">https://github.com/CherryHQ/cherry-studio</a>
</li>
<li>Email: support@cherry-ai.com</li>
</ul>
<div class="footer">
Last Updated: December 2024
</div>
</div>
</body>
</html>

View File

@@ -1,230 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>隐私协议</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: transparent;
margin: 0 auto;
}
body.dark {
background: transparent;
color: rgba(255, 255, 255, 0.85);
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 20px;
color: #1a1a1a;
}
body.dark h1 {
color: rgba(255, 255, 255, 0.95);
}
h2 {
font-size: 18px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
color: #2c2c2c;
}
body.dark h2 {
color: rgba(255, 255, 255, 0.9);
}
p {
margin: 12px 0;
line-height: 1.8;
}
body.dark p {
color: rgba(255, 255, 255, 0.8);
}
ul {
margin: 12px 0;
padding-left: 24px;
}
li {
margin: 6px 0;
line-height: 1.6;
}
body.dark li {
color: rgba(255, 255, 255, 0.75);
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
body.dark a {
color: #4da6ff;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
font-size: 13px;
color: #666;
}
body.dark .footer {
border-top-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.5);
}
.content-wrapper {
overflow-y: auto;
padding-right: 10px;
background: transparent;
}
/* 滚动条样式 - 亮色模式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 滚动条样式 - 暗色模式 */
body.dark ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
body.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
body.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
<script>
// 检测主题
document.addEventListener('DOMContentLoaded', function () {
const urlParams = new URLSearchParams(window.location.search);
const theme = urlParams.get('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
}
});
</script>
</head>
<body>
<div class="content-wrapper">
<h1>隐私协议</h1>
<p>
欢迎使用 Cherry Studio以下简称"本软件"或"我们")。我们高度重视您的隐私保护,本隐私协议将说明我们如何处理与保护您的个人信息和数据。请在使用本软件前仔细阅读并理解本协议:
</p>
<h2>一、我们收集的信息范围</h2>
<p>为了优化用户体验和提升软件质量,我们仅可能会匿名收集以下非个人化信息:</p>
<ul>
<li>软件版本信息;</li>
<li>软件功能的活跃度、使用频次;</li>
<li>匿名的崩溃、错误日志信息;</li>
</ul>
<p>上述信息完全匿名,不会涉及任何个人身份数据,也无法关联到您的个人信息。</p>
<h2>二、我们不会收集的任何信息</h2>
<p>为了最大限度保护您的隐私安全,我们明确承诺:</p>
<ul>
<li>不会收集、保存、传输或处理您输入到本软件中的模型服务 API Key 信息;</li>
<li>不会收集、保存、传输或处理您在使用本软件过程中产生的任何对话数据,包括但不限于聊天内容、指令信息、知识库信息、向量数据及其他自定义内容;</li>
<li>不会收集、保存、传输或处理任何可识别个人身份的敏感信息。</li>
</ul>
<h2>三、数据交互说明</h2>
<p>
本软件采用您自行申请并配置的第三方模型服务提供商的 API Key以完成相关模型的调用与对话功能。您使用的模型服务例如大模型、API 接口等)由您选择的第三方提供商直接提供,我们不会介入、监控或干扰数据传输过程。
</p>
<p>
您与第三方模型服务之间的数据交互受第三方服务提供商的隐私政策和用户协议约束,我们建议您在使用前充分了解相关服务商的隐私条款。
</p>
<h2>四、本地数据的安全保护</h2>
<p>本软件为本地化应用程序,所有数据默认存储在您的本地设备上。我们采取了以下措施保障数据安全:</p>
<ul>
<li>对话记录、配置信息等数据仅保存在您的本地设备中;</li>
<li>提供数据导入/导出功能,方便您自主管理和备份数据;</li>
<li>不会将您的本地数据上传至任何服务器或云端存储。</li>
</ul>
<h2>五、第三方服务</h2>
<p>
在使用本软件过程中,您可能会接入第三方服务(如 AI 模型 API、翻译服务等。这些第三方服务的使用受其各自的服务条款和隐私政策约束。我们强烈建议您在使用前仔细阅读并理解相关条款。
</p>
<h2>六、用户权利</h2>
<p>您对自己的数据拥有完全的控制权:</p>
<ul>
<li>您可以随时查看、修改、删除本地存储的所有数据;</li>
<li>您可以选择是否启用特定功能或服务;</li>
<li>您可以随时停止使用本软件并删除所有相关数据。</li>
</ul>
<h2>七、儿童隐私保护</h2>
<p>本软件不面向 18 岁以下的未成年人提供服务。如果您是未成年人,请在监护人的指导下使用本软件。</p>
<h2>八、隐私政策的更新</h2>
<p>
我们可能会根据法律法规要求或产品功能的变化更新本隐私协议。更新后的协议将在软件中发布,并在生效前通知您。如果您不同意更新后的条款,您可以选择停止使用本软件。
</p>
<h2>九、联系我们</h2>
<p>如果您对本隐私协议有任何疑问、建议或投诉,请通过以下方式联系我们:</p>
<ul>
<li>
GitHub: <a href="https://github.com/CherryHQ/cherry-studio" target="_blank"
rel="noopener noreferrer">https://github.com/CherryHQ/cherry-studio</a>
</li>
<li>Email: support@cherry-ai.com</li>
</ul>
<div class="footer">
最后更新日期2024年12月
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,53 @@
--> statement-breakpoint
CREATE TABLE `migrations` (
`version` integer PRIMARY KEY NOT NULL,
`tag` text NOT NULL,
`executed_at` integer NOT NULL
);
CREATE TABLE `agents` (
`id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`name` text NOT NULL,
`description` text,
`accessible_paths` text,
`instructions` text,
`model` text NOT NULL,
`plan_model` text,
`small_model` text,
`mcps` text,
`allowed_tools` text,
`configuration` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`agent_type` text NOT NULL,
`agent_id` text NOT NULL,
`name` text NOT NULL,
`description` text,
`accessible_paths` text,
`instructions` text,
`model` text NOT NULL,
`plan_model` text,
`small_model` text,
`mcps` text,
`allowed_tools` text,
`configuration` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `session_messages` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`session_id` text NOT NULL,
`role` text NOT NULL,
`content` text NOT NULL,
`metadata` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE `session_messages` ADD `agent_session_id` text DEFAULT '';

View File

@@ -0,0 +1,331 @@
{
"version": "6",
"dialect": "sqlite",
"id": "35efb412-0230-4767-9c76-7b7c4d40369f",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_messages": {
"name": "session_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"migrations": {
"name": "migrations",
"columns": {
"version": {
"name": "version",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"executed_at": {
"name": "executed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,339 @@
{
"version": "6",
"dialect": "sqlite",
"id": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
"prevId": "35efb412-0230-4767-9c76-7b7c4d40369f",
"tables": {
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_messages": {
"name": "session_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_session_id": {
"name": "agent_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"migrations": {
"name": "migrations",
"columns": {
"version": {
"name": "version",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"executed_at": {
"name": "executed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1758091173882,
"tag": "0000_confused_wendigo",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1758187378775,
"tag": "0001_woozy_captain_flint",
"breakpoints": true
}
]
}

View File

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

View File

@@ -1,177 +0,0 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_PKG_NAME = 'ovms250911.zip'
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
/**
* Downloads and extracts the OVMS binary for the specified platform
*/
async function downloadOvmsBinary() {
// Create output directory structure - OVMS goes into its own subdirectory
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
} 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...`)
}
}
}
// Check if any download succeeded
if (!downloadSuccess) {
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
return 103
}
try {
console.log(`Extracting to ${csDir}...`)
// 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}`)
// 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
}
return 104
}
return 0
}
/**
* Get the CPU Name and ID
*/
function getCpuInfo() {
const cpuInfo = {
name: '',
id: ''
}
// Use PowerShell to get CPU information
try {
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
const psOutput = execSync(psCommand).toString()
const cpuData = JSON.parse(psOutput)
if (Array.isArray(cpuData)) {
cpuInfo.name = cpuData[0].Name || ''
cpuInfo.id = cpuData[0].DeviceID || ''
} else {
cpuInfo.name = cpuData.Name || ''
cpuInfo.id = cpuData.DeviceID || ''
}
} catch (error) {
console.error(`Failed to get CPU info: ${error.message}`)
}
return cpuInfo
}
/**
* Main function to install OVMS
*/
async function installOvms() {
const platform = os.platform()
console.log(`Detected platform: ${platform}`)
const cpuName = getCpuInfo().name
console.log(`CPU Name: ${cpuName}`)
// Check if CPU name contains "Ultra"
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
return 101
}
// only support windows
if (platform !== 'win32') {
console.error('OVMS installation is only supported on Windows.')
return 102
}
return await downloadOvmsBinary()
}
// Run the installation
installOvms()
.then((retcode) => {
if (retcode === 0) {
console.log('OVMS installation successful')
} else {
console.error('OVMS installation failed')
}
process.exit(retcode)
})
.catch((error) => {
console.error('OVMS installation failed:', error)
process.exit(100)
})

View File

@@ -9,8 +9,9 @@ import * as path from 'path'
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
const baseLocale = 'zh-cn'
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
const baseFileName = `${baseLocale}.json`
const baseLocalePath = path.join(__dirname, '../src/renderer/src/i18n/locales', baseFileName)
type I18NValue = string | { [key: string]: I18NValue }
type I18N = { [key: string]: I18NValue }
@@ -105,6 +106,9 @@ const translateRecursively = async (originObj: I18N, systemPrompt: string): Prom
}
const main = async () => {
if (!fs.existsSync(baseLocalePath)) {
throw new Error(`${baseLocalePath} not found.`)
}
const localeFiles = fs
.readdirSync(localesDir)
.filter((file) => file.endsWith('.json') && file !== baseFileName)

View File

@@ -35,6 +35,9 @@ const allX64 = {
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
}
const claudeCodeVenderPath = '@anthropic-ai/claude-code/vendor'
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
const platformToArch = {
mac: 'darwin',
windows: 'win32',
@@ -46,9 +49,6 @@ exports.default = async function (context) {
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
const platform = context.packager.platform.name
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
const downloadPackages = async (packages) => {
console.log('downloading packages ......')
const downloadPromises = []
@@ -67,25 +67,39 @@ exports.default = async function (context) {
await Promise.all(downloadPromises)
}
const changeFilters = async (packages, filtersToExclude, filtersToInclude) => {
await downloadPackages(packages)
const changeFilters = async (filtersToExclude, filtersToInclude) => {
// remove filters for the target architecture (allow inclusion)
let filters = context.packager.config.files[0].filter
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
// add filters for other architectures (exclude them)
filters.push(...filtersToExclude)
context.packager.config.files[0].filter = filters
}
if (arch === Arch.arm64) {
await changeFilters(allArm64, x64Filters, arm64Filters)
return
}
await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64)
if (arch === Arch.x64) {
await changeFilters(allX64, arm64Filters, x64Filters)
return
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
const excludeClaudeCodeRipgrepFilters = claudeCodeVenders
.filter((f) => f !== `${archType}-${platformToArch[platform]}`)
.map((f) => '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + f + '/**')
const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin']
const includeClaudeCodeFilters = [
'!node_modules/' + claudeCodeVenderPath + '/' + `${archType}-${platformToArch[platform]}/**`
]
if (arch === Arch.arm64) {
await changeFilters(
[...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
[...arm64Filters, ...includeClaudeCodeFilters]
)
} else {
await changeFilters(
[...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
[...x64Filters, ...includeClaudeCodeFilters]
)
}
}

View File

@@ -4,7 +4,7 @@ import * as path from 'path'
import { sortedObjectByKeys } from './sort'
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
const baseLocale = 'zh-cn'
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
const baseFileName = `${baseLocale}.json`
const baseFilePath = path.join(translationsDir, baseFileName)

View File

@@ -5,7 +5,7 @@ import { sortedObjectByKeys } from './sort'
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
const baseLocale = 'zh-cn'
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
const baseFileName = `${baseLocale}.json`
const baseFilePath = path.join(localesDir, baseFileName)

View File

@@ -6,8 +6,10 @@ import { v4 as uuidv4 } from 'uuid'
import { authMiddleware } from './middleware/auth'
import { errorHandler } from './middleware/error'
import { setupOpenAPIDocumentation } from './middleware/openapi'
import { agentsRoutes } from './routes/agents'
import { chatRoutes } from './routes/chat'
import { mcpRoutes } from './routes/mcp'
import { messagesRoutes } from './routes/messages'
import { modelsRoutes } from './routes/models'
const logger = loggerService.withContext('ApiServer')
@@ -101,10 +103,7 @@ app.get('/', (_req, res) => {
name: 'Cherry Studio API',
version: '1.0.0',
endpoints: {
health: 'GET /health',
models: 'GET /v1/models',
chat: 'POST /v1/chat/completions',
mcp: 'GET /v1/mcps'
health: 'GET /health'
}
})
})
@@ -116,7 +115,9 @@ apiRouter.use(express.json())
// Mount routes
apiRouter.use('/chat', chatRoutes)
apiRouter.use('/mcps', mcpRoutes)
apiRouter.use('/messages', messagesRoutes)
apiRouter.use('/models', modelsRoutes)
apiRouter.use('/agents', agentsRoutes)
app.use('/v1', apiRouter)
// Setup OpenAPI documentation

View File

@@ -0,0 +1,532 @@
import { loggerService } from '@logger'
import { AgentModelValidationError, agentService } from '@main/services/agents'
import { ListAgentsResponse,type ReplaceAgentRequest, type UpdateAgentRequest } from '@types'
import { Request, Response } from 'express'
import type { ValidationRequest } from '../validators/zodValidator'
const logger = loggerService.withContext('ApiServerAgentsHandlers')
const modelValidationErrorBody = (error: AgentModelValidationError) => ({
error: {
message: `Invalid ${error.context.field}: ${error.detail.message}`,
type: 'invalid_request_error',
code: error.detail.code
}
})
/**
* @swagger
* /v1/agents:
* post:
* summary: Create a new agent
* description: Creates a new autonomous agent with the specified configuration
* tags: [Agents]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateAgentRequest'
* responses:
* 201:
* description: Agent created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export const createAgent = async (req: Request, res: Response): Promise<Response> => {
try {
logger.info('Creating new agent')
logger.debug('Agent data:', req.body)
const agent = await agentService.createAgent(req.body)
logger.info(`Agent created successfully: ${agent.id}`)
return res.status(201).json(agent)
} catch (error: any) {
if (error instanceof AgentModelValidationError) {
logger.warn('Agent model validation error during create:', {
agentType: error.context.agentType,
field: error.context.field,
model: error.context.model,
detail: error.detail
})
return res.status(400).json(modelValidationErrorBody(error))
}
logger.error('Error creating agent:', error)
return res.status(500).json({
error: {
message: `Failed to create agent: ${error.message}`,
type: 'internal_error',
code: 'agent_creation_failed'
}
})
}
}
/**
* @swagger
* /v1/agents:
* get:
* summary: List all agents
* description: Retrieves a paginated list of all agents
* tags: [Agents]
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 100
* default: 20
* description: Number of agents to return
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Number of agents to skip
* responses:
* 200:
* description: List of agents
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: array
* items:
* $ref: '#/components/schemas/AgentEntity'
* total:
* type: integer
* description: Total number of agents
* limit:
* type: integer
* description: Number of agents returned
* offset:
* type: integer
* description: Number of agents skipped
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export const listAgents = async (req: Request, res: Response): Promise<Response> => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
logger.info(`Listing agents with limit=${limit}, offset=${offset}`)
const result = await agentService.listAgents({ limit, offset })
logger.info(`Retrieved ${result.agents.length} agents (total: ${result.total})`)
return res.json({
data: result.agents,
total: result.total,
limit,
offset
} satisfies ListAgentsResponse)
} catch (error: any) {
logger.error('Error listing agents:', error)
return res.status(500).json({
error: {
message: 'Failed to list agents',
type: 'internal_error',
code: 'agent_list_failed'
}
})
}
}
/**
* @swagger
* /v1/agents/{agentId}:
* get:
* summary: Get agent by ID
* description: Retrieves a specific agent by its ID
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* responses:
* 200:
* description: Agent details
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export const getAgent = async (req: Request, res: Response): Promise<Response> => {
try {
const { agentId } = req.params
logger.info(`Getting agent: ${agentId}`)
const agent = await agentService.getAgent(agentId)
if (!agent) {
logger.warn(`Agent not found: ${agentId}`)
return res.status(404).json({
error: {
message: 'Agent not found',
type: 'not_found',
code: 'agent_not_found'
}
})
}
logger.info(`Agent retrieved successfully: ${agentId}`)
return res.json(agent)
} catch (error: any) {
logger.error('Error getting agent:', error)
return res.status(500).json({
error: {
message: 'Failed to get agent',
type: 'internal_error',
code: 'agent_get_failed'
}
})
}
}
/**
* @swagger
* /v1/agents/{agentId}:
* put:
* summary: Update agent
* description: Updates an existing agent with the provided data
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateAgentRequest'
* responses:
* 200:
* description: Agent updated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export const updateAgent = async (req: Request, res: Response): Promise<Response> => {
const { agentId } = req.params
try {
logger.info(`Updating agent: ${agentId}`)
logger.debug('Update data:', req.body)
const { validatedBody } = req as ValidationRequest
const replacePayload = (validatedBody ?? {}) as ReplaceAgentRequest
const agent = await agentService.updateAgent(agentId, replacePayload, { replace: true })
if (!agent) {
logger.warn(`Agent not found for update: ${agentId}`)
return res.status(404).json({
error: {
message: 'Agent not found',
type: 'not_found',
code: 'agent_not_found'
}
})
}
logger.info(`Agent updated successfully: ${agentId}`)
return res.json(agent)
} catch (error: any) {
if (error instanceof AgentModelValidationError) {
logger.warn('Agent model validation error during update:', {
agentId,
agentType: error.context.agentType,
field: error.context.field,
model: error.context.model,
detail: error.detail
})
return res.status(400).json(modelValidationErrorBody(error))
}
logger.error('Error updating agent:', error)
return res.status(500).json({
error: {
message: 'Failed to update agent: ' + error.message,
type: 'internal_error',
code: 'agent_update_failed'
}
})
}
}
/**
* @swagger
* /v1/agents/{agentId}:
* patch:
* summary: Partially update agent
* description: Partially updates an existing agent with only the provided fields
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description: Agent name
* description:
* type: string
* description: Agent description
* avatar:
* type: string
* description: Agent avatar URL
* instructions:
* type: string
* description: System prompt/instructions
* model:
* type: string
* description: Main model ID
* plan_model:
* type: string
* description: Optional planning model ID
* small_model:
* type: string
* description: Optional small/fast model ID
* built_in_tools:
* type: array
* items:
* type: string
* description: Built-in tool IDs
* mcps:
* type: array
* items:
* type: string
* description: MCP tool IDs
* knowledges:
* type: array
* items:
* type: string
* description: Knowledge base IDs
* configuration:
* type: object
* description: Extensible settings
* accessible_paths:
* type: array
* items:
* type: string
* description: Accessible directory paths
* permission_mode:
* type: string
* enum: [readOnly, acceptEdits, bypassPermissions]
* description: Permission mode
* max_steps:
* type: integer
* description: Maximum steps the agent can take
* description: Only include the fields you want to update
* responses:
* 200:
* description: Agent updated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export const patchAgent = async (req: Request, res: Response): Promise<Response> => {
const { agentId } = req.params
try {
logger.info(`Partially updating agent: ${agentId}`)
logger.debug('Partial update data:', req.body)
const { validatedBody } = req as ValidationRequest
const updatePayload = (validatedBody ?? {}) as UpdateAgentRequest
const agent = await agentService.updateAgent(agentId, updatePayload)
if (!agent) {
logger.warn(`Agent not found for partial update: ${agentId}`)
return res.status(404).json({
error: {
message: 'Agent not found',
type: 'not_found',
code: 'agent_not_found'
}
})
}
logger.info(`Agent partially updated successfully: ${agentId}`)
return res.json(agent)
} catch (error: any) {
if (error instanceof AgentModelValidationError) {
logger.warn('Agent model validation error during partial update:', {
agentId,
agentType: error.context.agentType,
field: error.context.field,
model: error.context.model,
detail: error.detail
})
return res.status(400).json(modelValidationErrorBody(error))
}
logger.error('Error partially updating agent:', error)
return res.status(500).json({
error: {
message: `Failed to partially update agent: ${error.message}`,
type: 'internal_error',
code: 'agent_patch_failed'
}
})
}
}
/**
* @swagger
* /v1/agents/{agentId}:
* delete:
* summary: Delete agent
* description: Deletes an agent and all associated sessions and logs
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* responses:
* 204:
* description: Agent deleted successfully
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export const deleteAgent = async (req: Request, res: Response): Promise<Response> => {
try {
const { agentId } = req.params
logger.info(`Deleting agent: ${agentId}`)
const deleted = await agentService.deleteAgent(agentId)
if (!deleted) {
logger.warn(`Agent not found for deletion: ${agentId}`)
return res.status(404).json({
error: {
message: 'Agent not found',
type: 'not_found',
code: 'agent_not_found'
}
})
}
logger.info(`Agent deleted successfully: ${agentId}`)
return res.status(204).send()
} catch (error: any) {
logger.error('Error deleting agent:', error)
return res.status(500).json({
error: {
message: 'Failed to delete agent',
type: 'internal_error',
code: 'agent_delete_failed'
}
})
}
}

View File

@@ -0,0 +1,3 @@
export * as agentHandlers from './agents'
export * as messageHandlers from './messages'
export * as sessionHandlers from './sessions'

View File

@@ -0,0 +1,230 @@
import { loggerService } from '@logger'
import { Request, Response } from 'express'
import { agentService, sessionMessageService, sessionService } from '../../../../services/agents'
const logger = loggerService.withContext('ApiServerMessagesHandlers')
// Helper function to verify agent and session exist and belong together
const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
const agentExists = await agentService.agentExists(agentId)
if (!agentExists) {
throw { status: 404, code: 'agent_not_found', message: 'Agent not found' }
}
const session = await sessionService.getSession(agentId, sessionId)
if (!session) {
throw { status: 404, code: 'session_not_found', message: 'Session not found' }
}
if (session.agent_id !== agentId) {
throw { status: 404, code: 'session_not_found', message: 'Session not found for this agent' }
}
return session
}
export const createMessage = async (req: Request, res: Response): Promise<void> => {
try {
const { agentId, sessionId } = req.params
const session = await verifyAgentAndSession(agentId, sessionId)
const messageData = req.body
logger.info(`Creating streaming message for session: ${sessionId}`)
logger.debug('Streaming message data:', messageData)
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
const abortController = new AbortController()
const { stream, completion } = await sessionMessageService.createSessionMessage(
session,
messageData,
abortController
)
const reader = stream.getReader()
// Track stream lifecycle so we keep the SSE connection open until persistence finishes
let responseEnded = false
let streamFinished = false
const finalizeResponse = () => {
if (responseEnded) {
return
}
if (!streamFinished) {
return
}
responseEnded = true
try {
// res.write('data: {"type":"finish"}\n\n')
res.write('data: [DONE]\n\n')
} catch (writeError) {
logger.error('Error writing final sentinel to SSE stream:', { error: writeError as Error })
}
res.end()
}
/**
* Client Disconnect Detection for Server-Sent Events (SSE)
*
* We monitor multiple HTTP events to reliably detect when a client disconnects
* from the streaming response. This is crucial for:
* - Aborting long-running Claude Code processes
* - Cleaning up resources and preventing memory leaks
* - Avoiding orphaned processes
*
* Event Priority & Behavior:
* 1. res.on('close') - Most common for SSE client disconnects (browser tab close, curl Ctrl+C)
* 2. req.on('aborted') - Explicit request abortion
* 3. req.on('close') - Request object closure (less common with SSE)
*
* When any disconnect event fires, we:
* - Abort the Claude Code SDK process via abortController
* - Clean up event listeners to prevent memory leaks
* - Mark the response as ended to prevent further writes
*/
const handleDisconnect = () => {
if (responseEnded) return
logger.info(`Client disconnected from streaming message for session: ${sessionId}`)
responseEnded = true
abortController.abort('Client disconnected')
reader.cancel('Client disconnected').catch(() => {})
}
req.on('close', handleDisconnect)
req.on('aborted', handleDisconnect)
res.on('close', handleDisconnect)
const pumpStream = async () => {
try {
while (!responseEnded) {
const { done, value } = await reader.read()
if (done) {
break
}
res.write(`data: ${JSON.stringify(value)}\n\n`)
}
streamFinished = true
finalizeResponse()
} catch (error) {
if (responseEnded) return
logger.error('Error reading agent stream:', { error })
try {
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
message: (error as Error).message || 'Stream processing error',
type: 'stream_error',
code: 'stream_processing_failed'
}
})}\n\n`
)
} catch (writeError) {
logger.error('Error writing stream error to SSE:', { error: writeError })
}
responseEnded = true
res.end()
}
}
pumpStream().catch((error) => {
logger.error('Pump stream failure:', { error })
})
completion
.then(() => {
streamFinished = true
finalizeResponse()
})
.catch((error) => {
if (responseEnded) return
logger.error(`Streaming message error for session: ${sessionId}:`, error)
try {
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
message: (error as { message?: string })?.message || 'Stream processing error',
type: 'stream_error',
code: 'stream_processing_failed'
}
})}\n\n`
)
} catch (writeError) {
logger.error('Error writing completion error to SSE stream:', { error: writeError })
}
responseEnded = true
res.end()
})
// Set a timeout to prevent hanging indefinitely
const timeout = setTimeout(
() => {
if (!responseEnded) {
logger.error(`Streaming message timeout for session: ${sessionId}`)
try {
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
message: 'Stream timeout',
type: 'timeout_error',
code: 'stream_timeout'
}
})}\n\n`
)
} catch (writeError) {
logger.error('Error writing timeout to SSE stream:', { error: writeError })
}
abortController.abort('stream timeout')
reader.cancel('stream timeout').catch(() => {})
responseEnded = true
res.end()
}
},
10 * 60 * 1000
) // 10 minutes timeout
// Clear timeout when response ends
res.on('close', () => clearTimeout(timeout))
res.on('finish', () => clearTimeout(timeout))
} catch (error: any) {
logger.error('Error in streaming message handler:', error)
// Send error as SSE if possible
if (!res.headersSent) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
}
try {
const errorResponse = {
type: 'error',
error: {
message: error.status ? error.message : 'Failed to create streaming message',
type: error.status ? 'not_found' : 'internal_error',
code: error.status ? error.code : 'stream_creation_failed'
}
}
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
} catch (writeError) {
logger.error('Error writing initial error to SSE stream:', { error: writeError })
}
res.end()
}
}

View File

@@ -0,0 +1,370 @@
import { loggerService } from '@logger'
import {
AgentModelValidationError,
sessionMessageService,
sessionService
} from '@main/services/agents'
import {
CreateSessionResponse,
ListAgentSessionsResponse,
type ReplaceSessionRequest,
UpdateSessionResponse
} from '@types'
import { Request, Response } from 'express'
import type { ValidationRequest } from '../validators/zodValidator'
const logger = loggerService.withContext('ApiServerSessionsHandlers')
const modelValidationErrorBody = (error: AgentModelValidationError) => ({
error: {
message: `Invalid ${error.context.field}: ${error.detail.message}`,
type: 'invalid_request_error',
code: error.detail.code
}
})
export const createSession = async (req: Request, res: Response): Promise<Response> => {
const { agentId } = req.params
try {
const sessionData = req.body
logger.info(`Creating new session for agent: ${agentId}`)
logger.debug('Session data:', sessionData)
const session = (await sessionService.createSession(agentId, sessionData)) satisfies CreateSessionResponse
logger.info(`Session created successfully: ${session.id}`)
return res.status(201).json(session)
} catch (error: any) {
if (error instanceof AgentModelValidationError) {
logger.warn('Session model validation error during create:', {
agentId,
agentType: error.context.agentType,
field: error.context.field,
model: error.context.model,
detail: error.detail
})
return res.status(400).json(modelValidationErrorBody(error))
}
logger.error('Error creating session:', error)
return res.status(500).json({
error: {
message: `Failed to create session: ${error.message}`,
type: 'internal_error',
code: 'session_creation_failed'
}
})
}
}
export const listSessions = async (req: Request, res: Response): Promise<Response> => {
try {
const { agentId } = req.params
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
const status = req.query.status as any
logger.info(`Listing sessions for agent: ${agentId} with limit=${limit}, offset=${offset}, status=${status}`)
const result = await sessionService.listSessions(agentId, { limit, offset })
logger.info(`Retrieved ${result.sessions.length} sessions (total: ${result.total}) for agent: ${agentId}`)
return res.json({
data: result.sessions,
total: result.total,
limit,
offset
})
} catch (error: any) {
logger.error('Error listing sessions:', error)
return res.status(500).json({
error: {
message: 'Failed to list sessions',
type: 'internal_error',
code: 'session_list_failed'
}
})
}
}
export const getSession = async (req: Request, res: Response): Promise<Response> => {
try {
const { agentId, sessionId } = req.params
logger.info(`Getting session: ${sessionId} for agent: ${agentId}`)
const session = await sessionService.getSession(agentId, sessionId)
if (!session) {
logger.warn(`Session not found: ${sessionId}`)
return res.status(404).json({
error: {
message: 'Session not found',
type: 'not_found',
code: 'session_not_found'
}
})
}
// // Verify session belongs to the agent
// logger.warn(`Session ${sessionId} does not belong to agent ${agentId}`)
// return res.status(404).json({
// error: {
// message: 'Session not found for this agent',
// type: 'not_found',
// code: 'session_not_found'
// }
// })
// }
// Fetch session messages
logger.info(`Fetching messages for session: ${sessionId}`)
const { messages } = await sessionMessageService.listSessionMessages(sessionId)
// Add messages to session
const sessionWithMessages = {
...session,
messages: messages
}
logger.info(`Session retrieved successfully: ${sessionId} with ${messages.length} messages`)
return res.json(sessionWithMessages)
} catch (error: any) {
logger.error('Error getting session:', error)
return res.status(500).json({
error: {
message: 'Failed to get session',
type: 'internal_error',
code: 'session_get_failed'
}
})
}
}
export const updateSession = async (req: Request, res: Response): Promise<Response> => {
const { agentId, sessionId } = req.params
try {
logger.info(`Updating session: ${sessionId} for agent: ${agentId}`)
logger.debug('Update data:', req.body)
// First check if session exists and belongs to agent
const existingSession = await sessionService.getSession(agentId, sessionId)
if (!existingSession || existingSession.agent_id !== agentId) {
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
return res.status(404).json({
error: {
message: 'Session not found for this agent',
type: 'not_found',
code: 'session_not_found'
}
})
}
const { validatedBody } = req as ValidationRequest
const replacePayload = (validatedBody ?? {}) as ReplaceSessionRequest
const session = await sessionService.updateSession(agentId, sessionId, replacePayload)
if (!session) {
logger.warn(`Session not found for update: ${sessionId}`)
return res.status(404).json({
error: {
message: 'Session not found',
type: 'not_found',
code: 'session_not_found'
}
})
}
logger.info(`Session updated successfully: ${sessionId}`)
return res.json(session satisfies UpdateSessionResponse)
} catch (error: any) {
if (error instanceof AgentModelValidationError) {
logger.warn('Session model validation error during update:', {
agentId,
sessionId,
agentType: error.context.agentType,
field: error.context.field,
model: error.context.model,
detail: error.detail
})
return res.status(400).json(modelValidationErrorBody(error))
}
logger.error('Error updating session:', error)
return res.status(500).json({
error: {
message: `Failed to update session: ${error.message}`,
type: 'internal_error',
code: 'session_update_failed'
}
})
}
}
export const patchSession = async (req: Request, res: Response): Promise<Response> => {
const { agentId, sessionId } = req.params
try {
logger.info(`Patching session: ${sessionId} for agent: ${agentId}`)
logger.debug('Patch data:', req.body)
// First check if session exists and belongs to agent
const existingSession = await sessionService.getSession(agentId, sessionId)
if (!existingSession || existingSession.agent_id !== agentId) {
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
return res.status(404).json({
error: {
message: 'Session not found for this agent',
type: 'not_found',
code: 'session_not_found'
}
})
}
const updateSession = { ...existingSession, ...req.body }
const session = await sessionService.updateSession(agentId, sessionId, updateSession)
if (!session) {
logger.warn(`Session not found for patch: ${sessionId}`)
return res.status(404).json({
error: {
message: 'Session not found',
type: 'not_found',
code: 'session_not_found'
}
})
}
logger.info(`Session patched successfully: ${sessionId}`)
return res.json(session)
} catch (error: any) {
if (error instanceof AgentModelValidationError) {
logger.warn('Session model validation error during patch:', {
agentId,
sessionId,
agentType: error.context.agentType,
field: error.context.field,
model: error.context.model,
detail: error.detail
})
return res.status(400).json(modelValidationErrorBody(error))
}
logger.error('Error patching session:', error)
return res.status(500).json({
error: {
message: `Failed to patch session, ${error.message}`,
type: 'internal_error',
code: 'session_patch_failed'
}
})
}
}
export const deleteSession = async (req: Request, res: Response): Promise<Response> => {
try {
const { agentId, sessionId } = req.params
logger.info(`Deleting session: ${sessionId} for agent: ${agentId}`)
// First check if session exists and belongs to agent
const existingSession = await sessionService.getSession(agentId, sessionId)
if (!existingSession || existingSession.agent_id !== agentId) {
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
return res.status(404).json({
error: {
message: 'Session not found for this agent',
type: 'not_found',
code: 'session_not_found'
}
})
}
const deleted = await sessionService.deleteSession(agentId, sessionId)
if (!deleted) {
logger.warn(`Session not found for deletion: ${sessionId}`)
return res.status(404).json({
error: {
message: 'Session not found',
type: 'not_found',
code: 'session_not_found'
}
})
}
logger.info(`Session deleted successfully: ${sessionId}`)
return res.status(204).send()
} catch (error: any) {
logger.error('Error deleting session:', error)
return res.status(500).json({
error: {
message: 'Failed to delete session',
type: 'internal_error',
code: 'session_delete_failed'
}
})
}
}
// Convenience endpoints for sessions without agent context
export const listAllSessions = async (req: Request, res: Response): Promise<Response> => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
const status = req.query.status as any
logger.info(`Listing all sessions with limit=${limit}, offset=${offset}, status=${status}`)
const result = await sessionService.listSessions(undefined, { limit, offset })
logger.info(`Retrieved ${result.sessions.length} sessions (total: ${result.total})`)
return res.json({
data: result.sessions,
total: result.total,
limit,
offset
} satisfies ListAgentSessionsResponse)
} catch (error: any) {
logger.error('Error listing all sessions:', error)
return res.status(500).json({
error: {
message: 'Failed to list sessions',
type: 'internal_error',
code: 'session_list_failed'
}
})
}
}
export const getSessionById = async (req: Request, res: Response): Promise<Response> => {
try {
const { sessionId } = req.params
logger.info(`Getting session: ${sessionId}`)
const session = await sessionService.getSessionById(sessionId)
if (!session) {
logger.warn(`Session not found: ${sessionId}`)
return res.status(404).json({
error: {
message: 'Session not found',
type: 'not_found',
code: 'session_not_found'
}
})
}
logger.info(`Session retrieved successfully: ${sessionId}`)
return res.json(session)
} catch (error: any) {
logger.error('Error getting session:', error)
return res.status(500).json({
error: {
message: 'Failed to get session',
type: 'internal_error',
code: 'session_get_failed'
}
})
}
}

View File

@@ -0,0 +1,927 @@
import express from 'express'
import { agentHandlers, messageHandlers, sessionHandlers } from './handlers'
import { checkAgentExists, handleValidationErrors } from './middleware'
import {
validateAgent,
validateAgentId,
validateAgentReplace,
validateAgentUpdate,
validatePagination,
validateSession,
validateSessionId,
validateSessionMessage,
validateSessionReplace,
validateSessionUpdate
} from './validators'
// Create main agents router
const agentsRouter = express.Router()
/**
* @swagger
* components:
* schemas:
* PermissionMode:
* type: string
* enum: [default, acceptEdits, bypassPermissions, plan]
* description: Permission mode for agent operations
*
* AgentType:
* type: string
* enum: [claude-code]
* description: Type of agent
*
* AgentConfiguration:
* type: object
* properties:
* permission_mode:
* $ref: '#/components/schemas/PermissionMode'
* default: default
* max_turns:
* type: integer
* default: 10
* description: Maximum number of interaction turns
* additionalProperties: true
*
* AgentBase:
* type: object
* properties:
* name:
* type: string
* description: Agent name
* description:
* type: string
* description: Agent description
* accessible_paths:
* type: array
* items:
* type: string
* description: Array of directory paths the agent can access
* instructions:
* type: string
* description: System prompt/instructions
* model:
* type: string
* description: Main model ID
* plan_model:
* type: string
* description: Optional planning model ID
* small_model:
* type: string
* description: Optional small/fast model ID
* mcps:
* type: array
* items:
* type: string
* description: Array of MCP tool IDs
* allowed_tools:
* type: array
* items:
* type: string
* description: Array of allowed tool IDs (whitelist)
* configuration:
* $ref: '#/components/schemas/AgentConfiguration'
* required:
* - model
* - accessible_paths
*
* AgentEntity:
* allOf:
* - $ref: '#/components/schemas/AgentBase'
* - type: object
* properties:
* id:
* type: string
* description: Unique agent identifier
* type:
* $ref: '#/components/schemas/AgentType'
* created_at:
* type: string
* format: date-time
* description: ISO timestamp of creation
* updated_at:
* type: string
* format: date-time
* description: ISO timestamp of last update
* required:
* - id
* - type
* - created_at
* - updated_at
* CreateAgentRequest:
* allOf:
* - $ref: '#/components/schemas/AgentBase'
* - type: object
* properties:
* type:
* $ref: '#/components/schemas/AgentType'
* name:
* type: string
* minLength: 1
* description: Agent name (required)
* model:
* type: string
* minLength: 1
* description: Main model ID (required)
* required:
* - type
* - name
* - model
*
* UpdateAgentRequest:
* type: object
* properties:
* name:
* type: string
* description: Agent name
* description:
* type: string
* description: Agent description
* accessible_paths:
* type: array
* items:
* type: string
* description: Array of directory paths the agent can access
* instructions:
* type: string
* description: System prompt/instructions
* model:
* type: string
* description: Main model ID
* plan_model:
* type: string
* description: Optional planning model ID
* small_model:
* type: string
* description: Optional small/fast model ID
* mcps:
* type: array
* items:
* type: string
* description: Array of MCP tool IDs
* allowed_tools:
* type: array
* items:
* type: string
* description: Array of allowed tool IDs (whitelist)
* configuration:
* $ref: '#/components/schemas/AgentConfiguration'
* description: Partial update - all fields are optional
*
* ReplaceAgentRequest:
* $ref: '#/components/schemas/AgentBase'
*
* SessionEntity:
* allOf:
* - $ref: '#/components/schemas/AgentBase'
* - type: object
* properties:
* id:
* type: string
* description: Unique session identifier
* agent_id:
* type: string
* description: Primary agent ID for the session
* agent_type:
* $ref: '#/components/schemas/AgentType'
* created_at:
* type: string
* format: date-time
* description: ISO timestamp of creation
* updated_at:
* type: string
* format: date-time
* description: ISO timestamp of last update
* required:
* - id
* - agent_id
* - agent_type
* - created_at
* - updated_at
*
* CreateSessionRequest:
* allOf:
* - $ref: '#/components/schemas/AgentBase'
* - type: object
* properties:
* model:
* type: string
* minLength: 1
* description: Main model ID (required)
* required:
* - model
*
* UpdateSessionRequest:
* type: object
* properties:
* name:
* type: string
* description: Session name
* description:
* type: string
* description: Session description
* accessible_paths:
* type: array
* items:
* type: string
* description: Array of directory paths the agent can access
* instructions:
* type: string
* description: System prompt/instructions
* model:
* type: string
* description: Main model ID
* plan_model:
* type: string
* description: Optional planning model ID
* small_model:
* type: string
* description: Optional small/fast model ID
* mcps:
* type: array
* items:
* type: string
* description: Array of MCP tool IDs
* allowed_tools:
* type: array
* items:
* type: string
* description: Array of allowed tool IDs (whitelist)
* configuration:
* $ref: '#/components/schemas/AgentConfiguration'
* description: Partial update - all fields are optional
*
* ReplaceSessionRequest:
* allOf:
* - $ref: '#/components/schemas/AgentBase'
* - type: object
* properties:
* model:
* type: string
* minLength: 1
* description: Main model ID (required)
* required:
* - model
*
* CreateSessionMessageRequest:
* type: object
* properties:
* content:
* type: string
* minLength: 1
* description: Message content
* required:
* - content
*
* PaginationQuery:
* type: object
* properties:
* limit:
* type: integer
* minimum: 1
* maximum: 100
* default: 20
* description: Number of items to return
* offset:
* type: integer
* minimum: 0
* default: 0
* description: Number of items to skip
* status:
* type: string
* enum: [idle, running, completed, failed, stopped]
* description: Filter by session status
*
* ListAgentsResponse:
* type: object
* properties:
* agents:
* type: array
* items:
* $ref: '#/components/schemas/AgentEntity'
* total:
* type: integer
* description: Total number of agents
* limit:
* type: integer
* description: Number of items returned
* offset:
* type: integer
* description: Number of items skipped
* required:
* - agents
* - total
* - limit
* - offset
*
* ListSessionsResponse:
* type: object
* properties:
* sessions:
* type: array
* items:
* $ref: '#/components/schemas/SessionEntity'
* total:
* type: integer
* description: Total number of sessions
* limit:
* type: integer
* description: Number of items returned
* offset:
* type: integer
* description: Number of items skipped
* required:
* - sessions
* - total
* - limit
* - offset
*
* ErrorResponse:
* type: object
* properties:
* error:
* type: object
* properties:
* message:
* type: string
* description: Error message
* type:
* type: string
* description: Error type
* code:
* type: string
* description: Error code
* required:
* - message
* - type
* - code
* required:
* - error
*/
/**
* @swagger
* /api/agents:
* post:
* summary: Create a new agent
* tags: [Agents]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateAgentRequest'
* responses:
* 201:
* description: Agent created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 400:
* description: Invalid request body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
// Agent CRUD routes
agentsRouter.post('/', validateAgent, handleValidationErrors, agentHandlers.createAgent)
/**
* @swagger
* /api/agents:
* get:
* summary: List all agents with pagination
* tags: [Agents]
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 100
* default: 20
* description: Number of agents to return
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Number of agents to skip
* - in: query
* name: status
* schema:
* type: string
* enum: [idle, running, completed, failed, stopped]
* description: Filter by agent status
* responses:
* 200:
* description: List of agents
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListAgentsResponse'
*/
agentsRouter.get('/', validatePagination, handleValidationErrors, agentHandlers.listAgents)
/**
* @swagger
* /api/agents/{agentId}:
* get:
* summary: Get agent by ID
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* responses:
* 200:
* description: Agent details
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
agentsRouter.get('/:agentId', validateAgentId, handleValidationErrors, agentHandlers.getAgent)
/**
* @swagger
* /api/agents/{agentId}:
* put:
* summary: Replace agent (full update)
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ReplaceAgentRequest'
* responses:
* 200:
* description: Agent updated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 400:
* description: Invalid request body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
agentsRouter.put('/:agentId', validateAgentId, validateAgentReplace, handleValidationErrors, agentHandlers.updateAgent)
/**
* @swagger
* /api/agents/{agentId}:
* patch:
* summary: Update agent (partial update)
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateAgentRequest'
* responses:
* 200:
* description: Agent updated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/AgentEntity'
* 400:
* description: Invalid request body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
agentsRouter.patch('/:agentId', validateAgentId, validateAgentUpdate, handleValidationErrors, agentHandlers.patchAgent)
/**
* @swagger
* /api/agents/{agentId}:
* delete:
* summary: Delete agent
* tags: [Agents]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* responses:
* 204:
* description: Agent deleted successfully
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
agentsRouter.delete('/:agentId', validateAgentId, handleValidationErrors, agentHandlers.deleteAgent)
// Create sessions router with agent context
const createSessionsRouter = (): express.Router => {
const sessionsRouter = express.Router({ mergeParams: true })
// Session CRUD routes (nested under agent)
/**
* @swagger
* /api/agents/{agentId}/sessions:
* post:
* summary: Create a new session for an agent
* tags: [Sessions]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateSessionRequest'
* responses:
* 201:
* description: Session created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SessionEntity'
* 400:
* description: Invalid request body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
sessionsRouter.post('/', validateSession, handleValidationErrors, sessionHandlers.createSession)
/**
* @swagger
* /api/agents/{agentId}/sessions:
* get:
* summary: List sessions for an agent
* tags: [Sessions]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 100
* default: 20
* description: Number of sessions to return
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Number of sessions to skip
* - in: query
* name: status
* schema:
* type: string
* enum: [idle, running, completed, failed, stopped]
* description: Filter by session status
* responses:
* 200:
* description: List of sessions
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListSessionsResponse'
* 404:
* description: Agent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
sessionsRouter.get('/', validatePagination, handleValidationErrors, sessionHandlers.listSessions)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* get:
* summary: Get session by ID
* tags: [Sessions]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID
* responses:
* 200:
* description: Session details
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SessionEntity'
* 404:
* description: Agent or session not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
sessionsRouter.get('/:sessionId', validateSessionId, handleValidationErrors, sessionHandlers.getSession)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* put:
* summary: Replace session (full update)
* tags: [Sessions]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ReplaceSessionRequest'
* responses:
* 200:
* description: Session updated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SessionEntity'
* 400:
* description: Invalid request body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Agent or session not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
sessionsRouter.put(
'/:sessionId',
validateSessionId,
validateSessionReplace,
handleValidationErrors,
sessionHandlers.updateSession
)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* patch:
* summary: Update session (partial update)
* tags: [Sessions]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateSessionRequest'
* responses:
* 200:
* description: Session updated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SessionEntity'
* 400:
* description: Invalid request body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Agent or session not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
sessionsRouter.patch(
'/:sessionId',
validateSessionId,
validateSessionUpdate,
handleValidationErrors,
sessionHandlers.patchSession
)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}:
* delete:
* summary: Delete session
* tags: [Sessions]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID
* responses:
* 204:
* description: Session deleted successfully
* 404:
* description: Agent or session not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
sessionsRouter.delete('/:sessionId', validateSessionId, handleValidationErrors, sessionHandlers.deleteSession)
return sessionsRouter
}
// Create messages router with agent and session context
const createMessagesRouter = (): express.Router => {
const messagesRouter = express.Router({ mergeParams: true })
// Message CRUD routes (nested under agent/session)
/**
* @swagger
* /api/agents/{agentId}/sessions/{sessionId}/messages:
* post:
* summary: Create a new message in a session
* tags: [Messages]
* parameters:
* - in: path
* name: agentId
* required: true
* schema:
* type: string
* description: Agent ID
* - in: path
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateSessionMessageRequest'
* responses:
* 201:
* description: Message created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: number
* description: Message ID
* session_id:
* type: string
* description: Session ID
* role:
* type: string
* enum: [assistant, user, system, tool]
* description: Message role
* content:
* type: object
* description: Message content (AI SDK format)
* agent_session_id:
* type: string
* description: Agent session ID for resuming
* metadata:
* type: object
* description: Additional metadata
* created_at:
* type: string
* format: date-time
* updated_at:
* type: string
* format: date-time
* 400:
* description: Invalid request body
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Agent or session not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessage)
return messagesRouter
}
// Mount nested resources with clear hierarchy
const sessionsRouter = createSessionsRouter()
const messagesRouter = createMessagesRouter()
// Mount sessions under specific agent
agentsRouter.use('/:agentId/sessions', validateAgentId, checkAgentExists, handleValidationErrors, sessionsRouter)
// Mount messages under specific agent/session
agentsRouter.use(
'/:agentId/sessions/:sessionId/messages',
validateAgentId,
validateSessionId,
handleValidationErrors,
messagesRouter
)
// Export main router and convenience router
export const agentsRoutes = agentsRouter

View File

@@ -0,0 +1,41 @@
import { Request, Response } from 'express'
import { agentService } from '../../../../services/agents'
import { loggerService } from '../../../../services/LoggerService'
const logger = loggerService.withContext('ApiServerMiddleware')
// Since Zod validators handle their own errors, this is now a pass-through
export const handleValidationErrors = (_req: Request, _res: Response, next: any): void => {
next()
}
// Middleware to check if agent exists
export const checkAgentExists = async (req: Request, res: Response, next: any): Promise<void> => {
try {
const { agentId } = req.params
const exists = await agentService.agentExists(agentId)
if (!exists) {
res.status(404).json({
error: {
message: 'Agent not found',
type: 'not_found',
code: 'agent_not_found'
}
})
return
}
next()
} catch (error) {
logger.error('Error checking agent existence:', error as Error)
res.status(500).json({
error: {
message: 'Failed to validate agent',
type: 'internal_error',
code: 'agent_validation_failed'
}
})
}
}

View File

@@ -0,0 +1 @@
export * from './common'

View File

@@ -0,0 +1,24 @@
import {
AgentIdParamSchema,
CreateAgentRequestSchema,
ReplaceAgentRequestSchema,
UpdateAgentRequestSchema
} from '@types'
import { createZodValidator } from './zodValidator'
export const validateAgent = createZodValidator({
body: CreateAgentRequestSchema
})
export const validateAgentReplace = createZodValidator({
body: ReplaceAgentRequestSchema
})
export const validateAgentUpdate = createZodValidator({
body: UpdateAgentRequestSchema
})
export const validateAgentId = createZodValidator({
params: AgentIdParamSchema
})

View File

@@ -0,0 +1,7 @@
import { PaginationQuerySchema } from '@types'
import { createZodValidator } from './zodValidator'
export const validatePagination = createZodValidator({
query: PaginationQuerySchema
})

View File

@@ -0,0 +1,4 @@
export * from './agents'
export * from './common'
export * from './messages'
export * from './sessions'

View File

@@ -0,0 +1,7 @@
import { CreateSessionMessageRequestSchema } from '@types'
import { createZodValidator } from './zodValidator'
export const validateSessionMessage = createZodValidator({
body: CreateSessionMessageRequestSchema
})

View File

@@ -0,0 +1,24 @@
import {
CreateSessionRequestSchema,
ReplaceSessionRequestSchema,
SessionIdParamSchema,
UpdateSessionRequestSchema
} from '@types'
import { createZodValidator } from './zodValidator'
export const validateSession = createZodValidator({
body: CreateSessionRequestSchema
})
export const validateSessionReplace = createZodValidator({
body: ReplaceSessionRequestSchema
})
export const validateSessionUpdate = createZodValidator({
body: UpdateSessionRequestSchema
})
export const validateSessionId = createZodValidator({
params: SessionIdParamSchema
})

View File

@@ -0,0 +1,68 @@
import { NextFunction,Request, Response } from 'express'
import { ZodError, ZodType } from 'zod'
export interface ValidationRequest extends Request {
validatedBody?: any
validatedParams?: any
validatedQuery?: any
}
export interface ZodValidationConfig {
body?: ZodType
params?: ZodType
query?: ZodType
}
export const createZodValidator = (config: ZodValidationConfig) => {
return (req: ValidationRequest, res: Response, next: NextFunction): void => {
try {
if (config.body && req.body) {
req.validatedBody = config.body.parse(req.body)
}
if (config.params && req.params) {
req.validatedParams = config.params.parse(req.params)
}
if (config.query && req.query) {
req.validatedQuery = config.query.parse(req.query)
}
next()
} catch (error) {
if (error instanceof ZodError) {
const validationErrors = error.issues.map((err) => ({
type: 'field',
value: err.input,
msg: err.message,
path: err.path.map(p => String(p)).join('.'),
location: getLocationFromPath(err.path, config)
}))
res.status(400).json({
error: {
message: 'Validation failed',
type: 'validation_error',
details: validationErrors
}
})
return
}
res.status(500).json({
error: {
message: 'Internal validation error',
type: 'internal_error',
code: 'validation_processing_failed'
}
})
}
}
}
function getLocationFromPath(path: (string | number | symbol)[], config: ZodValidationConfig): string {
if (config.body && path.length > 0) return 'body'
if (config.params && path.length > 0) return 'params'
if (config.query && path.length > 0) return 'query'
return 'unknown'
}

View File

@@ -1,15 +1,105 @@
import express, { Request, Response } from 'express'
import OpenAI from 'openai'
import { ChatCompletionCreateParams } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import { chatCompletionService } from '../services/chat-completion'
import { validateModelId } from '../utils'
import {
ChatCompletionModelError,
chatCompletionService,
ChatCompletionValidationError
} from '../services/chat-completion'
const logger = loggerService.withContext('ApiServerChatRoutes')
const router = express.Router()
interface ErrorResponseBody {
error: {
message: string
type: string
code: string
}
}
const mapChatCompletionError = (error: unknown): { status: number; body: ErrorResponseBody } => {
if (error instanceof ChatCompletionValidationError) {
logger.warn('Chat completion validation error:', {
errors: error.errors
})
return {
status: 400,
body: {
error: {
message: error.errors.join('; '),
type: 'invalid_request_error',
code: 'validation_failed'
}
}
}
}
if (error instanceof ChatCompletionModelError) {
logger.warn('Chat completion model error:', error.error)
return {
status: 400,
body: {
error: {
message: error.error.message,
type: 'invalid_request_error',
code: error.error.code
}
}
}
}
if (error instanceof Error) {
let statusCode = 500
let errorType = 'server_error'
let errorCode = 'internal_error'
if (error.message.includes('API key') || error.message.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
errorCode = 'invalid_api_key'
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
errorCode = 'rate_limit_exceeded'
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
statusCode = 502
errorType = 'server_error'
errorCode = 'upstream_error'
}
logger.error('Chat completion error:', { error })
return {
status: statusCode,
body: {
error: {
message: error.message || 'Internal server error',
type: errorType,
code: errorCode
}
}
}
}
logger.error('Chat completion unknown error:', { error })
return {
status: 500,
body: {
error: {
message: 'Internal server error',
type: 'server_error',
code: 'internal_error'
}
}
}
}
/**
* @swagger
* /v1/chat/completions:
@@ -60,7 +150,7 @@ const router = express.Router()
* type: integer
* total_tokens:
* type: integer
* text/plain:
* text/event-stream:
* schema:
* type: string
* description: Server-sent events stream (when stream=true)
@@ -110,63 +200,22 @@ router.post('/completions', async (req: Request, res: Response) => {
temperature: request.temperature
})
// Validate request
const validation = chatCompletionService.validateRequest(request)
if (!validation.isValid) {
return res.status(400).json({
error: {
message: validation.errors.join('; '),
type: 'invalid_request_error',
code: 'validation_failed'
}
})
}
const isStreaming = !!request.stream
// Validate model ID and get provider
const modelValidation = await validateModelId(request.model)
if (!modelValidation.valid) {
const error = modelValidation.error!
logger.warn(`Model validation failed for '${request.model}':`, error)
return res.status(400).json({
error: {
message: error.message,
type: 'invalid_request_error',
code: error.code
}
})
}
if (isStreaming) {
const { stream } = await chatCompletionService.processStreamingCompletion(request)
const provider = modelValidation.provider!
const modelId = modelValidation.modelId!
logger.info('Model validation successful:', {
provider: provider.id,
providerType: provider.type,
modelId: modelId,
fullModelId: request.model
})
// Create OpenAI client
const client = new OpenAI({
baseURL: provider.apiHost,
apiKey: provider.apiKey
})
request.model = modelId
// Handle streaming
if (request.stream) {
const streamResponse = await client.chat.completions.create(request)
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
try {
for await (const chunk of streamResponse as any) {
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
}
res.write('data: [DONE]\n\n')
res.end()
} catch (streamError: any) {
logger.error('Stream error:', streamError)
res.write(
@@ -178,47 +227,17 @@ router.post('/completions', async (req: Request, res: Response) => {
}
})}\n\n`
)
} finally {
res.end()
}
return
}
// Handle non-streaming
const response = await client.chat.completions.create(request)
const { response } = await chatCompletionService.processCompletion(request)
return res.json(response)
} catch (error: any) {
logger.error('Chat completion error:', error)
let statusCode = 500
let errorType = 'server_error'
let errorCode = 'internal_error'
let errorMessage = 'Internal server error'
if (error instanceof Error) {
errorMessage = error.message
if (error.message.includes('API key') || error.message.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
errorCode = 'invalid_api_key'
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
errorCode = 'rate_limit_exceeded'
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
statusCode = 502
errorType = 'server_error'
errorCode = 'upstream_error'
}
}
return res.status(statusCode).json({
error: {
message: errorMessage,
type: errorType,
code: errorCode
}
})
} catch (error: unknown) {
const { status, body } = mapChatCompletionError(error)
return res.status(status).json(body)
}
})

View File

@@ -0,0 +1,290 @@
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
import express, { Request, Response } from 'express'
import { loggerService } from '../../services/LoggerService'
import { messagesService } from '../services/messages'
import { validateModelId } from '../utils'
const logger = loggerService.withContext('ApiServerMessagesRoutes')
const router = express.Router()
/**
* @swagger
* /v1/messages:
* post:
* summary: Create message
* description: Create a message response using Anthropic's API format
* tags: [Messages]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - model
* - max_tokens
* - messages
* properties:
* model:
* type: string
* description: Model ID in format "provider:model_id"
* example: "my-anthropic:claude-3-5-sonnet-20241022"
* max_tokens:
* type: integer
* minimum: 1
* description: Maximum number of tokens to generate
* example: 1024
* messages:
* type: array
* items:
* type: object
* properties:
* role:
* type: string
* enum: [user, assistant]
* content:
* oneOf:
* - type: string
* - type: array
* system:
* type: string
* description: System message
* temperature:
* type: number
* minimum: 0
* maximum: 1
* description: Sampling temperature
* top_p:
* type: number
* minimum: 0
* maximum: 1
* description: Nucleus sampling
* top_k:
* type: integer
* minimum: 0
* description: Top-k sampling
* stream:
* type: boolean
* description: Whether to stream the response
* tools:
* type: array
* description: Available tools for the model
* responses:
* 200:
* description: Message response
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* type:
* type: string
* example: message
* role:
* type: string
* example: assistant
* content:
* type: array
* items:
* type: object
* model:
* type: string
* stop_reason:
* type: string
* stop_sequence:
* type: string
* usage:
* type: object
* properties:
* input_tokens:
* type: integer
* output_tokens:
* type: integer
* text/event-stream:
* schema:
* type: string
* description: Server-sent events stream (when stream=true)
* 400:
* description: Bad request
* content:
* application/json:
* schema:
* type: object
* properties:
* type:
* type: string
* example: error
* error:
* type: object
* properties:
* type:
* type: string
* message:
* type: string
* 401:
* description: Unauthorized
* 429:
* description: Rate limit exceeded
* 500:
* description: Internal server error
*/
router.post('/', async (req: Request, res: Response) => {
try {
const request: MessageCreateParams = req.body
if (!request) {
return res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: 'Request body is required'
}
})
}
logger.info('Anthropic message request:', {
model: request.model,
messageCount: request.messages?.length || 0,
stream: request.stream,
max_tokens: request.max_tokens,
temperature: request.temperature
})
// Validate model ID and get provider
const modelValidation = await validateModelId(request.model)
if (!modelValidation.valid) {
const error = modelValidation.error!
logger.warn(`Model validation failed for '${request.model}':`, error)
return res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: error.message
}
})
}
const provider = modelValidation.provider!
// Ensure provider is Anthropic type
if (provider.type !== 'anthropic') {
return res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: `Invalid provider type '${provider.type}' for messages endpoint. Expected 'anthropic' provider.`
}
})
}
const modelId = modelValidation.modelId!
request.model = modelId
logger.info('Model validation successful:', {
provider: provider.id,
providerType: provider.type,
modelId: modelId,
fullModelId: request.model
})
// Validate request
const validation = messagesService.validateRequest(request)
if (!validation.isValid) {
return res.status(400).json({
type: 'error',
error: {
type: 'invalid_request_error',
message: validation.errors.join('; ')
}
})
}
// Handle streaming
if (request.stream) {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
try {
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
}
res.write('data: [DONE]\n\n')
} catch (streamError: any) {
logger.error('Stream error:', streamError)
res.write(
`data: ${JSON.stringify({
type: 'error',
error: {
type: 'api_error',
message: 'Stream processing error'
}
})}\n\n`
)
} finally {
res.end()
}
return
}
// Handle non-streaming
const response = await messagesService.processMessage(request, provider)
return res.json(response)
} catch (error: any) {
logger.error('Anthropic message error:', error)
let statusCode = 500
let errorType = 'api_error'
let errorMessage = 'Internal server error'
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
const anthropicError = error?.error
if (anthropicStatus) {
statusCode = anthropicStatus
}
if (anthropicError?.type) {
errorType = anthropicError.type
}
if (anthropicError?.message) {
errorMessage = anthropicError.message
} else if (error instanceof Error && error.message) {
errorMessage = error.message
}
if (!anthropicStatus && error instanceof Error) {
if (error.message.includes('API key') || error.message.includes('authentication')) {
statusCode = 401
errorType = 'authentication_error'
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
statusCode = 429
errorType = 'rate_limit_error'
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
statusCode = 502
errorType = 'api_error'
} else if (error.message.includes('validation') || error.message.includes('invalid')) {
statusCode = 400
errorType = 'invalid_request_error'
}
}
return res.status(statusCode).json({
type: 'error',
error: {
type: errorType,
message: errorMessage,
requestId: error?.request_id
}
})
}
})
export { router as messagesRoutes }

View File

@@ -1,73 +1,130 @@
import { ApiModelsFilterSchema, ApiModelsResponse } from '@types'
import express, { Request, Response } from 'express'
import { loggerService } from '../../services/LoggerService'
import { chatCompletionService } from '../services/chat-completion'
import { modelsService } from '../services/models'
const logger = loggerService.withContext('ApiServerModelsRoutes')
const router = express.Router()
const router = express
.Router()
/**
* @swagger
* /v1/models:
* get:
* summary: List available models
* description: Returns a list of available AI models from all configured providers
* tags: [Models]
* responses:
* 200:
* description: List of available models
* content:
* application/json:
* schema:
* type: object
* properties:
* object:
* type: string
* example: list
* data:
* type: array
* items:
* $ref: '#/components/schemas/Model'
* 503:
* description: Service unavailable
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/', async (_req: Request, res: Response) => {
try {
logger.info('Models list request received')
/**
* @swagger
* /v1/models:
* get:
* summary: List available models
* description: Returns a list of available AI models from all configured providers with optional filtering
* tags: [Models]
* parameters:
* - in: query
* name: providerType
* schema:
* type: string
* enum: [openai, openai-response, anthropic, gemini]
* description: Filter models by provider type
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Pagination offset
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* description: Maximum number of models to return
* responses:
* 200:
* description: List of available models
* content:
* application/json:
* schema:
* type: object
* properties:
* object:
* type: string
* example: list
* data:
* type: array
* items:
* $ref: '#/components/schemas/Model'
* total:
* type: integer
* description: Total number of models (when using pagination)
* offset:
* type: integer
* description: Current offset (when using pagination)
* limit:
* type: integer
* description: Current limit (when using pagination)
* 400:
* description: Invalid query parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 503:
* description: Service unavailable
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
.get('/', async (req: Request, res: Response) => {
try {
logger.info('Models list request received', { query: req.query })
const models = await chatCompletionService.getModels()
// Validate query parameters using Zod schema
const filterResult = ApiModelsFilterSchema.safeParse(req.query)
if (models.length === 0) {
logger.warn(
'No models available from providers. This may be because no OpenAI providers are configured or enabled.'
)
}
logger.info(`Returning ${models.length} models (OpenAI providers only)`)
logger.debug(
'Model IDs:',
models.map((m) => m.id)
)
return res.json({
object: 'list',
data: models
})
} catch (error: any) {
logger.error('Error fetching models:', error)
return res.status(503).json({
error: {
message: 'Failed to retrieve models from available providers',
type: 'service_unavailable',
code: 'models_unavailable'
if (!filterResult.success) {
logger.warn('Invalid query parameters:', filterResult.error.issues)
return res.status(400).json({
error: {
message: 'Invalid query parameters',
type: 'invalid_request_error',
code: 'invalid_parameters',
details: filterResult.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message
}))
}
})
}
})
}
})
const filter = filterResult.data
const response = await modelsService.getModels(filter)
if (response.data.length === 0) {
logger.warn(
'No models available from providers. This may be because no OpenAI/Anthropic providers are configured or enabled.',
{ filter }
)
}
logger.info(`Returning ${response.data.length} models`, {
filter,
total: response.total
})
logger.debug(
'Model IDs:',
response.data.map((m) => m.id)
)
return res.json(response satisfies ApiModelsResponse)
} catch (error: any) {
logger.error('Error fetching models:', error)
return res.status(503).json({
error: {
message: 'Failed to retrieve models from available providers',
type: 'service_unavailable',
code: 'models_unavailable'
}
})
}
})
export { router as modelsRoutes }

View File

@@ -1,5 +1,6 @@
import { createServer } from 'node:http'
import { agentService } from '../services/agents'
import { loggerService } from '../services/LoggerService'
import { app } from './app'
import { config } from './config'
@@ -18,6 +19,11 @@ export class ApiServer {
// Load config
const { port, host, apiKey } = await config.load()
// Initialize AgentService
logger.info('Initializing AgentService...')
await agentService.initialize()
logger.info('AgentService initialized successfully')
// Create server with Express app
this.server = createServer(app)

View File

@@ -1,83 +1,131 @@
import { Provider } from '@types'
import OpenAI from 'openai'
import { ChatCompletionCreateParams } from 'openai/resources'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import {
getProviderByModel,
getRealProviderModel,
listAllAvailableModels,
OpenAICompatibleModel,
transformModelToOpenAI,
validateProvider
} from '../utils'
import { ModelValidationError, validateModelId } from '../utils'
const logger = loggerService.withContext('ChatCompletionService')
export interface ModelData extends OpenAICompatibleModel {
provider_id: string
model_id: string
name: string
}
export interface ValidationResult {
isValid: boolean
errors: string[]
}
export class ChatCompletionValidationError extends Error {
constructor(public readonly errors: string[]) {
super(`Request validation failed: ${errors.join('; ')}`)
this.name = 'ChatCompletionValidationError'
}
}
export class ChatCompletionModelError extends Error {
constructor(public readonly error: ModelValidationError) {
super(`Model validation failed: ${error.message}`)
this.name = 'ChatCompletionModelError'
}
}
export type PrepareRequestResult =
| { status: 'validation_error'; errors: string[] }
| { status: 'model_error'; error: ModelValidationError }
| {
status: 'ok'
provider: Provider
modelId: string
client: OpenAI
providerRequest: ChatCompletionCreateParams
}
export class ChatCompletionService {
async getModels(): Promise<ModelData[]> {
try {
logger.info('Getting available models from providers')
async resolveProviderContext(model: string): Promise<
| { ok: false; error: ModelValidationError }
| { ok: true; provider: Provider; modelId: string; client: OpenAI }
> {
const modelValidation = await validateModelId(model)
if (!modelValidation.valid) {
return {
ok: false,
error: modelValidation.error!
}
}
const models = await listAllAvailableModels()
const provider = modelValidation.provider!
// Use Map to deduplicate models by their full ID (provider:model_id)
const uniqueModels = new Map<string, ModelData>()
for (const model of models) {
const openAIModel = transformModelToOpenAI(model)
const fullModelId = openAIModel.id // This is already in format "provider:model_id"
// Only add if not already present (first occurrence wins)
if (!uniqueModels.has(fullModelId)) {
uniqueModels.set(fullModelId, {
...openAIModel,
provider_id: model.provider,
model_id: model.id,
name: model.name
})
} else {
logger.debug(`Skipping duplicate model: ${fullModelId}`)
if (provider.type !== 'openai') {
return {
ok: false,
error: {
type: 'unsupported_provider_type',
message: `Provider '${provider.id}' of type '${provider.type}' is not supported for OpenAI chat completions`,
code: 'unsupported_provider_type'
}
}
}
const modelData = Array.from(uniqueModels.values())
const modelId = modelValidation.modelId!
logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`)
const client = new OpenAI({
baseURL: provider.apiHost,
apiKey: provider.apiKey
})
if (models.length > modelData.length) {
logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`)
return {
ok: true,
provider,
modelId,
client
}
}
async prepareRequest(request: ChatCompletionCreateParams, stream: boolean): Promise<PrepareRequestResult> {
const requestValidation = this.validateRequest(request)
if (!requestValidation.isValid) {
return {
status: 'validation_error',
errors: requestValidation.errors
}
}
return modelData
} catch (error: any) {
logger.error('Error getting models:', error)
return []
const providerContext = await this.resolveProviderContext(request.model!)
if (!providerContext.ok) {
return {
status: 'model_error',
error: providerContext.error
}
}
const { provider, modelId, client } = providerContext
logger.info('Model validation successful:', {
provider: provider.id,
providerType: provider.type,
modelId,
fullModelId: request.model
})
return {
status: 'ok',
provider,
modelId,
client,
providerRequest: stream
? {
...request,
model: modelId,
stream: true as const
}
: {
...request,
model: modelId,
stream: false as const
}
}
}
validateRequest(request: ChatCompletionCreateParams): ValidationResult {
const errors: string[] = []
// Validate model
if (!request.model) {
errors.push('Model is required')
} else if (typeof request.model !== 'string') {
errors.push('Model must be a string')
} else if (!request.model.includes(':')) {
errors.push('Model must be in format "provider:model_id"')
}
// Validate messages
if (!request.messages) {
errors.push('Messages array is required')
@@ -98,17 +146,6 @@ export class ChatCompletionService {
}
// Validate optional parameters
if (request.temperature !== undefined) {
if (typeof request.temperature !== 'number' || request.temperature < 0 || request.temperature > 2) {
errors.push('Temperature must be a number between 0 and 2')
}
}
if (request.max_tokens !== undefined) {
if (typeof request.max_tokens !== 'number' || request.max_tokens < 1) {
errors.push('max_tokens must be a positive number')
}
}
return {
isValid: errors.length === 0,
@@ -116,7 +153,11 @@ export class ChatCompletionService {
}
}
async processCompletion(request: ChatCompletionCreateParams): Promise<OpenAI.Chat.Completions.ChatCompletion> {
async processCompletion(request: ChatCompletionCreateParams): Promise<{
provider: Provider
modelId: string
response: OpenAI.Chat.Completions.ChatCompletion
}> {
try {
logger.info('Processing chat completion request:', {
model: request.model,
@@ -124,38 +165,16 @@ export class ChatCompletionService {
stream: request.stream
})
// Validate request
const validation = this.validateRequest(request)
if (!validation.isValid) {
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
const preparation = await this.prepareRequest(request, false)
if (preparation.status === 'validation_error') {
throw new ChatCompletionValidationError(preparation.errors)
}
// Get provider for the model
const provider = await getProviderByModel(request.model!)
if (!provider) {
throw new Error(`Provider not found for model: ${request.model}`)
if (preparation.status === 'model_error') {
throw new ChatCompletionModelError(preparation.error)
}
// Validate provider
if (!validateProvider(provider)) {
throw new Error(`Provider validation failed for: ${provider.id}`)
}
// Extract model ID from the full model string
const modelId = getRealProviderModel(request.model)
// Create OpenAI client for the provider
const client = new OpenAI({
baseURL: provider.apiHost,
apiKey: provider.apiKey
})
// Prepare request with the actual model ID
const providerRequest = {
...request,
model: modelId,
stream: false
}
const { provider, modelId, client, providerRequest } = preparation
logger.debug('Sending request to provider:', {
provider: provider.id,
@@ -166,54 +185,40 @@ export class ChatCompletionService {
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
logger.info('Successfully processed chat completion')
return response
return {
provider,
modelId,
response
}
} catch (error: any) {
logger.error('Error processing chat completion:', error)
throw error
}
}
async *processStreamingCompletion(
async processStreamingCompletion(
request: ChatCompletionCreateParams
): AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk> {
): Promise<{
provider: Provider
modelId: string
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
}> {
try {
logger.info('Processing streaming chat completion request:', {
model: request.model,
messageCount: request.messages.length
})
// Validate request
const validation = this.validateRequest(request)
if (!validation.isValid) {
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
const preparation = await this.prepareRequest(request, true)
if (preparation.status === 'validation_error') {
throw new ChatCompletionValidationError(preparation.errors)
}
// Get provider for the model
const provider = await getProviderByModel(request.model!)
if (!provider) {
throw new Error(`Provider not found for model: ${request.model}`)
if (preparation.status === 'model_error') {
throw new ChatCompletionModelError(preparation.error)
}
// Validate provider
if (!validateProvider(provider)) {
throw new Error(`Provider validation failed for: ${provider.id}`)
}
// Extract model ID from the full model string
const modelId = getRealProviderModel(request.model)
// Create OpenAI client for the provider
const client = new OpenAI({
baseURL: provider.apiHost,
apiKey: provider.apiKey
})
// Prepare streaming request
const streamingRequest = {
...request,
model: modelId,
stream: true as const
}
const { provider, modelId, client, providerRequest } = preparation
logger.debug('Sending streaming request to provider:', {
provider: provider.id,
@@ -221,13 +226,17 @@ export class ChatCompletionService {
apiHost: provider.apiHost
})
const stream = await client.chat.completions.create(streamingRequest)
const streamRequest = providerRequest as ChatCompletionCreateParamsStreaming
const stream = (await client.chat.completions.create(streamRequest)) as AsyncIterable<
OpenAI.Chat.Completions.ChatCompletionChunk
>
for await (const chunk of stream) {
yield chunk
logger.info('Successfully started streaming chat completion')
return {
provider,
modelId,
stream
}
logger.info('Successfully completed streaming chat completion')
} catch (error: any) {
logger.error('Error processing streaming chat completion:', error)
throw error

View File

@@ -13,8 +13,7 @@ import { Request, Response } from 'express'
import { IncomingMessage, ServerResponse } from 'http'
import { loggerService } from '../../services/LoggerService'
import { reduxService } from '../../services/ReduxService'
import { getMcpServerById } from '../utils/mcp'
import { getMcpServerById, getMCPServersFromRedux } from '../utils/mcp'
const logger = loggerService.withContext('MCPApiService')
const transports: Record<string, StreamableHTTPServerTransport> = {}
@@ -57,34 +56,10 @@ class MCPApiService extends EventEmitter {
this.transport.onmessage = this.onMessage
}
/**
* Get servers directly from Redux store
*/
private async getServersFromRedux(): Promise<MCPServer[]> {
try {
logger.silly('Getting servers from Redux store')
// Try to get from cache first (faster)
const cachedServers = reduxService.selectSync<MCPServer[]>('state.mcp.servers')
if (cachedServers && Array.isArray(cachedServers)) {
logger.silly(`Found ${cachedServers.length} servers in Redux cache`)
return cachedServers
}
// If cache is not available, get fresh data
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
return servers || []
} catch (error: any) {
logger.error('Failed to get servers from Redux:', error)
return []
}
}
// get all activated servers
async getAllServers(req: Request): Promise<McpServersResp> {
try {
const servers = await this.getServersFromRedux()
const servers = await getMCPServersFromRedux()
logger.silly(`Returning ${servers.length} servers`)
const resp: McpServersResp = {
servers: {}
@@ -111,7 +86,7 @@ class MCPApiService extends EventEmitter {
async getServerById(id: string): Promise<MCPServer | null> {
try {
logger.silly(`getServerById called with id: ${id}`)
const servers = await this.getServersFromRedux()
const servers = await getMCPServersFromRedux()
const server = servers.find((s) => s.id === id)
if (!server) {
logger.warn(`Server with id ${id} not found`)

View File

@@ -0,0 +1,106 @@
import Anthropic from '@anthropic-ai/sdk'
import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
import { Provider } from '@types'
import { loggerService } from '../../services/LoggerService'
const logger = loggerService.withContext('MessagesService')
export interface ValidationResult {
isValid: boolean
errors: string[]
}
export class MessagesService {
// oxlint-disable-next-line no-unused-vars
validateRequest(request: MessageCreateParams): ValidationResult {
// TODO: Implement comprehensive request validation
const errors: string[] = []
if (!request.model) {
errors.push('Model is required')
}
if (!request.max_tokens || request.max_tokens < 1) {
errors.push('max_tokens is required and must be at least 1')
}
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
errors.push('messages is required and must be a non-empty array')
}
return {
isValid: errors.length === 0,
errors
}
}
async processMessage(request: MessageCreateParams, provider: Provider): Promise<Message> {
logger.info('Processing Anthropic message request:', {
model: request.model,
messageCount: request.messages.length,
stream: request.stream,
max_tokens: request.max_tokens
})
// Create Anthropic client for the provider
const client = new Anthropic({
baseURL: provider.apiHost,
apiKey: provider.apiKey
})
// Prepare request with the actual model ID
const anthropicRequest: MessageCreateParams = {
...request,
stream: false
}
logger.debug('Sending request to Anthropic provider:', {
provider: provider.id,
apiHost: provider.apiHost
})
const response = await client.messages.create(anthropicRequest)
logger.info('Successfully processed Anthropic message')
return response
}
async *processStreamingMessage(
request: MessageCreateParams,
provider: Provider
): AsyncIterable<RawMessageStreamEvent> {
logger.info('Processing streaming Anthropic message request:', {
model: request.model,
messageCount: request.messages.length
})
// Create Anthropic client for the provider
const client = new Anthropic({
baseURL: provider.apiHost,
apiKey: provider.apiKey
})
// Prepare streaming request
const streamingRequest: MessageCreateParams = {
...request,
stream: true
}
logger.debug('Sending streaming request to Anthropic provider:', {
provider: provider.id,
apiHost: provider.apiHost
})
const stream = client.messages.stream(streamingRequest)
for await (const chunk of stream) {
yield chunk
}
logger.info('Successfully completed streaming Anthropic message')
}
}
// Export singleton instance
export const messagesService = new MessagesService()

View File

@@ -0,0 +1,93 @@
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
import { loggerService } from '../../services/LoggerService'
import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils'
const logger = loggerService.withContext('ModelsService')
// Re-export for backward compatibility
export type ModelsFilter = ApiModelsFilter
export class ModelsService {
async getModels(filter: ModelsFilter): Promise<ApiModelsResponse> {
try {
logger.debug('Getting available models from providers', { filter })
const models = await listAllAvailableModels()
const providers = await getAvailableProviders()
// Use Map to deduplicate models by their full ID (provider:model_id)
const uniqueModels = new Map<string, ApiModel>()
for (const model of models) {
const openAIModel = transformModelToOpenAI(model, providers)
const fullModelId = openAIModel.id // This is already in format "provider:model_id"
// Only add if not already present (first occurrence wins)
if (!uniqueModels.has(fullModelId)) {
uniqueModels.set(fullModelId, openAIModel)
} else {
logger.debug(`Skipping duplicate model: ${fullModelId}`)
}
}
let modelData = Array.from(uniqueModels.values())
if (filter.providerType) {
// Apply filters
const providerType = filter.providerType
modelData = modelData.filter((model) => {
// Find the provider for this model and check its type
return model.provider_type === providerType
})
logger.debug(`Filtered by provider type '${providerType}': ${modelData.length} models`)
}
const total = modelData.length
// Apply pagination
const offset = filter?.offset || 0
const limit = filter?.limit
if (limit !== undefined) {
modelData = modelData.slice(offset, offset + limit)
logger.debug(
`Applied pagination: offset=${offset}, limit=${limit}, showing ${modelData.length} of ${total} models`
)
} else if (offset > 0) {
modelData = modelData.slice(offset)
logger.debug(`Applied offset: offset=${offset}, showing ${modelData.length} of ${total} models`)
}
logger.info(`Successfully retrieved ${modelData.length} models from ${models.length} total models`)
if (models.length > total) {
logger.debug(`Filtered out ${models.length - total} models after deduplication and filtering`)
}
const response: ApiModelsResponse = {
object: 'list',
data: modelData
}
// Add pagination metadata if applicable
if (filter?.limit !== undefined || filter?.offset !== undefined) {
response.total = total
response.offset = offset
if (filter?.limit !== undefined) {
response.limit = filter.limit
}
}
return response
} catch (error: any) {
logger.error('Error getting models:', error)
return {
object: 'list',
data: []
}
}
}
}
// Export singleton instance
export const modelsService = new ModelsService()

View File

@@ -1,34 +1,41 @@
import { CacheService } from '@main/services/CacheService'
import { loggerService } from '@main/services/LoggerService'
import { reduxService } from '@main/services/ReduxService'
import { Model, Provider } from '@types'
import { ApiModel, Model, Provider } from '@types'
const logger = loggerService.withContext('ApiServerUtils')
// OpenAI compatible model format
export interface OpenAICompatibleModel {
id: string
object: 'model'
created: number
owned_by: string
provider?: string
provider_model_id?: string
}
// Cache configuration
const PROVIDERS_CACHE_KEY = 'api-server:providers'
const PROVIDERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
export async function getAvailableProviders(): Promise<Provider[]> {
try {
// Wait for store to be ready before accessing providers
// Try to get from cache first (faster)
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
if (cachedSupportedProviders) {
logger.debug(`Found ${cachedSupportedProviders.length} supported providers (from cache)`)
return cachedSupportedProviders
}
// If cache is not available, get fresh data from Redux
const providers = await reduxService.select('state.llm.providers')
if (!providers || !Array.isArray(providers)) {
logger.warn('No providers found in Redux store, returning empty array')
return []
}
// Only support OpenAI type providers for API server
const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai')
// Support OpenAI and Anthropic type providers for API server
const supportedProviders = providers.filter(
(p: Provider) => p.enabled && (p.type === 'openai' || p.type === 'anthropic')
)
logger.info(`Filtered to ${openAIProviders.length} OpenAI providers from ${providers.length} total providers`)
// Cache the filtered results
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
return openAIProviders
logger.info(`Filtered to ${supportedProviders.length} supported providers from ${providers.length} total providers`)
return supportedProviders
} catch (error: any) {
logger.error('Failed to get providers from Redux store:', error)
return []
@@ -181,13 +188,18 @@ export async function validateModelId(
}
}
export function transformModelToOpenAI(model: Model): OpenAICompatibleModel {
export function transformModelToOpenAI(model: Model, providers: Provider[]): ApiModel {
const provider = providers.find((p) => p.id === model.provider)
const providerDisplayName = provider?.name
return {
id: `${model.provider}:${model.id}`,
object: 'model',
name: model.name,
created: Math.floor(Date.now() / 1000),
owned_by: model.owned_by || model.provider,
owned_by: model.owned_by || providerDisplayName || model.provider,
provider: model.provider,
provider_name: providerDisplayName,
provider_type: provider?.type,
provider_model_id: model.id
}
}
@@ -215,10 +227,10 @@ export function validateProvider(provider: Provider): boolean {
return false
}
// Only support OpenAI type providers
if (provider.type !== 'openai') {
// Support OpenAI and Anthropic type providers
if (provider.type !== 'openai' && provider.type !== 'anthropic') {
logger.debug(
`Provider type '${provider.type}' not supported, only 'openai' type is currently supported: ${provider.id}`
`Provider type '${provider.type}' not supported, only 'openai' and 'anthropic' types are currently supported: ${provider.id}`
)
return false
}

View File

@@ -1,3 +1,4 @@
import { CacheService } from '@main/services/CacheService'
import mcpService from '@main/services/MCPService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
@@ -8,6 +9,10 @@ import { reduxService } from '../../services/ReduxService'
const logger = loggerService.withContext('MCPApiService')
// Cache configuration
const MCP_SERVERS_CACHE_KEY = 'api-server:mcp-servers'
const MCP_SERVERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
const cachedServers: Record<string, Server> = {}
async function handleListToolsRequest(request: any, extra: any): Promise<ListToolsResult> {
@@ -33,18 +38,33 @@ async function handleCallToolRequest(request: any, extra: any): Promise<any> {
}
async function getMcpServerConfigById(id: string): Promise<MCPServer | undefined> {
const servers = await getServersFromRedux()
const servers = await getMCPServersFromRedux()
return servers.find((s) => s.id === id || s.name === id)
}
/**
* Get servers directly from Redux store
*/
async function getServersFromRedux(): Promise<MCPServer[]> {
export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
try {
logger.silly('Getting servers from Redux store')
// Try to get from cache first (faster)
const cachedServers = CacheService.get<MCPServer[]>(MCP_SERVERS_CACHE_KEY)
if (cachedServers) {
logger.silly(`Found ${cachedServers.length} servers (from cache)`)
return cachedServers
}
// If cache is not available, get fresh data from Redux
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
return servers || []
const serverList = servers || []
// Cache the results
CacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL)
logger.silly(`Fetched ${serverList.length} servers from Redux store`)
return serverList
} catch (error: any) {
logger.error('Failed to get servers from Redux:', error)
return []
@@ -54,7 +74,7 @@ async function getServersFromRedux(): Promise<MCPServer[]> {
export async function getMcpServerById(id: string): Promise<Server> {
const server = cachedServers[id]
if (!server) {
const servers = await getServersFromRedux()
const servers = await getMCPServersFromRedux()
const mcpServer = servers.find((s) => s.id === id || s.name === id)
if (!mcpServer) {
throw new Error(`Server not found: ${id}`)

View File

@@ -21,4 +21,4 @@ export const titleBarOverlayLight = {
symbolColor: '#000'
}
global.CHERRYAI_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYAI_CLIENT_SECRET
global.CHERRYIN_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYIN_CLIENT_SECRET

View File

@@ -10,9 +10,13 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { isDev, isLinux, isWin } from './constant'
import process from 'node:process'
import { registerIpc } from './ipc'
import { agentService } from './services/agents'
import { apiServerService } from './services/ApiServerService'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
import { nodeTraceService } from './services/NodeTraceService'
@@ -26,8 +30,6 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import process from 'node:process'
import { apiServerService } from './services/ApiServerService'
const logger = loggerService.withContext('MainEntry')
@@ -147,6 +149,14 @@ if (!app.requestSingleInstanceLock()) {
//start selection assistant service
initSelectionService()
// Initialize Agent Service
try {
await agentService.initialize()
logger.info('Agent service initialized successfully')
} catch (error: any) {
logger.error('Failed to initialize Agent service:', error)
}
// Start API server if enabled
try {
const config = await apiServerService.getCurrentConfig()

View File

@@ -1 +0,0 @@
var _0xe15d9a;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0xe15d9a=(988194^988194)+(417607^417603);var _0x9b_0x742=(247379^247387)+(371889^371892);const CLIENT_ID="\u0063\u0068\u0065\u0072\u0072\u0079\u002D\u0073\u0074\u0075\u0064\u0069\u006F";_0x9b_0x742=(202849^202856)+(796590^796585);var _0xa971e=(422203^422203)+(167917^167919);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0xa971e=(607707^607705)+(127822^127823);const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0041\u0049\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{'\u006D\u0065\u0074\u0068\u006F\u0064':method,'\u0070\u0061\u0074\u0068':path,'\u0071\u0075\u0065\u0072\u0079':query='','\u0062\u006F\u0064\u0079':body=''}=options;var _0x99a7f=(735625^735624)+(520507^520508);const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(351300^352172))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();_0x99a7f=376728^376729;var _0x733a=(876666^876671)+(658949^658944);let bodyString='';_0x733a="kgclcd".split("").reverse().join("");if(body){if(typeof body==="tcejbo".split("").reverse().join("")){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}var _0xd8edff;const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];_0xd8edff=(929945^929951)+(569907^569915);var _0x9g3c3b=(705579^705579)+(981211^981209);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x9g3c3b=527497^527499;var _0x95b35f=(811203^811200)+(628072^628076);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);_0x95b35f=104120^104112;hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0xd0f6g;const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("xeh".split("").reverse().join(""));_0xd0f6g=(615019^615018)+(266997^266992);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],"\u0058\u002D\u0054\u0069\u006D\u0065\u0073\u0074\u0061\u006D\u0070":timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,"generateSignature":generateSignature};

View File

@@ -0,0 +1 @@
var _0x6gg;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0x6gg='\u006D\u006F\u006C\u006A\u0065\u0065';var _0x111cbe;const CLIENT_ID="oiduts-yrrehc".split("").reverse().join("");_0x111cbe=(977158^977167)+(164595^164594);var _0x6d6adc=(756649^756650)+(497587^497587);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0x6d6adc=233169^233176;const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0049\u004E\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{"method":method,"path":path,"query":query='',"body":body=''}=options;const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(110765^111429))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();var _0xe08cc=(212246^212244)+(773521^773523);let bodyString='';_0xe08cc=(606778^606776)+(962748^962740);if(body){if(typeof body==="\u006F\u0062\u006A\u0065\u0063\u0074"){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];var _0x5693g=(936664^936668)+(685268^685277);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x5693g=(266582^266576)+(337322^337315);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0x5fba=(354480^354481)+(537437^537434);const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("\u0068\u0065\u0078");_0x5fba=(249614^249610)+(915906^915914);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],'X-Timestamp':timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,'\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065':generateSignature};

View File

@@ -4,7 +4,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { generateSignature } from '@main/integration/cherryai'
import { generateSignature } from '@main/integration/cherryin'
import anthropicService from '@main/services/AnthropicService'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
@@ -16,6 +16,7 @@ import checkDiskSpace from 'check-disk-space'
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
import fontList from 'font-list'
import { agentMessageRepository } from './services/agents/database'
import { apiServerService } from './services/ApiServerService'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
@@ -35,7 +36,6 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -82,7 +82,6 @@ const obsidianVaultService = new ObsidianVaultService()
const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance()
const dxtService = new DxtService()
const ovmsManager = new OvmsManager()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
@@ -128,11 +127,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
ipcMain.handle(IpcChannel.App_Quit, () => app.quit())
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -202,6 +200,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
ipcMain.handle(IpcChannel.AgentMessage_PersistExchange, async (_event, payload) => {
try {
return await agentMessageRepository.persistExchange(payload)
} catch (error) {
logger.error('Failed to persist agent session messages', error as Error)
throw error
}
})
//only for mac
if (isMac) {
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
@@ -434,7 +441,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// system
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()
@@ -713,7 +719,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
//copilot
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
@@ -829,33 +834,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// CodeTools
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
ipcMain.handle(IpcChannel.CodeTools_GetAvailableTerminals, () => codeToolsService.getAvailableTerminalsForPlatform())
ipcMain.handle(IpcChannel.CodeTools_SetCustomTerminalPath, (_, terminalId: string, path: string) =>
codeToolsService.setCustomTerminalPath(terminalId, path)
)
ipcMain.handle(IpcChannel.CodeTools_GetCustomTerminalPath, (_, terminalId: string) =>
codeToolsService.getCustomTerminalPath(terminalId)
)
ipcMain.handle(IpcChannel.CodeTools_RemoveCustomTerminalPath, (_, terminalId: string) =>
codeToolsService.removeCustomTerminalPath(terminalId)
)
// OCR
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider)
)
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
ovmsManager.addModel(modelName, modelId, modelSource, task)
)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
// CherryIN
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
}

View File

@@ -1,29 +1,25 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, net } from 'electron'
import { app, BrowserWindow, dialog, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
const logger = loggerService.withContext('AppUpdater')
// Language markers constants for multi-language release notes
const LANG_MARKERS = {
EN_START: '<!--LANG:en-->',
ZH_CN_START: '<!--LANG:zh-CN-->',
END: '<!--LANG:END-->'
} as const
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -34,8 +30,7 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId()
'User-Agent': generateUserAgent()
}
autoUpdater.on('error', (error) => {
@@ -45,8 +40,7 @@ export default class AppUpdater {
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
logger.info('update available', releaseInfo)
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, processedReleaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
})
// 检测到不需要更新时
@@ -61,9 +55,9 @@ export default class AppUpdater {
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
logger.info('update downloaded', processedReleaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
this.releaseInfo = releaseInfo
logger.info('update downloaded', releaseInfo)
})
if (isWin) {
@@ -243,79 +237,49 @@ export default class AppUpdater {
}
}
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
}
/**
* Check if release notes contain multi-language markers
*/
private hasMultiLanguageMarkers(releaseNotes: string): boolean {
return releaseNotes.includes(LANG_MARKERS.EN_START)
}
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
/**
* Parse multi-language release notes and return the appropriate language version
* @param releaseNotes - Release notes string with language markers
* @returns Parsed release notes for the user's language
*
* Expected format:
* <!--LANG:en-->English content<!--LANG:zh-CN-->Chinese content<!--LANG:END-->
*/
private parseMultiLangReleaseNotes(releaseNotes: string): string {
try {
const language = configManager.getLanguage()
const isChineseUser = language === 'zh-CN' || language === 'zh-TW'
// Create regex patterns using constants
const enPattern = new RegExp(
`${LANG_MARKERS.EN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
)
const zhPattern = new RegExp(
`${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
)
// Extract language sections
const enMatch = releaseNotes.match(enPattern)
const zhMatch = releaseNotes.match(zhPattern)
// Return appropriate language version with proper fallback
if (isChineseUser && zhMatch) {
return zhMatch[1].trim()
} else if (enMatch) {
return enMatch[1].trim()
} else {
// Clean fallback: remove all language markers
logger.warn('Failed to extract language-specific release notes, using cleaned fallback')
return releaseNotes
.replace(new RegExp(`${LANG_MARKERS.EN_START}|${LANG_MARKERS.ZH_CN_START}|${LANG_MARKERS.END}`, 'g'), '')
.trim()
}
} catch (error) {
logger.error('Failed to parse multi-language release notes', error as Error)
// Return original notes as safe fallback
if (typeof releaseNotes === 'string') {
return releaseNotes
}
}
/**
* Process release info to handle multi-language release notes
* @param releaseInfo - Original release info from updater
* @returns Processed release info with localized release notes
*/
private processReleaseInfo(releaseInfo: UpdateInfo): UpdateInfo {
const processedInfo = { ...releaseInfo }
// Handle multi-language release notes in string format
if (releaseInfo.releaseNotes && typeof releaseInfo.releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseInfo.releaseNotes)) {
processedInfo.releaseNotes = this.parseMultiLangReleaseNotes(releaseInfo.releaseNotes)
}
}
return processedInfo
return releaseNotes.map((note) => note.note).join('\n')
}
}
interface GithubReleaseInfo {
@@ -323,3 +287,7 @@ interface GithubReleaseInfo {
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@@ -3,20 +3,11 @@ import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
import { isMac, isWin } from '@main/constant'
import { isWin } from '@main/constant'
import { removeEnvProxy } from '@main/utils'
import { isUserInChina } from '@main/utils/ipService'
import { getBinaryName } from '@main/utils/process'
import {
codeTools,
MACOS_TERMINALS,
MACOS_TERMINALS_WITH_COMMANDS,
terminalApps,
TerminalConfig,
TerminalConfigWithCommand,
WINDOWS_TERMINALS,
WINDOWS_TERMINALS_WITH_COMMANDS
} from '@shared/config/constant'
import { codeTools } from '@shared/config/constant'
import { spawn } from 'child_process'
import { promisify } from 'util'
@@ -31,13 +22,7 @@ interface VersionInfo {
class CodeToolsService {
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
private terminalsCache: {
terminals: TerminalConfig[]
timestamp: number
} | null = null
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
constructor() {
this.getBunPath = this.getBunPath.bind(this)
@@ -47,23 +32,6 @@ class CodeToolsService {
this.getVersionInfo = this.getVersionInfo.bind(this)
this.updatePackage = this.updatePackage.bind(this)
this.run = this.run.bind(this)
if (isMac || isWin) {
this.preloadTerminals()
}
}
/**
* Preload available terminals in background
*/
private async preloadTerminals(): Promise<void> {
try {
logger.info('Preloading available terminals...')
await this.getAvailableTerminals()
logger.info('Terminal preloading completed')
} catch (error) {
logger.warn('Terminal preloading failed:', error as Error)
}
}
public async getBunPath() {
@@ -85,8 +53,6 @@ class CodeToolsService {
return '@qwen-code/qwen-code'
case codeTools.iFlowCli:
return '@iflow-ai/iflow-cli'
case codeTools.githubCopilotCli:
return '@github/copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -104,267 +70,15 @@ class CodeToolsService {
return 'qwen'
case codeTools.iFlowCli:
return 'iflow'
case codeTools.githubCopilotCli:
return 'copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
}
/**
* Check if a single terminal is available
*/
private async checkTerminalAvailability(terminal: TerminalConfig): Promise<TerminalConfig | null> {
try {
if (isMac && terminal.bundleId) {
// macOS: Check if application is installed via bundle ID with timeout
const { stdout } = await execAsync(`mdfind "kMDItemCFBundleIdentifier == '${terminal.bundleId}'"`, {
timeout: 3000
})
if (stdout.trim()) {
return terminal
}
} else if (isWin) {
// Windows: Check terminal availability
return await this.checkWindowsTerminalAvailability(terminal)
} else {
// TODO: Check if terminal is available in linux
await execAsync(`which ${terminal.id}`, { timeout: 2000 })
return terminal
}
} catch (error) {
logger.debug(`Terminal ${terminal.id} not available:`, error as Error)
}
return null
}
/**
* Check Windows terminal availability (simplified - user configured paths)
*/
private async checkWindowsTerminalAvailability(terminal: TerminalConfig): Promise<TerminalConfig | null> {
try {
switch (terminal.id) {
case terminalApps.cmd:
// CMD is always available on Windows
return terminal
case terminalApps.powershell:
// Check for PowerShell in PATH
try {
await execAsync('powershell -Command "Get-Host"', {
timeout: 3000
})
return terminal
} catch {
try {
await execAsync('pwsh -Command "Get-Host"', { timeout: 3000 })
return terminal
} catch {
return null
}
}
case terminalApps.windowsTerminal:
// Check for Windows Terminal via where command (doesn't launch the terminal)
try {
await execAsync('where wt', { timeout: 3000 })
return terminal
} catch {
return null
}
case terminalApps.wsl:
// Check for WSL
try {
await execAsync('wsl --status', { timeout: 3000 })
return terminal
} catch {
return null
}
default:
// For other terminals (Alacritty, WezTerm), check if user has configured custom path
return await this.checkCustomTerminalPath(terminal)
}
} catch (error) {
logger.debug(`Windows terminal ${terminal.id} not available:`, error as Error)
return null
}
}
/**
* Check if user has configured custom path for terminal
*/
private async checkCustomTerminalPath(terminal: TerminalConfig): Promise<TerminalConfig | null> {
// Check if user has configured custom path
const customPath = this.customTerminalPaths.get(terminal.id)
if (customPath && fs.existsSync(customPath)) {
try {
await execAsync(`"${customPath}" --version`, { timeout: 3000 })
return { ...terminal, customPath }
} catch {
return null
}
}
// Fallback to PATH check
try {
const command = terminal.id === terminalApps.alacritty ? 'alacritty' : 'wezterm'
await execAsync(`${command} --version`, { timeout: 3000 })
return terminal
} catch {
return null
}
}
/**
* Set custom path for a terminal (called from settings UI)
*/
public setCustomTerminalPath(terminalId: string, path: string): void {
logger.info(`Setting custom path for terminal ${terminalId}: ${path}`)
this.customTerminalPaths.set(terminalId, path)
// Clear terminals cache to force refresh
this.terminalsCache = null
}
/**
* Get custom path for a terminal
*/
public getCustomTerminalPath(terminalId: string): string | undefined {
return this.customTerminalPaths.get(terminalId)
}
/**
* Remove custom path for a terminal
*/
public removeCustomTerminalPath(terminalId: string): void {
logger.info(`Removing custom path for terminal ${terminalId}`)
this.customTerminalPaths.delete(terminalId)
// Clear terminals cache to force refresh
this.terminalsCache = null
}
/**
* Get available terminals (with caching and parallel checking)
*/
private async getAvailableTerminals(): Promise<TerminalConfig[]> {
const now = Date.now()
// Check cache first
if (this.terminalsCache && now - this.terminalsCache.timestamp < this.TERMINALS_CACHE_DURATION) {
logger.info(`Using cached terminals list (${this.terminalsCache.terminals.length} terminals)`)
return this.terminalsCache.terminals
}
logger.info('Checking available terminals in parallel...')
const startTime = Date.now()
// Get terminal list based on platform
const terminalList = isWin ? WINDOWS_TERMINALS : MACOS_TERMINALS
// Check all terminals in parallel
const terminalPromises = terminalList.map((terminal) => this.checkTerminalAvailability(terminal))
try {
// Wait for all checks to complete with a global timeout
const results = await Promise.allSettled(
terminalPromises.map((p) =>
Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))])
)
)
const availableTerminals: TerminalConfig[] = []
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
availableTerminals.push(result.value as TerminalConfig)
} else if (result.status === 'rejected') {
logger.debug(`Terminal check failed for ${MACOS_TERMINALS[index].id}:`, result.reason)
}
})
const endTime = Date.now()
logger.info(
`Terminal availability check completed in ${endTime - startTime}ms, found ${availableTerminals.length} terminals`
)
// Cache the results
this.terminalsCache = {
terminals: availableTerminals,
timestamp: now
}
return availableTerminals
} catch (error) {
logger.error('Error checking terminal availability:', error as Error)
// Return cached result if available, otherwise empty array
return this.terminalsCache?.terminals || []
}
}
/**
* Get terminal config by ID, fallback to system default
*/
private async getTerminalConfig(terminalId?: string): Promise<TerminalConfigWithCommand> {
const availableTerminals = await this.getAvailableTerminals()
const terminalCommands = isWin ? WINDOWS_TERMINALS_WITH_COMMANDS : MACOS_TERMINALS_WITH_COMMANDS
const defaultTerminal = isWin ? terminalApps.cmd : terminalApps.systemDefault
if (terminalId) {
let requestedTerminal = terminalCommands.find(
(t) => t.id === terminalId && availableTerminals.some((at) => at.id === t.id)
)
if (requestedTerminal) {
// Apply custom path if configured
const customPath = this.customTerminalPaths.get(terminalId)
if (customPath && isWin) {
requestedTerminal = this.applyCustomPath(requestedTerminal, customPath)
}
return requestedTerminal
} else {
logger.warn(`Requested terminal ${terminalId} not available, falling back to system default`)
}
}
// Fallback to system default Terminal
const systemTerminal = terminalCommands.find(
(t) => t.id === defaultTerminal && availableTerminals.some((at) => at.id === t.id)
)
if (systemTerminal) {
return systemTerminal
}
// If even system Terminal is not found, return the first available
const firstAvailable = terminalCommands.find((t) => availableTerminals.some((at) => at.id === t.id))
if (firstAvailable) {
return firstAvailable
}
// Last resort fallback
return terminalCommands.find((t) => t.id === defaultTerminal)!
}
/**
* Apply custom path to terminal configuration
*/
private applyCustomPath(terminal: TerminalConfigWithCommand, customPath: string): TerminalConfigWithCommand {
return {
...terminal,
customPath,
command: (directory: string, fullCommand: string) => {
const originalCommand = terminal.command(directory, fullCommand)
return {
...originalCommand,
command: customPath // Replace command with custom path
}
}
}
}
private async isPackageInstalled(cliTool: string): Promise<boolean> {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
@@ -391,11 +105,9 @@ class CodeToolsService {
try {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, {
timeout: 10000
})
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
// Extract version number from output (format may vary by tool)
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
@@ -436,10 +148,7 @@ class CodeToolsService {
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
this.versionCache.set(cacheKey, {
version: latestVersion!,
timestamp: now
})
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
logger.debug(`Cached latest version for ${packageName}`)
} catch (error) {
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
@@ -482,17 +191,6 @@ class CodeToolsService {
}
}
/**
* Get available terminals for the current platform
*/
public async getAvailableTerminalsForPlatform(): Promise<TerminalConfig[]> {
if (isMac || isWin) {
return this.getAvailableTerminals()
}
// For other platforms, return empty array for now
return []
}
/**
* Update a CLI tool to the latest version
*/
@@ -504,9 +202,10 @@ class CodeToolsService {
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
const registryUrl = await this.getNpmRegistryUrl()
const installEnvPrefix = isWin
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const installEnvPrefix =
process.platform === 'win32'
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
logger.info(`Executing update command: ${updateCommand}`)
@@ -542,7 +241,7 @@ class CodeToolsService {
_model: string,
directory: string,
env: Record<string, string>,
options: { autoUpdateToLatest?: boolean; terminal?: string } = {}
options: { autoUpdateToLatest?: boolean } = {}
) {
logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`)
logger.debug(`Environment variables:`, Object.keys(env))
@@ -552,7 +251,7 @@ class CodeToolsService {
const bunPath = await this.getBunPath()
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
logger.debug(`Package name: ${packageName}`)
logger.debug(`Bun path: ${bunPath}`)
@@ -596,13 +295,7 @@ class CodeToolsService {
// Build environment variable prefix (based on platform)
const buildEnvPrefix = (isWindows: boolean) => {
if (Object.keys(env).length === 0) {
logger.info('No environment variables to set')
return ''
}
logger.info('Setting environment variables:', Object.keys(env))
logger.info('Environment variable values:', env)
if (Object.keys(env).length === 0) return ''
if (isWindows) {
// Windows uses set command
@@ -611,29 +304,13 @@ class CodeToolsService {
.join(' && ')
} else {
// Unix-like systems use export command
const validEntries = Object.entries(env).filter(([key, value]) => {
if (!key || key.trim() === '') {
return false
}
if (value === undefined || value === null) {
return false
}
return true
})
const envCommands = validEntries
.map(([key, value]) => {
const sanitizedValue = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const exportCmd = `export ${key}="${sanitizedValue}"`
logger.info(`Setting env var: ${key}="${sanitizedValue}"`)
logger.info(`Export command: ${exportCmd}`)
return exportCmd
})
return Object.entries(env)
.map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`)
.join(' && ')
return envCommands
}
}
// Build command to execute
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
// Add configuration parameters for OpenAI Codex
@@ -674,20 +351,20 @@ class CodeToolsService {
switch (platform) {
case 'darwin': {
// macOS - Support multiple terminals
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
// Combine directory change with the main command to ensure they execute in the same shell session
const fullCommand = `cd "${directory.replace(/"/g, '\\"')}" && clear && ${command}`
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
const terminalConfig = await this.getTerminalConfig(options.terminal)
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
const { command: cmd, args } = terminalConfig.command(directory, fullCommand)
terminalCommand = cmd
terminalArgs = args
terminalCommand = 'osascript'
terminalArgs = [
'-e',
`tell application "Terminal"
do script "${fullCommand.replace(/"/g, '\\"')}"
activate
end tell`
]
break
}
case 'win32': {
@@ -747,23 +424,9 @@ class CodeToolsService {
throw new Error(`Failed to create launch script: ${error}`)
}
// Use selected terminal configuration
const terminalConfig = await this.getTerminalConfig(options.terminal)
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
// Get command and args from terminal configuration
// Pass the bat file path as the command to execute
const fullCommand = batFilePath
const { command: cmd, args } = terminalConfig.command(directory, fullCommand)
// Override if it's a custom terminal with a custom path
if (terminalConfig.customPath) {
terminalCommand = terminalConfig.customPath
terminalArgs = args
} else {
terminalCommand = cmd
terminalArgs = args
}
// Launch bat file - Use safest start syntax, no title parameter
terminalCommand = 'cmd'
terminalArgs = ['/c', 'start', batFilePath]
// Set cleanup task (delete temp file after 5 minutes)
setTimeout(() => {

View File

@@ -2,7 +2,6 @@ import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
import { v4 as uuidv4 } from 'uuid'
import { locales } from '../utils/locales'
@@ -28,8 +27,7 @@ export enum ConfigKeys {
SelectionAssistantFilterList = 'selectionAssistantFilterList',
DisableHardwareAcceleration = 'disableHardwareAcceleration',
Proxy = 'proxy',
EnableDeveloperMode = 'enableDeveloperMode',
ClientId = 'clientId'
EnableDeveloperMode = 'enableDeveloperMode'
}
export class ConfigManager {
@@ -243,17 +241,6 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDeveloperMode, value)
}
getClientId(): string {
let clientId = this.get<string>(ConfigKeys.ClientId)
if (!clientId) {
clientId = uuidv4()
this.set(ConfigKeys.ClientId, clientId)
}
return clientId
}
set(key: string, value: unknown, isNotify: boolean = false) {
this.store.set(key, value)
isNotify && this.notifySubscribers(key, value)

View File

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

View File

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

View File

@@ -256,7 +256,7 @@ export class WindowService {
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
mainWindow.webContents.on('will-navigate', (event, url) => {
if (url.includes('localhost:517')) {
if (url.includes('localhost:5173')) {
return
}
@@ -275,8 +275,7 @@ export class WindowService {
'https://aihubmix.com/topup',
'https://aihubmix.com/statistics',
'https://dash.302.ai/sso/login',
'https://dash.302.ai/charge',
'https://www.aiionly.com/login'
'https://dash.302.ai/charge'
]
if (oauthProviderUrls.some((link) => url.startsWith(link))) {

View File

@@ -1,277 +0,0 @@
import { UpdateInfo } from 'builder-util-runtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock dependencies
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn()
})
}
}))
vi.mock('../ConfigManager', () => ({
configManager: {
getLanguage: vi.fn(),
getAutoUpdate: vi.fn(() => false),
getTestPlan: vi.fn(() => false),
getTestChannel: vi.fn(),
getClientId: vi.fn(() => 'test-client-id')
}
}))
vi.mock('../WindowService', () => ({
windowService: {
getMainWindow: vi.fn()
}
}))
vi.mock('@main/constant', () => ({
isWin: false
}))
vi.mock('@main/utils/ipService', () => ({
getIpCountry: vi.fn(() => 'US')
}))
vi.mock('@main/utils/locales', () => ({
locales: {
en: { translation: { update: {} } },
'zh-CN': { translation: { update: {} } }
}
}))
vi.mock('@main/utils/systemInfo', () => ({
generateUserAgent: vi.fn(() => 'test-user-agent')
}))
vi.mock('electron', () => ({
app: {
isPackaged: true,
getVersion: vi.fn(() => '1.0.0'),
getPath: vi.fn(() => '/test/path')
},
dialog: {
showMessageBox: vi.fn()
},
BrowserWindow: vi.fn(),
net: {
fetch: vi.fn()
}
}))
vi.mock('electron-updater', () => ({
autoUpdater: {
logger: null,
forceDevUpdateConfig: false,
autoDownload: false,
autoInstallOnAppQuit: false,
requestHeaders: {},
on: vi.fn(),
setFeedURL: vi.fn(),
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
channel: '',
allowDowngrade: false,
disableDifferentialDownload: false,
currentVersion: '1.0.0'
},
Logger: vi.fn(),
NsisUpdater: vi.fn(),
AppUpdater: vi.fn()
}))
// Import after mocks
import AppUpdater from '../AppUpdater'
import { configManager } from '../ConfigManager'
describe('AppUpdater', () => {
let appUpdater: AppUpdater
beforeEach(() => {
vi.clearAllMocks()
appUpdater = new AppUpdater()
})
describe('parseMultiLangReleaseNotes', () => {
const sampleReleaseNotes = `<!--LANG:en-->
🚀 New Features:
- Feature A
- Feature B
🎨 UI Improvements:
- Improvement A
<!--LANG:zh-CN-->
🚀 新功能:
- 功能 A
- 功能 B
🎨 界面改进:
- 改进 A
<!--LANG:END-->`
it('should return Chinese notes for zh-CN users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('新功能')
expect(result).toContain('功能 A')
expect(result).not.toContain('New Features')
})
it('should return Chinese notes for zh-TW users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('新功能')
expect(result).toContain('功能 A')
expect(result).not.toContain('New Features')
})
it('should return English notes for non-Chinese users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('New Features')
expect(result).toContain('Feature A')
expect(result).not.toContain('新功能')
})
it('should return English notes for other language users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('New Features')
expect(result).not.toContain('新功能')
})
it('should handle missing language sections gracefully', () => {
const malformedNotes = 'Simple release notes without markers'
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
expect(result).toBe('Simple release notes without markers')
})
it('should handle malformed markers', () => {
const malformedNotes = `<!--LANG:en-->English only`
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
// Should clean up markers and return cleaned content
expect(result).toContain('English only')
expect(result).not.toContain('<!--LANG:')
})
it('should handle empty release notes', () => {
const result = (appUpdater as any).parseMultiLangReleaseNotes('')
expect(result).toBe('')
})
it('should handle errors gracefully', () => {
// Force an error by mocking configManager to throw
vi.mocked(configManager.getLanguage).mockImplementation(() => {
throw new Error('Test error')
})
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
// Should return original notes as fallback
expect(result).toBe(sampleReleaseNotes)
})
})
describe('hasMultiLanguageMarkers', () => {
it('should return true when markers are present', () => {
const notes = '<!--LANG:en-->Test'
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
expect(result).toBe(true)
})
it('should return false when no markers are present', () => {
const notes = 'Simple text without markers'
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
expect(result).toBe(false)
})
})
describe('processReleaseInfo', () => {
it('should process multi-language release notes in string format', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: `<!--LANG:en-->English notes<!--LANG:zh-CN-->中文说明<!--LANG:END-->`
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toBe('中文说明')
})
it('should not process release notes without markers', () => {
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: 'Simple release notes'
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toBe('Simple release notes')
})
it('should handle array format release notes', () => {
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toEqual(releaseInfo.releaseNotes)
})
it('should handle null release notes', () => {
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: null
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toBeNull()
})
})
})

View File

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

View File

@@ -0,0 +1,311 @@
import { type Client, createClient } from '@libsql/client'
import { loggerService } from '@logger'
import { ModelValidationError, validateModelId } from '@main/apiServer/utils'
import { AgentType, objectKeys, Provider } from '@types'
import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql'
import fs from 'fs'
import path from 'path'
import { MigrationService } from './database/MigrationService'
import * as schema from './database/schema'
import { dbPath } from './drizzle.config'
import { AgentModelField, AgentModelValidationError } from './errors'
const logger = loggerService.withContext('BaseService')
/**
* Base service class providing shared database connection and utilities
* for all agent-related services.
*
* Features:
* - Programmatic schema management (no CLI dependencies)
* - Automatic table creation and migration
* - Schema version tracking and compatibility checks
* - Transaction-based operations for safety
* - Development vs production mode handling
* - Connection retry logic with exponential backoff
*/
export abstract class BaseService {
protected static client: Client | null = null
protected static db: LibSQLDatabase<typeof schema> | null = null
protected static isInitialized = false
protected static initializationPromise: Promise<void> | null = null
protected jsonFields: string[] = ['built_in_tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
/**
* Initialize database with retry logic and proper error handling
*/
protected static async initialize(): Promise<void> {
// Return existing initialization if in progress
if (BaseService.initializationPromise) {
return BaseService.initializationPromise
}
if (BaseService.isInitialized) {
return
}
BaseService.initializationPromise = BaseService.performInitialization()
return BaseService.initializationPromise
}
private static async performInitialization(): Promise<void> {
const maxRetries = 3
let lastError: Error
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
logger.info(`Initializing Agent database at: ${dbPath} (attempt ${attempt}/${maxRetries})`)
// Ensure the database directory exists
const dbDir = path.dirname(dbPath)
if (!fs.existsSync(dbDir)) {
logger.info(`Creating database directory: ${dbDir}`)
fs.mkdirSync(dbDir, { recursive: true })
}
BaseService.client = createClient({
url: `file:${dbPath}`
})
BaseService.db = drizzle(BaseService.client, { schema })
// Run database migrations
const migrationService = new MigrationService(BaseService.db, BaseService.client)
await migrationService.runMigrations()
BaseService.isInitialized = true
logger.info('Agent database initialized successfully')
return
} catch (error) {
lastError = error as Error
logger.warn(`Database initialization attempt ${attempt} failed:`, lastError)
// Clean up on failure
if (BaseService.client) {
try {
BaseService.client.close()
} catch (closeError) {
logger.warn('Failed to close client during cleanup:', closeError as Error)
}
}
BaseService.client = null
BaseService.db = null
// Wait before retrying (exponential backoff)
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000 // 2s, 4s, 8s
logger.info(`Retrying in ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
// All retries failed
BaseService.initializationPromise = null
logger.error('Failed to initialize Agent database after all retries:', lastError!)
throw lastError!
}
protected ensureInitialized(): void {
if (!BaseService.isInitialized || !BaseService.db || !BaseService.client) {
throw new Error('Database not initialized. Call initialize() first.')
}
}
protected get database(): LibSQLDatabase<typeof schema> {
this.ensureInitialized()
return BaseService.db!
}
protected get rawClient(): Client {
this.ensureInitialized()
return BaseService.client!
}
protected serializeJsonFields(data: any): any {
const serialized = { ...data }
for (const field of this.jsonFields) {
if (serialized[field] !== undefined) {
serialized[field] =
Array.isArray(serialized[field]) || typeof serialized[field] === 'object'
? JSON.stringify(serialized[field])
: serialized[field]
}
}
return serialized
}
protected deserializeJsonFields(data: any): any {
if (!data) return data
const deserialized = { ...data }
for (const field of this.jsonFields) {
if (deserialized[field] && typeof deserialized[field] === 'string') {
try {
deserialized[field] = JSON.parse(deserialized[field])
} catch (error) {
logger.warn(`Failed to parse JSON field ${field}:`, error as Error)
}
}
}
// convert null from db to undefined to satisfy type definition
for (const key of objectKeys(data)) {
if (deserialized[key] === null) {
deserialized[key] = undefined
}
}
return deserialized
}
/**
* Validate, normalize, and ensure filesystem access for a set of absolute paths.
*
* - Requires every entry to be an absolute path and throws if not.
* - Normalizes each path and deduplicates while preserving order.
* - Creates missing directories (or parent directories for file-like paths).
*/
protected ensurePathsExist(paths?: string[]): string[] {
if (!paths?.length) {
return []
}
const sanitizedPaths: string[] = []
const seenPaths = new Set<string>()
for (const rawPath of paths) {
if (!rawPath) {
continue
}
if (!path.isAbsolute(rawPath)) {
throw new Error(`Accessible path must be absolute: ${rawPath}`)
}
// Normalize to provide consistent values to downstream consumers.
const resolvedPath = path.normalize(rawPath)
let stats: fs.Stats | null = null
try {
// Attempt to stat the path to understand whether it already exists and if it is a file.
if (fs.existsSync(resolvedPath)) {
stats = fs.statSync(resolvedPath)
}
} catch (error) {
logger.warn('Failed to inspect accessible path', {
path: rawPath,
error: error instanceof Error ? error.message : String(error)
})
}
const looksLikeFile =
(stats && stats.isFile()) || (!stats && path.extname(resolvedPath) !== '' && !resolvedPath.endsWith(path.sep))
// For file-like targets create the parent directory; otherwise ensure the directory itself.
const directoryToEnsure = looksLikeFile ? path.dirname(resolvedPath) : resolvedPath
if (!fs.existsSync(directoryToEnsure)) {
try {
fs.mkdirSync(directoryToEnsure, { recursive: true })
} catch (error) {
logger.error('Failed to create accessible path directory', {
path: directoryToEnsure,
error: error instanceof Error ? error.message : String(error)
})
throw error
}
}
// Preserve the first occurrence only to avoid duplicates while keeping caller order stable.
if (!seenPaths.has(resolvedPath)) {
seenPaths.add(resolvedPath)
sanitizedPaths.push(resolvedPath)
}
}
return sanitizedPaths
}
/**
* Force re-initialization (for development/testing)
*/
protected async validateAgentModels(
agentType: AgentType,
models: Partial<Record<AgentModelField, string | undefined>>
): Promise<void> {
const entries = Object.entries(models) as [AgentModelField, string | undefined][]
if (entries.length === 0) {
return
}
for (const [field, rawValue] of entries) {
if (rawValue === undefined || rawValue === null) {
continue
}
const modelValue = rawValue
const validation = await validateModelId(modelValue)
if (!validation.valid || !validation.provider) {
const detail: ModelValidationError = validation.error ?? {
type: 'invalid_format',
message: 'Unknown model validation error',
code: 'validation_error'
}
throw new AgentModelValidationError({ agentType, field, model: modelValue }, detail)
}
if (!validation.provider.apiKey) {
throw new AgentModelValidationError(
{ agentType, field, model: modelValue },
{
type: 'invalid_format',
message: `Provider '${validation.provider.id}' is missing an API key`,
code: 'provider_api_key_missing'
}
)
}
// different agent types may have different provider requirements
const agentTypeProviderRequirements: Record<AgentType, Provider['type']> = {
'claude-code': 'anthropic'
}
for (const [ak, pk] of Object.entries(agentTypeProviderRequirements)) {
if (agentType === ak && validation.provider.type !== pk) {
throw new AgentModelValidationError(
{ agentType, field, model: modelValue },
{
type: 'unsupported_provider_type',
message: `Provider type '${validation.provider.type}' is not supported for agent type '${agentType}'. Expected '${pk}'`,
code: 'unsupported_provider_type'
}
)
}
}
}
}
static async reinitialize(): Promise<void> {
BaseService.isInitialized = false
BaseService.initializationPromise = null
if (BaseService.client) {
try {
BaseService.client.close()
} catch (error) {
logger.warn('Failed to close client during reinitialize:', error as Error)
}
}
BaseService.client = null
BaseService.db = null
await BaseService.initialize()
}
}

View File

@@ -0,0 +1,81 @@
# Agents Service
Simplified Drizzle ORM implementation for agent and session management in Cherry Studio.
## Features
- **Native Drizzle migrations** - Uses built-in migrate() function
- **Zero CLI dependencies** in production
- **Auto-initialization** with retry logic
- **Full TypeScript** type safety
- **Model validation** to ensure models exist and provider configuration matches the agent type
## Schema
- `agents.schema.ts` - Agent definitions
- `sessions.schema.ts` - Session and message tables
- `migrations.schema.ts` - Migration tracking
## Usage
```typescript
import { agentService } from './services'
// Create agent - fully typed
const agent = await agentService.createAgent({
type: 'custom',
name: 'My Agent',
model: 'anthropic:claude-3-5-sonnet-20241022'
})
```
## Model Validation
- Model identifiers must use the `provider:model_id` format (for example `anthropic:claude-3-5-sonnet-20241022`).
- `model`, `plan_model`, and `small_model` are validated against the configured providers before the database is touched.
- Invalid configurations return a `400 invalid_request_error` response and the create/update operation is aborted.
## Development Commands
```bash
# Apply schema changes
yarn agents:generate
# Quick development sync
yarn agents:push
# Database tools
yarn agents:studio # Open Drizzle Studio
yarn agents:health # Health check
yarn agents:drop # Reset database
```
## Workflow
1. **Edit schema** in `/database/schema/`
2. **Generate migration** with `yarn agents:generate`
3. **Test changes** with `yarn agents:health`
4. **Deploy** - migrations apply automatically
## Services
- `AgentService` - Agent CRUD operations
- `SessionService` - Session management
- `SessionMessageService` - Message logging
- `BaseService` - Database utilities
- `schemaSyncer` - Migration handler
## Troubleshooting
```bash
# Check status
yarn agents:health
# Apply migrations
yarn agents:migrate
# Reset completely
yarn agents:reset --yes
```
The simplified migration system reduced complexity from 463 to ~30 lines while maintaining all functionality through Drizzle's native migration system.

View File

@@ -0,0 +1,35 @@
# Agents Service Refactor TODO (interface-level)
- [x] **SessionMessageService.createSessionMessage**
- Replace the current `EventEmitter` that emits `UIMessageChunk` with a readable stream of `TextStreamPart` objects (same shape produced by `/api/messages` in `messageThunk`).
- Update `startSessionMessageStream` to call a new adapter (`claudeToTextStreamPart(chunk)`) that maps Claude Code chunk payloads to `{ type: 'text-delta' | 'tool-call' | ... }` parts used by `AiSdkToChunkAdapter`.
- Add a secondary return value (promise) resolving to the persisted `ModelMessage[]` once streaming completes, so the renderer thunk can await save confirmation.
- [x] **main -> renderer transport**
- Update the existing SSE handler in `src/main/apiServer/routes/agents/handlers/messages.ts` (e.g., `createMessage`) to forward the new `TextStreamPart` stream over HTTP, preserving the current agent endpoint contract.
- Keep abort handling compatible with the current HTTP server (honor `AbortController` on the request to terminate the stream).
- [x] **renderer thunk integration**
- Introduce a thin IPC contract (e.g., `AgentMessagePersistence`) surfaced by `src/main/services/agents/database/index.ts` so the renderer thunk can request session-message writes without going through `SessionMessageService`.
- Define explicit entry points on the main side:
- `persistUserMessage({ sessionId, agentSessionId, payload, createdAt?, metadata? })`
- `persistAssistantMessage({ sessionId, agentSessionId, payload, createdAt?, metadata? })`
- `persistExchange({ sessionId, agentSessionId, user, assistant })` which runs the above in a single transaction and returns both records.
- Export these helpers via an `agentMessageRepository` object so both IPC handlers and legacy services share the same persistence path.
- Normalize persisted payloads to `{ message, blocks }` matching the renderer schema instead of AI-SDK `ModelMessage` chunks.
- Extend `messageThunk.sendMessage` to call the agent transport when the topic corresponds to a session, pipe chunks through `createStreamProcessor` + `AiSdkToChunkAdapter`, and invoke the new persistence interface once streaming resolves.
- Replace `useSession().createSessionMessage` optimistic insert with dispatching the thunk so Redux/Dexie persistence happens via the shared save helpers.
- [x] **persistence alignment**
- Remove `persistUserMessage` / `persistAssistantMessage` calls from `SessionMessageService`; instead expose a `SessionMessageRepository` in `main` that the thunk invokes via existing Dexie helpers.
- On renderer side, persist agent exchanges via IPC after streaming completes, storing `{ message, blocks }` payloads while skipping Dexie writes for agent sessions so the single source of truth remains `session_messages`.
- [x] **Blocks renderer**
- Replace `AgentSessionMessages` simple `<div>` render with the shared `Blocks` component (`src/renderer/src/pages/home/Messages/Blocks`) wired to the Redux store.
- Adjust `useSession` to only fetch metadata (e.g., session info) and rely on store selectors for message list.
- [x] **API client clean-up**
- Remove `AgentApiClient.createMessage` direct POST once thunk is in place; calls should go through renderer thunk -> stream -> final persistence.
- [ ] **Regression tests**
- Add integration test to assert agent sessions render incremental text the same way as standard assistant messages.

View File

@@ -0,0 +1,161 @@
import { type Client } from '@libsql/client'
import { loggerService } from '@logger'
import { getResourcePath } from '@main/utils'
import { type LibSQLDatabase } from 'drizzle-orm/libsql'
import fs from 'fs'
import path from 'path'
import * as schema from './schema'
import { migrations, type NewMigration } from './schema/migrations.schema'
const logger = loggerService.withContext('MigrationService')
interface MigrationJournal {
version: string
dialect: string
entries: Array<{
idx: number
version: string
when: number
tag: string
breakpoints: boolean
}>
}
export class MigrationService {
private db: LibSQLDatabase<typeof schema>
private client: Client
private migrationDir: string
constructor(db: LibSQLDatabase<typeof schema>, client: Client) {
this.db = db
this.client = client
this.migrationDir = path.join(getResourcePath(), 'database', 'drizzle')
}
async runMigrations(): Promise<void> {
try {
logger.info('Starting migration check...')
const hasMigrationsTable = await this.migrationsTableExists()
if (!hasMigrationsTable) {
logger.info('Migrations table not found; assuming fresh database state')
}
// Read migration journal
const journal = await this.readMigrationJournal()
if (!journal.entries.length) {
logger.info('No migrations found in journal')
return
}
// Get applied migrations
const appliedMigrations = hasMigrationsTable ? await this.getAppliedMigrations() : []
const appliedVersions = new Set(appliedMigrations.map((m) => Number(m.version)))
const latestAppliedVersion = appliedMigrations.reduce(
(max, migration) => Math.max(max, Number(migration.version)),
0
)
const latestJournalVersion = journal.entries.reduce((max, entry) => Math.max(max, entry.idx), 0)
logger.info(`Latest applied migration: v${latestAppliedVersion}, latest available: v${latestJournalVersion}`)
// Find pending migrations (compare journal idx with stored version, which is the same value)
const pendingMigrations = journal.entries
.filter((entry) => !appliedVersions.has(entry.idx))
.sort((a, b) => a.idx - b.idx)
if (pendingMigrations.length === 0) {
logger.info('Database is up to date')
return
}
logger.info(`Found ${pendingMigrations.length} pending migrations`)
// Execute pending migrations
for (const migration of pendingMigrations) {
await this.executeMigration(migration)
}
logger.info('All migrations completed successfully')
} catch (error) {
logger.error('Migration failed:', { error })
throw error
}
}
private async migrationsTableExists(): Promise<boolean> {
try {
const table = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`)
return table.rows.length > 0
} catch (error) {
logger.error('Failed to check migrations table status:', { error })
throw error
}
}
private async readMigrationJournal(): Promise<MigrationJournal> {
const journalPath = path.join(this.migrationDir, 'meta', '_journal.json')
if (!fs.existsSync(journalPath)) {
logger.warn('Migration journal not found:', { journalPath })
return { version: '7', dialect: 'sqlite', entries: [] }
}
try {
const journalContent = fs.readFileSync(journalPath, 'utf-8')
return JSON.parse(journalContent)
} catch (error) {
logger.error('Failed to read migration journal:', { error })
throw error
}
}
private async getAppliedMigrations(): Promise<schema.Migration[]> {
try {
return await this.db.select().from(migrations)
} catch (error) {
// This should not happen since we ensure the table exists in runMigrations()
logger.error('Failed to query applied migrations:', { error })
throw error
}
}
private async executeMigration(migration: MigrationJournal['entries'][0]): Promise<void> {
const sqlFilePath = path.join(this.migrationDir, `${migration.tag}.sql`)
if (!fs.existsSync(sqlFilePath)) {
throw new Error(`Migration SQL file not found: ${sqlFilePath}`)
}
try {
logger.info(`Executing migration ${migration.tag}...`)
const startTime = Date.now()
// Read and execute SQL
const sqlContent = fs.readFileSync(sqlFilePath, 'utf-8')
await this.client.executeMultiple(sqlContent)
// Record migration as applied (store journal idx as version for tracking)
const newMigration: NewMigration = {
version: migration.idx,
tag: migration.tag,
executedAt: Date.now()
}
if (!(await this.migrationsTableExists())) {
throw new Error('Migrations table missing after executing migration; cannot record progress')
}
await this.db.insert(migrations).values(newMigration)
const executionTime = Date.now() - startTime
logger.info(`Migration ${migration.tag} completed in ${executionTime}ms`)
} catch (error) {
logger.error(`Migration ${migration.tag} failed:`, { error })
throw error
}
}
}

View File

@@ -0,0 +1,14 @@
/**
* Database Module
*
* This module provides centralized access to Drizzle ORM schemas
* for type-safe database operations.
*
* Schema evolution is handled by Drizzle Kit migrations.
*/
// Drizzle ORM schemas
export * from './schema'
// Repository helpers
export * from './sessionMessageRepository'

View File

@@ -0,0 +1,35 @@
/**
* Drizzle ORM schema for agents table
*/
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const agentsTable = sqliteTable('agents', {
id: text('id').primaryKey(),
type: text('type').notNull(),
name: text('name').notNull(),
description: text('description'),
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access
instructions: text('instructions'),
model: text('model').notNull(), // Main model ID (required)
plan_model: text('plan_model'), // Optional plan/thinking model ID
small_model: text('small_model'), // Optional small/fast model ID
mcps: text('mcps'), // JSON array of MCP tool IDs
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
configuration: text('configuration'), // JSON, extensible settings
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
})
// Indexes for agents table
export const agentsNameIdx = index('idx_agents_name').on(agentsTable.name)
export const agentsTypeIdx = index('idx_agents_type').on(agentsTable.type)
export const agentsCreatedAtIdx = index('idx_agents_created_at').on(agentsTable.created_at)
export type AgentRow = typeof agentsTable.$inferSelect
export type InsertAgentRow = typeof agentsTable.$inferInsert

View File

@@ -0,0 +1,8 @@
/**
* Drizzle ORM schema exports
*/
export * from './agents.schema'
export * from './messages.schema'
export * from './migrations.schema'
export * from './sessions.schema'

View File

@@ -0,0 +1,30 @@
import { foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { sessionsTable } from './sessions.schema'
// session_messages table to log all messages, thoughts, actions, observations in a session
export const sessionMessagesTable = sqliteTable('session_messages', {
id: integer('id').primaryKey({ autoIncrement: true }),
session_id: text('session_id').notNull(),
role: text('role').notNull(), // 'user', 'agent', 'system', 'tool'
content: text('content').notNull(), // JSON structured data
agent_session_id: text('agent_session_id').default(''),
metadata: text('metadata'), // JSON metadata (optional)
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
})
// Indexes for session_messages table
export const sessionMessagesSessionIdIdx = index('idx_session_messages_session_id').on(sessionMessagesTable.session_id)
export const sessionMessagesCreatedAtIdx = index('idx_session_messages_created_at').on(sessionMessagesTable.created_at)
export const sessionMessagesUpdatedAtIdx = index('idx_session_messages_updated_at').on(sessionMessagesTable.updated_at)
// Foreign keys for session_messages table
export const sessionMessagesFkSession = foreignKey({
columns: [sessionMessagesTable.session_id],
foreignColumns: [sessionsTable.id],
name: 'fk_session_messages_session_id'
}).onDelete('cascade')
export type SessionMessageRow = typeof sessionMessagesTable.$inferSelect
export type InsertSessionMessageRow = typeof sessionMessagesTable.$inferInsert

View File

@@ -0,0 +1,14 @@
/**
* Migration tracking schema
*/
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const migrations = sqliteTable('migrations', {
version: integer('version').primaryKey(),
tag: text('tag').notNull(),
executedAt: integer('executed_at').notNull()
})
export type Migration = typeof migrations.$inferSelect
export type NewMigration = typeof migrations.$inferInsert

View File

@@ -0,0 +1,45 @@
/**
* Drizzle ORM schema for sessions and session_logs tables
*/
import { foreignKey, index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { agentsTable } from './agents.schema'
export const sessionsTable = sqliteTable('sessions', {
id: text('id').primaryKey(),
agent_type: text('agent_type').notNull(),
agent_id: text('agent_id').notNull(), // Primary agent ID for the session
name: text('name').notNull(),
description: text('description'),
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access
instructions: text('instructions'),
model: text('model').notNull(), // Main model ID (required)
plan_model: text('plan_model'), // Optional plan/thinking model ID
small_model: text('small_model'), // Optional small/fast model ID
mcps: text('mcps'), // JSON array of MCP tool IDs
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
configuration: text('configuration'), // JSON, extensible settings
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
})
// Foreign keys for sessions table
export const sessionsFkAgent = foreignKey({
columns: [sessionsTable.agent_id],
foreignColumns: [agentsTable.id],
name: 'fk_session_agent_id'
}).onDelete('cascade')
// Indexes for sessions table
export const sessionsCreatedAtIdx = index('idx_sessions_created_at').on(sessionsTable.created_at)
export const sessionsMainAgentIdIdx = index('idx_sessions_agent_id').on(sessionsTable.agent_id)
export const sessionsModelIdx = index('idx_sessions_model').on(sessionsTable.model)
export type SessionRow = typeof sessionsTable.$inferSelect
export type InsertSessionRow = typeof sessionsTable.$inferInsert

View File

@@ -0,0 +1,181 @@
import { loggerService } from '@logger'
import type {
AgentMessageAssistantPersistPayload,
AgentMessagePersistExchangePayload,
AgentMessagePersistExchangeResult,
AgentMessageUserPersistPayload,
AgentPersistedMessage,
AgentSessionMessageEntity
} from '@types'
import { BaseService } from '../BaseService'
import type { InsertSessionMessageRow } from './schema'
import { sessionMessagesTable } from './schema'
const logger = loggerService.withContext('AgentMessageRepository')
type TxClient = any
export type PersistUserMessageParams = AgentMessageUserPersistPayload & {
sessionId: string
agentSessionId?: string
tx?: TxClient
}
export type PersistAssistantMessageParams = AgentMessageAssistantPersistPayload & {
sessionId: string
agentSessionId: string
tx?: TxClient
}
type PersistExchangeParams = AgentMessagePersistExchangePayload & {
tx?: TxClient
}
type PersistExchangeResult = AgentMessagePersistExchangeResult
class AgentMessageRepository extends BaseService {
private static instance: AgentMessageRepository | null = null
static getInstance(): AgentMessageRepository {
if (!AgentMessageRepository.instance) {
AgentMessageRepository.instance = new AgentMessageRepository()
}
return AgentMessageRepository.instance
}
private serializeMessage(payload: AgentPersistedMessage): string {
return JSON.stringify(payload)
}
private serializeMetadata(metadata?: Record<string, unknown>): string | undefined {
if (!metadata) {
return undefined
}
try {
return JSON.stringify(metadata)
} catch (error) {
logger.warn('Failed to serialize session message metadata', error as Error)
return undefined
}
}
private deserialize(row: any): AgentSessionMessageEntity {
if (!row) return row
const deserialized = { ...row }
if (typeof deserialized.content === 'string') {
try {
deserialized.content = JSON.parse(deserialized.content)
} catch (error) {
logger.warn('Failed to parse session message content JSON', error as Error)
}
}
if (typeof deserialized.metadata === 'string') {
try {
deserialized.metadata = JSON.parse(deserialized.metadata)
} catch (error) {
logger.warn('Failed to parse session message metadata JSON', error as Error)
}
}
return deserialized
}
private getWriter(tx?: TxClient): TxClient {
return tx ?? this.database
}
async persistUserMessage(params: PersistUserMessageParams): Promise<AgentSessionMessageEntity> {
await AgentMessageRepository.initialize()
this.ensureInitialized()
const writer = this.getWriter(params.tx)
const now = params.createdAt ?? params.payload.message.createdAt ?? new Date().toISOString()
const insertData: InsertSessionMessageRow = {
session_id: params.sessionId,
role: params.payload.message.role,
content: this.serializeMessage(params.payload),
agent_session_id: params.agentSessionId ?? '',
metadata: this.serializeMetadata(params.metadata),
created_at: now,
updated_at: now
}
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
return this.deserialize(saved)
}
async persistAssistantMessage(params: PersistAssistantMessageParams): Promise<AgentSessionMessageEntity> {
await AgentMessageRepository.initialize()
this.ensureInitialized()
const writer = this.getWriter(params.tx)
const now = params.createdAt ?? params.payload.message.createdAt ?? new Date().toISOString()
const insertData: InsertSessionMessageRow = {
session_id: params.sessionId,
role: params.payload.message.role,
content: this.serializeMessage(params.payload),
agent_session_id: params.agentSessionId,
metadata: this.serializeMetadata(params.metadata),
created_at: now,
updated_at: now
}
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
return this.deserialize(saved)
}
async persistExchange(params: PersistExchangeParams): Promise<PersistExchangeResult> {
await AgentMessageRepository.initialize()
this.ensureInitialized()
const { sessionId, agentSessionId, user, assistant } = params
const result = await this.database.transaction(async (tx) => {
const exchangeResult: PersistExchangeResult = {}
if (user?.payload) {
if (!user.payload.message?.role) {
throw new Error('User message payload missing role')
}
exchangeResult.userMessage = await this.persistUserMessage({
sessionId,
agentSessionId,
payload: user.payload,
metadata: user.metadata,
createdAt: user.createdAt,
tx
})
}
if (assistant?.payload) {
if (!assistant.payload.message?.role) {
throw new Error('Assistant message payload missing role')
}
exchangeResult.assistantMessage = await this.persistAssistantMessage({
sessionId,
agentSessionId,
payload: assistant.payload,
metadata: assistant.metadata,
createdAt: assistant.createdAt,
tx
})
}
return exchangeResult
})
return result
}
}
export const agentMessageRepository = AgentMessageRepository.getInstance()

View File

@@ -0,0 +1,31 @@
/**
* Drizzle Kit configuration for agents database
*/
import os from 'node:os'
import path from 'node:path'
import { defineConfig } from 'drizzle-kit'
import { app } from 'electron'
function getDbPath() {
if (process.env.NODE_ENV === 'development') {
return path.join(os.homedir(), '.cherrystudio', 'data', 'agents.db')
}
return path.join(app.getPath('userData'), 'agents.db')
}
const resolvedDbPath = getDbPath()
export const dbPath = resolvedDbPath
export default defineConfig({
dialect: 'sqlite',
schema: './src/main/services/agents/database/schema/index.ts',
out: './resources/database/drizzle',
dbCredentials: {
url: `file:${resolvedDbPath}`
},
verbose: true,
strict: true
})

View File

@@ -0,0 +1,23 @@
import { ModelValidationError } from '@main/apiServer/utils'
import { AgentType } from '@types'
export type AgentModelField = 'model' | 'plan_model' | 'small_model'
export interface AgentModelValidationContext {
agentType: AgentType
field: AgentModelField
model?: string
}
export class AgentModelValidationError extends Error {
readonly context: AgentModelValidationContext
readonly detail: ModelValidationError
constructor(context: AgentModelValidationContext, detail: ModelValidationError) {
super(`Validation failed for ${context.agentType}.${context.field}: ${detail.message}`)
this.name = 'AgentModelValidationError'
this.context = context
this.detail = detail
}
}

View File

@@ -0,0 +1,25 @@
/**
* Agents Service Module
*
* This module provides a complete autonomous agent management system with:
* - Agent lifecycle management (CRUD operations)
* - Session handling with conversation history
* - Comprehensive logging and audit trails
* - Database operations with Drizzle ORM and migration support
* - RESTful API endpoints for external integration
*/
// === Core Services ===
// Main service classes and singleton instances
export * from './services'
// === Error Types ===
export { type AgentModelField, AgentModelValidationError } from './errors'
// === Base Infrastructure ===
// Shared database utilities and base service class
export { BaseService } from './BaseService'
// === Database Layer ===
// Drizzle ORM schemas, migrations, and database utilities
export * as Database from './database'

View File

@@ -0,0 +1,31 @@
// Agent-agnostic streaming interface
// This interface should be implemented by all agent services
import { EventEmitter } from 'node:events'
import { GetAgentSessionResponse } from '@types'
import type { TextStreamPart } from 'ai'
// Generic agent stream event that works with any agent type
export interface AgentStreamEvent {
type: 'chunk' | 'error' | 'complete' | 'cancelled'
chunk?: TextStreamPart<any> // Standard AI SDK chunk for UI consumption
error?: Error
}
// Agent stream interface that all agents should implement
export interface AgentStream extends EventEmitter {
emit(event: 'data', data: AgentStreamEvent): boolean
on(event: 'data', listener: (data: AgentStreamEvent) => void): this
once(event: 'data', listener: (data: AgentStreamEvent) => void): this
}
// Base agent service interface
export interface AgentServiceInterface {
invoke(
prompt: string,
session: GetAgentSessionResponse,
abortController: AbortController,
lastAgentSessionId?: string
): Promise<AgentStream>
}

View File

@@ -0,0 +1,201 @@
import path from 'node:path'
import { getDataPath } from '@main/utils'
import {
AgentBaseSchema,
AgentEntity,
CreateAgentRequest,
CreateAgentResponse,
GetAgentResponse,
ListOptions,
UpdateAgentRequest,
UpdateAgentResponse
} from '@types'
import { count, eq } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
import { AgentModelField } from '../errors'
import { builtinTools } from './claudecode/tools'
export class AgentService extends BaseService {
private static instance: AgentService | null = null
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
static getInstance(): AgentService {
if (!AgentService.instance) {
AgentService.instance = new AgentService()
}
return AgentService.instance
}
async initialize(): Promise<void> {
await BaseService.initialize()
}
// Agent Methods
async createAgent(req: CreateAgentRequest): Promise<CreateAgentResponse> {
this.ensureInitialized()
const id = `agent_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
const now = new Date().toISOString()
if (!req.accessible_paths || req.accessible_paths.length === 0) {
const defaultPath = path.join(getDataPath(), 'agents', id)
req.accessible_paths = [defaultPath]
}
if (req.accessible_paths !== undefined) {
req.accessible_paths = this.ensurePathsExist(req.accessible_paths)
}
await this.validateAgentModels(req.type, {
model: req.model,
plan_model: req.plan_model,
small_model: req.small_model
})
const serializedReq = this.serializeJsonFields(req)
const insertData: InsertAgentRow = {
id,
type: req.type,
name: req.name || 'New Agent',
description: req.description,
instructions: req.instructions || 'You are a helpful assistant.',
model: req.model,
plan_model: req.plan_model,
small_model: req.small_model,
configuration: serializedReq.configuration,
accessible_paths: serializedReq.accessible_paths,
created_at: now,
updated_at: now
}
await this.database.insert(agentsTable).values(insertData)
const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
if (!result[0]) {
throw new Error('Failed to create agent')
}
const agent = this.deserializeJsonFields(result[0]) as AgentEntity
return agent
}
async getAgent(id: string): Promise<GetAgentResponse | null> {
this.ensureInitialized()
const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
if (!result[0]) {
return null
}
const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse
if (agent.type === 'claude-code') {
agent.built_in_tools = builtinTools
}
return agent
}
async listAgents(options: ListOptions = {}): Promise<{ agents: AgentEntity[]; total: number }> {
this.ensureInitialized() // Build query with pagination
const totalResult = await this.database.select({ count: count() }).from(agentsTable)
const baseQuery = this.database.select().from(agentsTable).orderBy(agentsTable.created_at)
const result =
options.limit !== undefined
? options.offset !== undefined
? await baseQuery.limit(options.limit).offset(options.offset)
: await baseQuery.limit(options.limit)
: await baseQuery
const agents = result.map((row) => this.deserializeJsonFields(row)) as GetAgentResponse[]
agents.forEach((agent) => {
if (agent.type === 'claude-code') {
agent.built_in_tools = builtinTools
}
})
return { agents, total: totalResult[0].count }
}
async updateAgent(
id: string,
updates: UpdateAgentRequest,
options: { replace?: boolean } = {}
): Promise<UpdateAgentResponse | null> {
this.ensureInitialized()
// Check if agent exists
const existing = await this.getAgent(id)
if (!existing) {
return null
}
const now = new Date().toISOString()
if (updates.accessible_paths !== undefined) {
updates.accessible_paths = this.ensurePathsExist(updates.accessible_paths)
}
const modelUpdates: Partial<Record<AgentModelField, string | undefined>> = {}
for (const field of this.modelFields) {
if (Object.prototype.hasOwnProperty.call(updates, field)) {
modelUpdates[field] = updates[field as keyof UpdateAgentRequest] as string | undefined
}
}
if (Object.keys(modelUpdates).length > 0) {
await this.validateAgentModels(existing.type, modelUpdates)
}
const serializedUpdates = this.serializeJsonFields(updates)
const updateData: Partial<AgentRow> = {
updated_at: now
}
const replaceableFields = Object.keys(AgentBaseSchema.shape) as (keyof AgentRow)[]
const shouldReplace = options.replace ?? false
for (const field of replaceableFields) {
if (shouldReplace || Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
if (Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
const value = serializedUpdates[field as keyof typeof serializedUpdates]
;(updateData as Record<string, unknown>)[field] = value ?? null
} else if (shouldReplace) {
;(updateData as Record<string, unknown>)[field] = null
}
}
}
await this.database.update(agentsTable).set(updateData).where(eq(agentsTable.id, id))
return await this.getAgent(id)
}
async deleteAgent(id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database.delete(agentsTable).where(eq(agentsTable.id, id))
return result.rowsAffected > 0
}
async agentExists(id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
.select({ id: agentsTable.id })
.from(agentsTable)
.where(eq(agentsTable.id, id))
.limit(1)
return result.length > 0
}
}
export const agentService = AgentService.getInstance()

View File

@@ -0,0 +1,329 @@
import { loggerService } from '@logger'
import type {
AgentSessionMessageEntity,
CreateSessionMessageRequest,
GetAgentSessionResponse,
ListOptions
} from '@types'
import { ModelMessage, TextStreamPart } from 'ai'
import { desc, eq } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { sessionMessagesTable } from '../database/schema'
import { AgentStreamEvent } from '../interfaces/AgentStreamInterface'
import ClaudeCodeService from './claudecode'
const logger = loggerService.withContext('SessionMessageService')
type SessionStreamResult = {
stream: ReadableStream<TextStreamPart<Record<string, any>>>
completion: Promise<{
userMessage?: AgentSessionMessageEntity
assistantMessage?: AgentSessionMessageEntity
}>
}
// Ensure errors emitted through SSE are serializable
function serializeError(error: unknown): { message: string; name?: string; stack?: string } {
if (error instanceof Error) {
return {
message: error.message,
name: error.name,
stack: error.stack
}
}
if (typeof error === 'string') {
return { message: error }
}
return {
message: 'Unknown error'
}
}
class TextStreamAccumulator {
private textBuffer = ''
private totalText = ''
private readonly toolCalls = new Map<string, { toolName?: string; input?: unknown }>()
private readonly toolResults = new Map<string, unknown>()
add(part: TextStreamPart<Record<string, any>>): void {
switch (part.type) {
case 'text-start':
this.textBuffer = ''
break
case 'text-delta':
if (part.text) {
this.textBuffer += part.text
}
break
case 'text-end': {
const blockText = (part.providerMetadata?.text?.value as string | undefined) ?? this.textBuffer
if (blockText) {
this.totalText += blockText
}
this.textBuffer = ''
break
}
case 'tool-call':
if (part.toolCallId) {
this.toolCalls.set(part.toolCallId, {
toolName: part.toolName,
input: part.input ?? part.args ?? part.providerMetadata?.raw?.input
})
}
break
case 'tool-result':
if (part.toolCallId) {
this.toolResults.set(part.toolCallId, part.output ?? part.result ?? part.providerMetadata?.raw)
}
break
default:
break
}
}
toModelMessage(role: ModelMessage['role'] = 'assistant'): ModelMessage {
const content = this.totalText || this.textBuffer || ''
const toolInvocations = Array.from(this.toolCalls.entries()).map(([toolCallId, info]) => ({
toolCallId,
toolName: info.toolName,
args: info.input,
result: this.toolResults.get(toolCallId)
}))
const message: Record<string, unknown> = {
role,
content
}
if (toolInvocations.length > 0) {
message.toolInvocations = toolInvocations
}
return message as ModelMessage
}
}
export class SessionMessageService extends BaseService {
private static instance: SessionMessageService | null = null
private cc: ClaudeCodeService = new ClaudeCodeService()
static getInstance(): SessionMessageService {
if (!SessionMessageService.instance) {
SessionMessageService.instance = new SessionMessageService()
}
return SessionMessageService.instance
}
async initialize(): Promise<void> {
await BaseService.initialize()
}
async sessionMessageExists(id: number): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
.select({ id: sessionMessagesTable.id })
.from(sessionMessagesTable)
.where(eq(sessionMessagesTable.id, id))
.limit(1)
return result.length > 0
}
async listSessionMessages(
sessionId: string,
options: ListOptions = {}
): Promise<{ messages: AgentSessionMessageEntity[] }> {
this.ensureInitialized()
// Get messages with pagination
const baseQuery = this.database
.select()
.from(sessionMessagesTable)
.where(eq(sessionMessagesTable.session_id, sessionId))
.orderBy(sessionMessagesTable.created_at)
const result =
options.limit !== undefined
? options.offset !== undefined
? await baseQuery.limit(options.limit).offset(options.offset)
: await baseQuery.limit(options.limit)
: await baseQuery
const messages = result.map((row) => this.deserializeSessionMessage(row)) as AgentSessionMessageEntity[]
return { messages }
}
async createSessionMessage(
session: GetAgentSessionResponse,
messageData: CreateSessionMessageRequest,
abortController: AbortController
): Promise<SessionStreamResult> {
this.ensureInitialized()
return await this.startSessionMessageStream(session, messageData, abortController)
}
private async startSessionMessageStream(
session: GetAgentSessionResponse,
req: CreateSessionMessageRequest,
abortController: AbortController
): Promise<SessionStreamResult> {
const agentSessionId = await this.getLastAgentSessionId(session.id)
let newAgentSessionId = ''
logger.debug('Session Message stream message data:', { message: req, session_id: agentSessionId })
if (session.agent_type !== 'claude-code') {
// TODO: Implement support for other agent types
logger.error('Unsupported agent type for streaming:', { agent_type: session.agent_type })
throw new Error('Unsupported agent type for streaming')
}
const claudeStream = await this.cc.invoke(req.content, session, abortController, agentSessionId)
const accumulator = new TextStreamAccumulator()
let resolveCompletion!: (value: {
userMessage?: AgentSessionMessageEntity
assistantMessage?: AgentSessionMessageEntity
}) => void
let rejectCompletion!: (reason?: unknown) => void
const completion = new Promise<{
userMessage?: AgentSessionMessageEntity
assistantMessage?: AgentSessionMessageEntity
}>((resolve, reject) => {
resolveCompletion = resolve
rejectCompletion = reject
})
let finished = false
const cleanup = () => {
if (finished) return
finished = true
claudeStream.removeAllListeners()
}
const stream = new ReadableStream<TextStreamPart<Record<string, any>>>({
start: (controller) => {
claudeStream.on('data', async (event: AgentStreamEvent) => {
if (finished) return
try {
switch (event.type) {
case 'chunk': {
const chunk = event.chunk as TextStreamPart<Record<string, any>> | undefined
if (!chunk) {
logger.warn('Received agent chunk event without chunk payload')
return
}
if (chunk.type === 'start' && chunk.messageId) {
newAgentSessionId = chunk.messageId
}
accumulator.add(chunk)
controller.enqueue(chunk)
break
}
case 'error': {
const stderrMessage = (event as any)?.data?.stderr as string | undefined
const underlyingError = event.error ?? (stderrMessage ? new Error(stderrMessage) : undefined)
cleanup()
const streamError = underlyingError ?? new Error('Stream error')
controller.error(streamError)
rejectCompletion(serializeError(streamError))
break
}
case 'complete': {
cleanup()
controller.close()
resolveCompletion({})
break
}
case 'cancelled': {
cleanup()
controller.close()
resolveCompletion({})
break
}
default:
logger.warn('Unknown event type from Claude Code service:', {
type: event.type
})
break
}
} catch (error) {
cleanup()
controller.error(error)
rejectCompletion(serializeError(error))
}
})
},
cancel: (reason) => {
cleanup()
abortController.abort(typeof reason === 'string' ? reason : 'stream cancelled')
resolveCompletion({})
}
})
return { stream, completion }
}
private async getLastAgentSessionId(sessionId: string): Promise<string> {
this.ensureInitialized()
try {
const result = await this.database
.select({ agent_session_id: sessionMessagesTable.agent_session_id })
.from(sessionMessagesTable)
.where(eq(sessionMessagesTable.session_id, sessionId))
.orderBy(desc(sessionMessagesTable.created_at))
.limit(1)
return result[0]?.agent_session_id || ''
} catch (error) {
logger.error('Failed to get last agent session ID', {
sessionId,
error
})
return ''
}
}
private deserializeSessionMessage(data: any): AgentSessionMessageEntity {
if (!data) return data
const deserialized = { ...data }
// Parse content JSON
if (deserialized.content && typeof deserialized.content === 'string') {
try {
deserialized.content = JSON.parse(deserialized.content)
} catch (error) {
logger.warn(`Failed to parse content JSON:`, error as Error)
}
}
// Parse metadata JSON
if (deserialized.metadata && typeof deserialized.metadata === 'string') {
try {
deserialized.metadata = JSON.parse(deserialized.metadata)
} catch (error) {
logger.warn(`Failed to parse metadata JSON:`, error as Error)
}
}
return deserialized
}
}
export const sessionMessageService = SessionMessageService.getInstance()

View File

@@ -0,0 +1,241 @@
import {
AgentBaseSchema,
type AgentEntity,
type AgentSessionEntity,
type CreateSessionRequest,
type CreateSessionResponse,
type GetAgentSessionResponse,
type ListOptions,
type UpdateSessionRequest,
UpdateSessionResponse
} from '@types'
import { and, count, eq, type SQL } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
import { AgentModelField } from '../errors'
export class SessionService extends BaseService {
private static instance: SessionService | null = null
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
static getInstance(): SessionService {
if (!SessionService.instance) {
SessionService.instance = new SessionService()
}
return SessionService.instance
}
async initialize(): Promise<void> {
await BaseService.initialize()
}
async createSession(agentId: string, req: CreateSessionRequest): Promise<CreateSessionResponse> {
this.ensureInitialized()
// Validate agent exists - we'll need to import AgentService for this check
// For now, we'll skip this validation to avoid circular dependencies
// The database foreign key constraint will handle this
const agents = await this.database.select().from(agentsTable).where(eq(agentsTable.id, agentId)).limit(1)
if (!agents[0]) {
throw new Error('Agent not found')
}
const agent = this.deserializeJsonFields(agents[0]) as AgentEntity
const id = `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
const now = new Date().toISOString()
// inherit configuration from agent by default, can be overridden by sessionData
const sessionData: Partial<CreateSessionRequest> = {
...agent,
...req
}
await this.validateAgentModels(agent.type, {
model: sessionData.model,
plan_model: sessionData.plan_model,
small_model: sessionData.small_model
})
if (sessionData.accessible_paths !== undefined) {
sessionData.accessible_paths = this.ensurePathsExist(sessionData.accessible_paths)
}
const serializedData = this.serializeJsonFields(sessionData)
const insertData: InsertSessionRow = {
id,
agent_id: agentId,
agent_type: agent.type,
name: serializedData.name || null,
description: serializedData.description || null,
accessible_paths: serializedData.accessible_paths || null,
instructions: serializedData.instructions || null,
model: serializedData.model || null,
plan_model: serializedData.plan_model || null,
small_model: serializedData.small_model || null,
mcps: serializedData.mcps || null,
configuration: serializedData.configuration || null,
created_at: now,
updated_at: now
}
await this.database.insert(sessionsTable).values(insertData)
const result = await this.database.select().from(sessionsTable).where(eq(sessionsTable.id, id)).limit(1)
if (!result[0]) {
throw new Error('Failed to create session')
}
return this.deserializeJsonFields(result[0]) as AgentSessionEntity
}
async getSession(agentId: string, id: string): Promise<GetAgentSessionResponse | null> {
this.ensureInitialized()
const result = await this.database
.select()
.from(sessionsTable)
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
.limit(1)
if (!result[0]) {
return null
}
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
return session
}
async getSessionById(id: string): Promise<GetAgentSessionResponse | null> {
this.ensureInitialized()
const result = await this.database.select().from(sessionsTable).where(eq(sessionsTable.id, id)).limit(1)
if (!result[0]) {
return null
}
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
return session
}
async listSessions(
agentId?: string,
options: ListOptions = {}
): Promise<{ sessions: AgentSessionEntity[]; total: number }> {
this.ensureInitialized()
// Build where conditions
const whereConditions: SQL[] = []
if (agentId) {
whereConditions.push(eq(sessionsTable.agent_id, agentId))
}
const whereClause =
whereConditions.length > 1
? and(...whereConditions)
: whereConditions.length === 1
? whereConditions[0]
: undefined
// Get total count
const totalResult = await this.database.select({ count: count() }).from(sessionsTable).where(whereClause)
const total = totalResult[0].count
// Build list query with pagination
const baseQuery = this.database.select().from(sessionsTable).where(whereClause).orderBy(sessionsTable.created_at)
const result =
options.limit !== undefined
? options.offset !== undefined
? await baseQuery.limit(options.limit).offset(options.offset)
: await baseQuery.limit(options.limit)
: await baseQuery
const sessions = result.map((row) => this.deserializeJsonFields(row)) as GetAgentSessionResponse[]
return { sessions, total }
}
async updateSession(
agentId: string,
id: string,
updates: UpdateSessionRequest
): Promise<UpdateSessionResponse | null> {
this.ensureInitialized()
// Check if session exists
const existing = await this.getSession(agentId, id)
if (!existing) {
return null
}
// Validate agent exists if changing main_agent_id
// We'll skip this validation for now to avoid circular dependencies
const now = new Date().toISOString()
if (updates.accessible_paths !== undefined) {
updates.accessible_paths = this.ensurePathsExist(updates.accessible_paths)
}
const modelUpdates: Partial<Record<AgentModelField, string | undefined>> = {}
for (const field of this.modelFields) {
if (Object.prototype.hasOwnProperty.call(updates, field)) {
modelUpdates[field] = updates[field as keyof UpdateSessionRequest] as string | undefined
}
}
if (Object.keys(modelUpdates).length > 0) {
await this.validateAgentModels(existing.agent_type, modelUpdates)
}
const serializedUpdates = this.serializeJsonFields(updates)
const updateData: Partial<SessionRow> = {
updated_at: now
}
const replaceableFields = Object.keys(AgentBaseSchema.shape) as (keyof SessionRow)[]
for (const field of replaceableFields) {
if (Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
const value = serializedUpdates[field as keyof typeof serializedUpdates]
;(updateData as Record<string, unknown>)[field] = value ?? null
}
}
await this.database.update(sessionsTable).set(updateData).where(eq(sessionsTable.id, id))
return await this.getSession(agentId, id)
}
async deleteSession(agentId: string, id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
.delete(sessionsTable)
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
return result.rowsAffected > 0
}
async sessionExists(agentId: string, id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
.select({ id: sessionsTable.id })
.from(sessionsTable)
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
.limit(1)
return result.length > 0
}
}
export const sessionService = SessionService.getInstance()

View File

@@ -0,0 +1,221 @@
// src/main/services/agents/services/claudecode/index.ts
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-code'
import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
import { validateModelId } from '@main/apiServer/utils'
import { GetAgentSessionResponse } from '../..'
import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { transformSDKMessageToStreamParts } from './transform'
const require_ = createRequire(import.meta.url)
const logger = loggerService.withContext('ClaudeCodeService')
class ClaudeCodeStream extends EventEmitter implements AgentStream {
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
declare on: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
declare once: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
}
class ClaudeCodeService implements AgentServiceInterface {
private claudeExecutablePath: string
constructor() {
// Resolve Claude Code CLI robustly (works in dev and in asar)
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-code/cli.js')
}
async invoke(
prompt: string,
session: GetAgentSessionResponse,
abortController: AbortController,
lastAgentSessionId?: string
): Promise<AgentStream> {
const aiStream = new ClaudeCodeStream()
// Validate session accessible paths and make sure it exists as a directory
const cwd = session.accessible_paths[0]
if (!cwd) {
aiStream.emit('data', {
type: 'error',
error: new Error('No accessible paths defined for the agent session')
})
return aiStream
}
// Validate model info
const modelInfo = await validateModelId(session.model)
if (!modelInfo.valid) {
aiStream.emit('data', {
type: 'error',
error: new Error(`Invalid model ID '${session.model}': ${JSON.stringify(modelInfo.error)}`)
})
return aiStream
}
if (modelInfo.provider?.type !== 'anthropic' || modelInfo.provider.apiKey === '') {
aiStream.emit('data', {
type: 'error',
error: new Error(`Invalid provider type '${modelInfo.provider?.type}'. Expected 'anthropic' provider type.`)
})
return aiStream
}
// TODO: use cherry studio api server config instead of direct provider config to provide more flexibility (e.g. custom headers, proxy, statistics, etc).
const apiConfig = await apiConfigService.get()
// process.env.ANTHROPIC_AUTH_TOKEN = apiConfig.apiKey
// process.env.ANTHROPIC_BASE_URL = `http://${apiConfig.host}:${apiConfig.port}`
process.env.ANTHROPIC_AUTH_TOKEN = modelInfo.provider.apiKey
process.env.ANTHROPIC_BASE_URL = modelInfo.provider.apiHost
// Build SDK options from parameters
const options: Options = {
abortController,
cwd,
pathToClaudeCodeExecutable: this.claudeExecutablePath,
stderr: (chunk: string) => {
logger.info('claude stderr', { chunk })
},
appendSystemPrompt: session.instructions,
permissionMode: session.configuration?.permission_mode,
maxTurns: session.configuration?.max_turns
}
if (session.accessible_paths.length > 1) {
options.additionalDirectories = session.accessible_paths.slice(1)
}
if (session.mcps && session.mcps.length > 0) {
// mcp configs
const mcpList: Record<string, McpHttpServerConfig> = {}
for (const mcpId of session.mcps) {
mcpList[mcpId] = {
type: 'http',
url: `http://${apiConfig.host}:${apiConfig.port}/v1/mcps/${mcpId}/mcp`,
headers: {
Authorization: `Bearer ${apiConfig.apiKey}`
}
}
}
options.mcpServers = mcpList
options.strictMcpConfig = true
}
if (lastAgentSessionId) {
options.resume = lastAgentSessionId
}
logger.info('Starting Claude Code SDK query', {
prompt,
options
})
// Start async processing
this.processSDKQuery(prompt, options, aiStream)
return aiStream
}
private async *userMessages(prompt: string) {
{
yield {
type: 'user' as const,
parent_tool_use_id: null,
session_id: '',
message: {
role: 'user' as const,
content: prompt
}
}
}
}
/**
* Process SDK query and emit stream events
*/
private async processSDKQuery(prompt: string, options: Options, stream: ClaudeCodeStream): Promise<void> {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
try {
// Process streaming responses using SDK query
for await (const message of query({
prompt: this.userMessages(prompt),
options
})) {
if (hasCompleted) break
jsonOutput.push(message)
logger.silly('claude response', { message })
if (message.type === 'assistant' || message.type === 'user') {
logger.silly('message content', {
message: JSON.stringify({ role: message.message.role, content: message.message.content })
})
}
// Transform SDKMessage to UIMessageChunks
const chunks = transformSDKMessageToStreamParts(message)
for (const chunk of chunks) {
stream.emit('data', {
type: 'chunk',
chunk
})
}
}
// Successfully completed
hasCompleted = true
const duration = Date.now() - startTime
logger.debug('SDK query completed successfully', {
duration,
messageCount: jsonOutput.length
})
// Emit completion event
stream.emit('data', {
type: 'complete'
})
} catch (error) {
if (hasCompleted) return
hasCompleted = true
const duration = Date.now() - startTime
// Check if this is an abort error
const errorObj = error as any
const isAborted =
errorObj?.name === 'AbortError' ||
errorObj?.message?.includes('aborted') ||
options.abortController?.signal.aborted
if (isAborted) {
logger.info('SDK query aborted by client disconnect', { duration })
// Simply cleanup and return - don't emit error events
stream.emit('data', {
type: 'cancelled',
error: new Error('Request aborted by client')
})
return
}
// Original error handling for non-abort errors
logger.error('SDK query error:', {
error: errorObj instanceof Error ? errorObj.message : String(errorObj),
duration,
messageCount: jsonOutput.length
})
// Emit error event
stream.emit('data', {
type: 'error',
error: errorObj instanceof Error ? errorObj : new Error(String(errorObj))
})
}
}
}
export default ClaudeCodeService

View File

@@ -0,0 +1,34 @@
// ported from https://github.com/ben-vargas/ai-sdk-provider-claude-code/blob/main/src/map-claude-code-finish-reason.ts#L22
import type { LanguageModelV2FinishReason } from '@ai-sdk/provider'
/**
* Maps Claude Code SDK result subtypes to AI SDK finish reasons.
*
* @param subtype - The result subtype from Claude Code SDK
* @returns The corresponding AI SDK finish reason
*
* @example
* ```typescript
* const finishReason = mapClaudeCodeFinishReason('error_max_turns');
* // Returns: 'length'
* ```
*
* @remarks
* Mappings:
* - 'success' -> 'stop' (normal completion)
* - 'error_max_turns' -> 'length' (hit turn limit)
* - 'error_during_execution' -> 'error' (execution error)
* - default -> 'stop' (unknown subtypes treated as normal completion)
*/
export function mapClaudeCodeFinishReason(subtype?: string): LanguageModelV2FinishReason {
switch (subtype) {
case 'success':
return 'stop'
case 'error_max_turns':
return 'length'
case 'error_during_execution':
return 'error'
default:
return 'stop'
}
}

View File

@@ -0,0 +1,48 @@
import { Tool } from '@types'
// https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude
export const builtinTools: Tool[] = [
{ id: 'Bash', name: 'Bash', description: 'Executes shell commands in your environment', requirePermissions: true },
{ id: 'Edit', name: 'Edit', description: 'Makes targeted edits to specific files', requirePermissions: true },
{ id: 'Glob', name: 'Glob', description: 'Finds files based on pattern matching', requirePermissions: false },
{ id: 'Grep', name: 'Grep', description: 'Searches for patterns in file contents', requirePermissions: false },
{
id: 'MultiEdit',
name: 'MultiEdit',
description: 'Performs multiple edits on a single file atomically',
requirePermissions: true
},
{
id: 'NotebookEdit',
name: 'NotebookEdit',
description: 'Modifies Jupyter notebook cells',
requirePermissions: true
},
{
id: 'NotebookRead',
name: 'NotebookRead',
description: 'Reads and displays Jupyter notebook contents',
requirePermissions: false
},
{ id: 'Read', name: 'Read', description: 'Reads the contents of files', requirePermissions: false },
{
id: 'Task',
name: 'Task',
description: 'Runs a sub-agent to handle complex, multi-step tasks',
requirePermissions: false
},
{
id: 'TodoWrite',
name: 'TodoWrite',
description: 'Creates and manages structured task lists',
requirePermissions: false
},
{ id: 'WebFetch', name: 'WebFetch', description: 'Fetches content from a specified URL', requirePermissions: true },
{
id: 'WebSearch',
name: 'WebSearch',
description: 'Performs web searches with domain filtering',
requirePermissions: true
},
{ id: 'Write', name: 'Write', description: 'Creates or overwrites files', requirePermissions: true }
]

View File

@@ -0,0 +1,354 @@
// This file is used to transform claude code json response to aisdk streaming format
import type { LanguageModelV2Usage } from '@ai-sdk/provider'
import { SDKMessage } from '@anthropic-ai/claude-code'
import { loggerService } from '@logger'
import type { ProviderMetadata, TextStreamPart } from 'ai'
import { v4 as uuidv4 } from 'uuid'
import { mapClaudeCodeFinishReason } from './map-claude-code-finish-reason'
const logger = loggerService.withContext('ClaudeCodeTransform')
type AgentStreamPart = TextStreamPart<Record<string, any>>
const contentBlockState = new Map<
string,
{
type: 'text' | 'tool-call'
toolCallId?: string
toolName?: string
input?: string
}
>()
// Helper function to generate unique IDs for text blocks
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
// Main transform function
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage): AgentStreamPart[] {
const chunks: AgentStreamPart[] = []
logger.debug('Transforming SDKMessage to stream parts', sdkMessage)
switch (sdkMessage.type) {
case 'assistant':
case 'user':
chunks.push(...handleUserOrAssistantMessage(sdkMessage))
break
case 'stream_event':
chunks.push(...handleStreamEvent(sdkMessage))
break
case 'system':
chunks.push(...handleSystemMessage(sdkMessage))
break
case 'result':
chunks.push(...handleResultMessage(sdkMessage))
break
default:
logger.warn('Unknown SDKMessage type:', { type: (sdkMessage as any).type })
break
}
return chunks
}
const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => {
return {
anthropic: {
uuid: message.uuid || generateMessageId(),
session_id: message.session_id
},
raw: message as Record<string, any>
}
}
function generateTextChunks(id: string, text: string, message: SDKMessage): AgentStreamPart[] {
const providerMetadata = sdkMessageToProviderMetadata(message)
return [
{
type: 'text-start',
id,
providerMetadata
},
{
type: 'text-delta',
id,
text,
providerMetadata
},
{
type: 'text-end',
id,
providerMetadata: {
...providerMetadata,
text: {
value: text
}
}
}
]
}
function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assistant' | 'user' }>): AgentStreamPart[] {
const chunks: AgentStreamPart[] = []
const messageId = message.uuid?.toString() || generateMessageId()
// handle normal text content
if (typeof message.message.content === 'string') {
const textContent = message.message.content
if (textContent) {
chunks.push(...generateTextChunks(messageId, textContent, message))
}
} else if (Array.isArray(message.message.content)) {
for (const block of message.message.content) {
switch (block.type) {
case 'text':
chunks.push(...generateTextChunks(messageId, block.text, message))
break
case 'tool_use':
chunks.push({
type: 'tool-call',
toolCallId: block.id,
toolName: block.name,
input: block.input,
providerExecuted: true,
providerMetadata: sdkMessageToProviderMetadata(message)
})
break
case 'tool_result':
// chunks.push({
// type: 'tool-result',
// toolCallId: block.tool_use_id,
// output: block.content,
// providerMetadata: sdkMessageToProviderMetadata(message)
// })
break
default:
logger.warn('Unknown content block type in user/assistant message:', {
type: block.type
})
break
}
}
}
return chunks
}
// Handle stream events (real-time streaming)
function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }>): AgentStreamPart[] {
const chunks: AgentStreamPart[] = []
const event = message.event
const blockKey = `${message.uuid ?? message.session_id ?? 'session'}:${event.index}`
switch (event.type) {
case 'message_start':
// No specific UI chunk needed for message start in this protocol
break
case 'content_block_start':
const contentBlockType = event.content_block.type
switch (contentBlockType) {
case 'text': {
contentBlockState.set(blockKey, { type: 'text' })
chunks.push({
type: 'text-start',
id: String(event.index),
providerMetadata: {
...sdkMessageToProviderMetadata(message),
anthropic: {
uuid: message.uuid,
session_id: message.session_id,
content_block_index: event.index
}
}
})
break
}
case 'tool_use': {
contentBlockState.set(blockKey, {
type: 'tool-call',
toolCallId: event.content_block.id,
toolName: event.content_block.name,
input: ''
})
chunks.push({
type: 'tool-call',
toolCallId: event.content_block.id,
toolName: event.content_block.name,
input: event.content_block.input,
providerExecuted: true,
providerMetadata: sdkMessageToProviderMetadata(message)
})
break
}
}
break
case 'content_block_delta':
switch (event.delta.type) {
case 'text_delta': {
chunks.push({
type: 'text-delta',
id: String(event.index),
text: event.delta.text,
providerMetadata: {
...sdkMessageToProviderMetadata(message),
anthropic: {
uuid: message.uuid,
session_id: message.session_id,
content_block_index: event.index
}
}
})
break
}
// case 'thinking_delta': {
// chunks.push({
// type: 'reasoning-delta',
// id: String(event.index),
// text: event.delta.thinking,
// });
// break
// }
// case 'signature_delta': {
// if (blockType === 'thinking') {
// chunks.push({
// type: 'reasoning-delta',
// id: String(event.index),
// text: '',
// providerMetadata: {
// ...sdkMessageToProviderMetadata(message),
// anthropic: {
// uuid: message.uuid,
// session_id: message.session_id,
// content_block_index: event.index,
// signature: event.delta.signature
// }
// }
// })
// }
// break
// }
case 'input_json_delta': {
const contentBlock = contentBlockState.get(blockKey)
if (contentBlock && contentBlock.type === 'tool-call') {
contentBlockState.set(blockKey, {
...contentBlock,
input: `${contentBlock.input ?? ''}${event.delta.partial_json ?? ''}`
})
}
break
}
}
break
case 'content_block_stop': {
const contentBlock = contentBlockState.get(blockKey)
if (contentBlock?.type === 'text') {
chunks.push({
type: 'text-end',
id: String(event.index)
})
}
contentBlockState.delete(blockKey)
}
case 'message_delta':
// Handle usage updates or other message-level deltas
break
case 'message_stop':
// This could signal the end of the message
break
default:
logger.warn('Unknown stream event type:', { type: (event as any).type })
break
}
return chunks
}
// Handle system messages
function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>): AgentStreamPart[] {
const chunks: AgentStreamPart[] = []
logger.debug('Received system message', {
subtype: message.subtype
})
switch (message.subtype) {
case 'init': {
chunks.push({
type: 'start'
})
}
}
return []
}
// Handle result messages (completion with usage stats)
function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>): AgentStreamPart[] {
const chunks: AgentStreamPart[] = []
let usage: LanguageModelV2Usage | undefined
if ('usage' in message) {
usage = {
inputTokens:
(message.usage.cache_creation_input_tokens ?? 0) +
(message.usage.cache_read_input_tokens ?? 0) +
(message.usage.input_tokens ?? 0),
outputTokens: message.usage.output_tokens ?? 0,
totalTokens:
(message.usage.cache_creation_input_tokens ?? 0) +
(message.usage.cache_read_input_tokens ?? 0) +
(message.usage.input_tokens ?? 0) +
(message.usage.output_tokens ?? 0)
}
}
if (message.subtype === 'success') {
chunks.push({
type: 'finish',
totalUsage: usage,
finishReason: mapClaudeCodeFinishReason(message.subtype),
providerMetadata: {
...sdkMessageToProviderMetadata(message),
usage: message.usage,
durationMs: message.duration_ms,
costUsd: message.total_cost_usd,
raw: message
}
} as AgentStreamPart)
} else {
chunks.push({
type: 'error',
error: {
message: `${message.subtype}: Process failed after ${message.num_turns} turns`
}
} as AgentStreamPart)
}
return chunks
}
// Convenience function to transform a stream of SDKMessages
export function* transformSDKMessageStream(sdkMessages: SDKMessage[]): Generator<AgentStreamPart> {
for (const sdkMessage of sdkMessages) {
const chunks = transformSDKMessageToStreamParts(sdkMessage)
for (const chunk of chunks) {
yield chunk
}
}
}
// Async version for async iterables
export async function* transformSDKMessageStreamAsync(
sdkMessages: AsyncIterable<SDKMessage>
): AsyncGenerator<AgentStreamPart> {
for await (const sdkMessage of sdkMessages) {
const chunks = transformSDKMessageToStreamParts(sdkMessage)
for (const chunk of chunks) {
yield chunk
}
}
}

View File

@@ -0,0 +1,26 @@
/**
* Agent Services Module
*
* This module provides service classes for managing agents, sessions, and session messages.
* All services extend BaseService and provide database operations with proper error handling.
*/
// Service classes
export { AgentService } from './AgentService'
export { SessionMessageService } from './SessionMessageService'
export { SessionService } from './SessionService'
// Service instances (singletons)
export { agentService } from './AgentService'
export { sessionMessageService } from './SessionMessageService'
export { sessionService } from './SessionService'
// Type definitions for service requests and responses
export type { AgentEntity, AgentSessionEntity, CreateAgentRequest, UpdateAgentRequest } from '@types'
export type {
AgentSessionMessageEntity,
CreateSessionRequest,
GetAgentSessionResponse,
ListOptions as SessionListOptions,
UpdateSessionRequest
} from '@types'

View File

@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto'
import * as fs from 'node:fs'
import { readFile } from 'node:fs/promises'
import os from 'node:os'
@@ -265,12 +264,11 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
if (entry.isDirectory() && options.includeDirectories) {
const stats = await fs.promises.stat(entryPath)
const externalDirPath = entryPath.replace(/\\/g, '/')
const dirTreeNode: NotesTreeNode = {
id: createHash('sha1').update(externalDirPath).digest('hex'),
id: uuidv4(),
name: entry.name,
treePath: treePath,
externalPath: externalDirPath,
externalPath: entryPath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'folder',
@@ -301,12 +299,11 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
: `/${nameWithoutExt}`
const externalFilePath = entryPath.replace(/\\/g, '/')
const fileTreeNode: NotesTreeNode = {
id: createHash('sha1').update(externalFilePath).digest('hex'),
id: uuidv4(),
name: name,
treePath: fileTreePath,
externalPath: externalFilePath,
externalPath: entryPath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'file'

View File

@@ -1,8 +1,9 @@
import { ImageFileMetadata } from '@types'
import { readFile } from 'fs/promises'
import sharp from 'sharp'
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => {
// Delayed loading: The Sharp module is only loaded when the OCR functionality is actually needed, not at app startup
const sharp = (await import('sharp')).default
return sharp(buffer)
.grayscale() // 转为灰度
.normalize()

View File

@@ -1,7 +1,7 @@
import { electronAPI } from '@electron-toolkit/preload'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import { UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
@@ -47,11 +47,10 @@ const api = {
getDiskInfo: (directoryPath: string): Promise<{ free: number; size: number } | null> =>
ipcRenderer.invoke(IpcChannel.App_GetDiskInfo, directoryPath),
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
quit: () => ipcRenderer.invoke(IpcChannel.App_Quit),
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -95,8 +94,7 @@ const api = {
},
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
},
devTools: {
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
@@ -221,7 +219,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
add: ({
base,
item,
@@ -286,16 +284,6 @@ const api = {
clearAuthCache: (projectId: string, clientEmail?: string) =>
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
},
ovms: {
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),
getModels: () => ipcRenderer.invoke(IpcChannel.Ovms_GetModels),
isRunning: () => ipcRenderer.invoke(IpcChannel.Ovms_IsRunning),
getStatus: () => ipcRenderer.invoke(IpcChannel.Ovms_GetStatus),
runOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_RunOVMS),
stopOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_StopOVMS)
},
config: {
set: (key: string, value: any, isNotify: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
@@ -361,7 +349,6 @@ const api = {
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
installOvmsBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallOvmsBinary),
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
@@ -452,24 +439,16 @@ const api = {
model: string,
directory: string,
env: Record<string, string>,
options?: { autoUpdateToLatest?: boolean; terminal?: string }
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options),
getAvailableTerminals: (): Promise<TerminalConfig[]> =>
ipcRenderer.invoke(IpcChannel.CodeTools_GetAvailableTerminals),
setCustomTerminalPath: (terminalId: string, path: string): Promise<void> =>
ipcRenderer.invoke(IpcChannel.CodeTools_SetCustomTerminalPath, terminalId, path),
getCustomTerminalPath: (terminalId: string): Promise<string | undefined> =>
ipcRenderer.invoke(IpcChannel.CodeTools_GetCustomTerminalPath, terminalId),
removeCustomTerminalPath: (terminalId: string): Promise<void> =>
ipcRenderer.invoke(IpcChannel.CodeTools_RemoveCustomTerminalPath, terminalId)
options?: { autoUpdateToLatest?: boolean }
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
},
cherryai: {
cherryin: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Cherryai_GetSignature, params)
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
},
windowControls: {
minimize: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Windows_Minimize),

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