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
144 changed files with 13063 additions and 1012 deletions

2
.gitignore vendored
View File

@@ -69,3 +69,5 @@ playwright-report
test-results
YOUR_MEMORY_FILE_PATH
.sessions/

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

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

@@ -84,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'),

View File

@@ -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",
@@ -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",
@@ -238,9 +247,11 @@
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"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",

View File

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

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

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

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

@@ -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'
@@ -199,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 => {

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

@@ -8,7 +8,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useSettings'
import AgentsPage from './pages/agents/AgentsPage'
import AssistantPresetsPage from './pages/assistantPresets/AssistantPresetsPage'
import CodeToolsPage from './pages/code/CodeToolsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
@@ -29,7 +29,7 @@ const Router: FC = () => {
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/assistantPresets" element={<AssistantPresetsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />

View File

@@ -32,16 +32,19 @@ export class AiSdkToChunkAdapter {
private accumulate: boolean | undefined
private isFirstChunk = true
private enableWebSearch: boolean = false
private onSessionUpdate?: (sessionId: string) => void
constructor(
private onChunk: (chunk: Chunk) => void,
mcpTools: MCPTool[] = [],
accumulate?: boolean,
enableWebSearch?: boolean
enableWebSearch?: boolean,
onSessionUpdate?: (sessionId: string) => void
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
this.enableWebSearch = enableWebSearch || false
this.onSessionUpdate = onSessionUpdate
}
/**
@@ -108,6 +111,15 @@ export class AiSdkToChunkAdapter {
chunk: TextStreamPart<any>,
final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string }
) {
const sessionId =
(chunk.providerMetadata as any)?.anthropic?.session_id ??
(chunk.providerMetadata as any)?.anthropic?.sessionId ??
(chunk.providerMetadata as any)?.raw?.session_id
if (typeof sessionId === 'string' && sessionId) {
this.onSessionUpdate?.(sessionId)
}
logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk)
switch (chunk.type) {
// === 文本相关事件 ===

View File

@@ -0,0 +1,237 @@
import { loggerService } from '@logger'
import { formatAgentServerError } from '@renderer/utils/error'
import {
AddAgentForm,
AgentServerErrorSchema,
ApiModelsFilter,
ApiModelsResponse,
ApiModelsResponseSchema,
CreateAgentRequest,
CreateAgentResponse,
CreateAgentResponseSchema,
CreateSessionForm,
CreateSessionRequest,
CreateSessionResponse,
CreateSessionResponseSchema,
GetAgentResponse,
GetAgentResponseSchema,
GetAgentSessionResponse,
ListAgentSessionsResponse,
ListAgentSessionsResponseSchema,
type ListAgentsResponse,
ListAgentsResponseSchema,
objectEntries,
objectKeys,
UpdateAgentForm,
UpdateAgentRequest,
UpdateAgentResponse,
UpdateAgentResponseSchema,
UpdateSessionForm,
UpdateSessionRequest,
UpdateSessionResponse,
UpdateSessionResponseSchema
} from '@types'
import axios, { Axios, AxiosRequestConfig, isAxiosError } from 'axios'
import { ZodError } from 'zod'
type ApiVersion = 'v1'
const logger = loggerService.withContext('AgentApiClient')
// const logger = loggerService.withContext('AgentClient')
const processError = (error: unknown, fallbackMessage: string) => {
logger.error(fallbackMessage, error as Error)
if (isAxiosError(error)) {
const result = AgentServerErrorSchema.safeParse(error.response?.data)
if (result.success) {
return new Error(formatAgentServerError(result.data))
}
} else if (error instanceof ZodError) {
return error
}
return new Error(fallbackMessage, { cause: error })
}
export class AgentApiClient {
private axios: Axios
private apiVersion: ApiVersion = 'v1'
constructor(config: AxiosRequestConfig, apiVersion?: ApiVersion) {
if (!config.baseURL || !config.headers?.Authorization) {
throw new Error('Please pass in baseUrl and Authroization header.')
}
if (config.baseURL.endsWith('/')) {
throw new Error('baseURL should not end with /')
}
this.axios = axios.create(config)
if (apiVersion) {
this.apiVersion = apiVersion
}
}
public agentPaths = {
base: `/${this.apiVersion}/agents`,
withId: (id: string) => `/${this.apiVersion}/agents/${id}`
}
public getSessionPaths = (agentId: string) => ({
base: `/${this.apiVersion}/agents/${agentId}/sessions`,
withId: (id: string) => `/${this.apiVersion}/agents/${agentId}/sessions/${id}`
})
public getSessionMessagesPath = (agentId: string, sessionId: string) =>
`/${this.apiVersion}/agents/${agentId}/sessions/${sessionId}/messages`
public getModelsPath = (props?: ApiModelsFilter) => {
const base = `/${this.apiVersion}/models`
if (!props) return base
if (objectKeys(props).length > 0) {
const params = objectEntries(props)
.map(([key, value]) => `${key}=${value}`)
.join('&')
return `${base}?${params}`
} else {
return base
}
}
public async listAgents(): Promise<ListAgentsResponse> {
const url = this.agentPaths.base
try {
const response = await this.axios.get(url)
const result = ListAgentsResponseSchema.safeParse(response.data)
if (!result.success) {
throw new Error('Not a valid Agents array.')
}
return result.data
} catch (error) {
throw processError(error, 'Failed to list agents.')
}
}
public async createAgent(form: AddAgentForm): Promise<CreateAgentResponse> {
const url = this.agentPaths.base
try {
const payload = form satisfies CreateAgentRequest
const response = await this.axios.post(url, payload)
const data = CreateAgentResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to create agent.')
}
}
public async getAgent(id: string): Promise<GetAgentResponse> {
const url = this.agentPaths.withId(id)
try {
const response = await this.axios.get(url)
const data = GetAgentResponseSchema.parse(response.data)
if (data.id !== id) {
throw new Error('Agent ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to get agent.')
}
}
public async deleteAgent(id: string): Promise<void> {
const url = this.agentPaths.withId(id)
try {
await this.axios.delete(url)
} catch (error) {
throw processError(error, 'Failed to delete agent.')
}
}
public async updateAgent(form: UpdateAgentForm): Promise<UpdateAgentResponse> {
const url = this.agentPaths.withId(form.id)
try {
const payload = form satisfies UpdateAgentRequest
const response = await this.axios.patch(url, payload)
const data = UpdateAgentResponseSchema.parse(response.data)
if (data.id !== form.id) {
throw new Error('Agent ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to updateAgent.')
}
}
public async listSessions(agentId: string): Promise<ListAgentSessionsResponse> {
const url = this.getSessionPaths(agentId).base
try {
const response = await this.axios.get(url)
const result = ListAgentSessionsResponseSchema.safeParse(response.data)
if (!result.success) {
throw new Error('Not a valid Sessions array.')
}
return result.data
} catch (error) {
throw processError(error, 'Failed to list sessions.')
}
}
public async createSession(agentId: string, session: CreateSessionForm): Promise<CreateSessionResponse> {
const url = this.getSessionPaths(agentId).base
try {
const payload = session satisfies CreateSessionRequest
const response = await this.axios.post(url, payload)
const data = CreateSessionResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to add session.')
}
}
public async getSession(agentId: string, sessionId: string): Promise<GetAgentSessionResponse> {
const url = this.getSessionPaths(agentId).withId(sessionId)
try {
const response = await this.axios.get(url)
// const data = GetAgentSessionResponseSchema.parse(response.data)
// TODO: enable validation
const data = response.data
if (sessionId !== data.id) {
throw new Error('Session ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to get session.')
}
}
public async deleteSession(agentId: string, sessionId: string): Promise<void> {
const url = this.getSessionPaths(agentId).withId(sessionId)
try {
await this.axios.delete(url)
} catch (error) {
throw processError(error, 'Failed to delete session.')
}
}
public async updateSession(agentId: string, session: UpdateSessionForm): Promise<UpdateSessionResponse> {
const url = this.getSessionPaths(agentId).withId(session.id)
try {
const payload = session satisfies UpdateSessionRequest
const response = await this.axios.patch(url, payload)
const data = UpdateSessionResponseSchema.parse(response.data)
if (session.id !== data.id) {
throw new Error('Session ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to update session.')
}
}
public async getModels(props?: ApiModelsFilter): Promise<ApiModelsResponse> {
const url = this.getModelsPath(props)
try {
const response = await this.axios.get(url)
const data = ApiModelsResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to get models.')
}
}
}

View File

@@ -20,7 +20,7 @@
}
*:focus {
outline: none;
outline-style: none;
}
* {

View File

@@ -6,6 +6,7 @@ interface ContextMenuProps {
children: React.ReactNode
}
// FIXME: Why does this component name look like a generic component but is not customizable at all?
const ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {
const { t } = useTranslation()
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)

View File

@@ -1,11 +1,11 @@
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useTimer } from '@renderer/hooks/useTimer'
import { useSystemAgents } from '@renderer/pages/agents'
import { useSystemAssistantPresets } from '@renderer/pages/assistantPresets'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Agent, Assistant } from '@renderer/types'
import { Assistant, AssistantPreset } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
import { take } from 'lodash'
@@ -25,30 +25,30 @@ interface Props {
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { agents: userAgents } = useAgents()
const { presets: userPresets } = useAssistantPresets()
const [searchText, setSearchText] = useState('')
const { defaultAssistant } = useDefaultAssistant()
const { assistants, addAssistant } = useAssistants()
const inputRef = useRef<InputRef>(null)
const systemAgents = useSystemAgents()
const systemPresets = useSystemAssistantPresets()
const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const { setTimeoutTimer } = useTimer()
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
const list = [defaultAssistant, ...allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))]
const presets = useMemo(() => {
const allPresets = [...userPresets, ...systemPresets] as AssistantPreset[]
const list = [defaultAssistant, ...allPresets.filter((preset) => !assistants.map((a) => a.id).includes(preset.id))]
const filtered = searchText
? list.filter(
(agent) =>
agent.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
agent.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
(preset) =>
preset.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
preset.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
)
: list
if (searchText.trim()) {
const newAgent: Agent = {
const newAgent: AssistantPreset = {
id: 'new',
name: searchText.trim(),
prompt: '',
@@ -59,15 +59,15 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return [newAgent, ...filtered]
}
return filtered
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
}, [assistants, defaultAssistant, searchText, systemPresets, userPresets])
// 重置选中索引当搜索或列表内容变更时
useEffect(() => {
setSelectedIndex(0)
}, [agents.length, searchText])
}, [presets.length, searchText])
const onCreateAssistant = useCallback(
async (agent: Agent) => {
async (preset: AssistantPreset) => {
if (loadingRef.current) {
return
}
@@ -75,11 +75,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
loadingRef.current = true
let assistant: Assistant
if (agent.id === 'default') {
assistant = { ...agent, id: uuid() }
if (preset.id === 'default') {
assistant = { ...preset, id: uuid() }
addAssistant(assistant)
} else {
assistant = await createAssistantFromAgent(agent)
assistant = await createAssistantFromAgent(preset)
}
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
@@ -93,28 +93,28 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
const displayedAgents = take(agents, 100)
const displayedPresets = take(presets, 100)
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
setSelectedIndex((prev) => (prev >= displayedPresets.length - 1 ? 0 : prev + 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
setSelectedIndex((prev) => (prev <= 0 ? displayedPresets.length - 1 : prev - 1))
break
case 'Enter':
case 'NumpadEnter':
// 如果焦点在输入框且有搜索内容,则默认选择第一项
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
onCreateAssistant(displayedPresets[selectedIndex])
}
// 否则选择当前选中项
else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
else if (selectedIndex >= 0 && selectedIndex < displayedPresets.length) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
onCreateAssistant(displayedPresets[selectedIndex])
}
break
}
@@ -122,14 +122,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
}, [open, selectedIndex, presets, searchText, onCreateAssistant])
// 确保选中项在可视区域
useEffect(() => {
if (containerRef.current) {
const agentItems = containerRef.current.querySelectorAll('.agent-item')
if (agentItems[selectedIndex]) {
agentItems[selectedIndex].scrollIntoView({
const presetItems = containerRef.current.querySelectorAll('.agent-item')
if (presetItems[selectedIndex]) {
presetItems[selectedIndex].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
@@ -193,19 +193,19 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</HStack>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<Container ref={containerRef}>
{take(agents, 100).map((agent, index) => (
{take(presets, 100).map((preset, index) => (
<AgentItem
key={agent.id}
onClick={() => onCreateAssistant(agent)}
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
key={preset.id}
onClick={() => onCreateAssistant(preset)}
className={`agent-item ${preset.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
onMouseEnter={() => setSelectedIndex(index)}>
<HStack alignItems="center" gap={5} style={{ overflow: 'hidden', maxWidth: '100%' }}>
<EmojiIcon emoji={agent.emoji || ''} />
<span className="text-nowrap">{agent.name}</span>
<EmojiIcon emoji={preset.emoji || ''} />
<span className="text-nowrap">{preset.name}</span>
</HStack>
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
{agent.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
{agent.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
{preset.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
{preset.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
{preset.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
</AgentItem>
))}
</Container>

View File

@@ -0,0 +1,392 @@
import {
Button,
cn,
Form,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectedItemProps,
SelectItem,
Textarea,
useDisclosure
} from '@heroui/react'
import { loggerService } from '@logger'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { getModelLogo } from '@renderer/config/models'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useModels } from '@renderer/hooks/agents/useModels'
import { AddAgentForm, AgentEntity, AgentType, BaseAgentForm, isAgentType, UpdateAgentForm } from '@renderer/types'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../../ErrorBoundary'
import { BaseOption, ModelOption, Option, renderOption } from './shared'
const logger = loggerService.withContext('AddAgentPopup')
interface AgentTypeOption extends BaseOption {
type: 'type'
key: AgentEntity['type']
name: AgentEntity['name']
}
type Option = AgentTypeOption | ModelOption
const buildAgentForm = (existing?: AgentEntity): BaseAgentForm => ({
type: existing?.type ?? 'claude-code',
name: existing?.name ?? 'Claude Code',
description: existing?.description,
instructions: existing?.instructions,
model: existing?.model ?? 'claude-4-sonnet',
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : []
})
interface BaseProps {
agent?: AgentEntity
}
interface TriggerProps extends BaseProps {
trigger: { content: ReactNode; className?: string }
isOpen?: never
onClose?: never
}
interface StateProps extends BaseProps {
trigger?: never
isOpen: boolean
onClose: () => void
}
type Props = TriggerProps | StateProps
/**
* Modal component for creating or editing an agent.
*
* Either trigger or isOpen and onClose is given.
* @param agent - Optional agent entity for editing mode.
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
* @param isOpen - Optional controlled modal open state. From useDisclosure.
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { addAgent, updateAgent } = useAgents()
// hard-coded. We only support anthropic for now.
const { models } = useModels({ providerType: 'anthropic' })
const isEditing = (agent?: AgentEntity) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
useEffect(() => {
if (isOpen) {
setForm(buildAgentForm(agent))
}
}, [agent, isOpen])
// add supported agents type here.
const agentConfig = useMemo(
() =>
[
{
type: 'type',
key: 'claude-code',
label: 'Claude Code',
name: 'Claude Code',
avatar: ClaudeIcon
}
] as const satisfies AgentTypeOption[],
[]
)
const agentOptions: AgentTypeOption[] = useMemo(
() =>
agentConfig.map(
(option) =>
({
...option,
rendered: <Option option={option} />
}) as const satisfies SelectedItemProps
),
[agentConfig]
)
const onAgentTypeChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string | undefined = form.name
if (prevConfig && prevConfig.name === form.name) {
const newConfig = agentConfig.find((config) => config.key === e.target.value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: e.target.value as AgentType,
name: newName
}))
},
[agentConfig, form.name, form.type]
)
const onNameChange = useCallback((name: string) => {
setForm((prev) => ({
...prev,
name
}))
}, [])
const onDescChange = useCallback((description: string) => {
setForm((prev) => ({
...prev,
description
}))
}, [])
const onInstChange = useCallback((instructions: string) => {
setForm((prev) => ({
...prev,
instructions
}))
}, [])
const addAccessiblePath = useCallback(async () => {
try {
const selected = await window.api.file.selectFolder()
if (!selected) {
return
}
setForm((prev) => {
if (prev.accessible_paths.includes(selected)) {
window.toast.warning(t('agent.session.accessible_paths.duplicate'))
return prev
}
return {
...prev,
accessible_paths: [...prev.accessible_paths, selected]
}
})
} catch (error) {
logger.error('Failed to select accessible path:', error as Error)
window.toast.error(t('agent.session.accessible_paths.select_failed'))
}
}, [t])
const removeAccessiblePath = useCallback((path: string) => {
setForm((prev) => ({
...prev,
accessible_paths: prev.accessible_paths.filter((item) => item !== path)
}))
}, [])
const modelOptions = useMemo(() => {
// mocked data. not final version
return (models ?? []).map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
setForm((prev) => ({
...prev,
model: e.target.value
}))
}, [])
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (loadingRef.current) {
return
}
loadingRef.current = true
// Additional validation check besides native HTML validation to ensure security
if (!isAgentType(form.type)) {
window.toast.error(t('agent.add.error.invalid_agent'))
loadingRef.current = false
return
}
if (!form.model) {
window.toast.error(t('error.model.not_exists'))
loadingRef.current = false
return
}
if (form.accessible_paths.length === 0) {
window.toast.error(t('agent.session.accessible_paths.required'))
loadingRef.current = false
return
}
if (isEditing(agent)) {
if (!agent) {
throw new Error('Agent is required for editing mode')
}
const updatePayload = {
id: agent.id,
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
} satisfies UpdateAgentForm
updateAgent(updatePayload)
logger.debug('Updated agent', updatePayload)
} else {
const newAgent = {
type: form.type,
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
} satisfies AddAgentForm
addAgent(newAgent)
logger.debug('Added agent', newAgent)
}
loadingRef.current = false
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
},
[
form.type,
form.model,
form.name,
form.description,
form.instructions,
form.accessible_paths,
agent,
onClose,
t,
updateAgent,
addAgent
]
)
return (
<ErrorBoundary>
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
encapsulated component. This is because the Modal component needs to bind the onOpen
event handler to the Button for proper focus management.
Or just use external isOpen/onOpen/onClose to control modal state.
*/}
{trigger && (
<div
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
className={cn('w-full', trigger.className)}>
{trigger.content}
</div>
)}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}</ModalHeader>
<Form onSubmit={onSubmit} className="w-full">
<ModalBody className="w-full">
<Select
isRequired
isDisabled={isEditing(agent)}
selectionMode="single"
selectedKeys={[form.type]}
onChange={onAgentTypeChange}
items={agentOptions}
label={t('agent.add.type.label')}
placeholder={t('agent.add.type.placeholder')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
{/* FIXME: Model type definition is string. It cannot be related to provider. Just mock a model now. */}
<Select
isRequired
selectionMode="single"
selectedKeys={form.model ? [form.model] : []}
onChange={onModelChange}
items={modelOptions}
label={t('common.model')}
placeholder={t('common.placeholders.select.model')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Textarea
label={t('common.description')}
value={form.description ?? ''}
onValueChange={onDescChange}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{t('agent.session.accessible_paths.label')}
</span>
<Button size="sm" variant="flat" onPress={addAccessiblePath}>
{t('agent.session.accessible_paths.add')}
</Button>
</div>
{form.accessible_paths.length > 0 ? (
<div className="space-y-2">
{form.accessible_paths.map((path) => (
<div
key={path}
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
<span className="truncate text-sm" title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
{t('common.delete')}
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-foreground-400">{t('agent.session.accessible_paths.empty')}</p>
)}
</div>
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
</ModalBody>
<ModalFooter className="w-full">
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" type="submit" isLoading={loadingRef.current}>
{isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button>
</ModalFooter>
</Form>
</>
)}
</ModalContent>
</Modal>
</ErrorBoundary>
)
}

View File

@@ -0,0 +1,276 @@
import {
Button,
cn,
Form,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectedItemProps,
SelectedItems,
SelectItem,
Textarea,
useDisclosure
} from '@heroui/react'
import { loggerService } from '@logger'
import { getModelLogo } from '@renderer/config/models'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useModels } from '@renderer/hooks/agents/useModels'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { AgentEntity, AgentSessionEntity, BaseSessionForm, CreateSessionForm, UpdateSessionForm } from '@renderer/types'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../../ErrorBoundary'
import { BaseOption, ModelOption, Option } from './shared'
const logger = loggerService.withContext('SessionAgentPopup')
type Option = ModelOption
const buildSessionForm = (existing?: AgentSessionEntity, agent?: AgentEntity): BaseSessionForm => ({
name: existing?.name ?? agent?.name ?? 'Claude Code',
description: existing?.description ?? agent?.description,
instructions: existing?.instructions ?? agent?.instructions,
model: existing?.model ?? agent?.model ?? '',
accessible_paths: existing?.accessible_paths
? [...existing.accessible_paths]
: agent?.accessible_paths
? [...agent.accessible_paths]
: []
})
interface BaseProps {
agentId: string
session?: AgentSessionEntity
}
interface TriggerProps extends BaseProps {
trigger: { content: ReactNode; className?: string }
isOpen?: never
onClose?: never
}
interface StateProps extends BaseProps {
trigger?: never
isOpen: boolean
onClose: () => void
}
type Props = TriggerProps | StateProps
/**
* Modal component for creating or editing a Session.
*
* Either trigger or isOpen and onClose is given.
* @param agentId - The ID of agent which the session is related.
* @param session - Optional session entity for editing mode.
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
* @param isOpen - Optional controlled modal open state. From useDisclosure.
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { createSession, updateSession } = useSessions(agentId)
// Only support claude code for now
const { models } = useModels({ providerType: 'anthropic' })
const { agent } = useAgent(agentId)
const isEditing = (session?: AgentSessionEntity) => session !== undefined
const [form, setForm] = useState<BaseSessionForm>(() => buildSessionForm(session, agent ?? undefined))
useEffect(() => {
if (isOpen) {
setForm(buildSessionForm(session, agent ?? undefined))
}
}, [session, agent, isOpen])
const Item = useCallback(({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />, [])
const renderOption = useCallback(
(items: SelectedItems<BaseOption>) => items.map((item) => <Item key={item.key} item={item} />),
[Item]
)
const onNameChange = useCallback((name: string) => {
setForm((prev) => ({
...prev,
name
}))
}, [])
const onDescChange = useCallback((description: string) => {
setForm((prev) => ({
...prev,
description
}))
}, [])
const onInstChange = useCallback((instructions: string) => {
setForm((prev) => ({
...prev,
instructions
}))
}, [])
const modelOptions = useMemo(() => {
// mocked data. not final version
return (models ?? []).map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
setForm((prev) => ({
...prev,
model: e.target.value
}))
}, [])
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (loadingRef.current) {
return
}
loadingRef.current = true
// Additional validation check besides native HTML validation to ensure security
if (!form.model) {
window.toast.error(t('error.model.not_exists'))
loadingRef.current = false
return
}
if (form.accessible_paths.length === 0) {
window.toast.error(t('agent.session.accessible_paths.required'))
loadingRef.current = false
return
}
if (isEditing(session)) {
if (!session) {
throw new Error('Agent is required for editing mode')
}
const updatePayload = {
id: session.id,
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
} satisfies UpdateSessionForm
updateSession(updatePayload)
logger.debug('Updated agent', updatePayload)
} else {
const newSession = {
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
} satisfies CreateSessionForm
createSession(newSession)
logger.debug('Added agent', newSession)
}
loadingRef.current = false
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
},
[
form.model,
form.name,
form.description,
form.instructions,
form.accessible_paths,
session,
onClose,
t,
updateSession,
createSession
]
)
return (
<ErrorBoundary>
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
encapsulated component. This is because the Modal component needs to bind the onOpen
event handler to the Button for proper focus management.
Or just use external isOpen/onOpen/onClose to control modal state.
*/}
{trigger && (
<div
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
className={cn('w-full', trigger.className)}>
{trigger.content}
</div>
)}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>
{isEditing(session) ? t('agent.session.edit.title') : t('agent.session.add.title')}
</ModalHeader>
<Form onSubmit={onSubmit} className="w-full">
<ModalBody className="w-full">
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
{/* FIXME: Model type definition is string. It cannot be related to provider. Just mock a model now. */}
<Select
isRequired
selectionMode="single"
selectedKeys={form.model ? [form.model] : []}
onChange={onModelChange}
items={modelOptions}
label={t('common.model')}
placeholder={t('common.placeholders.select.model')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Textarea
label={t('common.description')}
value={form.description ?? ''}
onValueChange={onDescChange}
/>
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
</ModalBody>
<ModalFooter className="w-full">
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" type="submit" isLoading={loadingRef.current}>
{isEditing(session) ? t('common.confirm') : t('common.add')}
</Button>
</ModalFooter>
</Form>
</>
)}
</ModalContent>
</Modal>
</ErrorBoundary>
)
}

