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.
This commit is contained in:
Vaayne 2025-09-16 15:12:03 +08:00
parent a8e2df6bed
commit 58dbb514e0
24 changed files with 1717 additions and 700 deletions

View File

@ -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. - **Correctness**: Ensure code is correct, well-tested, and robust.
- **Efficiency**: Write performant code and use resources judiciously. - **Efficiency**: Write performant code and use resources judiciously.
## Development Commands ## MUST Follow Rules
- **Install**: `yarn install` 1. **Code Search**: Use `ast-grep` for semantic code pattern searches when available. Fallback to `rg` (ripgrep) or `grep` for text-based searches.
- **Development**: `yarn dev` - Runs Electron app in development mode 2. **UI Framework**: Exclusively use **HeroUI** for all new UI components. The use of `antd` or `styled-components` is strictly **PROHIBITED**.
- **Debug**: `yarn debug` - Starts with debugging enabled, use chrome://inspect 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).
- **Build Check**: `yarn build:check` - REQUIRED before commits (lint + test + typecheck) 4. **Session Documentation**: MUST Consistently maintain the session SDLC log file following the template structure outlined in the Session Tracking section.
- **Test**: `yarn test` - Run all tests (Vitest) 5. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`.
- **Single Test**: `yarn test:main` or `yarn test:renderer` 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.
- **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)
```
## Session Tracking ## Session Tracking
@ -79,11 +56,34 @@ ALWAYS maintain a session log in `.sessions/YYYY-MM-DD-HH-MM-SS-<feature>.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. - **Install**: `yarn install`
2. **UI Framework**: Exclusively use **HeroUI** for all new UI components. The use of `antd` or `styled-components` is strictly **PROHIBITED**. - **Development**: `yarn dev` - Runs Electron app in development mode
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). - **Debug**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
4. **Session Documentation**: Consistently maintain the session SDLC log file following the template structure outlined in the Session Tracking section. - **Build Check**: `yarn build:check` - REQUIRED before commits (lint + test + typecheck)
5. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`. - **Test**: `yarn test` - Run all tests (Vitest)
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. - **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)
```

View File

@ -44,7 +44,6 @@
"release": "node scripts/version.js", "release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push", "publish": "yarn build:check && yarn release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -", "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: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: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", "agents:studio": "drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
@ -82,6 +81,7 @@
"@libsql/win32-x64-msvc": "^0.4.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", "@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", "@strongtz/win32-arm64-msvc": "^0.4.7",
"@types/uuid": "^10.0.0",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"express": "^5.1.0", "express": "^5.1.0",
"express-validator": "^7.2.1", "express-validator": "^7.2.1",
@ -346,7 +346,7 @@
"typescript": "^5.6.2", "typescript": "^5.6.2",
"undici": "6.21.2", "undici": "6.21.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"uuid": "^10.0.0", "uuid": "^13.0.0",
"vite": "npm:rolldown-vite@latest", "vite": "npm:rolldown-vite@latest",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"webdav": "^5.8.0", "webdav": "^5.8.0",

View File

@ -24,7 +24,7 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
return session return session
} }
export const createMessage = async (req: Request, res: Response): Promise<Response> => { export const createMessageStream = async (req: Request, res: Response): Promise<void> => {
try { try {
const { agentId, sessionId } = req.params const { agentId, sessionId } = req.params
@ -32,286 +32,155 @@ export const createMessage = async (req: Request, res: Response): Promise<Respon
const messageData = { ...req.body, session_id: sessionId } const messageData = { ...req.body, session_id: sessionId }
session.external_session_id logger.info(`Creating streaming message for session: ${sessionId}`)
logger.debug('Streaming message data:', messageData)
logger.info(`Creating new message for session: ${sessionId}`) // Set SSE headers
logger.debug('Message data:', messageData) 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 message = await sessionMessageService.createSessionMessage(messageData) // Send initial connection event
res.write('data: {"type":"connected"}\n\n')
logger.info(`Message created successfully: ${message.id}`) const messageStream = sessionMessageService.createSessionMessageStream(session, messageData)
return res.status(201).json(message)
} catch (error: any) { // Track if the response has ended to prevent further writes
if (error.status) { let responseEnded = false
return res.status(error.status).json({
error: { // Handle client disconnect
message: error.message, req.on('close', () => {
type: 'not_found', logger.info(`Client disconnected from streaming message for session: ${sessionId}`)
code: error.code 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<Response> => { // Handle stream errors
try { messageStream.on('error', (error: Error) => {
const { agentId, sessionId } = req.params if (responseEnded) return
await verifyAgentAndSession(agentId, sessionId) logger.error(`Stream error for session: ${sessionId}:`, { error })
try {
const messagesData = req.body.map((msg: any) => ({ ...msg, session_id: sessionId })) res.write(
`data: ${JSON.stringify({
logger.info(`Creating ${messagesData.length} messages for session: ${sessionId}`) type: 'error',
logger.debug('Messages data:', messagesData) error: {
message: error.message || 'Stream processing error',
const messages = await sessionMessageService.bulkCreateSessionMessages(messagesData) type: 'stream_error',
code: 'stream_processing_failed'
logger.info(`${messages.length} messages created successfully for session: ${sessionId}`) }
return res.status(201).json(messages) })}\n\n`
} catch (error: any) { )
if (error.status) { } catch (writeError) {
return res.status(error.status).json({ logger.error('Error writing error to SSE stream:', { error: writeError })
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'
} }
responseEnded = true
res.end()
}) })
}
}
export const listMessages = async (req: Request, res: Response): Promise<Response> => { // Set a timeout to prevent hanging indefinitely
try { const timeout = setTimeout(
const { agentId, sessionId } = req.params () => {
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) // Clear timeout when response ends
res.on('close', () => clearTimeout(timeout))
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50 res.on('finish', () => clearTimeout(timeout))
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
})
} catch (error: any) { } catch (error: any) {
if (error.status) { logger.error('Error in streaming message handler:', error)
return res.status(error.status).json({
error: { // Send error as SSE if possible
message: error.message, if (!res.headersSent) {
type: 'not_found', res.setHeader('Content-Type', 'text/event-stream')
code: error.code res.setHeader('Cache-Control', 'no-cache')
} res.setHeader('Connection', 'keep-alive')
})
} }
logger.error('Error listing messages:', error) try {
return res.status(500).json({ const errorResponse = {
error: { type: 'error',
message: 'Failed to list messages', error: {
type: 'internal_error', message: error.status ? error.message : 'Failed to create streaming message',
code: 'message_list_failed' 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 })
export const getMessage = async (req: Request, res: Response): Promise<Response> => { }
try {
const { agentId, sessionId, messageId } = req.params res.end()
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<Response> => {
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<Response> => {
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'
}
})
} }
} }

View File

@ -6,13 +6,10 @@ import {
validateAgent, validateAgent,
validateAgentId, validateAgentId,
validateAgentUpdate, validateAgentUpdate,
validateBulkSessionMessages,
validateMessageId,
validatePagination, validatePagination,
validateSession, validateSession,
validateSessionId, validateSessionId,
validateSessionMessage, validateSessionMessage,
validateSessionMessageUpdate,
validateSessionUpdate validateSessionUpdate
} from './validators' } from './validators'
@ -191,19 +188,7 @@ const createMessagesRouter = (): express.Router => {
const messagesRouter = express.Router({ mergeParams: true }) const messagesRouter = express.Router({ mergeParams: true })
// Message CRUD routes (nested under agent/session) // Message CRUD routes (nested under agent/session)
messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessage) messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessageStream)
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)
return messagesRouter return messagesRouter
} }

View File

@ -1,24 +1,6 @@
import { body, param } from 'express-validator' import { body } from 'express-validator'
export const validateSessionMessage = [ export const validateSessionMessage = [
body('role').notEmpty().isIn(['user', 'agent', 'system', 'tool']).withMessage('Valid role is required'), body('role').notEmpty().isIn(['user', 'agent', 'system', 'tool']).withMessage('Valid role is required'),
body('type').notEmpty().isString().withMessage('Type is required'), body('content').notEmpty().isString().withMessage('Content must be a valid string')
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')
] ]

View File

@ -10,17 +10,8 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron' import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer' import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { isDev, isLinux, isWin } from './constant' 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 process from 'node:process'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'

View File

@ -6,7 +6,6 @@ import path from 'path'
import * as schema from './database/schema' import * as schema from './database/schema'
import { dbPath } from './drizzle.config' import { dbPath } from './drizzle.config'
import { getSchemaInfo, needsInitialization, syncDatabaseSchema } from './schemaSyncer'
const logger = loggerService.withContext('BaseService') const logger = loggerService.withContext('BaseService')
@ -66,15 +65,8 @@ export abstract class BaseService {
BaseService.db = drizzle(BaseService.client, { schema }) 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 BaseService.isInitialized = true
logger.info(`Agent database initialized successfully (version: ${result.version})`) logger.info('Agent database initialized successfully')
return return
} catch (error) { } catch (error) {
lastError = error as Error lastError = error as Error
@ -157,61 +149,6 @@ export abstract class BaseService {
return deserialized 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) * Force re-initialization (for development/testing)
*/ */

View File

@ -1,6 +1,6 @@
CREATE TABLE `agents` ( CREATE TABLE `agents` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`type` text DEFAULT 'custom' NOT NULL, `type` text DEFAULT 'claude-code' NOT NULL,
`name` text NOT NULL, `name` text NOT NULL,
`description` text, `description` text,
`avatar` text, `avatar` text,
@ -13,19 +13,12 @@ CREATE TABLE `agents` (
`knowledges` text, `knowledges` text,
`configuration` text, `configuration` text,
`accessible_paths` text, `accessible_paths` text,
`permission_mode` text DEFAULT 'readOnly', `permission_mode` text DEFAULT 'default',
`max_steps` integer DEFAULT 10, `max_steps` integer DEFAULT 10,
`created_at` text NOT NULL, `created_at` text NOT NULL,
`updated_at` text NOT NULL `updated_at` text NOT NULL
); );
--> statement-breakpoint --> 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` ( CREATE TABLE `session_messages` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`session_id` text NOT NULL, `session_id` text NOT NULL,
@ -54,7 +47,7 @@ CREATE TABLE `sessions` (
`knowledges` text, `knowledges` text,
`configuration` text, `configuration` text,
`accessible_paths` text, `accessible_paths` text,
`permission_mode` text DEFAULT 'readOnly', `permission_mode` text DEFAULT 'default',
`max_steps` integer DEFAULT 10, `max_steps` integer DEFAULT 10,
`created_at` text NOT NULL, `created_at` text NOT NULL,
`updated_at` text NOT NULL `updated_at` text NOT NULL

View File

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "eaa59638-309f-4902-92fb-7528051ad1c3", "id": "c8b65142-dcf4-4d20-8f0e-a17625b34fa7",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"agents": { "agents": {
@ -20,7 +20,7 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false, "autoincrement": false,
"default": "'custom'" "default": "'claude-code'"
}, },
"name": { "name": {
"name": "name", "name": "name",
@ -112,7 +112,7 @@
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false, "autoincrement": false,
"default": "'readOnly'" "default": "'default'"
}, },
"max_steps": { "max_steps": {
"name": "max_steps", "name": "max_steps",
@ -143,44 +143,6 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "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": { "session_messages": {
"name": "session_messages", "name": "session_messages",
"columns": { "columns": {
@ -369,7 +331,7 @@
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
"autoincrement": false, "autoincrement": false,
"default": "'readOnly'" "default": "'default'"
}, },
"max_steps": { "max_steps": {
"name": "max_steps", "name": "max_steps",

View File

@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1757901637668, "when": 1757946608023,
"tag": "0000_wild_baron_strucker", "tag": "0000_bizarre_la_nuit",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@ -6,7 +6,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const agentsTable = sqliteTable('agents', { export const agentsTable = sqliteTable('agents', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
type: text('type').notNull().default('custom'), // 'claudeCode', 'codex', 'custom' type: text('type').notNull().default('claude-code'),
name: text('name').notNull(), name: text('name').notNull(),
description: text('description'), description: text('description'),
avatar: text('avatar'), avatar: text('avatar'),
@ -19,7 +19,7 @@ export const agentsTable = sqliteTable('agents', {
knowledges: text('knowledges'), // JSON array of enabled knowledge base IDs knowledges: text('knowledges'), // JSON array of enabled knowledge base IDs
configuration: text('configuration'), // JSON, extensible settings like temperature, top_p configuration: text('configuration'), // JSON, extensible settings like temperature, top_p
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access 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 max_steps: integer('max_steps').default(10), // Maximum number of steps the agent can take
created_at: text('created_at').notNull(), created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull() updated_at: text('updated_at').notNull()

View File

@ -21,7 +21,7 @@ export const sessionsTable = sqliteTable('sessions', {
knowledges: text('knowledges'), // JSON array of enabled knowledge base IDs knowledges: text('knowledges'), // JSON array of enabled knowledge base IDs
configuration: text('configuration'), // JSON, extensible settings like temperature, top_p configuration: text('configuration'), // JSON, extensible settings like temperature, top_p
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access 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 max_steps: integer('max_steps').default(10), // Maximum number of steps the agent can take
created_at: text('created_at').notNull(), created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull() updated_at: text('updated_at').notNull()

View File

@ -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<MigrationResult> {
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<boolean> {
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
}
}
}

View File

@ -1,8 +1,12 @@
import path from 'node:path'
import { getDataPath } from '@main/utils'
import type { AgentEntity, AgentType, PermissionMode } from '@types' import type { AgentEntity, AgentType, PermissionMode } from '@types'
import { count, eq } from 'drizzle-orm' import { count, eq } from 'drizzle-orm'
import { BaseService } from '../BaseService' import { BaseService } from '../BaseService'
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema' import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
// import { builtinTools } from './claudecode/tools'
export interface CreateAgentRequest { export interface CreateAgentRequest {
type: AgentType type: AgentType
@ -11,12 +15,11 @@ export interface CreateAgentRequest {
avatar?: string avatar?: string
instructions?: string instructions?: string
model: string model: string
plan_model?: string // plan_model?: string
small_model?: string // small_model?: string
built_in_tools?: string[] // mcps?: string[]
mcps?: string[] // knowledges?: string[]
knowledges?: string[] // configuration?: Record<string, any>
configuration?: Record<string, any>
accessible_paths?: string[] accessible_paths?: string[]
permission_mode?: PermissionMode permission_mode?: PermissionMode
max_steps?: number max_steps?: number
@ -28,12 +31,11 @@ export interface UpdateAgentRequest {
avatar?: string avatar?: string
instructions?: string instructions?: string
model?: string model?: string
plan_model?: string // plan_model?: string
small_model?: string // small_model?: string
built_in_tools?: string[] // mcps?: string[]
mcps?: string[] // knowledges?: string[]
knowledges?: string[] // configuration?: Record<string, any>
configuration?: Record<string, any>
accessible_paths?: string[] accessible_paths?: string[]
permission_mode?: PermissionMode permission_mode?: PermissionMode
max_steps?: number 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 id = `agent_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
const now = new Date().toISOString() 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 serializedData = this.serializeJsonFields(agentData)
const insertData: InsertAgentRow = { const insertData: InsertAgentRow = {
@ -82,12 +89,17 @@ export class AgentService extends BaseService {
knowledges: serializedData.knowledges || null, knowledges: serializedData.knowledges || null,
configuration: serializedData.configuration || null, configuration: serializedData.configuration || null,
accessible_paths: serializedData.accessible_paths || 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, max_steps: serializedData.max_steps || 10,
created_at: now, created_at: now,
updated_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) await this.database.insert(agentsTable).values(insertData)
const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1) 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') 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<AgentEntity | null> { async getAgent(id: string): Promise<AgentEntity | null> {

View File

@ -1,9 +1,13 @@
import { EventEmitter } from 'node:events'
import { loggerService } from '@logger' 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 { count, eq } from 'drizzle-orm'
import { BaseService } from '../BaseService' 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') const logger = loggerService.withContext('SessionMessageService')
@ -12,7 +16,7 @@ export interface CreateSessionMessageRequest {
parent_id?: number parent_id?: number
role: 'user' | 'agent' | 'system' | 'tool' role: 'user' | 'agent' | 'system' | 'tool'
type: string type: string
content: Record<string, any> content: string
metadata?: Record<string, any> metadata?: Record<string, any>
} }
@ -40,57 +44,16 @@ export class SessionMessageService extends BaseService {
await BaseService.initialize() await BaseService.initialize()
} }
async createSessionMessage(messageData: CreateSessionMessageRequest): Promise<SessionMessageEntity> { async sessionMessageExists(id: number): Promise<boolean> {
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<SessionMessageEntity | null> {
this.ensureInitialized() this.ensureInitialized()
const result = await this.database const result = await this.database
.select() .select({ id: sessionMessagesTable.id })
.from(sessionMessagesTable) .from(sessionMessagesTable)
.where(eq(sessionMessagesTable.id, id)) .where(eq(sessionMessagesTable.id, id))
.limit(1) .limit(1)
if (!result[0]) { return result.length > 0
return null
}
return this.deserializeSessionMessage(result[0]) as SessionMessageEntity
} }
async listSessionMessages( async listSessionMessages(
@ -126,66 +89,133 @@ export class SessionMessageService extends BaseService {
return { messages, total } return { messages, total }
} }
async updateSessionMessage(id: number, updates: UpdateSessionMessageRequest): Promise<SessionMessageEntity | null> { createSessionMessageStream(session: AgentSessionEntity, messageData: CreateSessionMessageRequest): EventEmitter {
this.ensureInitialized() this.ensureInitialized()
// Check if message exists // Create a new EventEmitter to manage the session message lifecycle
const existing = await this.getSessionMessage(id) const sessionStream = new EventEmitter()
if (!existing) {
return null // 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() return sessionStream
const updateData: Partial<SessionMessageRow> = {
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)
} }
async deleteSessionMessage(id: number): Promise<boolean> { private startClaudeCodeStream(
this.ensureInitialized() 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<boolean> { // Handle Claude Code stream events
this.ensureInitialized() 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 case 'error':
.select({ id: sessionMessagesTable.id }) sessionStream.emit('data', {
.from(sessionMessagesTable) type: 'error',
.where(eq(sessionMessagesTable.id, id)) error: event.error
.limit(1) })
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<SessionMessageEntity[]> { const now = new Date().toISOString()
this.ensureInitialized() 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 if (result[0]) {
for (const messageData of messages) { sessionMessage = this.deserializeSessionMessage(result[0]) as SessionMessageEntity
const result = await this.createSessionMessage(messageData) logger.info(`Session message saved with ID: ${sessionMessage.id}`)
results.push(result)
}
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 { private deserializeSessionMessage(data: any): SessionMessageEntity {

View File

@ -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 { and, count, eq, type SQL } from 'drizzle-orm'
import { BaseService } from '../BaseService' 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 { export interface CreateSessionRequest {
name?: string name?: string
@ -19,7 +19,7 @@ export interface CreateSessionRequest {
knowledges?: string[] knowledges?: string[]
configuration?: Record<string, any> configuration?: Record<string, any>
accessible_paths?: string[] accessible_paths?: string[]
permission_mode?: 'readOnly' | 'acceptEdits' | 'bypassPermissions' permission_mode?: PermissionMode
max_steps?: number max_steps?: number
} }
@ -38,7 +38,7 @@ export interface UpdateSessionRequest {
knowledges?: string[] knowledges?: string[]
configuration?: Record<string, any> configuration?: Record<string, any>
accessible_paths?: string[] accessible_paths?: string[]
permission_mode?: 'readOnly' | 'acceptEdits' | 'bypassPermissions' permission_mode?: PermissionMode
max_steps?: number max_steps?: number
} }
@ -69,9 +69,36 @@ export class SessionService extends BaseService {
// For now, we'll skip this validation to avoid circular dependencies // For now, we'll skip this validation to avoid circular dependencies
// The database foreign key constraint will handle this // 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 id = `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
const now = new Date().toISOString() 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 serializedData = this.serializeJsonFields(sessionData)
const insertData: InsertSessionRow = { const insertData: InsertSessionRow = {
@ -85,7 +112,6 @@ export class SessionService extends BaseService {
model: serializedData.model || null, model: serializedData.model || null,
plan_model: serializedData.plan_model || null, plan_model: serializedData.plan_model || null,
small_model: serializedData.small_model || null, small_model: serializedData.small_model || null,
built_in_tools: serializedData.built_in_tools || null,
mcps: serializedData.mcps || null, mcps: serializedData.mcps || null,
knowledges: serializedData.knowledges || null, knowledges: serializedData.knowledges || null,
configuration: serializedData.configuration || null, configuration: serializedData.configuration || null,

View File

@ -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 (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map(message => (
<div key={message.id} className="whitespace-pre-wrap">
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <div key={\`${message.id}-${i}\`}>{part.text}</div>;
}
})}
</div>
))}
<form
onSubmit={e => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input
className="fixed dark:bg-zinc-900 bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 dark:border-zinc-800 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={e => setInput(e.currentTarget.value)}
/>
</form>
</div>
);
}
```
## 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 (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map(message => (
<div key={message.id} className="whitespace-pre-wrap">
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <div key={\`${message.id}-${i}\`}>{part.text}</div>;
}
})}
</div>
))}
<form
onSubmit={e => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input
className="fixed dark:bg-zinc-900 bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 dark:border-zinc-800 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={e => setInput(e.currentTarget.value)}
/>
</form>
</div>
);
}
```

View File

@ -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<ClaudeCodeResult> {
// 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<ClaudeCodeResult> {
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

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,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<SDKMessage, { type: 'assistant' }>): 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<SDKMessage, { type: 'user' }>): 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<SDKMessage, { type: 'stream_event' }>): 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<SDKMessage, { type: 'system' }>): 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<SDKMessage, { type: 'result' }>): 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<UIMessageChunk> {
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<SDKMessage>
): AsyncGenerator<UIMessageChunk> {
for await (const sdkMessage of sdkMessages) {
const chunks = transformSDKMessageToUIChunk(sdkMessage)
for (const chunk of chunks) {
yield chunk
}
}
}

View File

@ -1,8 +1,9 @@
import { ImageFileMetadata } from '@types' import { ImageFileMetadata } from '@types'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import sharp from 'sharp'
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => { 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) return sharp(buffer)
.grayscale() // 转为灰度 .grayscale() // 转为灰度
.normalize() .normalize()

View File

@ -6,22 +6,33 @@ import { TextStreamPart } from 'ai'
export type SessionStatus = 'idle' | 'running' | 'completed' | 'failed' | 'stopped' export type SessionStatus = 'idle' | 'running' | 'completed' | 'failed' | 'stopped'
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
export type SessionMessageRole = 'assistant' | 'user' | 'system' | 'tool' 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<Record<string, any>>['type'] export type SessionMessageType = TextStreamPart<Record<string, any>>['type']
export interface Tool {
id: string
name: string
description: string
requirePermissions: boolean
}
// Shared configuration interface for both agents and sessions // Shared configuration interface for both agents and sessions
export interface AgentConfiguration { export interface AgentConfiguration {
model: string // Main Model ID (required) model: string // Main Model ID (required)
plan_model?: string // Optional plan/thinking model ID plan_model?: string // Optional plan/thinking model ID
small_model?: string // Optional small/fast 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 mcps?: string[] // Array of MCP tool IDs
knowledges?: string[] // Array of enabled knowledge base IDs knowledges?: string[] // Array of enabled knowledge base IDs
configuration?: Record<string, any> // Extensible settings like temperature, top_p configuration?: Record<string, any> // Extensible settings like temperature, top_p
accessible_paths?: string[] // Array of directory paths the agent can access accessible_paths: string[] // Array of directory paths the agent can access
permission_mode?: PermissionMode // Permission mode permission_mode: PermissionMode // Permission mode
max_steps?: number // Maximum number of steps the agent can take max_steps: number // Maximum number of steps the agent can take
} }
// Agent entity representing an autonomous agent configuration // Agent entity representing an autonomous agent configuration

View File

@ -1,7 +1,8 @@
@host=http://localhost:23333 @host=http://localhost:23333
@token=cs-sk-af798ed4-7cf5-4fd7-ae4b-df203b164194 @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 ### List Sessions
GET {{host}}/v1/agents/{{agent_id}}/sessions GET {{host}}/v1/agents/{{agent_id}}/sessions
@ -15,8 +16,8 @@ Authorization: Bearer {{token}}
Content-Type: application/json Content-Type: application/json
{ {
"name": "Code Review Session", "name": "Joke telling Session",
"user_goal": "Review the newly implemented feature for bugs and improvements" "user_goal": "Tell me a funny joke"
} }
### Get Session Details ### Get Session Details
@ -41,12 +42,22 @@ Content-Type: application/json
### Create Session Message ### 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}} Authorization: Bearer {{token}}
Content-Type: application/json Content-Type: application/json
{ {
"role": "assistant", "role": "user",
"content": "Sure! Please provide the code or details of the feature you would like me to review.", "content": "a joke about programmers"
"parent_message_id": null }
### 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"
} }

View File

@ -14119,6 +14119,7 @@ __metadata:
"@types/swagger-ui-express": "npm:^4.1.8" "@types/swagger-ui-express": "npm:^4.1.8"
"@types/tinycolor2": "npm:^1" "@types/tinycolor2": "npm:^1"
"@types/turndown": "npm:^5.0.5" "@types/turndown": "npm:^5.0.5"
"@types/uuid": "npm:^10.0.0"
"@types/word-extractor": "npm:^1" "@types/word-extractor": "npm:^1"
"@uiw/codemirror-extensions-langs": "npm:^4.25.1" "@uiw/codemirror-extensions-langs": "npm:^4.25.1"
"@uiw/codemirror-themes-all": "npm:^4.25.1" "@uiw/codemirror-themes-all": "npm:^4.25.1"
@ -14269,7 +14270,7 @@ __metadata:
typescript: "npm:^5.6.2" typescript: "npm:^5.6.2"
undici: "npm:6.21.2" undici: "npm:6.21.2"
unified: "npm:^11.0.5" unified: "npm:^11.0.5"
uuid: "npm:^10.0.0" uuid: "npm:^13.0.0"
vite: "npm:rolldown-vite@latest" vite: "npm:rolldown-vite@latest"
vitest: "npm:^3.2.4" vitest: "npm:^3.2.4"
webdav: "npm:^5.8.0" webdav: "npm:^5.8.0"
@ -29618,6 +29619,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "uuid@npm:^9.0.1":
version: 9.0.1 version: 9.0.1
resolution: "uuid@npm:9.0.1" resolution: "uuid@npm:9.0.1"