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:
@@ -24,7 +24,7 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
|
||||
return session
|
||||
}
|
||||
|
||||
export const createMessage = async (req: Request, res: Response): Promise<Response> => {
|
||||
export const createMessageStream = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
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 }
|
||||
|
||||
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}`)
|
||||
logger.debug('Message data:', messageData)
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
|
||||
|
||||
const message = await sessionMessageService.createSessionMessage(messageData)
|
||||
// Send initial connection event
|
||||
res.write('data: {"type":"connected"}\n\n')
|
||||
|
||||
logger.info(`Message created successfully: ${message.id}`)
|
||||
return res.status(201).json(message)
|
||||
} catch (error: any) {
|
||||
if (error.status) {
|
||||
return res.status(error.status).json({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: 'not_found',
|
||||
code: error.code
|
||||
const messageStream = sessionMessageService.createSessionMessageStream(session, messageData)
|
||||
|
||||
// Track if the response has ended to prevent further writes
|
||||
let responseEnded = false
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<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'
|
||||
}
|
||||
})
|
||||
|
||||
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing initial error to SSE stream:', { error: writeError })
|
||||
}
|
||||
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
+3
-10
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1757901637668,
|
||||
"tag": "0000_wild_baron_strucker",
|
||||
"when": 1757946608023,
|
||||
"tag": "0000_bizarre_la_nuit",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>
|
||||
// plan_model?: string
|
||||
// small_model?: string
|
||||
// mcps?: string[]
|
||||
// knowledges?: string[]
|
||||
// configuration?: Record<string, any>
|
||||
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<string, any>
|
||||
// plan_model?: string
|
||||
// small_model?: string
|
||||
// mcps?: string[]
|
||||
// knowledges?: string[]
|
||||
// configuration?: Record<string, any>
|
||||
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<AgentEntity | null> {
|
||||
|
||||
@@ -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<string, any>
|
||||
content: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
@@ -40,57 +44,16 @@ export class SessionMessageService extends BaseService {
|
||||
await BaseService.initialize()
|
||||
}
|
||||
|
||||
async createSessionMessage(messageData: CreateSessionMessageRequest): Promise<SessionMessageEntity> {
|
||||
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> {
|
||||
async sessionMessageExists(id: number): Promise<boolean> {
|
||||
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<SessionMessageEntity | null> {
|
||||
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<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)
|
||||
return sessionStream
|
||||
}
|
||||
|
||||
async deleteSessionMessage(id: number): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<SessionMessageEntity[]> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, any>
|
||||
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<string, any>
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ImageFileMetadata } from '@types'
|
||||
import { readFile } from 'fs/promises'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => {
|
||||
// Delayed loading: The Sharp module is only loaded when the OCR functionality is actually needed, not at app startup
|
||||
const sharp = (await import('sharp')).default
|
||||
return sharp(buffer)
|
||||
.grayscale() // 转为灰度
|
||||
.normalize()
|
||||
|
||||
@@ -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<Record<string, any>>['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<string, any> // 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
|
||||
|
||||
Reference in New Issue
Block a user