View File

@@ -0,0 +1,51 @@
import { Avatar, SelectedItemProps, SelectedItems } from '@heroui/react'
import { getProviderLabel } from '@renderer/i18n/label'
import { useTranslation } from 'react-i18next'
export interface BaseOption {
type: 'type' | 'model'
key: string
label: string
// img src
avatar: string
}
export interface ModelOption extends BaseOption {
providerId?: string
providerName?: string
}
export function isModelOption(option: BaseOption): option is ModelOption {
return option.type === 'model'
}
export const Item = ({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />
export const renderOption = (items: SelectedItems<BaseOption>) =>
items.map((item) => <Item key={item.key} item={item} />)
export const Option = ({ option }: { option?: BaseOption | null }) => {
const { t } = useTranslation()
if (!option) {
return (
<div className="flex gap-2">
<Avatar name="?" className="h-5 w-5" />
{t('common.invalid_value')}
</div>
)
}
const providerLabel = (() => {
if (!isModelOption(option)) return null
if (option.providerName) return option.providerName
if (option.providerId) return getProviderLabel(option.providerId)
return null
})()
return (
<div className="flex gap-2">
<Avatar src={option.avatar} className="h-5 w-5" />
<span className="truncate">{option.label}</span>
{providerLabel ? <span className="truncate text-foreground-500">| {providerLabel}</span> : null}
</div>
)
}

View File

@@ -58,7 +58,7 @@ const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined
switch (tabId) {
case 'home':
return <Home size={14} />
case 'agents':
case 'assistantPresets':
return <Sparkle size={14} />
case 'translate':
return <Languages size={14} />

View File

@@ -0,0 +1,21 @@
import ClaudeAvatar from '@renderer/assets/images/models/claude.png'
import { AgentBase, AgentEntity } from '@renderer/types'
// base agent config. no default config for now.
const DEFAULT_AGENT_CONFIG: Omit<AgentBase, 'model'> = {
accessible_paths: []
} as const
// no default config for now.
export const DEFAULT_CLAUDE_CODE_CONFIG: Omit<AgentBase, 'model'> = {
...DEFAULT_AGENT_CONFIG
} as const
export const getAgentAvatar = (type: AgentEntity['type']): string => {
switch (type) {
case 'claude-code':
return ClaudeAvatar
default:
return ''
}
}

View File

@@ -0,0 +1,42 @@
import { UpdateAgentForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useAgent = (id: string | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = id ? client.agentPaths.withId(id) : null
const fetcher = useCallback(async () => {
if (!id) {
return null
}
const result = await client.getAgent(id)
return result
}, [client, id])
const { data, error, isLoading, mutate } = useSWR(key, id ? fetcher : null)
const updateAgent = useCallback(
async (form: UpdateAgentForm) => {
try {
// may change to optimistic update
const result = await client.updateAgent(form)
mutate(result)
window.toast.success(t('common.update_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
}
},
[client, mutate, t]
)
return {
agent: data,
error,
isLoading,
updateAgent
}
}

View File

@@ -0,0 +1,15 @@
import { AgentApiClient } from '@renderer/api/agent'
import { useSettings } from '../useSettings'
export const useAgentClient = () => {
const { apiServer } = useSettings()
const { host, port, apiKey } = apiServer
const client = new AgentApiClient({
baseURL: `http://${host}:${port}`,
headers: {
Authorization: `Bearer ${apiKey}`
}
})
return client
}

View File

@@ -0,0 +1,76 @@
import { AddAgentForm, UpdateAgentForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useAgents = () => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.agentPaths.base
const fetcher = useCallback(async () => {
const result = await client.listAgents()
return result.data
}, [client])
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const addAgent = useCallback(
async (form: AddAgentForm) => {
try {
const result = await client.createAgent(form)
mutate((prev) => [...(prev ?? []), result])
window.toast.success(t('common.add_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.add.error.failed')))
}
},
[client, mutate, t]
)
const updateAgent = useCallback(
async (form: UpdateAgentForm) => {
try {
// may change to optimistic update
const result = await client.updateAgent(form)
mutate((prev) => prev?.map((a) => (a.id === result.id ? result : a)) ?? [])
window.toast.success(t('common.update_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
}
},
[client, mutate, t]
)
const deleteAgent = useCallback(
async (id: string) => {
try {
await client.deleteAgent(id)
mutate((prev) => prev?.filter((a) => a.id !== id) ?? [])
window.toast.success(t('common.delete_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.delete.error.failed')))
}
},
[client, mutate, t]
)
const getAgent = useCallback(
async (id: string) => {
const result = await client.getAgent(id)
mutate((prev) => prev?.map((a) => (a.id === result.id ? result : a)) ?? [])
},
[client, mutate]
)
return {
agents: data ?? [],
error,
isLoading,
addAgent,
updateAgent,
deleteAgent,
getAgent
}
}

View File

@@ -0,0 +1,19 @@
import { ApiModelsFilter } from '@renderer/types'
import { useCallback } from 'react'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useModels = (filter?: ApiModelsFilter) => {
const client = useAgentClient()
const path = client.getModelsPath(filter)
const fetcher = useCallback(() => {
return client.getModels(filter)
}, [client, filter])
const { data, error, isLoading } = useSWR(path, fetcher)
return {
models: data?.data,
error,
isLoading
}
}

View File

@@ -0,0 +1,78 @@
import { useAppDispatch } from '@renderer/store'
import { removeManyBlocks,upsertManyBlocks } from '@renderer/store/messageBlock'
import { newMessagesActions } from '@renderer/store/newMessage'
import { AgentPersistedMessage, UpdateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useSession = (agentId: string, sessionId: string) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.getSessionPaths(agentId).withId(sessionId)
const dispatch = useAppDispatch()
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
const blockIdsRef = useRef<string[]>([])
const fetcher = async () => {
const data = await client.getSession(agentId, sessionId)
return data
}
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
useEffect(() => {
const messages = data?.messages ?? []
if (!messages.length) {
dispatch(newMessagesActions.messagesReceived({ topicId: sessionTopicId, messages: [] }))
blockIdsRef.current = []
return
}
const persistedEntries = messages
.map((entity) => entity.content as AgentPersistedMessage | undefined)
.filter((entry): entry is AgentPersistedMessage => Boolean(entry))
const allBlocks = persistedEntries.flatMap((entry) => entry.blocks)
if (allBlocks.length > 0) {
dispatch(upsertManyBlocks(allBlocks))
}
blockIdsRef.current = allBlocks.map((block) => block.id)
const messageRecords = persistedEntries.map((entry) => entry.message)
dispatch(newMessagesActions.messagesReceived({ topicId: sessionTopicId, messages: messageRecords }))
}, [data?.messages, dispatch, sessionTopicId])
useEffect(() => {
return () => {
if (blockIdsRef.current.length > 0) {
dispatch(removeManyBlocks(blockIdsRef.current))
}
dispatch(newMessagesActions.clearTopicMessages(sessionTopicId))
}
}, [dispatch, sessionTopicId])
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
if (!agentId) return
try {
const result = await client.updateSession(agentId, form)
mutate(result)
} catch (error) {
window.toast.error(t('agent.session.update.error.failed'))
}
},
[agentId, client, mutate, t]
)
return {
session: data,
error,
isLoading,
updateSession,
mutate
}
}

View File

@@ -0,0 +1,80 @@
import { CreateSessionForm, UpdateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useSessions = (agentId: string) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.getSessionPaths(agentId).base
const fetcher = async () => {
const data = await client.listSessions(agentId)
return data.data
}
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const createSession = useCallback(
async (form: CreateSessionForm) => {
try {
const result = await client.createSession(agentId, form)
mutate((prev) => [...(prev ?? []), result])
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed')))
}
},
[agentId, client, mutate, t]
)
// TODO: including messages field
const getSession = useCallback(
async (id: string) => {
try {
const result = await client.getSession(agentId, id)
mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session)))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed')))
}
},
[agentId, client, mutate, t]
)
const deleteSession = useCallback(
async (id: string) => {
if (!agentId) return
try {
await client.deleteSession(agentId, id)
mutate((prev) => prev?.filter((session) => session.id !== id))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed')))
}
},
[agentId, client, mutate, t]
)
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
if (!agentId) return
try {
const result = await client.updateSession(agentId, form)
mutate((prev) => prev?.map((session) => (session.id === form.id ? result : session)))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.update.error.failed')))
}
},
[agentId, client, mutate, t]
)
return {
sessions: data ?? [],
error,
isLoading,
createSession,
getSession,
deleteSession,
updateSession
}
}

