diff --git a/CLAUDE.md b/CLAUDE.md index 9f5c9156e5..ed25053b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,37 +9,14 @@ This file provides guidance to AI coding assistants when working with code in th - **Correctness**: Ensure code is correct, well-tested, and robust. - **Efficiency**: Write performant code and use resources judiciously. -## Development Commands +## MUST Follow Rules -- **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 - -## Architecture - -### 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 - -### 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 -import { loggerService } from '@logger' -const logger = loggerService.withContext('moduleName') -// Renderer: loggerService.initWindowSource('windowName') first -logger.info('message', CONTEXT) -``` +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. **Session Documentation**: MUST Consistently maintain the session SDLC log file following the template structure outlined in the Session Tracking section. +5. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`. +6. **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. ## Session Tracking @@ -79,11 +56,34 @@ ALWAYS maintain a session log in `.sessions/YYYY-MM-DD-HH-MM-SS-.md`. T ``` -## MUST Follow Rules +## Development Commands -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. **Session Documentation**: Consistently maintain the session SDLC log file following the template structure outlined in the Session Tracking section. -5. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`. -6. **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. +- **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 + +## Project Architecture + +### 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 + +### 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 +import { loggerService } from '@logger' +const logger = loggerService.withContext('moduleName') +// Renderer: loggerService.initWindowSource('windowName') first +logger.info('message', CONTEXT) +``` diff --git a/package.json b/package.json index c7a9587d3a..9d4cecd31a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "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": "drizzle-kit generate --config src/main/services/agents/drizzle.config.ts", "agents:push": "drizzle-kit push --config src/main/services/agents/drizzle.config.ts", "agents:studio": "drizzle-kit studio --config src/main/services/agents/drizzle.config.ts", @@ -82,6 +81,7 @@ "@libsql/win32-x64-msvc": "^0.4.7", "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@strongtz/win32-arm64-msvc": "^0.4.7", + "@types/uuid": "^10.0.0", "drizzle-orm": "^0.44.5", "express": "^5.1.0", "express-validator": "^7.2.1", @@ -346,7 +346,7 @@ "typescript": "^5.6.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", diff --git a/src/main/apiServer/routes/agents/handlers/messages.ts b/src/main/apiServer/routes/agents/handlers/messages.ts index 5275315649..e290ec9de3 100644 --- a/src/main/apiServer/routes/agents/handlers/messages.ts +++ b/src/main/apiServer/routes/agents/handlers/messages.ts @@ -24,7 +24,7 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => { return session } -export const createMessage = async (req: Request, res: Response): Promise => { +export const createMessageStream = async (req: Request, res: Response): Promise => { try { const { agentId, sessionId } = req.params @@ -32,286 +32,155 @@ export const createMessage = async (req: Request, res: Response): Promise { + logger.info(`Client disconnected from streaming message for session: ${sessionId}`) + responseEnded = true + messageStream.removeAllListeners() + }) + + // Handle stream events + messageStream.on('data', (event: any) => { + if (responseEnded) return + + try { + switch (event.type) { + case 'chunk': + // Format UIMessageChunk as SSE event following AI SDK protocol + res.write(`data: ${JSON.stringify(event.chunk)}\n\n`) + break + + case 'error': { + // Send error as AI SDK error chunk + const errorChunk = { + type: 'error', + errorText: event.error?.message || 'Stream processing error' + } + res.write(`data: ${JSON.stringify(errorChunk)}\n\n`) + logger.error(`Streaming message error for session: ${sessionId}:`, event.error) + responseEnded = true + res.write('data: [DONE]\n\n') + res.end() + break + } + + case 'complete': + // Send completion marker following AI SDK protocol + logger.info(`Streaming message completed for session: ${sessionId}`) + responseEnded = true + res.write('data: [DONE]\n\n') + res.end() + break + + default: + // Handle other event types as generic data + res.write(`data: ${JSON.stringify(event)}\n\n`) + break + } + } catch (writeError) { + logger.error('Error writing to SSE stream:', { error: writeError }) + if (!responseEnded) { + responseEnded = true + res.end() } - }) - } - - logger.error('Error creating message:', error) - return res.status(500).json({ - error: { - message: 'Failed to create message', - type: 'internal_error', - code: 'message_creation_failed' } }) - } -} -export const createBulkMessages = async (req: Request, res: Response): Promise => { - try { - const { agentId, sessionId } = req.params + // Handle stream errors + messageStream.on('error', (error: Error) => { + if (responseEnded) return - await verifyAgentAndSession(agentId, sessionId) - - const messagesData = req.body.map((msg: any) => ({ ...msg, session_id: sessionId })) - - logger.info(`Creating ${messagesData.length} messages for session: ${sessionId}`) - logger.debug('Messages data:', messagesData) - - const messages = await sessionMessageService.bulkCreateSessionMessages(messagesData) - - logger.info(`${messages.length} messages created successfully for session: ${sessionId}`) - return res.status(201).json(messages) - } catch (error: any) { - if (error.status) { - return res.status(error.status).json({ - error: { - message: error.message, - type: 'not_found', - code: error.code - } - }) - } - - logger.error('Error creating bulk messages:', error) - return res.status(500).json({ - error: { - message: 'Failed to create messages', - type: 'internal_error', - code: 'bulk_message_creation_failed' + logger.error(`Stream error for session: ${sessionId}:`, { error }) + try { + res.write( + `data: ${JSON.stringify({ + type: 'error', + error: { + message: error.message || 'Stream processing error', + type: 'stream_error', + code: 'stream_processing_failed' + } + })}\n\n` + ) + } catch (writeError) { + logger.error('Error writing error to SSE stream:', { error: writeError }) } + responseEnded = true + res.end() }) - } -} -export const listMessages = async (req: Request, res: Response): Promise => { - try { - const { agentId, sessionId } = req.params + // 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 }) + } + responseEnded = true + res.end() + } + }, + 5 * 60 * 1000 + ) // 5 minutes timeout - await verifyAgentAndSession(agentId, sessionId) - - const limit = req.query.limit ? parseInt(req.query.limit as string) : 50 - const offset = req.query.offset ? parseInt(req.query.offset as string) : 0 - - logger.info(`Listing messages for session: ${sessionId} with limit=${limit}, offset=${offset}`) - - const result = await sessionMessageService.listSessionMessages(sessionId, { limit, offset }) - - logger.info(`Retrieved ${result.messages.length} messages (total: ${result.total}) for session: ${sessionId}`) - return res.json({ - data: result.messages, - total: result.total, - limit, - offset - }) + // Clear timeout when response ends + res.on('close', () => clearTimeout(timeout)) + res.on('finish', () => clearTimeout(timeout)) } catch (error: any) { - if (error.status) { - return res.status(error.status).json({ - error: { - message: error.message, - type: 'not_found', - code: error.code - } - }) + 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') } - logger.error('Error listing messages:', error) - return res.status(500).json({ - error: { - message: 'Failed to list messages', - type: 'internal_error', - code: 'message_list_failed' + 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' + } } - }) - } -} - -export const getMessage = async (req: Request, res: Response): Promise => { - try { - const { agentId, sessionId, messageId } = req.params - - await verifyAgentAndSession(agentId, sessionId) - - logger.info(`Getting message: ${messageId} for session: ${sessionId}`) - - const message = await sessionMessageService.getSessionMessage(parseInt(messageId)) - - if (!message) { - logger.warn(`Message not found: ${messageId}`) - return res.status(404).json({ - error: { - message: 'Message not found', - type: 'not_found', - code: 'message_not_found' - } - }) - } - - // Verify message belongs to the session - if (message.session_id !== sessionId) { - logger.warn(`Message ${messageId} does not belong to session ${sessionId}`) - return res.status(404).json({ - error: { - message: 'Message not found for this session', - type: 'not_found', - code: 'message_not_found' - } - }) - } - - logger.info(`Message retrieved successfully: ${messageId}`) - return res.json(message) - } catch (error: any) { - if (error.status) { - return res.status(error.status).json({ - error: { - message: error.message, - type: 'not_found', - code: error.code - } - }) - } - - logger.error('Error getting message:', error) - return res.status(500).json({ - error: { - message: 'Failed to get message', - type: 'internal_error', - code: 'message_get_failed' - } - }) - } -} - -export const updateMessage = async (req: Request, res: Response): Promise => { - try { - const { agentId, sessionId, messageId } = req.params - - await verifyAgentAndSession(agentId, sessionId) - - logger.info(`Updating message: ${messageId} for session: ${sessionId}`) - logger.debug('Update data:', req.body) - - // First check if message exists and belongs to session - const existingMessage = await sessionMessageService.getSessionMessage(parseInt(messageId)) - if (!existingMessage || existingMessage.session_id !== sessionId) { - logger.warn(`Message ${messageId} not found for session ${sessionId}`) - return res.status(404).json({ - error: { - message: 'Message not found for this session', - type: 'not_found', - code: 'message_not_found' - } - }) - } - - const message = await sessionMessageService.updateSessionMessage(parseInt(messageId), req.body) - - if (!message) { - logger.warn(`Message not found for update: ${messageId}`) - return res.status(404).json({ - error: { - message: 'Message not found', - type: 'not_found', - code: 'message_not_found' - } - }) - } - - logger.info(`Message updated successfully: ${messageId}`) - return res.json(message) - } catch (error: any) { - if (error.status) { - return res.status(error.status).json({ - error: { - message: error.message, - type: 'not_found', - code: error.code - } - }) - } - - logger.error('Error updating message:', error) - return res.status(500).json({ - error: { - message: 'Failed to update message', - type: 'internal_error', - code: 'message_update_failed' - } - }) - } -} - -export const deleteMessage = async (req: Request, res: Response): Promise => { - try { - const { agentId, sessionId, messageId } = req.params - - await verifyAgentAndSession(agentId, sessionId) - - logger.info(`Deleting message: ${messageId} for session: ${sessionId}`) - - // First check if message exists and belongs to session - const existingMessage = await sessionMessageService.getSessionMessage(parseInt(messageId)) - if (!existingMessage || existingMessage.session_id !== sessionId) { - logger.warn(`Message ${messageId} not found for session ${sessionId}`) - return res.status(404).json({ - error: { - message: 'Message not found for this session', - type: 'not_found', - code: 'message_not_found' - } - }) - } - - const deleted = await sessionMessageService.deleteSessionMessage(parseInt(messageId)) - - if (!deleted) { - logger.warn(`Message not found for deletion: ${messageId}`) - return res.status(404).json({ - error: { - message: 'Message not found', - type: 'not_found', - code: 'message_not_found' - } - }) - } - - logger.info(`Message deleted successfully: ${messageId}`) - return res.status(204).send() - } catch (error: any) { - if (error.status) { - return res.status(error.status).json({ - error: { - message: error.message, - type: 'not_found', - code: error.code - } - }) - } - - logger.error('Error deleting message:', error) - return res.status(500).json({ - error: { - message: 'Failed to delete message', - type: 'internal_error', - code: 'message_delete_failed' - } - }) + + res.write(`data: ${JSON.stringify(errorResponse)}\n\n`) + } catch (writeError) { + logger.error('Error writing initial error to SSE stream:', { error: writeError }) + } + + res.end() } } diff --git a/src/main/apiServer/routes/agents/index.ts b/src/main/apiServer/routes/agents/index.ts index c3b02b0a0f..171a78f24d 100644 --- a/src/main/apiServer/routes/agents/index.ts +++ b/src/main/apiServer/routes/agents/index.ts @@ -6,13 +6,10 @@ import { validateAgent, validateAgentId, validateAgentUpdate, - validateBulkSessionMessages, - validateMessageId, validatePagination, validateSession, validateSessionId, validateSessionMessage, - validateSessionMessageUpdate, validateSessionUpdate } from './validators' @@ -191,19 +188,7 @@ const createMessagesRouter = (): express.Router => { const messagesRouter = express.Router({ mergeParams: true }) // Message CRUD routes (nested under agent/session) - messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessage) - messagesRouter.post('/bulk', validateBulkSessionMessages, handleValidationErrors, messageHandlers.createBulkMessages) - messagesRouter.get('/', validatePagination, handleValidationErrors, messageHandlers.listMessages) - messagesRouter.get('/:messageId', validateMessageId, handleValidationErrors, messageHandlers.getMessage) - messagesRouter.put( - '/:messageId', - validateMessageId, - validateSessionMessageUpdate, - handleValidationErrors, - messageHandlers.updateMessage - ) - messagesRouter.delete('/:messageId', validateMessageId, handleValidationErrors, messageHandlers.deleteMessage) - + messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessageStream) return messagesRouter } diff --git a/src/main/apiServer/routes/agents/validators/messages.ts b/src/main/apiServer/routes/agents/validators/messages.ts index e1fafc7b2a..b210938520 100644 --- a/src/main/apiServer/routes/agents/validators/messages.ts +++ b/src/main/apiServer/routes/agents/validators/messages.ts @@ -1,24 +1,6 @@ -import { body, param } from 'express-validator' +import { body } from 'express-validator' export const validateSessionMessage = [ body('role').notEmpty().isIn(['user', 'agent', 'system', 'tool']).withMessage('Valid role is required'), - body('type').notEmpty().isString().withMessage('Type is required'), - body('content').notEmpty().isObject().withMessage('Content must be a valid object') -] - -export const validateSessionMessageUpdate = [ - body('content').optional().isObject().withMessage('Content must be a valid object') -] - -export const validateBulkSessionMessages = [ - body().isArray().withMessage('Request body must be an array'), - body('*.parent_id').optional().isInt({ min: 1 }).withMessage('Parent ID must be a positive integer'), - body('*.role').notEmpty().isIn(['user', 'agent', 'system', 'tool']).withMessage('Valid role is required'), - body('*.type').notEmpty().isString().withMessage('Type is required'), - body('*.content').notEmpty().isObject().withMessage('Content must be a valid object'), - body('*.metadata').optional().isObject().withMessage('Metadata must be a valid object') -] - -export const validateMessageId = [ - param('messageId').isInt({ min: 1 }).withMessage('Message ID must be a positive integer') + body('content').notEmpty().isString().withMessage('Content must be a valid string') ] diff --git a/src/main/index.ts b/src/main/index.ts index 9b2dd96fc7..fa5139bb5c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,17 +10,8 @@ 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' -// Enable live-reload for Electron app in development -// This will automatically restart the app when files change during development -if (isDev) { - require('electron-reload')(__dirname, { - electron: require('electron'), - hardResetMethod: 'exit' - }) -} import process from 'node:process' import { registerIpc } from './ipc' diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index 2b7e6b008f..52d29afc69 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -6,7 +6,6 @@ import path from 'path' import * as schema from './database/schema' import { dbPath } from './drizzle.config' -import { getSchemaInfo, needsInitialization, syncDatabaseSchema } from './schemaSyncer' const logger = loggerService.withContext('BaseService') @@ -66,15 +65,8 @@ export abstract class BaseService { BaseService.db = drizzle(BaseService.client, { schema }) - // Auto-sync database schema on startup - const result = await syncDatabaseSchema(BaseService.client) - - if (!result.success) { - throw result.error || new Error('Schema synchronization failed') - } - BaseService.isInitialized = true - logger.info(`Agent database initialized successfully (version: ${result.version})`) + logger.info('Agent database initialized successfully') return } catch (error) { lastError = error as Error @@ -157,61 +149,6 @@ export abstract class BaseService { return deserialized } - /** - * Check if database is healthy and initialized - */ - static async healthCheck(): Promise<{ - isHealthy: boolean - version?: string - error?: string - }> { - try { - if (!BaseService.isInitialized || !BaseService.client) { - return { isHealthy: false, error: 'Database not initialized' } - } - - const schemaInfo = await getSchemaInfo(BaseService.client) - if (!schemaInfo) { - return { isHealthy: false, error: 'Failed to get schema info' } - } - - return { - isHealthy: true, - version: schemaInfo.status === 'ready' ? 'latest' : 'unknown' - } - } catch (error) { - return { - isHealthy: false, - error: (error as Error).message - } - } - } - - /** - * Get database status for debugging - */ - static async getStatus() { - try { - if (!BaseService.client) { - return { status: 'not_initialized' } - } - - const schemaInfo = await getSchemaInfo(BaseService.client) - const needsInit = await needsInitialization(BaseService.client) - - return { - status: BaseService.isInitialized ? 'initialized' : 'initializing', - needsInitialization: needsInit, - schemaInfo - } - } catch (error) { - return { - status: 'error', - error: (error as Error).message - } - } - } - /** * Force re-initialization (for development/testing) */ diff --git a/src/main/services/agents/database/drizzle/0000_wild_baron_strucker.sql b/src/main/services/agents/database/drizzle/0000_bizarre_la_nuit.sql similarity index 79% rename from src/main/services/agents/database/drizzle/0000_wild_baron_strucker.sql rename to src/main/services/agents/database/drizzle/0000_bizarre_la_nuit.sql index 75f4e89910..11e60f74c2 100644 --- a/src/main/services/agents/database/drizzle/0000_wild_baron_strucker.sql +++ b/src/main/services/agents/database/drizzle/0000_bizarre_la_nuit.sql @@ -1,6 +1,6 @@ CREATE TABLE `agents` ( `id` text PRIMARY KEY NOT NULL, - `type` text DEFAULT 'custom' NOT NULL, + `type` text DEFAULT 'claude-code' NOT NULL, `name` text NOT NULL, `description` text, `avatar` text, @@ -13,19 +13,12 @@ CREATE TABLE `agents` ( `knowledges` text, `configuration` text, `accessible_paths` text, - `permission_mode` text DEFAULT 'readOnly', + `permission_mode` text DEFAULT 'default', `max_steps` integer DEFAULT 10, `created_at` text NOT NULL, `updated_at` text NOT NULL ); --> statement-breakpoint -CREATE TABLE `migrations` ( - `id` text PRIMARY KEY NOT NULL, - `description` text NOT NULL, - `executed_at` text NOT NULL, - `execution_time` integer -); ---> statement-breakpoint CREATE TABLE `session_messages` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` text NOT NULL, @@ -54,7 +47,7 @@ CREATE TABLE `sessions` ( `knowledges` text, `configuration` text, `accessible_paths` text, - `permission_mode` text DEFAULT 'readOnly', + `permission_mode` text DEFAULT 'default', `max_steps` integer DEFAULT 10, `created_at` text NOT NULL, `updated_at` text NOT NULL diff --git a/src/main/services/agents/database/drizzle/meta/0000_snapshot.json b/src/main/services/agents/database/drizzle/meta/0000_snapshot.json index 3052bd2c03..ce9720e751 100644 --- a/src/main/services/agents/database/drizzle/meta/0000_snapshot.json +++ b/src/main/services/agents/database/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "eaa59638-309f-4902-92fb-7528051ad1c3", + "id": "c8b65142-dcf4-4d20-8f0e-a17625b34fa7", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "agents": { @@ -20,7 +20,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'custom'" + "default": "'claude-code'" }, "name": { "name": "name", @@ -112,7 +112,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "'readOnly'" + "default": "'default'" }, "max_steps": { "name": "max_steps", @@ -143,44 +143,6 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "migrations": { - "name": "migrations", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "executed_at": { - "name": "executed_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "execution_time": { - "name": "execution_time", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, "session_messages": { "name": "session_messages", "columns": { @@ -369,7 +331,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "'readOnly'" + "default": "'default'" }, "max_steps": { "name": "max_steps", diff --git a/src/main/services/agents/database/drizzle/meta/_journal.json b/src/main/services/agents/database/drizzle/meta/_journal.json index e44efe1d01..39a34ff84d 100644 --- a/src/main/services/agents/database/drizzle/meta/_journal.json +++ b/src/main/services/agents/database/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1757901637668, - "tag": "0000_wild_baron_strucker", + "when": 1757946608023, + "tag": "0000_bizarre_la_nuit", "breakpoints": true } ] diff --git a/src/main/services/agents/database/schema/agents.schema.ts b/src/main/services/agents/database/schema/agents.schema.ts index ceb253b324..f92dd2787e 100644 --- a/src/main/services/agents/database/schema/agents.schema.ts +++ b/src/main/services/agents/database/schema/agents.schema.ts @@ -6,7 +6,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' export const agentsTable = sqliteTable('agents', { id: text('id').primaryKey(), - type: text('type').notNull().default('custom'), // 'claudeCode', 'codex', 'custom' + type: text('type').notNull().default('claude-code'), name: text('name').notNull(), description: text('description'), avatar: text('avatar'), @@ -19,7 +19,7 @@ export const agentsTable = sqliteTable('agents', { knowledges: text('knowledges'), // JSON array of enabled knowledge base IDs configuration: text('configuration'), // JSON, extensible settings like temperature, top_p accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access - permission_mode: text('permission_mode').default('readOnly'), // 'readOnly', 'acceptEdits', 'bypassPermissions' + permission_mode: text('permission_mode').default('default'), // 'readOnly', 'acceptEdits', 'bypassPermissions' max_steps: integer('max_steps').default(10), // Maximum number of steps the agent can take created_at: text('created_at').notNull(), updated_at: text('updated_at').notNull() diff --git a/src/main/services/agents/database/schema/sessions.schema.ts b/src/main/services/agents/database/schema/sessions.schema.ts index 6dfe4b9060..d564b8aa05 100644 --- a/src/main/services/agents/database/schema/sessions.schema.ts +++ b/src/main/services/agents/database/schema/sessions.schema.ts @@ -21,7 +21,7 @@ export const sessionsTable = sqliteTable('sessions', { knowledges: text('knowledges'), // JSON array of enabled knowledge base IDs configuration: text('configuration'), // JSON, extensible settings like temperature, top_p accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access - permission_mode: text('permission_mode').default('readOnly'), // 'readOnly', 'acceptEdits', 'bypassPermissions' + permission_mode: text('permission_mode').default('default'), max_steps: integer('max_steps').default(10), // Maximum number of steps the agent can take created_at: text('created_at').notNull(), updated_at: text('updated_at').notNull() diff --git a/src/main/services/agents/schemaSyncer.ts b/src/main/services/agents/schemaSyncer.ts deleted file mode 100644 index 860cd7f704..0000000000 --- a/src/main/services/agents/schemaSyncer.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { type Client } from '@libsql/client' -import { loggerService } from '@logger' -import { drizzle } from 'drizzle-orm/libsql' -import { migrate } from 'drizzle-orm/libsql/migrator' -import fs from 'fs' -import path from 'path' - -import * as schema from './database/schema' - -const logger = loggerService.withContext('SchemaSyncer') - -export interface MigrationResult { - success: boolean - version?: string - error?: Error - executionTime?: number -} - -/** - * Simplified database schema synchronization using native Drizzle migrations. - * This replaces the complex custom MigrationManager with Drizzle's built-in migration system. - */ -export async function syncDatabaseSchema(client: Client): Promise { - const startTime = Date.now() - - try { - logger.info('Starting database schema synchronization...') - - const db = drizzle(client, { schema }) - const migrationsFolder = path.resolve('./src/main/services/agents/database/drizzle') - - // Check if migrations folder exists - if (!fs.existsSync(migrationsFolder)) { - logger.warn('No migrations folder found, skipping migration') - return { - success: true, - version: 'none', - executionTime: Date.now() - startTime - } - } - - // Run migrations using Drizzle's built-in migrator - await migrate(db, { migrationsFolder }) - - const executionTime = Date.now() - startTime - logger.info(`Database schema synchronized successfully in ${executionTime}ms`) - - return { - success: true, - version: 'latest', - executionTime - } - } catch (error) { - const executionTime = Date.now() - startTime - logger.error('Schema synchronization failed:', error as Error) - return { - success: false, - error: error as Error, - executionTime - } - } -} - -/** - * Check if database needs initialization (simplified check) - */ -export async function needsInitialization(client: Client): Promise { - try { - // Simple check - try to query the agents table - await client.execute('SELECT COUNT(*) FROM agents LIMIT 1') - return false - } catch (error) { - // If query fails, database likely needs initialization - return true - } -} - -/** - * Get basic schema information for debugging - */ -export async function getSchemaInfo(client: Client) { - try { - // Get list of tables - const result = await client.execute(` - SELECT name FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - ORDER BY name - `) - - const tables = result.rows.map((row) => row.name as string) - - return { - tables, - status: 'ready' - } - } catch (error) { - logger.error('Failed to get schema info:', error as Error) - return { - tables: [], - status: 'error', - error: error as Error - } - } -} diff --git a/src/main/services/agents/services/AgentService.ts b/src/main/services/agents/services/AgentService.ts index d988923ef9..02a97f644a 100644 --- a/src/main/services/agents/services/AgentService.ts +++ b/src/main/services/agents/services/AgentService.ts @@ -1,8 +1,12 @@ +import path from 'node:path' + +import { getDataPath } from '@main/utils' import type { AgentEntity, AgentType, PermissionMode } from '@types' import { count, eq } from 'drizzle-orm' import { BaseService } from '../BaseService' import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema' +// import { builtinTools } from './claudecode/tools' export interface CreateAgentRequest { type: AgentType @@ -11,12 +15,11 @@ export interface CreateAgentRequest { avatar?: string instructions?: string model: string - plan_model?: string - small_model?: string - built_in_tools?: string[] - mcps?: string[] - knowledges?: string[] - configuration?: Record + // plan_model?: string + // small_model?: string + // mcps?: string[] + // knowledges?: string[] + // configuration?: Record accessible_paths?: string[] permission_mode?: PermissionMode max_steps?: number @@ -28,12 +31,11 @@ export interface UpdateAgentRequest { avatar?: string instructions?: string model?: string - plan_model?: string - small_model?: string - built_in_tools?: string[] - mcps?: string[] - knowledges?: string[] - configuration?: Record + // plan_model?: string + // small_model?: string + // mcps?: string[] + // knowledges?: string[] + // configuration?: Record accessible_paths?: string[] permission_mode?: PermissionMode max_steps?: number @@ -65,6 +67,11 @@ export class AgentService extends BaseService { const id = `agent_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` const now = new Date().toISOString() + if (!agentData.accessible_paths || agentData.accessible_paths.length === 0) { + const defaultPath = path.join(getDataPath(), 'agents', id) + agentData.accessible_paths = [defaultPath] + } + const serializedData = this.serializeJsonFields(agentData) const insertData: InsertAgentRow = { @@ -82,12 +89,17 @@ export class AgentService extends BaseService { knowledges: serializedData.knowledges || null, configuration: serializedData.configuration || null, accessible_paths: serializedData.accessible_paths || null, - permission_mode: serializedData.permission_mode || 'readOnly', + permission_mode: serializedData.permission_mode || 'default', max_steps: serializedData.max_steps || 10, created_at: now, updated_at: now } + if (serializedData.name === 'claude-code') { + // insertData.built_in_tools = JSON.stringify(builtinTools) + insertData.built_in_tools = JSON.stringify([]) + } + await this.database.insert(agentsTable).values(insertData) const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1) @@ -96,7 +108,8 @@ export class AgentService extends BaseService { throw new Error('Failed to create agent') } - return this.deserializeJsonFields(result[0]) as AgentEntity + const agent = this.deserializeJsonFields(result[0]) as AgentEntity + return agent } async getAgent(id: string): Promise { diff --git a/src/main/services/agents/services/SessionMessageService.ts b/src/main/services/agents/services/SessionMessageService.ts index 4a02c56b8c..051bb771fe 100644 --- a/src/main/services/agents/services/SessionMessageService.ts +++ b/src/main/services/agents/services/SessionMessageService.ts @@ -1,9 +1,13 @@ +import { EventEmitter } from 'node:events' + import { loggerService } from '@logger' -import type { SessionMessageEntity } from '@types' +import type { AgentSessionEntity, SessionMessageEntity } from '@types' +import { UIMessageChunk } from 'ai' import { count, eq } from 'drizzle-orm' import { BaseService } from '../BaseService' -import { type InsertSessionMessageRow, type SessionMessageRow, sessionMessagesTable } from '../database/schema' +import { type InsertSessionMessageRow, sessionMessagesTable } from '../database/schema' +import ClaudeCodeService from './claudecode' const logger = loggerService.withContext('SessionMessageService') @@ -12,7 +16,7 @@ export interface CreateSessionMessageRequest { parent_id?: number role: 'user' | 'agent' | 'system' | 'tool' type: string - content: Record + content: string metadata?: Record } @@ -40,57 +44,16 @@ export class SessionMessageService extends BaseService { await BaseService.initialize() } - async createSessionMessage(messageData: CreateSessionMessageRequest): Promise { - this.ensureInitialized() - - // Validate session exists - we'll need to import SessionService for this check - // For now, we'll skip this validation to avoid circular dependencies - // The database foreign key constraint will handle this - - // Validate parent exists if specified - if (messageData.parent_id) { - const parentExists = await this.sessionMessageExists(messageData.parent_id) - if (!parentExists) { - throw new Error(`Parent message with id ${messageData.parent_id} does not exist`) - } - } - - const now = new Date().toISOString() - - const insertData: InsertSessionMessageRow = { - session_id: messageData.session_id, - parent_id: messageData.parent_id || null, - role: messageData.role, - type: messageData.type, - content: JSON.stringify(messageData.content), - metadata: messageData.metadata ? JSON.stringify(messageData.metadata) : null, - created_at: now, - updated_at: now - } - - const result = await this.database.insert(sessionMessagesTable).values(insertData).returning() - - if (!result[0]) { - throw new Error('Failed to create session message') - } - - return this.deserializeSessionMessage(result[0]) as SessionMessageEntity - } - - async getSessionMessage(id: number): Promise { + async sessionMessageExists(id: number): Promise { this.ensureInitialized() const result = await this.database - .select() + .select({ id: sessionMessagesTable.id }) .from(sessionMessagesTable) .where(eq(sessionMessagesTable.id, id)) .limit(1) - if (!result[0]) { - return null - } - - return this.deserializeSessionMessage(result[0]) as SessionMessageEntity + return result.length > 0 } async listSessionMessages( @@ -126,66 +89,133 @@ export class SessionMessageService extends BaseService { return { messages, total } } - async updateSessionMessage(id: number, updates: UpdateSessionMessageRequest): Promise { + createSessionMessageStream(session: AgentSessionEntity, messageData: CreateSessionMessageRequest): EventEmitter { this.ensureInitialized() - // Check if message exists - const existing = await this.getSessionMessage(id) - if (!existing) { - return null + // Create a new EventEmitter to manage the session message lifecycle + const sessionStream = new EventEmitter() + + // Validate parent exists if specified + if (messageData.parent_id) { + this.sessionMessageExists(messageData.parent_id) + .then((exists) => { + if (!exists) { + process.nextTick(() => { + sessionStream.emit('data', { + type: 'error', + error: new Error(`Parent message with id ${messageData.parent_id} does not exist`) + }) + }) + return + } + + // Start the Claude Code stream after validation passes + this.startClaudeCodeStream(session, messageData, sessionStream) + }) + .catch((error) => { + process.nextTick(() => { + sessionStream.emit('data', { + type: 'error', + error: error as Error + }) + }) + }) + } else { + // No parent validation needed, start immediately + this.startClaudeCodeStream(session, messageData, sessionStream) } - const now = new Date().toISOString() - - const updateData: Partial = { - updated_at: now - } - - if (updates.content !== undefined) { - updateData.content = JSON.stringify(updates.content) - } - - if (updates.metadata !== undefined) { - updateData.metadata = updates.metadata ? JSON.stringify(updates.metadata) : null - } - - await this.database.update(sessionMessagesTable).set(updateData).where(eq(sessionMessagesTable.id, id)) - - return await this.getSessionMessage(id) + return sessionStream } - async deleteSessionMessage(id: number): Promise { - this.ensureInitialized() + private startClaudeCodeStream( + session: AgentSessionEntity, + messageData: CreateSessionMessageRequest, + sessionStream: EventEmitter + ): void { + const cc = new ClaudeCodeService() - const result = await this.database.delete(sessionMessagesTable).where(eq(sessionMessagesTable.id, id)) + // Create the streaming Claude Code invocation + const claudeStream = cc.invokeStream( + messageData.content, + session.accessible_paths[0], + session.external_session_id, + { + permissionMode: session.permission_mode, + maxTurns: session.max_steps + } + ) - return result.rowsAffected > 0 - } + let sessionMessage: SessionMessageEntity | null = null - async sessionMessageExists(id: number): Promise { - this.ensureInitialized() + // Handle Claude Code stream events + claudeStream.on('data', async (event: any) => { + try { + switch (event.type) { + case 'chunk': + // Forward UIMessageChunk directly + sessionStream.emit('data', { + type: 'chunk', + chunk: event.chunk as UIMessageChunk + }) + break - const result = await this.database - .select({ id: sessionMessagesTable.id }) - .from(sessionMessagesTable) - .where(eq(sessionMessagesTable.id, id)) - .limit(1) + case 'error': + sessionStream.emit('data', { + type: 'error', + error: event.error + }) + break - return result.length > 0 - } + case 'complete': { + // Save the final message to database when Claude Code completes + logger.info('Claude Code stream completed, saving message to database') - async bulkCreateSessionMessages(messages: CreateSessionMessageRequest[]): Promise { - this.ensureInitialized() + const now = new Date().toISOString() + const insertData: InsertSessionMessageRow = { + session_id: messageData.session_id, + parent_id: messageData.parent_id || null, + role: messageData.role, + type: messageData.type, + content: JSON.stringify(event.result), + metadata: messageData.metadata ? JSON.stringify(messageData.metadata) : null, + created_at: now, + updated_at: now + } - const results: SessionMessageEntity[] = [] + const result = await this.database.insert(sessionMessagesTable).values(insertData).returning() - // Use a transaction for bulk insert - for (const messageData of messages) { - const result = await this.createSessionMessage(messageData) - results.push(result) - } + if (result[0]) { + sessionMessage = this.deserializeSessionMessage(result[0]) as SessionMessageEntity + logger.info(`Session message saved with ID: ${sessionMessage.id}`) - return results + // Emit the complete event with the saved message + sessionStream.emit('data', { + type: 'complete', + result: event.result, + message: sessionMessage + }) + } else { + sessionStream.emit('data', { + type: 'error', + error: new Error('Failed to save session message to database') + }) + } + break + } + + default: + logger.warn('Unknown event type from Claude Code service:', { type: event.type }) + break + } + } catch (error) { + logger.error('Error handling Claude Code stream event:', { error }) + sessionStream.emit('data', { + type: 'error', + error: error as Error + }) + } + }) } private deserializeSessionMessage(data: any): SessionMessageEntity { diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index 0760b1743b..dc61542aa5 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -1,8 +1,8 @@ -import type { AgentSessionEntity, SessionStatus } from '@types' +import type { AgentSessionEntity, PermissionMode, SessionStatus } from '@types' import { and, count, eq, type SQL } from 'drizzle-orm' import { BaseService } from '../BaseService' -import { type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema' +import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema' export interface CreateSessionRequest { name?: string @@ -19,7 +19,7 @@ export interface CreateSessionRequest { knowledges?: string[] configuration?: Record accessible_paths?: string[] - permission_mode?: 'readOnly' | 'acceptEdits' | 'bypassPermissions' + permission_mode?: PermissionMode max_steps?: number } @@ -38,7 +38,7 @@ export interface UpdateSessionRequest { knowledges?: string[] configuration?: Record accessible_paths?: string[] - permission_mode?: 'readOnly' | 'acceptEdits' | 'bypassPermissions' + permission_mode?: PermissionMode max_steps?: number } @@ -69,9 +69,36 @@ export class SessionService extends BaseService { // 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, sessionData.main_agent_id)) + .limit(1) + if (!agents[0]) { + throw new Error('Agent not found') + } + const agent = this.deserializeJsonFields(agents[0]) as AgentSessionEntity + 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 + sessionData = { + ...{ + model: agent.model, + plan_model: agent.plan_model, + small_model: agent.small_model, + mcps: agent.mcps, + knowledges: agent.knowledges, + configuration: agent.configuration, + accessible_paths: agent.accessible_paths, + permission_mode: agent.permission_mode, + max_steps: agent.max_steps, + status: 'idle' + }, + ...sessionData + } + const serializedData = this.serializeJsonFields(sessionData) const insertData: InsertSessionRow = { @@ -85,7 +112,6 @@ export class SessionService extends BaseService { model: serializedData.model || null, plan_model: serializedData.plan_model || null, small_model: serializedData.small_model || null, - built_in_tools: serializedData.built_in_tools || null, mcps: serializedData.mcps || null, knowledges: serializedData.knowledges || null, configuration: serializedData.configuration || null, diff --git a/src/main/services/agents/services/claudecode/aisdk-stream-protocel.md b/src/main/services/agents/services/claudecode/aisdk-stream-protocel.md new file mode 100644 index 0000000000..b00da2a54c --- /dev/null +++ b/src/main/services/agents/services/claudecode/aisdk-stream-protocel.md @@ -0,0 +1,384 @@ +AI SDK UI functions such as `useChat` and `useCompletion` support both text streams and data streams. The stream protocol defines how the data is streamed to the frontend on top of the HTTP protocol. + +This page describes both protocols and how to use them in the backend and frontend. + +You can use this information to develop custom backends and frontends for your use case, e.g., to provide compatible API endpoints that are implemented in a different language such as Python. + +For instance, here's an example using [FastAPI](https://github.com/vercel/ai/tree/main/examples/next-fastapi) as a backend. + +## Text Stream Protocol + +A text stream contains chunks in plain text, that are streamed to the frontend. Each chunk is then appended together to form a full text response. + +Text streams are supported by `useChat`, `useCompletion`, and `useObject`. When you use `useChat` or `useCompletion`, you need to enable text streaming by setting the `streamProtocol` options to `text`. + +You can generate text streams with `streamText` in the backend. When you call `toTextStreamResponse()` on the result object, a streaming HTTP response is returned. + +Text streams only support basic text data. If you need to stream other types of data such as tool calls, use data streams. + +### Text Stream Example + +Here is a Next.js example that uses the text stream protocol: + +app/page.tsx + +```tsx +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { TextStreamChatTransport } from 'ai'; +import { useState } from 'react'; + +export default function Chat() { + const [input, setInput] = useState(''); + const { messages, sendMessage } = useChat({ + transport: new TextStreamChatTransport({ api: '/api/chat' }), + }); + + return ( +
+ {messages.map(message => ( +
+ {message.role === 'user' ? 'User: ' : 'AI: '} + {message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return
{part.text}
; + } + })} +
+ ))} + +
{ + e.preventDefault(); + sendMessage({ text: input }); + setInput(''); + }} + > + setInput(e.currentTarget.value)} + /> +
+
+ ); +} +``` + +## Data Stream Protocol + +A data stream follows a special protocol that the AI SDK provides to send information to the frontend. + +The data stream protocol uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling. + +The following stream parts are currently supported: + +### Message Start Part + +Indicates the beginning of a new message with metadata. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"start","messageId":"..."} +``` + +### Text Parts + +Text content is streamed using a start/delta/end pattern with unique IDs for each text block. + +#### Text Start Part + +Indicates the beginning of a text block. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"text-start","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} +``` + +#### Text Delta Part + +Contains incremental text content for the text block. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"text-delta","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d","delta":"Hello"} +``` + +#### Text End Part + +Indicates the completion of a text block. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"text-end","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"} +``` + +### Reasoning Parts + +Reasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block. + +#### Reasoning Start Part + +Indicates the beginning of a reasoning block. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"reasoning-start","id":"reasoning_123"} +``` + +#### Reasoning Delta Part + +Contains incremental reasoning content for the reasoning block. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"reasoning-delta","id":"reasoning_123","delta":"This is some reasoning"} +``` + +#### Reasoning End Part + +Indicates the completion of a reasoning block. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"reasoning-end","id":"reasoning_123"} +``` + +### Source Parts + +Source parts provide references to external content sources. + +#### Source URL Part + +References to external URLs. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"source-url","sourceId":"https://example.com","url":"https://example.com"} +``` + +#### Source Document Part + +References to documents or files. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"source-document","sourceId":"https://example.com","mediaType":"file","title":"Title"} +``` + +### File Part + +The file parts contain references to files with their media type. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"file","url":"https://example.com/file.png","mediaType":"image/png"} +``` + +### Data Parts + +Custom data parts allow streaming of arbitrary structured data with type-specific handling. + +Format: Server-Sent Event with JSON object where the type includes a custom suffix + +Example: + +``` +data: {"type":"data-weather","data":{"location":"SF","temperature":100}} +``` + +The `data-*` type pattern allows you to define custom data types that your frontend can handle specifically. + +The error parts are appended to the message as they are received. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"error","errorText":"error message"} +``` + +### Tool Input Start Part + +Indicates the beginning of tool input streaming. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"tool-input-start","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation"} +``` + +### Tool Input Delta Part + +Incremental chunks of tool input as it's being generated. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"tool-input-delta","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","inputTextDelta":"San Francisco"} +``` + +### Tool Input Available Part + +Indicates that tool input is complete and ready for execution. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"tool-input-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation","input":{"city":"San Francisco"}} +``` + +### Tool Output Available Part + +Contains the result of tool execution. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"tool-output-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","output":{"city":"San Francisco","weather":"sunny"}} +``` + +### Start Step Part + +A part indicating the start of a step. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"start-step"} +``` + +### Finish Step Part + +A part indicating that a step (i.e., one LLM API call in the backend) has been completed. + +This part is necessary to correctly process multiple stitched assistant calls, e.g. when calling tools in the backend, and using steps in `useChat` at the same time. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"finish-step"} +``` + +### Finish Message Part + +A part indicating the completion of a message. + +Format: Server-Sent Event with JSON object + +Example: + +``` +data: {"type":"finish"} +``` + +### Stream Termination + +The stream ends with a special `[DONE]` marker. + +Format: Server-Sent Event with literal `[DONE]` + +Example: + +``` +data: [DONE] +``` + +The data stream protocol is supported by `useChat` and `useCompletion` on the frontend and used by default.`useCompletion` only supports the `text` and `data` stream parts. + +On the backend, you can use `toUIMessageStreamResponse()` from the `streamText` result object to return a streaming HTTP response. + +### UI Message Stream Example + +Here is a Next.js example that uses the UI message stream protocol: + +app/page.tsx + +```tsx +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { useState } from 'react'; + +export default function Chat() { + const [input, setInput] = useState(''); + const { messages, sendMessage } = useChat(); + + return ( +
+ {messages.map(message => ( +
+ {message.role === 'user' ? 'User: ' : 'AI: '} + {message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return
{part.text}
; + } + })} +
+ ))} + +
{ + e.preventDefault(); + sendMessage({ text: input }); + setInput(''); + }} + > + setInput(e.currentTarget.value)} + /> +
+
+ ); +} +``` diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts new file mode 100644 index 0000000000..aca42ef747 --- /dev/null +++ b/src/main/services/agents/services/claudecode/index.ts @@ -0,0 +1,468 @@ +// src/main/services/agents/services/claudecode/index.ts +import { ChildProcess, spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' + +import { Options, SDKMessage } from '@anthropic-ai/claude-code' +import { loggerService } from '@logger' +import { UIMessageChunk } from 'ai' + +import { transformSDKMessageToUIChunk } from './transform' + +const require_ = createRequire(import.meta.url) +const logger = loggerService.withContext('ClaudeCodeService') + +interface ClaudeCodeResult { + success: boolean + stdout: string + stderr: string + jsonOutput: any[] + error?: Error + exitCode?: number +} + +interface ClaudeCodeStreamEvent { + type: 'message' | 'error' | 'complete' + data?: any + error?: Error + result?: ClaudeCodeResult +} + +class ClaudeCodeStream extends EventEmitter { + declare emit: (event: 'data', data: ClaudeCodeStreamEvent) => boolean + declare on: (event: 'data', listener: (data: ClaudeCodeStreamEvent) => void) => this + declare once: (event: 'data', listener: (data: ClaudeCodeStreamEvent) => void) => this +} + +// AI SDK compatible stream events +interface AISDKStreamEvent { + type: 'chunk' | 'error' | 'complete' + chunk?: UIMessageChunk + error?: Error + result?: ClaudeCodeResult +} + +class AISDKStream extends EventEmitter { + declare emit: (event: 'data', data: AISDKStreamEvent) => boolean + declare on: (event: 'data', listener: (data: AISDKStreamEvent) => void) => this + declare once: (event: 'data', listener: (data: AISDKStreamEvent) => void) => this +} + +class ClaudeCodeService { + 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, cwd: string, session_id?: string, base?: Options): Promise { + // Ensure Electron behaves like Node for any child process that resolves to process.execPath + // process.env.ELECTRON_RUN_AS_NODE = '1' + + const args: string[] = [this.claudeExecutablePath, '--output-format', 'stream-json', '--verbose', 'cwd', cwd] + + if (session_id) { + args.push('--resume', session_id) + } + if (base?.maxTurns) { + args.push('--max-turns', base.maxTurns.toString()) + } + if (base?.permissionMode) { + args.push('--permission-mode', base.permissionMode) + } + + args.push('--print', prompt) + + logger.info('Spawning Claude Code process', { args, cwd }) + + const p = spawn(process.execPath, args, { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + detached: false + }) + + // Log process creation + logger.info('Process created', { pid: p.pid }) + + // Close stdin immediately since we're passing the prompt via --print + if (p.stdin) { + p.stdin.end() + logger.debug('Closed stdin') + } + + return this.setupProcessHandlers(p) + } + + invokeStream(prompt: string, cwd: string, session_id?: string, base?: Options): EventEmitter { + const aiStream = new AISDKStream() + const rawStream = new ClaudeCodeStream() + + // Spawn process with same parameters as invoke + const args: string[] = [this.claudeExecutablePath, '--output-format', 'stream-json', '--verbose'] + + if (session_id) { + args.push('--resume', session_id) + } + if (base?.maxTurns) { + args.push('--max-turns', base.maxTurns.toString()) + } + if (base?.permissionMode) { + args.push('--permission-mode', base.permissionMode) + } + + args.push('--print', prompt) + + logger.info('Spawning Claude Code streaming process', { args, cwd }) + + const p = spawn(process.execPath, args, { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + detached: false + }) + + logger.info('Streaming process created', { pid: p.pid }) + + // Close stdin immediately + if (p.stdin) { + p.stdin.end() + logger.debug('Closed stdin for streaming process') + } + + this.setupStreamingHandlers(p, rawStream) + this.setupAISDKTransform(rawStream, aiStream) + + return aiStream + } + + /** + * Set up process event handlers for streaming output + */ + private setupStreamingHandlers(process: ChildProcess, stream: ClaudeCodeStream): void { + let stdoutData = '' + let stderrData = '' + const jsonOutput: any[] = [] + let hasCompleted = false + + const startTime = Date.now() + + // Handle stdout with streaming events + if (process.stdout) { + process.stdout.setEncoding('utf8') + process.stdout.on('data', (data: string) => { + stdoutData += data + logger.debug('Streaming stdout chunk:', { length: data.length }) + + // Parse JSON stream output line by line and emit events + const lines = data.split('\n') + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const parsed = JSON.parse(trimmed) + stream.emit('data', { type: 'message', data: parsed }) + logger.debug('Parsed JSON line', { parsed }) + } catch { + // If you expect NDJSON only, you may want to keep this in buffer instead of emitting. + stream.emit('data', { type: 'message', data: { text: trimmed } }) + logger.debug('Non-JSON line', { line: trimmed }) + } + } + }) + + process.stdout.on('end', () => { + logger.debug('Streaming stdout ended') + }) + } + + // Handle stderr + if (process.stderr) { + process.stderr.setEncoding('utf8') + process.stderr.on('data', (data: string) => { + stderrData += data + logger.warn('Streaming stderr chunk:', { data: data.trim() }) + + // Emit stderr as error events + stream.emit('data', { + type: 'error', + data: { stderr: data.trim() } + }) + }) + + process.stderr.on('end', () => { + logger.debug('Streaming stderr ended') + }) + } + + // Handle process completion + const completeProcess = (code: number | null, signal: NodeJS.Signals | null, error?: Error) => { + if (hasCompleted) return + hasCompleted = true + + const duration = Date.now() - startTime + const success = !error && code === 0 + + logger.info('Streaming process completed', { + code, + signal, + success, + duration, + stdoutLength: stdoutData.length, + stderrLength: stderrData.length, + jsonItems: jsonOutput.length, + error: error?.message + }) + + const result: ClaudeCodeResult = { + success, + stdout: stdoutData, + stderr: stderrData, + jsonOutput, + exitCode: code || undefined, + error + } + + // Emit completion event + stream.emit('data', { + type: 'complete', + result + }) + } + + // Handle process exit + process.on('exit', (code, signal) => { + completeProcess(code, signal) + }) + + // Handle process errors + process.on('error', (error) => { + const duration = Date.now() - startTime + logger.error('Streaming process error:', { + error: error.message, + duration, + stdoutLength: stdoutData.length, + stderrLength: stderrData.length + }) + + completeProcess(null, null, error) + }) + + // Handle close event as a fallback + process.on('close', (code, signal) => { + logger.debug('Streaming process closed', { code, signal }) + completeProcess(code, signal) + }) + + // Set timeout to prevent hanging + const timeout = setTimeout(() => { + if (!hasCompleted) { + logger.error('Streaming process timeout after 600 seconds', { + pid: process.pid, + stdoutLength: stdoutData.length, + stderrLength: stderrData.length, + jsonItems: jsonOutput.length + }) + process.kill('SIGTERM') + completeProcess(null, null, new Error('Process timeout after 600 seconds')) + } + }, 600 * 1000) + + // Clear timeout when process ends + process.on('exit', () => clearTimeout(timeout)) + process.on('error', () => clearTimeout(timeout)) + } + + /** + * Transform raw Claude Code stream events to AI SDK format + */ + private setupAISDKTransform(rawStream: ClaudeCodeStream, aiStream: AISDKStream): void { + rawStream.on('data', (event: ClaudeCodeStreamEvent) => { + try { + switch (event.type) { + case 'message': + // Transform SDKMessage to UIMessageChunk + if (event.data) { + const chunks = transformSDKMessageToUIChunk(event.data as SDKMessage) + for (const chunk of chunks) { + aiStream.emit('data', { type: 'chunk', chunk }) + } + } + break + + case 'error': + aiStream.emit('data', { type: 'error', error: event.error }) + break + + case 'complete': + aiStream.emit('data', { type: 'complete', result: event.result }) + break + + default: + logger.warn('Unknown raw stream event type:', { type: (event as any).type }) + break + } + } catch (error) { + logger.error('Error transforming stream event:', { error, event }) + aiStream.emit('data', { + type: 'error', + error: error instanceof Error ? error : new Error('Transform error') + }) + } + }) + } + + /** + * Set up process event handlers and return a promise that resolves with complete output + */ + private setupProcessHandlers(process: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let stdoutData = '' + let stderrData = '' + const jsonOutput: any[] = [] + let hasResolved = false + + const startTime = Date.now() + + // Handle stdout with proper encoding and buffering + if (process.stdout) { + process.stdout.setEncoding('utf8') + process.stdout.on('data', (data: string) => { + stdoutData += data + logger.debug('Agent stdout chunk:', { length: data.length }) + + // Parse JSON stream output line by line + const lines = data.split('\n') + for (const line of lines) { + if (line.trim()) { + try { + const parsed = JSON.parse(line.trim()) + jsonOutput.push(parsed) + logger.silly('Parsed JSON output:', parsed) + } catch (e) { + // Not JSON, might be plain text output + logger.debug('Non-JSON stdout line:', { line: line.trim() }) + } + } + } + }) + + process.stdout.on('end', () => { + logger.debug('Agent stdout stream ended') + }) + } + + // Handle stderr with proper encoding + if (process.stderr) { + process.stderr.setEncoding('utf8') + process.stderr.on('data', (data: string) => { + stderrData += data + logger.warn('Agent stderr chunk:', { data: data.trim() }) + }) + + process.stderr.on('end', () => { + logger.debug('Agent stderr stream ended') + }) + } + + // Handle process exit + process.on('exit', (code, signal) => { + const duration = Date.now() - startTime + const success = code === 0 + const status = success ? 'completed' : 'failed' + + logger.info('Agent process exited', { + code, + signal, + success, + status, + duration, + stdoutLength: stdoutData.length, + stderrLength: stderrData.length, + jsonItems: jsonOutput.length + }) + + if (!hasResolved) { + hasResolved = true + resolve({ + success, + stdout: stdoutData, + stderr: stderrData, + jsonOutput, + exitCode: code || undefined + }) + } + }) + + // Handle process errors + process.on('error', (error) => { + const duration = Date.now() - startTime + logger.error('Agent process error:', { + error: error.message, + duration, + stdoutLength: stdoutData.length, + stderrLength: stderrData.length + }) + + if (!hasResolved) { + hasResolved = true + reject({ + success: false, + stdout: stdoutData, + stderr: stderrData, + jsonOutput, + error + }) + } + }) + + // Handle close event as a fallback + process.on('close', (code, signal) => { + const duration = Date.now() - startTime + logger.debug('Agent process closed', { code, signal, duration }) + + // Only resolve here if exit event hasn't fired + if (!hasResolved) { + hasResolved = true + const success = code === 0 + resolve({ + success, + stdout: stdoutData, + stderr: stderrData, + jsonOutput, + exitCode: code || undefined + }) + } + }) + + // Set a timeout to prevent hanging indefinitely (reduced for debugging) + const timeout = setTimeout(() => { + if (!hasResolved) { + hasResolved = true + logger.error('Agent process timeout after 30 seconds', { + pid: process.pid, + stdoutLength: stdoutData.length, + stderrLength: stderrData.length, + jsonItems: jsonOutput.length + }) + process.kill('SIGTERM') + reject({ + success: false, + stdout: stdoutData, + stderr: stderrData, + jsonOutput, + error: new Error('Process timeout after 30 seconds') + }) + } + }, 30 * 1000) // 30 seconds timeout for debugging + + // Clear timeout when process ends + process.on('exit', () => clearTimeout(timeout)) + process.on('error', () => clearTimeout(timeout)) + }) + } +} + +export default ClaudeCodeService diff --git a/src/main/services/agents/services/claudecode/tools.ts b/src/main/services/agents/services/claudecode/tools.ts new file mode 100644 index 0000000000..96dd0eeb28 --- /dev/null +++ b/src/main/services/agents/services/claudecode/tools.ts @@ -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 } +] diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts new file mode 100644 index 0000000000..deacc30de6 --- /dev/null +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -0,0 +1,400 @@ +// This file is used to transform claude code json response to aisdk streaming format + +import { SDKMessage } from '@anthropic-ai/claude-code' +import { MessageParam } from '@anthropic-ai/sdk/resources' +import { loggerService } from '@logger' +import { UIMessageChunk } from 'ai' +import { v4 as uuidv4 } from 'uuid' + +const logger = loggerService.withContext('ClaudeCodeTransform') + +// Helper function to generate unique IDs for text blocks +const generateMessageId = (): string => { + return `msg_${uuidv4().replace(/-/g, '')}` +} + +// Helper function to extract text content from Anthropic messages +const extractTextContent = (message: MessageParam): string => { + if (typeof message.content === 'string') { + return message.content + } + + if (Array.isArray(message.content)) { + return message.content + .filter((block) => block.type === 'text') + .map((block) => ('text' in block ? block.text : '')) + .join('') + } + + return '' +} + +// Helper function to extract tool calls from assistant messages +const extractToolCalls = (message: any): any[] => { + if (!message.content || !Array.isArray(message.content)) { + return [] + } + + return message.content.filter((block: any) => block.type === 'tool_use') +} + +// Main transform function +export function transformSDKMessageToUIChunk(sdkMessage: SDKMessage): UIMessageChunk[] { + const chunks: UIMessageChunk[] = [] + + switch (sdkMessage.type) { + case 'assistant': + chunks.push(...handleAssistantMessage(sdkMessage)) + break + + case 'user': + chunks.push(...handleUserMessage(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: + // Handle unknown message types gracefully + logger.warn('Unknown SDKMessage type:', { type: (sdkMessage as any).type }) + break + } + + return chunks +} + +// Handle assistant messages +function handleAssistantMessage(message: Extract): UIMessageChunk[] { + const chunks: UIMessageChunk[] = [] + const messageId = generateMessageId() + + // Extract text content + const textContent = extractTextContent(message.message as MessageParam) + if (textContent) { + chunks.push( + { + type: 'text-start', + id: messageId, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id + } + } + }, + { + type: 'text-delta', + id: messageId, + delta: textContent, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id + } + } + }, + { + type: 'text-end', + id: messageId, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id + } + } + } + ) + } + + // Handle tool calls + const toolCalls = extractToolCalls(message.message) + for (const toolCall of toolCalls) { + chunks.push({ + type: 'tool-input-available', + toolCallId: toolCall.id, + toolName: toolCall.name, + input: toolCall.input, + providerExecuted: true + }) + } + + return chunks +} + +// Handle user messages +function handleUserMessage(message: Extract): UIMessageChunk[] { + const chunks: UIMessageChunk[] = [] + const messageId = generateMessageId() + + const textContent = extractTextContent(message.message) + if (textContent) { + chunks.push( + { + type: 'text-start', + id: messageId, + providerMetadata: { + anthropic: { + session_id: message.session_id, + role: 'user' + } + } + }, + { + type: 'text-delta', + id: messageId, + delta: textContent, + providerMetadata: { + anthropic: { + session_id: message.session_id, + role: 'user' + } + } + }, + { + type: 'text-end', + id: messageId, + providerMetadata: { + anthropic: { + session_id: message.session_id, + role: 'user' + } + } + } + ) + } + + return chunks +} + +// Handle stream events (real-time streaming) +function handleStreamEvent(message: Extract): UIMessageChunk[] { + const chunks: UIMessageChunk[] = [] + const event = message.event + + switch (event.type) { + case 'message_start': + // No specific UI chunk needed for message start in this protocol + break + + case 'content_block_start': + if (event.content_block?.type === 'text') { + chunks.push({ + type: 'text-start', + id: event.index?.toString() || generateMessageId(), + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id, + content_block_index: event.index + } + } + }) + } else if (event.content_block?.type === 'tool_use') { + chunks.push({ + type: 'tool-input-start', + toolCallId: event.content_block.id, + toolName: event.content_block.name, + providerExecuted: true + }) + } + break + + case 'content_block_delta': + if (event.delta?.type === 'text_delta') { + chunks.push({ + type: 'text-delta', + id: event.index?.toString() || generateMessageId(), + delta: event.delta.text, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id, + content_block_index: event.index + } + } + }) + } else if (event.delta?.type === 'input_json_delta') { + chunks.push({ + type: 'tool-input-delta', + toolCallId: (event as any).content_block?.id || '', + inputTextDelta: event.delta.partial_json + }) + } + break + + case 'content_block_stop': { + // Determine if this was a text block or tool use block + const blockId = event.index?.toString() || generateMessageId() + chunks.push({ + type: 'text-end', + id: blockId, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id, + content_block_index: event.index + } + } + }) + break + } + + 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): UIMessageChunk[] { + const chunks: UIMessageChunk[] = [] + + if (message.subtype === 'init') { + // System initialization - could emit as a data chunk or skip + chunks.push({ + type: 'data-system' as any, + data: { + type: 'init', + cwd: message.cwd, + tools: message.tools, + model: message.model, + mcp_servers: message.mcp_servers + } + }) + } else if (message.subtype === 'compact_boundary') { + chunks.push({ + type: 'data-system' as any, + data: { + type: 'compact_boundary', + metadata: message.compact_metadata + } + }) + } + + return chunks +} + +// Handle result messages (completion with usage stats) +function handleResultMessage(message: Extract): UIMessageChunk[] { + const chunks: UIMessageChunk[] = [] + + if (message.subtype === 'success') { + // Emit the final result text if available + if (message.result) { + const messageId = generateMessageId() + chunks.push( + { + type: 'text-start', + id: messageId, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id, + final_result: true + } + } + }, + { + type: 'text-delta', + id: messageId, + delta: message.result, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id, + final_result: true + } + } + }, + { + type: 'text-end', + id: messageId, + providerMetadata: { + anthropic: { + uuid: message.uuid, + session_id: message.session_id, + final_result: true + } + } + } + ) + } + + // Emit usage and cost data + chunks.push({ + type: 'data-usage' as any, + data: { + duration_ms: message.duration_ms, + duration_api_ms: message.duration_api_ms, + num_turns: message.num_turns, + total_cost_usd: message.total_cost_usd, + usage: message.usage, + modelUsage: message.modelUsage, + permission_denials: message.permission_denials + } + }) + } else { + // Handle error cases + chunks.push({ + type: 'error', + errorText: `${message.subtype}: Process failed after ${message.num_turns} turns` + }) + + // Still emit usage data for failed requests + chunks.push({ + type: 'data-usage' as any, + data: { + duration_ms: message.duration_ms, + duration_api_ms: message.duration_api_ms, + num_turns: message.num_turns, + total_cost_usd: message.total_cost_usd, + usage: message.usage, + modelUsage: message.modelUsage, + permission_denials: message.permission_denials + } + }) + } + + return chunks +} + +// Convenience function to transform a stream of SDKMessages +export function* transformSDKMessageStream(sdkMessages: SDKMessage[]): Generator { + for (const sdkMessage of sdkMessages) { + const chunks = transformSDKMessageToUIChunk(sdkMessage) + for (const chunk of chunks) { + yield chunk + } + } +} + +// Async version for async iterables +export async function* transformSDKMessageStreamAsync( + sdkMessages: AsyncIterable +): AsyncGenerator { + for await (const sdkMessage of sdkMessages) { + const chunks = transformSDKMessageToUIChunk(sdkMessage) + for (const chunk of chunks) { + yield chunk + } + } +} diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts index 446fbe63d6..493b837dd9 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -1,8 +1,9 @@ import { ImageFileMetadata } from '@types' import { readFile } from 'fs/promises' -import sharp from 'sharp' const preprocessImage = async (buffer: Buffer): Promise => { + // 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() diff --git a/src/renderer/src/types/agent.ts b/src/renderer/src/types/agent.ts index c4264dd0a4..611f2d593a 100644 --- a/src/renderer/src/types/agent.ts +++ b/src/renderer/src/types/agent.ts @@ -6,22 +6,33 @@ import { TextStreamPart } from 'ai' export type SessionStatus = 'idle' | 'running' | 'completed' | 'failed' | 'stopped' export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' export type SessionMessageRole = 'assistant' | 'user' | 'system' | 'tool' -export type AgentType = 'claude-code' | 'codex' | 'gemini-cli' +export type AgentType = 'claude-code' + +export const isAgentType = (type: string): type is AgentType => { + return ['claude-code'].includes(type) +} export type SessionMessageType = TextStreamPart>['type'] +export interface Tool { + id: string + name: string + description: string + requirePermissions: boolean +} + // Shared configuration interface for both agents and sessions export interface AgentConfiguration { model: string // Main Model ID (required) plan_model?: string // Optional plan/thinking model ID small_model?: string // Optional small/fast model ID - built_in_tools?: string[] // Array of built-in tool IDs + built_in_tools?: Tool[] // Array of built-in tool IDs mcps?: string[] // Array of MCP tool IDs knowledges?: string[] // Array of enabled knowledge base IDs configuration?: Record // Extensible settings like temperature, top_p - accessible_paths?: string[] // Array of directory paths the agent can access - permission_mode?: PermissionMode // Permission mode - max_steps?: number // Maximum number of steps the agent can take + accessible_paths: string[] // Array of directory paths the agent can access + permission_mode: PermissionMode // Permission mode + max_steps: number // Maximum number of steps the agent can take } // Agent entity representing an autonomous agent configuration diff --git a/tests/apis/agents/sessions.http b/tests/apis/agents/sessions.http index fa8bec5c1f..289fbc3eae 100644 --- a/tests/apis/agents/sessions.http +++ b/tests/apis/agents/sessions.http @@ -1,7 +1,8 @@ @host=http://localhost:23333 @token=cs-sk-af798ed4-7cf5-4fd7-ae4b-df203b164194 -@agent_id=agent_1757663884173_4tyeh3vqq +@agent_id=agent_1757947603408_t1y2mbnq4 +@session_id=session_1757947684264_z2wcwn8t7 ### List Sessions GET {{host}}/v1/agents/{{agent_id}}/sessions @@ -15,8 +16,8 @@ Authorization: Bearer {{token}} Content-Type: application/json { - "name": "Code Review Session", - "user_goal": "Review the newly implemented feature for bugs and improvements" + "name": "Joke telling Session", + "user_goal": "Tell me a funny joke" } ### Get Session Details @@ -41,12 +42,22 @@ Content-Type: application/json ### Create Session Message -POST {{host}}/v1/agents/{{agent_id}}/sessions/session_1757815260195_eldvompnv/messages +POST {{host}}/v1/agents/{{agent_id}}/sessions/{{session_id}}/messages Authorization: Bearer {{token}} Content-Type: application/json { - "role": "assistant", - "content": "Sure! Please provide the code or details of the feature you would like me to review.", - "parent_message_id": null + "role": "user", + "content": "a joke about programmers" +} + + +### Create Session Message Stream +POST {{host}}/v1/agents/{{agent_id}}/sessions/{{session_id}}/messages/stream +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "role": "user", + "content": "a joke about programmers" } diff --git a/yarn.lock b/yarn.lock index 74be2e4375..199a13d23c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14119,6 +14119,7 @@ __metadata: "@types/swagger-ui-express": "npm:^4.1.8" "@types/tinycolor2": "npm:^1" "@types/turndown": "npm:^5.0.5" + "@types/uuid": "npm:^10.0.0" "@types/word-extractor": "npm:^1" "@uiw/codemirror-extensions-langs": "npm:^4.25.1" "@uiw/codemirror-themes-all": "npm:^4.25.1" @@ -14269,7 +14270,7 @@ __metadata: typescript: "npm:^5.6.2" undici: "npm:6.21.2" unified: "npm:^11.0.5" - uuid: "npm:^10.0.0" + uuid: "npm:^13.0.0" vite: "npm:rolldown-vite@latest" vitest: "npm:^3.2.4" webdav: "npm:^5.8.0" @@ -29618,6 +29619,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^13.0.0": + version: 13.0.0 + resolution: "uuid@npm:13.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67 + languageName: node + linkType: hard + "uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1"