mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-03 19:30:04 +08:00
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:
parent
a8e2df6bed
commit
58dbb514e0
74
CLAUDE.md
74
CLAUDE.md
@ -9,37 +9,14 @@ This file provides guidance to AI coding assistants when working with code in th
|
|||||||
- **Correctness**: Ensure code is correct, well-tested, and robust.
|
- **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)
|
||||||
|
```
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 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> {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
468
src/main/services/agents/services/claudecode/index.ts
Normal file
468
src/main/services/agents/services/claudecode/index.ts
Normal 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
|
||||||
48
src/main/services/agents/services/claudecode/tools.ts
Normal file
48
src/main/services/agents/services/claudecode/tools.ts
Normal 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 }
|
||||||
|
]
|
||||||
400
src/main/services/agents/services/claudecode/transform.ts
Normal file
400
src/main/services/agents/services/claudecode/transform.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
12
yarn.lock
12
yarn.lock
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user