View File

@@ -1,28 +0,0 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addAgent, removeAgent, updateAgent, updateAgents, updateAgentSettings } from '@renderer/store/agents'
import { Agent, AssistantSettings } from '@renderer/types'
export function useAgents() {
const agents = useAppSelector((state) => state.agents.agents)
const dispatch = useAppDispatch()
return {
agents,
updateAgents: (agents: Agent[]) => dispatch(updateAgents(agents)),
addAgent: (agent: Agent) => dispatch(addAgent(agent)),
removeAgent: (id: string) => dispatch(removeAgent({ id }))
}
}
export function useAgent(id: string) {
const agent = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as Agent)
const dispatch = useAppDispatch()
return {
agent,
updateAgent: (agent: Agent) => dispatch(updateAgent(agent)),
updateAgentSettings: (settings: Partial<AssistantSettings>) => {
dispatch(updateAgentSettings({ assistantId: agent.id, settings }))
}
}
}

View File

@@ -0,0 +1,35 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addAssistantPreset,
removeAssistantPreset,
setAssistantPresets,
updateAssistantPreset,
updateAssistantPresetSettings
} from '@renderer/store/agents'
import { AssistantPreset, AssistantSettings } from '@renderer/types'
export function useAssistantPresets() {
const presets = useAppSelector((state) => state.agents.agents)
const dispatch = useAppDispatch()
return {
presets,
setAssistantPresets: (presets: AssistantPreset[]) => dispatch(setAssistantPresets(presets)),
addAssistantPreset: (preset: AssistantPreset) => dispatch(addAssistantPreset(preset)),
removeAssistantPreset: (id: string) => dispatch(removeAssistantPreset({ id }))
}
}
export function useAssistantPreset(id: string) {
// FIXME: undefined is not handled
const preset = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as AssistantPreset)
const dispatch = useAppDispatch()
return {
preset,
updateAssistantPreset: (preset: AssistantPreset) => dispatch(updateAssistantPreset(preset)),
updateAssistantPresetSettings: (settings: Partial<AssistantSettings>) => {
dispatch(updateAssistantPresetSettings({ assistantId: preset.id, settings }))
}
}
}

View File

@@ -32,8 +32,8 @@ import { cloneDeep } from 'lodash'
import { useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useAgents } from './useAgents'
import { useAssistants } from './useAssistant'
import { useAssistantPresets } from './useAssistantPresets'
import { useTimer } from './useTimer'
export const useKnowledge = (baseId: string) => {
@@ -346,7 +346,7 @@ export const useKnowledgeBases = () => {
const dispatch = useDispatch()
const bases = useSelector((state: RootState) => state.knowledge.bases)
const { assistants, updateAssistants } = useAssistants()
const { agents, updateAgents } = useAgents()
const { presets, setAssistantPresets } = useAssistantPresets()
const addKnowledgeBase = (base: KnowledgeBase) => {
dispatch(addBase(base))
@@ -373,7 +373,7 @@ export const useKnowledgeBases = () => {
})
// remove agent knowledge_base
const _agents = agents.map((agent) => {
const _presets = presets.map((agent) => {
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
return {
...agent,
@@ -384,7 +384,7 @@ export const useKnowledgeBases = () => {
})
updateAssistants(_assistants)
updateAgents(_agents)
setAssistantPresets(_presets)
}
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {

View File

@@ -125,7 +125,8 @@ export const getRestoreProgressLabel = (key: string): string => {
}
const titleKeyMap = {
agents: 'title.agents',
// TODO: update i18n key
assistantPresets: 'title.agents',
apps: 'title.apps',
code: 'title.code',
files: 'title.files',

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "Failed to add a agent",
"invalid_agent": "Invalid Agent"
},
"title": "Add Agent",
"type": {
"label": "Agent Type",
"placeholder": "Select an agent type"
}
},
"delete": {
"content": "Deleting the agent will forcibly stop and delete all sessions associated with the agent. Are you sure?",
"error": {
"failed": "Failed to delete the agent"
},
"title": "Delete Agent"
},
"edit": {
"title": "Edit Agent"
},
"session": {
"add": {
"title": "Add a session"
},
"create": {
"error": {
"failed": "Failed to add a session"
}
},
"delete": {
"content": "Are you sure to delete this session?",
"error": {
"failed": "Failed to delete the session"
},
"title": "Delete session"
},
"edit": {
"title": "Edit session"
},
"get": {
"error": {
"failed": "Failed to get the session"
}
},
"label_one": "Session",
"label_other": "Sessions",
"update": {
"error": {
"failed": "Failed to update the session"
}
},
"accessible_paths": {
"label": "Accessible directories",
"add": "Add directory",
"empty": "Select at least one directory that the agent can access.",
"required": "Please select at least one accessible directory.",
"duplicate": "This directory is already included.",
"select_failed": "Failed to select directory."
}
},
"update": {
"error": {
"failed": "Failed to update the agent"
}
}
},
"agents": {
"add": {
"button": "Add to Assistant",
@@ -737,9 +805,14 @@
},
"common": {
"add": "Add",
"add_success": "Added successfully",
"advanced_settings": "Advanced Settings",
"agent_one": "Agent",
"agent_other": "Agents",
"and": "and",
"assistant": "Assistant",
"assistant": "Agent",
"assistant_one": "Assistant",
"assistant_other": "Assistants",
"avatar": "Avatar",
"back": "Back",
"browse": "Browse",
@@ -756,6 +829,8 @@
"default": "Default",
"delete": "Delete",
"delete_confirm": "Are you sure you want to delete?",
"delete_failed": "Failed to delete",
"delete_success": "Deleted successfully",
"description": "Description",
"detail": "Detail",
"disabled": "Disabled",
@@ -765,6 +840,10 @@
"edit": "Edit",
"enabled": "Enabled",
"error": "error",
"errors": {
"create_message": "Failed to create message",
"validation": "Verification failed"
},
"expand": "Expand",
"file": {
"not_supported": "Unsupported file type {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "Go to settings",
"i_know": "I know",
"inspect": "Inspect",
"invalid_value": "Invalid Value",
"knowledge_base": "Knowledge Base",
"language": "Language",
"loading": "Loading...",
@@ -786,6 +866,11 @@
"none": "None",
"open": "Open",
"paste": "Paste",
"placeholders": {
"select": {
"model": "Select a model"
}
},
"preview": "Preview",
"prompt": "Prompt",
"provider": "Provider",
@@ -812,6 +897,7 @@
"success": "Success",
"swap": "Swap",
"topics": "Topics",
"update_success": "Update successfully",
"upload_files": "Upload file",
"warning": "Warning",
"you": "You"
@@ -884,6 +970,7 @@
"modelType": "Model Type",
"name": "Error name",
"no_api_key": "API key is not configured",
"no_response": "No response",
"originalError": "Original Error",
"originalMessage": "Original Message",
"parameter": "Parameter",

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "添加 Agent 失败",
"invalid_agent": "无效的 Agent"
},
"title": "添加 Agent",
"type": {
"label": "Agent 类型",
"placeholder": "选择 Agent 类型"
}
},
"delete": {
"content": "删除该 Agent 将强制终止并删除该 Agent 下的所有会话。您确定吗?",
"error": {
"failed": "删除 Agent 失败"
},
"title": "删除 Agent"
},
"edit": {
"title": "编辑 Agent"
},
"session": {
"add": {
"title": "添加会话"
},
"create": {
"error": {
"failed": "添加会话失败"
}
},
"delete": {
"content": "确定要删除此会话吗?",
"error": {
"failed": "删除会话失败"
},
"title": "删除会话"
},
"edit": {
"title": "编辑会话"
},
"get": {
"error": {
"failed": "获取会话失败"
}
},
"label_one": "会话",
"label_other": "会话",
"update": {
"error": {
"failed": "更新会话失败"
}
},
"accessible_paths": {
"label": "工作目录",
"add": "添加目录",
"empty": "请选择至少一个智能体可访问的目录。",
"required": "请至少选择一个可访问的目录。",
"duplicate": "该目录已添加。",
"select_failed": "选择目录失败"
}
},
"update": {
"error": {
"failed": "更新 Agent 失败"
}
}
},
"agents": {
"add": {
"button": "添加到助手",
@@ -737,9 +805,14 @@
},
"common": {
"add": "添加",
"add_success": "添加成功",
"advanced_settings": "高级设置",
"agent_one": "Agent",
"agent_other": "Agents",
"and": "和",
"assistant": "智能体",
"assistant_one": "助手",
"assistant_other": "助手",
"avatar": "头像",
"back": "返回",
"browse": "浏览",
@@ -756,6 +829,8 @@
"default": "默认",
"delete": "删除",
"delete_confirm": "确定要删除吗?",
"delete_failed": "删除失败",
"delete_success": "删除成功",
"description": "描述",
"detail": "详情",
"disabled": "已禁用",
@@ -765,6 +840,10 @@
"edit": "编辑",
"enabled": "已启用",
"error": "错误",
"errors": {
"create_message": "创建消息失败",
"validation": "验证失败"
},
"expand": "展开",
"file": {
"not_supported": "不支持的文件类型 {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "前往设置",
"i_know": "我知道了",
"inspect": "检查",
"invalid_value": "无效值",
"knowledge_base": "知识库",
"language": "语言",
"loading": "加载中...",
@@ -786,6 +866,11 @@
"none": "无",
"open": "打开",
"paste": "粘贴",
"placeholders": {
"select": {
"model": "选择模型"
}
},
"preview": "预览",
"prompt": "提示词",
"provider": "提供商",
@@ -812,6 +897,7 @@
"success": "成功",
"swap": "交换",
"topics": "话题",
"update_success": "更新成功",
"upload_files": "上传文件",
"warning": "警告",
"you": "用户"
@@ -884,6 +970,7 @@
"modelType": "模型类型",
"name": "错误名称",
"no_api_key": "API 密钥未配置",
"no_response": "无响应",
"originalError": "原错误",
"originalMessage": "原消息",
"parameter": "参数",

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "無法新增代理人",
"invalid_agent": "無效的 Agent"
},
"title": "新增代理",
"type": {
"label": "代理類型",
"placeholder": "選擇 Agent 類型"
}
},
"delete": {
"content": "刪除該 Agent 將強制終止並刪除該 Agent 下的所有會話。您確定嗎?",
"error": {
"failed": "刪除代理程式失敗"
},
"title": "刪除 Agent"
},
"edit": {
"title": "編輯 Agent"
},
"session": {
"add": {
"title": "新增會議"
},
"create": {
"error": {
"failed": "無法新增工作階段"
}
},
"delete": {
"content": "您確定要刪除此工作階段嗎?",
"error": {
"failed": "無法刪除工作階段"
},
"title": "刪除工作階段"
},
"edit": {
"title": "編輯工作階段"
},
"get": {
"error": {
"failed": "無法取得工作階段"
}
},
"label_one": "會議",
"label_other": "Sessions",
"update": {
"error": {
"failed": "無法更新工作階段"
}
},
"accessible_paths": {
"label": "可存取的目錄",
"add": "新增目錄",
"empty": "選擇至少一個代理可以存取的目錄。",
"required": "請至少選擇一個可存取的目錄。",
"duplicate": "此目錄已包含在內。",
"select_failed": "無法選擇目錄。"
}
},
"update": {
"error": {
"failed": "無法更新代理程式"
}
}
},
"agents": {
"add": {
"button": "新增到助手",
@@ -737,9 +805,14 @@
},
"common": {
"add": "新增",
"add_success": "新增成功",
"advanced_settings": "進階設定",
"agent_one": "代理人",
"agent_other": "代理人",
"and": "與",
"assistant": "智慧代理人",
"assistant_one": "助手",
"assistant_other": "助手",
"avatar": "頭像",
"back": "返回",
"browse": "瀏覽",
@@ -756,6 +829,8 @@
"default": "預設",
"delete": "刪除",
"delete_confirm": "確定要刪除嗎?",
"delete_failed": "刪除失敗",
"delete_success": "刪除成功",
"description": "描述",
"detail": "詳情",
"disabled": "已停用",
@@ -765,6 +840,10 @@
"edit": "編輯",
"enabled": "已啟用",
"error": "錯誤",
"errors": {
"create_message": "無法建立訊息",
"validation": "驗證失敗"
},
"expand": "展開",
"file": {
"not_supported": "不支持的文件類型 {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "前往設定",
"i_know": "我知道了",
"inspect": "檢查",
"invalid_value": "無效值",
"knowledge_base": "知識庫",
"language": "語言",
"loading": "加載中...",
@@ -786,6 +866,11 @@
"none": "無",
"open": "開啟",
"paste": "貼上",
"placeholders": {
"select": {
"model": "選擇模型"
}
},
"preview": "預覽",
"prompt": "提示詞",
"provider": "供應商",
@@ -812,6 +897,7 @@
"success": "成功",
"swap": "交換",
"topics": "話題",
"update_success": "更新成功",
"upload_files": "上傳檔案",
"warning": "警告",
"you": "您"
@@ -884,6 +970,7 @@
"modelType": "模型類型",
"name": "錯誤名稱",
"no_api_key": "API 金鑰未設定",
"no_response": "無回應",
"originalError": "原錯誤",
"originalMessage": "原消息",
"parameter": "參數",

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "Αποτυχία προσθήκης πράκτορα",
"invalid_agent": "Μη έγκυρος Agent"
},
"title": "Προσθήκη Agent",
"type": {
"label": "Τύπος πράκτορα",
"placeholder": "Επιλέξτε τύπο Agent"
}
},
"delete": {
"content": "Η διαγραφή αυτού του Agent θα τερματίσει βίαια και θα διαγράψει όλες τις συνεδρίες υπό αυτόν τον Agent. Είστε σίγουροι;",
"error": {
"failed": "Αποτυχία διαγραφής του πράκτορα"
},
"title": "Διαγραφή Agent"
},
"edit": {
"title": "Επεξεργαστής Agent"
},
"session": {
"add": {
"title": "Προσθήκη συνεδρίας"
},
"create": {
"error": {
"failed": "Αποτυχία προσθήκης συνεδρίας"
}
},
"delete": {
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις αυτήν τη συνεδρία;",
"error": {
"failed": "Αποτυχία διαγραφής της συνεδρίας"
},
"title": "Διαγραφή συνεδρίας"
},
"edit": {
"title": "Συνεδρία επεξεργασίας"
},
"get": {
"error": {
"failed": "Αποτυχία λήψης της συνεδρίας"
}
},
"label_one": "Συνεδρία",
"label_other": "Συνεδρίες",
"update": {
"error": {
"failed": "Αποτυχία ενημέρωσης της συνεδρίας"
}
},
"accessible_paths": {
"label": "Προσβάσιμοι κατάλογοι",
"add": "Προσθήκη καταλόγου",
"empty": "Επιλέξτε τουλάχιστον έναν κατάλογο στον οποίο ο πράκτορας μπορεί να έχει πρόσβαση.",
"required": "Παρακαλώ επιλέξτε τουλάχιστον έναν προσβάσιμο κατάλογο.",
"duplicate": "Αυτός ο κατάλογος έχει ήδη συμπεριληφθεί.",
"select_failed": "Αποτυχία επιλογής καταλόγου."
}
},
"update": {
"error": {
"failed": "Αποτυχία ενημέρωσης του πράκτορα"
}
}
},
"agents": {
"add": {
"button": "Προσθήκη στο Βοηθό",
@@ -737,9 +805,14 @@
},
"common": {
"add": "Προσθέστε",
"add_success": "Η προσθήκη ήταν επιτυχής",
"advanced_settings": "Προχωρημένες ρυθμίσεις",
"agent_one": "Πράκτορας",
"agent_other": "Πράκτορες",
"and": "και",
"assistant": "Εξυπνιασμένη Ενότητα",
"assistant_one": "βοηθός",
"assistant_other": "βοηθός",
"avatar": "Εικονίδιο",
"back": "Πίσω",
"browse": "Περιήγηση",
@@ -756,6 +829,8 @@
"default": "Προεπιλογή",
"delete": "Διαγραφή",
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
"delete_failed": "Αποτυχία διαγραφής",
"delete_success": "Η διαγραφή ήταν επιτυχής",
"description": "Περιγραφή",
"detail": "Λεπτομέρειες",
"disabled": "Απενεργοποιημένο",
@@ -765,6 +840,10 @@
"edit": "Επεξεργασία",
"enabled": "Ενεργοποιημένο",
"error": "σφάλμα",
"errors": {
"create_message": "Αποτυχία δημιουργίας μηνύματος",
"validation": "Η επαλήθευση απέτυχε"
},
"expand": "Επεκτάση",
"file": {
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "Πηγαίνετε στις ρυθμίσεις",
"i_know": "Το έχω καταλάβει",
"inspect": "Επιθεώρηση",
"invalid_value": "Μη έγκυρη τιμή",
"knowledge_base": "Βάση Γνώσεων",
"language": "Γλώσσα",
"loading": "Φόρτωση...",
@@ -786,6 +866,11 @@
"none": "Χωρίς",
"open": "Άνοιγμα",
"paste": "Επικόλληση",
"placeholders": {
"select": {
"model": "Επιλέξτε μοντέλο"
}
},
"preview": "Προεπισκόπηση",
"prompt": "Ενδεικτικός ρήματος",
"provider": "Παρέχων",
@@ -812,6 +897,7 @@
"success": "Επιτυχία",
"swap": "Εναλλαγή",
"topics": "Θέματα",
"update_success": "Επιτυχής ενημέρωση",
"upload_files": "Ανέβασμα αρχείου",
"warning": "Προσοχή",
"you": "Εσείς"
@@ -884,6 +970,7 @@
"modelType": "Τύπος μοντέλου",
"name": "Λάθος όνομα",
"no_api_key": "Δεν έχετε ρυθμίσει το κλειδί API",
"no_response": "Καμία απάντηση",
"originalError": "Αρχικό σφάλμα",
"originalMessage": "Αρχικό μήνυμα",
"parameter": "παράμετροι",

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "Error al añadir agente",
"invalid_agent": "Agent inválido"
},
"title": "Agregar Agente",
"type": {
"label": "Tipo de agente",
"placeholder": "Seleccionar tipo de Agente"
}
},
"delete": {
"content": "Eliminar este Agente forzará la terminación y eliminación de todas las sesiones bajo este Agente. ¿Está seguro?",
"error": {
"failed": "Error al eliminar el agente"
},
"title": "Eliminar Agent"
},
"edit": {
"title": "Agent de edición"
},
"session": {
"add": {
"title": "Agregar una sesión"
},
"create": {
"error": {
"failed": "Error al añadir una sesión"
}
},
"delete": {
"content": "¿Estás seguro de eliminar esta sesión?",
"error": {
"failed": "Error al eliminar la sesión"
},
"title": "Eliminar sesión"
},
"edit": {
"title": "Sesión de edición"
},
"get": {
"error": {
"failed": "Error al obtener la sesión"
}
},
"label_one": "Sesión",
"label_other": "Sesiones",
"update": {
"error": {
"failed": "Error al actualizar la sesión"
}
},
"accessible_paths": {
"label": "Directorios accesibles",
"add": "Agregar directorio",
"empty": "Selecciona al menos un directorio al que el agente pueda acceder.",
"required": "Por favor, seleccione al menos un directorio accesible.",
"duplicate": "Este directorio ya está incluido.",
"select_failed": "Error al seleccionar el directorio."
}
},
"update": {
"error": {
"failed": "Error al actualizar el agente"
}
}
},
"agents": {
"add": {
"button": "Agregar al asistente",
@@ -737,9 +805,14 @@
},
"common": {
"add": "Agregar",
"add_success": "Añadido con éxito",
"advanced_settings": "Configuración avanzada",
"agent_one": "Agente",
"agent_other": "Agentes",
"and": "y",
"assistant": "Agente inteligente",
"assistant_one": "Asistente",
"assistant_other": "Asistente",
"avatar": "Avatar",
"back": "Atrás",
"browse": "Examinar",
@@ -756,6 +829,8 @@
"default": "Predeterminado",
"delete": "Eliminar",
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
"delete_failed": "Error al eliminar",
"delete_success": "Eliminación exitosa",
"description": "Descripción",
"detail": "Detalles",
"disabled": "Desactivado",
@@ -765,6 +840,10 @@
"edit": "Editar",
"enabled": "Activado",
"error": "error",
"errors": {
"create_message": "Error al crear el mensaje",
"validation": "Fallo en la verificación"
},
"expand": "Expandir",
"file": {
"not_supported": "Tipo de archivo no compatible {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "Ir a la configuración",
"i_know": "Entendido",
"inspect": "Inspeccionar",
"invalid_value": "Valor inválido",
"knowledge_base": "Base de conocimiento",
"language": "Idioma",
"loading": "Cargando...",
@@ -786,6 +866,11 @@
"none": "无",
"open": "Abrir",
"paste": "Pegar",
"placeholders": {
"select": {
"model": "Seleccionar modelo"
}
},
"preview": "Vista previa",
"prompt": "Prompt",
"provider": "Proveedor",
@@ -812,6 +897,7 @@
"success": "Éxito",
"swap": "Intercambiar",
"topics": "Temas",
"update_success": "Actualización exitosa",
"upload_files": "Subir archivo",
"warning": "Advertencia",
"you": "Usuario"
@@ -884,6 +970,7 @@
"modelType": "Tipo de modelo",
"name": "Nombre de error",
"no_api_key": "La clave API no está configurada",
"no_response": "Sin respuesta",
"originalError": "Error original",
"originalMessage": "mensaje original",
"parameter": "parámetro",

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "Échec de l'ajout de l'agent",
"invalid_agent": "Agent invalide"
},
"title": "Ajouter un agent",
"type": {
"label": "Type d'agent",
"placeholder": "Sélectionner le type d'Agent"
}
},
"delete": {
"content": "La suppression de cet Agent entraînera la terminaison forcée et la suppression de toutes les sessions associées. Êtes-vous certain ?",
"error": {
"failed": "Échec de la suppression de l'agent"
},
"title": "Supprimer l'Agent"
},
"edit": {
"title": "Éditer Agent"
},
"session": {
"add": {
"title": "Ajouter une session"
},
"create": {
"error": {
"failed": "Échec de l'ajout d'une session"
}
},
"delete": {
"content": "Êtes-vous sûr de vouloir supprimer cette session ?",
"error": {
"failed": "Échec de la suppression de la session"
},
"title": "Supprimer la session"
},
"edit": {
"title": "Session d'édition"
},
"get": {
"error": {
"failed": "Échec de l'obtention de la session"
}
},
"label_one": "Session",
"label_other": "Séances",
"update": {
"error": {
"failed": "Échec de la mise à jour de la session"
}
},
"accessible_paths": {
"label": "Répertoires accessibles",
"add": "Ajouter un répertoire",
"empty": "Sélectionnez au moins un répertoire auquel l'agent peut accéder.",
"required": "Veuillez sélectionner au moins un répertoire accessible.",
"duplicate": "Ce répertoire est déjà inclus.",
"select_failed": "Échec de la sélection du répertoire."
}
},
"update": {
"error": {
"failed": "Échec de la mise à jour de l'agent"
}
}
},
"agents": {
"add": {
"button": "Ajouter à l'assistant",
@@ -737,9 +805,14 @@
},
"common": {
"add": "Ajouter",
"add_success": "Ajout réussi",
"advanced_settings": "Paramètres avancés",
"agent_one": "Agent",
"agent_other": "Agents",
"and": "et",
"assistant": "Intelligence artificielle",
"assistant_one": "assistant",
"assistant_other": "assistant",
"avatar": "Avatar",
"back": "Retour",
"browse": "Parcourir",
@@ -756,6 +829,8 @@
"default": "Défaut",
"delete": "Supprimer",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
"delete_failed": "Échec de la suppression",
"delete_success": "Suppression réussie",
"description": "Description",
"detail": "détails",
"disabled": "Désactivé",
@@ -765,6 +840,10 @@
"edit": "Éditer",
"enabled": "Activé",
"error": "erreur",
"errors": {
"create_message": "Échec de la création du message",
"validation": "Échec de la vérification"
},
"expand": "Développer",
"file": {
"not_supported": "Type de fichier non pris en charge {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "Aller aux paramètres",
"i_know": "J'ai compris",
"inspect": "Vérifier",
"invalid_value": "valeur invalide",
"knowledge_base": "Base de connaissances",
"language": "Langue",
"loading": "Chargement...",
@@ -786,6 +866,11 @@
"none": "Aucun",
"open": "Ouvrir",
"paste": "Coller",
"placeholders": {
"select": {
"model": "Choisir le modèle"
}
},
"preview": "Aperçu",
"prompt": "Prompt",
"provider": "Fournisseur",
@@ -812,6 +897,7 @@
"success": "Succès",
"swap": "Échanger",
"topics": "Sujets",
"update_success": "Mise à jour réussie",
"upload_files": "Uploader des fichiers",
"warning": "Avertissement",
"you": "Vous"
@@ -884,6 +970,7 @@
"modelType": "Type de modèle",
"name": "Nom d'erreur",
"no_api_key": "La clé API n'est pas configurée",
"no_response": "Pas de réponse",
"originalError": "Erreur d'origine",
"originalMessage": "message original",
"parameter": "paramètre",

View File

@@ -1,4 +1,64 @@
{
"agent": {
"add": {
"error": {
"failed": "エージェントの追加に失敗しました",
"invalid_agent": "無効なエージェント"
},
"title": "エージェントを追加",
"type": {
"label": "エージェントタイプ",
"placeholder": "エージェントタイプを選択"
}
},
"delete": {
"content": "このエージェントを削除すると、このエージェントのすべてのセッションが強制的に終了し、削除されます。本当によろしいですか?",
"error": {
"failed": "エージェントの削除に失敗しました"
},
"title": "エージェントを削除"
},
"edit": {
"title": "編集エージェント"
},
"session": {
"add": {
"title": "セッションを追加"
},
"create": {
"error": {
"failed": "セッションの追加に失敗しました"
}
},
"delete": {
"content": "このセッションを削除してもよろしいですか?",
"error": {
"failed": "セッションの削除に失敗しました"
},
"title": "セッションを削除"
},
"edit": {
"title": "編集セッション"
},
"get": {
"error": {
"failed": "セッションの取得に失敗しました"
}
},
"label_one": "セッション",
"label_other": "セッション",
"update": {
"error": {
"failed": "セッションの更新に失敗しました"
}
}
},
"update": {
"error": {
"failed": "エージェントの更新に失敗しました"
}
}
},
"agents": {
"add": {
"button": "アシスタントに追加",
@@ -737,9 +797,14 @@
},
"common": {
"add": "追加",
"add_success": "追加成功",
"advanced_settings": "詳細設定",
"agent_one": "エージェント",
"agent_other": "エージェント",
"and": "と",
"assistant": "アシスタント",
"assistant_one": "助手",
"assistant_other": "助手",
"avatar": "アバター",
"back": "戻る",
"browse": "参照",
@@ -756,6 +821,8 @@
"default": "デフォルト",
"delete": "削除",
"delete_confirm": "削除してもよろしいですか?",
"delete_failed": "削除に失敗しました",
"delete_success": "削除に成功しました",
"description": "説明",
"detail": "詳細",
"disabled": "無効",
@@ -765,6 +832,10 @@
"edit": "編集",
"enabled": "有効",
"error": "エラー",
"errors": {
"create_message": "メッセージの作成に失敗しました",
"validation": "検証に失敗しました"
},
"expand": "展開",
"file": {
"not_supported": "サポートされていないファイルタイプ {{type}}"
@@ -775,6 +846,7 @@
"go_to_settings": "設定に移動",
"i_know": "わかりました",
"inspect": "検査",
"invalid_value": "無効な値",
"knowledge_base": "ナレッジベース",
"language": "言語",
"loading": "読み込み中...",
@@ -786,6 +858,11 @@
"none": "無",
"open": "開く",
"paste": "貼り付け",
"placeholders": {
"select": {
"model": "モデルを選択"
}
},
"preview": "プレビュー",
"prompt": "プロンプト",
"provider": "プロバイダー",
@@ -812,6 +889,7 @@
"success": "成功",
"swap": "交換",
"topics": "トピック",
"update_success": "更新成功",
"upload_files": "ファイルをアップロードする",
"warning": "警告",
"you": "あなた"
@@ -884,6 +962,7 @@
"modelType": "モデルの種類",
"name": "エラー名",
"no_api_key": "APIキーが設定されていません",
"no_response": "応答なし",
"originalError": "元のエラー",
"originalMessage": "元のメッセージ",
"parameter": "パラメータ",

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "Falha ao adicionar agente",
"invalid_agent": "Agent inválido"
},
"title": "Adicionar Agente",
"type": {
"label": "Tipo de Agente",
"placeholder": "Selecionar tipo de Agente"
}
},
"delete": {
"content": "Excluir este Agente forçará a terminação e exclusão de todas as sessões sob ele. Tem certeza?",
"error": {
"failed": "Falha ao excluir o agente"
},
"title": "删除代理"
},
"edit": {
"title": "Agent Editor"
},
"session": {
"add": {
"title": "Adicionar uma sessão"
},
"create": {
"error": {
"failed": "Falha ao adicionar uma sessão"
}
},
"delete": {
"content": "Tem certeza de que deseja excluir esta sessão?",
"error": {
"failed": "Falha ao excluir a sessão"
},
"title": "Excluir sessão"
},
"edit": {
"title": "Sessão de edição"
},
"get": {
"error": {
"failed": "Falha ao obter a sessão"
}
},
"label_one": "Sessão",
"label_other": "Sessões",
"update": {
"error": {
"failed": "Falha ao atualizar a sessão"
}
},
"accessible_paths": {
"label": "Diretórios acessíveis",
"add": "Adicionar diretório",
"empty": "Selecione pelo menos um diretório ao qual o agente possa acessar.",
"required": "Por favor, selecione pelo menos um diretório acessível.",
"duplicate": "Este diretório já está incluído.",
"select_failed": "Falha ao selecionar o diretório."
}
},
"update": {
"error": {
"failed": "Falha ao atualizar o agente"
}
}
},
"agents": {
"add": {
"button": "Adicionar ao Assistente",
@@ -737,9 +805,14 @@
},
"common": {
"add": "Adicionar",
"add_success": "Adicionado com sucesso",
"advanced_settings": "Configurações Avançadas",
"agent_one": "Agente",
"agent_other": "Agentes",
"and": "e",
"assistant": "Agente Inteligente",
"assistant_one": "assistente",
"assistant_other": "assistente",
"avatar": "Avatar",
"back": "Voltar",
"browse": "Navegar",
@@ -756,6 +829,8 @@
"default": "Padrão",
"delete": "Excluir",
"delete_confirm": "Tem certeza de que deseja excluir?",
"delete_failed": "Falha ao excluir",
"delete_success": "Excluído com sucesso",
"description": "Descrição",
"detail": "detalhes",
"disabled": "Desativado",
@@ -765,6 +840,10 @@
"edit": "Editar",
"enabled": "Ativado",
"error": "错误",
"errors": {
"create_message": "Falha ao criar mensagem",
"validation": "Falha na verificação"
},
"expand": "Expandir",
"file": {
"not_supported": "Tipo de arquivo não suportado {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "Ir para configurações",
"i_know": "Entendi",
"inspect": "Verificar",
"invalid_value": "Valor inválido",
"knowledge_base": "Base de Conhecimento",
"language": "Língua",
"loading": "Carregando...",
@@ -786,6 +866,11 @@
"none": "Nenhum",
"open": "Abrir",
"paste": "Colar",
"placeholders": {
"select": {
"model": "Selecionar modelo"
}
},
"preview": "Pré-visualização",
"prompt": "Prompt",
"provider": "Fornecedor",
@@ -812,6 +897,7 @@
"success": "Sucesso",
"swap": "Trocar",
"topics": "Tópicos",
"update_success": "Atualização bem-sucedida",
"upload_files": "Carregar arquivo",
"warning": "Aviso",
"you": "Você"
@@ -884,6 +970,7 @@
"modelType": "Tipo de modelo",
"name": "Nome do erro",
"no_api_key": "A chave da API não foi configurada",
"no_response": "Sem resposta",
"originalError": "Erro original",
"originalMessage": "Mensagem original",
"parameter": "parâmetro",

View File

@@ -1,4 +1,72 @@
{
"agent": {
"add": {
"error": {
"failed": "Не удалось добавить агента",
"invalid_agent": "Недействительный агент"
},
"title": "Добавить агента",
"type": {
"label": "Тип агента",
"placeholder": "Выбор типа агента"
}
},
"delete": {
"content": "Удаление этого агента приведёт к принудительному завершению и удалению всех сессий, связанных с ним. Вы уверены?",
"error": {
"failed": "Не удалось удалить агента"
},
"title": "Удалить агента"
},
"edit": {
"title": "Редактировать агент"
},
"session": {
"add": {
"title": "Добавить сеанс"
},
"create": {
"error": {
"failed": "Не удалось добавить сеанс"
}
},
"delete": {
"content": "Вы уверены, что хотите удалить этот сеанс?",
"error": {
"failed": "Не удалось удалить сеанс"
},
"title": "Удалить сеанс"
},
"edit": {
"title": "Сессия редактирования"
},
"get": {
"error": {
"failed": "Не удалось получить сеанс"
}
},
"label_one": "Сессия",
"label_other": "Сессии",
"update": {
"error": {
"failed": "Не удалось обновить сеанс"
}
},
"accessible_paths": {
"label": "Доступные директории",
"add": "Добавить каталог",
"empty": "Выберите хотя бы один каталог, к которому агент имеет доступ.",
"required": "Пожалуйста, выберите хотя бы один доступный каталог.",
"duplicate": "Этот каталог уже включён.",
"select_failed": "Не удалось выбрать каталог."
}
},
"update": {
"error": {
"failed": "Не удалось обновить агента"
}
}
},
"agents": {
"add": {
"button": "Добавить в ассистента",
@@ -737,9 +805,14 @@
},
"common": {
"add": "Добавить",
"add_success": "Успешно добавлено",
"advanced_settings": "Дополнительные настройки",
"agent_one": "Агент",
"agent_other": "Агенты",
"and": "и",
"assistant": "Ассистент",
"assistant_one": "Помощник",
"assistant_other": "ассистент",
"avatar": "Аватар",
"back": "Назад",
"browse": "Обзор",
@@ -756,6 +829,8 @@
"default": "По умолчанию",
"delete": "Удалить",
"delete_confirm": "Вы уверены, что хотите удалить?",
"delete_failed": "Не удалось удалить",
"delete_success": "Удаление выполнено успешно",
"description": "Описание",
"detail": "Подробности",
"disabled": "Отключено",
@@ -765,6 +840,10 @@
"edit": "Редактировать",
"enabled": "Включено",
"error": "ошибка",
"errors": {
"create_message": "Не удалось создать сообщение",
"validation": "Ошибка проверки"
},
"expand": "Развернуть",
"file": {
"not_supported": "Неподдерживаемый тип файла {{type}}"
@@ -775,6 +854,7 @@
"go_to_settings": "Перейти в настройки",
"i_know": "Я понял",
"inspect": "Осмотреть",
"invalid_value": "недопустимое значение",
"knowledge_base": "База знаний",
"language": "Язык",
"loading": "Загрузка...",
@@ -786,6 +866,11 @@
"none": "без",
"open": "Открыть",
"paste": "Вставить",
"placeholders": {
"select": {
"model": "Выбор модели"
}
},
"preview": "Предварительный просмотр",
"prompt": "Промпт",
"provider": "Провайдер",
@@ -812,6 +897,7 @@
"success": "Успешно",
"swap": "Поменять местами",
"topics": "Топики",
"update_success": "Обновление выполнено успешно",
"upload_files": "Загрузить файл",
"warning": "Предупреждение",
"you": "Вы"
@@ -884,6 +970,7 @@
"modelType": "Тип модели",
"name": "Название ошибки",
"no_api_key": "Ключ API не настроен",
"no_response": "Нет ответа",
"originalError": "Исходная ошибка",
"originalMessage": "исходное сообщение",
"parameter": "параметр",

View File

@@ -4,10 +4,10 @@ import { HStack } from '@renderer/components/Layout'
import ListItem from '@renderer/components/ListItem'
import Scrollbar from '@renderer/components/Scrollbar'
import CustomTag from '@renderer/components/Tags/CustomTag'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types'
import { AssistantPreset } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Button, Empty, Flex, Input } from 'antd'
import { omit } from 'lodash'
@@ -17,65 +17,65 @@ import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import styled from 'styled-components'
import { groupByCategories, useSystemAgents } from '.'
import { groupTranslations } from './agentGroupTranslations'
import AddAgentPopup from './components/AddAgentPopup'
import AgentCard from './components/AgentCard'
import { AgentGroupIcon } from './components/AgentGroupIcon'
import ImportAgentPopup from './components/ImportAgentPopup'
import { groupByCategories, useSystemAssistantPresets } from '.'
import { groupTranslations } from './assistantPresetGroupTranslations'
import AddAssistantPresetPopup from './components/AddAssistantPresetPopup'
import AssistantPresetCard from './components/AssistantPresetCard'
import { AssistantPresetGroupIcon } from './components/AssistantPresetGroupIcon'
import ImportAssistantPresetPopup from './components/ImportAssistantPresetPopup'
const AgentsPage: FC = () => {
const AssistantPresetsPage: FC = () => {
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [activeGroup, setActiveGroup] = useState('我的')
const [agentGroups, setAgentGroups] = useState<Record<string, Agent[]>>({})
const [agentGroups, setAgentGroups] = useState<Record<string, AssistantPreset[]>>({})
const [isSearchExpanded, setIsSearchExpanded] = useState(false)
const systemAgents = useSystemAgents()
const { agents: userAgents } = useAgents()
const systemPresets = useSystemAssistantPresets()
const { presets: userPresets } = useAssistantPresets()
const { isTopNavbar } = useNavbarPosition()
useEffect(() => {
const systemAgentsGroupList = groupByCategories(systemAgents)
const systemAgentsGroupList = groupByCategories(systemPresets)
const agentsGroupList = {
我的: userAgents,
我的: userPresets,
: [],
...systemAgentsGroupList
} as Record<string, Agent[]>
} as Record<string, AssistantPreset[]>
setAgentGroups(agentsGroupList)
}, [systemAgents, userAgents])
}, [systemPresets, userPresets])
const filteredAgents = useMemo(() => {
const filteredPresets = useMemo(() => {
// 搜索框为空直接返回「我的」分组下的 agent
if (!search.trim()) {
return agentGroups[activeGroup] || []
}
const uniqueAgents = new Map<string, Agent>()
const uniquePresets = new Map<string, AssistantPreset>()
Object.entries(agentGroups).forEach(([, agents]) => {
agents.forEach((agent) => {
if (
agent.name.toLowerCase().includes(search.toLowerCase()) ||
agent.description?.toLowerCase().includes(search.toLowerCase())
) {
uniqueAgents.set(agent.id, agent)
uniquePresets.set(agent.id, agent)
}
})
})
return Array.from(uniqueAgents.values())
return Array.from(uniquePresets.values())
}, [agentGroups, activeGroup, search])
const { t, i18n } = useTranslation()
const onAddAgentConfirm = useCallback(
(agent: Agent) => {
const onAddPresetConfirm = useCallback(
(preset: AssistantPreset) => {
window.modal.confirm({
title: agent.name,
title: preset.name,
content: (
<Flex gap={16} vertical style={{ width: 'calc(100% + 12px)' }}>
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
{preset.description && <AgentDescription>{preset.description}</AgentDescription>}
{agent.prompt && (
{preset.prompt && (
<AgentPrompt className="markdown">
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
<ReactMarkdown>{preset.prompt}</ReactMarkdown>
</AgentPrompt>
)}
</Flex>
@@ -87,16 +87,16 @@ const AgentsPage: FC = () => {
centered: true,
okButtonProps: { type: 'primary' },
okText: t('agents.add.button'),
onOk: () => createAssistantFromAgent(agent)
onOk: () => createAssistantFromAgent(preset)
})
},
[t]
)
const getAgentFromSystemAgent = useCallback((agent: (typeof systemAgents)[number]) => {
const getPresetFromSystemPreset = useCallback((preset: (typeof systemPresets)[number]) => {
return {
...omit(agent, 'group'),
name: agent.name,
...omit(preset, 'group'),
name: preset.name,
id: uuid(),
topics: [],
type: 'agent'
@@ -161,14 +161,14 @@ const AgentsPage: FC = () => {
}
const handleAddAgent = () => {
AddAgentPopup.show().then(() => {
AddAssistantPresetPopup.show().then(() => {
handleSearchClear()
})
}
const handleImportAgent = async () => {
try {
await ImportAgentPopup.show()
await ImportAssistantPresetPopup.show()
} catch (error) {
window.toast.error(error instanceof Error ? error.message : t('message.agents.import.error'))
}
@@ -207,7 +207,7 @@ const AgentsPage: FC = () => {
title={
<Flex gap={16} align="center" justify="space-between">
<Flex gap={10} align="center">
<AgentGroupIcon groupName={group} />
<AssistantPresetGroupIcon groupName={group} />
{getLocalizedGroupName(group)}
</Flex>
{
@@ -229,19 +229,19 @@ const AgentsPage: FC = () => {
<AgentsListTitle>
{search.trim() ? (
<>
<AgentGroupIcon groupName="搜索" size={24} />
<AssistantPresetGroupIcon groupName="搜索" size={24} />
{search.trim()}{' '}
</>
) : (
<>
<AgentGroupIcon groupName={activeGroup} size={24} />
<AssistantPresetGroupIcon groupName={activeGroup} size={24} />
{getLocalizedGroupName(activeGroup)}
</>
)}
{
<CustomTag color="#A0A0A0" size={10}>
{filteredAgents.length}
{filteredPresets.length}
</CustomTag>
}
</AgentsListTitle>
@@ -282,13 +282,13 @@ const AgentsPage: FC = () => {
</Flex>
</AgentsListHeader>
{filteredAgents.length > 0 ? (
{filteredPresets.length > 0 ? (
<AgentsList>
{filteredAgents.map((agent, index) => (
<AgentCard
{filteredPresets.map((agent, index) => (
<AssistantPresetCard
key={agent.id || index}
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent))}
agent={agent}
onClick={() => onAddPresetConfirm(getPresetFromSystemPreset(agent))}
preset={agent}
activegroup={activeGroup}
getLocalizedGroupName={getLocalizedGroupName}
/>
@@ -390,4 +390,4 @@ const EmptyView = styled.div`
color: var(--color-text-secondary);
`
export default AgentsPage
export default AssistantPresetsPage

View File

@@ -1,3 +1,4 @@
// FIXME: Just use i18next!
export type GroupTranslations = {
[key: string]: {
'el-GR': string

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