Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86a2780e2c | |||
| d960a42d6e | |||
| 3ae1b3d4cb | |||
| 1c045231c8 | |||
| 282aa6e81a | |||
| 117e390cf1 | |||
| 34b05a138b | |||
| 6c233fef9f | |||
| 1c813aa6c3 | |||
| dd5592ddbb | |||
| 6e9d8a1747 | |||
| cee78c6610 | |||
| 0b2dfbb88f | |||
| 1fd44a68b0 | |||
| fcacc50fdc | |||
| 009b58c9c3 | |||
| 77c64cf868 | |||
| f5acddbfeb | |||
| ae35d689ec | |||
| 825b5e1be4 | |||
| 17df1db120 | |||
| d56521260c | |||
| 8efafc6ba9 | |||
| f35987a9a9 | |||
| c7ec55c69a | |||
| c77d7dff78 | |||
| b282e4d729 | |||
| c426876d0d | |||
| 027ef17a2e | |||
| f0ac74dccf | |||
| d6468f33c5 | |||
| 1515f511a1 | |||
| 1c2211aefb | |||
| 49f9dff9da | |||
| 92ba1e4fc3 | |||
| 7060aab33d | |||
| 0cce8220ce | |||
| 1722d9f435 | |||
| 078fd57eb5 | |||
| 4bd6087dc0 | |||
| e45231376c | |||
| 01c7e509fd | |||
| 5ddf9683b4 | |||
| d91df12dbc | |||
| 64e3de9ada | |||
| 2cf2f04a70 | |||
| 73380d76df | |||
| 38076babcf | |||
| 00cc410dcc | |||
| 7fc676bbc3 | |||
| 798126d39c | |||
| 874d74cf6e | |||
| d73834e7f6 | |||
| cb3afaceab | |||
| fc0ba5d0d5 | |||
| 7ce4fc50ea | |||
| b5ef8a93ca | |||
| 8ead4e9c0f | |||
| 432d84cda5 | |||
| d3378dcf78 | |||
| f0724af2aa | |||
| f127150ea1 | |||
| b3ef6d4534 | |||
| 1ce791d517 | |||
| 3d561ad8e3 | |||
| 14509d1077 | |||
| a424e3a039 | |||
| eaa5ec5545 | |||
| 5850e5da66 | |||
| eb3ff6f570 | |||
| ae9c78e643 | |||
| 445528aff7 | |||
| d13c25444c | |||
| 5386716ebe | |||
| da3cd62486 | |||
| d8b47e30c4 | |||
| 1c19e529ac | |||
| 514b60f704 | |||
| df1d4cd62b | |||
| 4839b91cef | |||
| 5048d6987d | |||
| 809736dd33 | |||
| 369cc37071 | |||
| d0b64dabc2 | |||
| 02d2838424 | |||
| 4c4039283f | |||
| 77df6fd58e | |||
| 100801821f | |||
| 2201ebbb88 | |||
| 9810f01330 | |||
| 7b428be93d | |||
| a4c2ed5328 | |||
| 934cc0dd33 | |||
| da61500e34 | |||
| db2042800b | |||
| 08772741e6 | |||
| f5f542911f | |||
| 3b5b1986e6 | |||
| 3b0995c8ef | |||
| 34c95ca787 | |||
| a4c2a1d435 | |||
| e4f0743e2f | |||
| 7632efda88 | |||
| ab90eb2aab | |||
| 4c5bed0b1f | |||
| 302331043a | |||
| 09f5e7af8c | |||
| 664304241a | |||
| 27f98b02a6 | |||
| af6a3c87d6 | |||
| d1819274bb | |||
| 8058ed21b3 | |||
| eaf302bb40 | |||
| 3405b7e429 | |||
| 2fc1df8793 | |||
| ec82eb2881 | |||
| 1c978e0684 | |||
| e938e1572c | |||
| 9a7681c5c8 | |||
| 259f2157f6 | |||
| 21ce139df0 | |||
| 71536d6ef5 | |||
| ef1a035701 | |||
| 2b76c326ee | |||
| 64ee5c528b | |||
| 136d343c18 | |||
| 0b1b9a913f | |||
| cb0833a915 | |||
| 984c28d4be | |||
| 49add96dc0 | |||
| db58762a13 | |||
| 6e89d0037f | |||
| e1ab17387c | |||
| 54de2341bd | |||
| b131f0c48c | |||
| 9e4b792fc3 | |||
| 7abd5da57d | |||
| be7399b3c4 | |||
| 8d92b515ab | |||
| 524098d6d3 | |||
| 42fa2d94be | |||
| a65b30f3a1 | |||
| f1991b356b | |||
| 352c23180a | |||
| 825c376c5c | |||
| 231a923c9d | |||
| dbf01652f8 | |||
| 842a6cb178 | |||
| d56c526709 | |||
| 70a68bef27 | |||
| f9b49ffde6 | |||
| a264fd42e4 | |||
| 219844cb74 | |||
| 230205d210 | |||
| f9fb0f9125 | |||
| 0f777e357d | |||
| 5c578c191b | |||
| 7a4952f773 | |||
| 71a1daddef | |||
| 0a82955e91 | |||
| 62d2da3815 | |||
| 84aab66aa6 | |||
| 2d0d599ac8 | |||
| dca6be45b0 | |||
| 5a71807cc9 | |||
| 0d0ab4dcf5 | |||
| ac3da51890 | |||
| 9ea361f7e8 | |||
| cc6160892a | |||
| 49eed449c3 | |||
| 8ada7ffaf6 | |||
| e7c37231e0 | |||
| c196a02c95 | |||
| d1ff8591a6 | |||
| 219d162e1a | |||
| 669f60273c | |||
| 697f7d1946 | |||
| e4d04f8346 | |||
| c37af25525 | |||
| ea90c6c9cb | |||
| 58dbb514e0 | |||
| a8e2df6bed | |||
| 2f74becb31 | |||
| b31ac74f96 | |||
| 54b4e6a80b | |||
| 079d2c3cb3 | |||
| 911f9d8bc9 | |||
| f90bda861f | |||
| 71ed94de31 | |||
| dc16cf2aa7 | |||
| be12898b7b | |||
| 3fc92e093b | |||
| b6187ad637 | |||
| ca8ac9911e | |||
| 95a1e210b6 | |||
| b55f419a95 | |||
| 8953961a51 | |||
| 8836663c35 | |||
| aaba77c360 | |||
| 532bad8eb7 | |||
| e5b43c8176 | |||
| 5f9c2d7f6a | |||
| 568257e7b6 | |||
| df31629c5f | |||
| e6dc8619d9 | |||
| 9a71a01b66 | |||
| 941a6666e6 | |||
| 1bf63865a8 | |||
| ba41d8021f | |||
| 1e919a908f | |||
| 702612e3f9 | |||
| fa380619ce | |||
| a30b2e2cb2 | |||
| ae0cee9ef4 | |||
| dc9fb381f7 | |||
| 943fccf655 | |||
| e0d2d44f35 | |||
| 5a6413f356 | |||
| f3ef4c77f5 | |||
| 751e391db6 | |||
| 3e04c9493f | |||
| 6b0a1a42ad | |||
| ee82b23886 | |||
| 0d2dc2c257 | |||
| c785be82dd | |||
| a4bb82a02d | |||
| e8c94f3584 | |||
| d123eec476 | |||
| 002a443281 | |||
| 64f3d08d4e | |||
| 9c956a30ea | |||
| 5eaa90a7a2 | |||
| e3f5033bc4 | |||
| 2ec3b20b23 | |||
| d26d02babc | |||
| 675671688b | |||
| bcdd48615d | |||
| 1f974558f8 | |||
| 0f1ad59e58 |
@@ -69,3 +69,5 @@ playwright-report
|
|||||||
test-results
|
test-results
|
||||||
|
|
||||||
YOUR_MEMORY_FILE_PATH
|
YOUR_MEMORY_FILE_PATH
|
||||||
|
|
||||||
|
.sessions/
|
||||||
|
|||||||
Vendored
+2
-2
@@ -34,10 +34,10 @@
|
|||||||
"*.css": "tailwindcss"
|
"*.css": "tailwindcss"
|
||||||
},
|
},
|
||||||
"files.eol": "\n",
|
"files.eol": "\n",
|
||||||
"i18n-ally.displayLanguage": "zh-cn",
|
// "i18n-ally.displayLanguage": "zh-cn", // 界面显示语言
|
||||||
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
||||||
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
||||||
"i18n-ally.fullReloadOnChanged": true, // 界面显示语言
|
"i18n-ally.fullReloadOnChanged": true,
|
||||||
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
||||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
||||||
// "i18n-ally.namespace": true, // 开启命名空间
|
// "i18n-ally.namespace": true, // 开启命名空间
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
diff --git a/sdk.mjs b/sdk.mjs
|
||||||
|
index e2dbafb4e2faa1bf2b6b02f0009a2b9bbf57c757..3f07a1d5c2949a246fe5414e69ab45942fa605a2 100644
|
||||||
|
--- a/sdk.mjs
|
||||||
|
+++ b/sdk.mjs
|
||||||
|
@@ -6355,11 +6355,11 @@ class ProcessTransport {
|
||||||
|
prompt,
|
||||||
|
additionalDirectories = [],
|
||||||
|
cwd,
|
||||||
|
- executable = isRunningWithBun() ? "bun" : "node",
|
||||||
|
+ executable = process.execPath,
|
||||||
|
executableArgs = [],
|
||||||
|
extraArgs = {},
|
||||||
|
pathToClaudeCodeExecutable,
|
||||||
|
- env = { ...process.env },
|
||||||
|
+ env = { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||||
|
stderr,
|
||||||
|
customSystemPrompt,
|
||||||
|
appendSystemPrompt,
|
||||||
@@ -1,127 +1,52 @@
|
|||||||
# CLAUDE.md
|
# AI Assistant Guide
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to AI coding assistants when working with code in this repository. Adherence to these guidelines is crucial for maintaining code quality and consistency.
|
||||||
|
|
||||||
|
## Guiding Principles
|
||||||
|
|
||||||
|
- **Clarity and Simplicity**: Write code that is easy to understand and maintain.
|
||||||
|
- **Consistency**: Follow existing patterns and conventions in the codebase.
|
||||||
|
- **Correctness**: Ensure code is correct, well-tested, and robust.
|
||||||
|
- **Efficiency**: Write performant code and use resources judiciously.
|
||||||
|
|
||||||
|
## MUST Follow Rules
|
||||||
|
|
||||||
|
1. **Code Search**: Use `ast-grep` for semantic code pattern searches when available. Fallback to `rg` (ripgrep) or `grep` for text-based searches.
|
||||||
|
2. **UI Framework**: Exclusively use **HeroUI** for all new UI components. The use of `antd` or `styled-components` is strictly **PROHIBITED**.
|
||||||
|
3. **Quality Assurance**: **Always** run `yarn build:check` before finalizing your work or making any commits. This ensures code quality (linting, testing, and type checking).
|
||||||
|
4. **Centralized Logging**: Use the `loggerService` exclusively for all application logging (info, warn, error levels) with proper context. Do not use `console.log`.
|
||||||
|
5. **External Research**: Leverage `subagent` for gathering external information, including latest documentation, API references, news, or web-based research. This keeps the main conversation focused on the task at hand.
|
||||||
|
6. **Code Reviews**: Always seek a code review from a human developer before merging significant changes. This ensures adherence to project standards and catches potential issues.
|
||||||
|
7. **Documentation**: Update or create documentation for any new features, modules, or significant changes to existing functionality.
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Environment Setup
|
- **Install**: `yarn install`
|
||||||
|
- **Development**: `yarn dev` - Runs Electron app in development mode
|
||||||
|
- **Debug**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
|
||||||
|
- **Build Check**: `yarn build:check` - REQUIRED before commits (lint + test + typecheck)
|
||||||
|
- **Test**: `yarn test` - Run all tests (Vitest)
|
||||||
|
- **Single Test**: `yarn test:main` or `yarn test:renderer`
|
||||||
|
- **Lint**: `yarn lint` - Fix linting issues and run typecheck
|
||||||
|
|
||||||
- **Prerequisites**: Node.js v22.x.x or higher, Yarn 4.9.1
|
## Project Architecture
|
||||||
- **Setup Yarn**: `corepack enable && corepack prepare yarn@4.9.1 --activate`
|
|
||||||
- **Install Dependencies**: `yarn install`
|
|
||||||
- **Add New Dependencies**: `yarn add -D` for renderer-specific dependencies, `yarn add` for others.
|
|
||||||
|
|
||||||
### Development
|
### Electron Structure
|
||||||
|
- **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.)
|
||||||
|
- **Renderer Process** (`src/renderer/`): React UI with Redux state management
|
||||||
|
- **Preload Scripts** (`src/preload/`): Secure IPC bridge
|
||||||
|
|
||||||
- **Start Development**: `yarn dev` - Runs Electron app in development mode
|
### Key Components
|
||||||
- **Debug Mode**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
|
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
|
||||||
|
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
|
||||||
### Testing & Quality
|
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
|
||||||
|
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
|
||||||
- **Run Tests**: `yarn test` - Runs all tests (Vitest)
|
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
|
||||||
- **Run E2E Tests**: `yarn test:e2e` - Playwright end-to-end tests
|
|
||||||
- **Type Check**: `yarn typecheck` - Checks TypeScript for both node and web
|
|
||||||
- **Lint**: `yarn lint` - ESLint with auto-fix
|
|
||||||
- **Format**: `yarn format` - Biome formatting
|
|
||||||
|
|
||||||
### Build & Release
|
|
||||||
|
|
||||||
- **Build**: `yarn build` - Builds for production (includes typecheck)
|
|
||||||
- **Platform-specific builds**:
|
|
||||||
- Windows: `yarn build:win`
|
|
||||||
- macOS: `yarn build:mac`
|
|
||||||
- Linux: `yarn build:linux`
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
### Electron Multi-Process Architecture
|
|
||||||
|
|
||||||
- **Main Process** (`src/main/`): Node.js backend handling system integration, file operations, and services
|
|
||||||
- **Renderer Process** (`src/renderer/`): React-based UI running in Chromium
|
|
||||||
- **Preload Scripts** (`src/preload/`): Secure bridge between main and renderer processes
|
|
||||||
|
|
||||||
### Key Architectural Components
|
|
||||||
|
|
||||||
#### Main Process Services (`src/main/services/`)
|
|
||||||
|
|
||||||
- **MCPService**: Model Context Protocol server management
|
|
||||||
- **KnowledgeService**: Document processing and knowledge base management
|
|
||||||
- **FileStorage/S3Storage/WebDav**: Multiple storage backends
|
|
||||||
- **WindowService**: Multi-window management (main, mini, selection windows)
|
|
||||||
- **ProxyManager**: Network proxy handling
|
|
||||||
- **SearchService**: Full-text search capabilities
|
|
||||||
|
|
||||||
#### AI Core (`src/renderer/src/aiCore/`)
|
|
||||||
|
|
||||||
- **Middleware System**: Composable pipeline for AI request processing
|
|
||||||
- **Client Factory**: Supports multiple AI providers (OpenAI, Anthropic, Gemini, etc.)
|
|
||||||
- **Stream Processing**: Real-time response handling
|
|
||||||
|
|
||||||
#### State Management (`src/renderer/src/store/`)
|
|
||||||
|
|
||||||
- **Redux Toolkit**: Centralized state management
|
|
||||||
- **Persistent Storage**: Redux-persist for data persistence
|
|
||||||
- **Thunks**: Async actions for complex operations
|
|
||||||
|
|
||||||
#### Knowledge Management
|
|
||||||
|
|
||||||
- **Embeddings**: Vector search with multiple providers (OpenAI, Voyage, etc.)
|
|
||||||
- **OCR**: Document text extraction (system OCR, Doc2x, Mineru)
|
|
||||||
- **Preprocessing**: Document preparation pipeline
|
|
||||||
- **Loaders**: Support for various file formats (PDF, DOCX, EPUB, etc.)
|
|
||||||
|
|
||||||
### Build System
|
|
||||||
|
|
||||||
- **Electron-Vite**: Development and build tooling (v4.0.0)
|
|
||||||
- **Rolldown-Vite**: Using experimental rolldown-vite instead of standard vite
|
|
||||||
- **Workspaces**: Monorepo structure with `packages/` directory
|
|
||||||
- **Multiple Entry Points**: Main app, mini window, selection toolbar
|
|
||||||
- **Styled Components**: CSS-in-JS styling with SWC optimization
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
|
|
||||||
- **Vitest**: Unit and integration testing
|
|
||||||
- **Playwright**: End-to-end testing
|
|
||||||
- **Component Testing**: React Testing Library
|
|
||||||
- **Coverage**: Available via `yarn test:coverage`
|
|
||||||
|
|
||||||
### Key Patterns
|
|
||||||
|
|
||||||
- **IPC Communication**: Secure main-renderer communication via preload scripts
|
|
||||||
- **Service Layer**: Clear separation between UI and business logic
|
|
||||||
- **Plugin Architecture**: Extensible via MCP servers and middleware
|
|
||||||
- **Multi-language Support**: i18n with dynamic loading
|
|
||||||
- **Theme System**: Light/dark themes with custom CSS variables
|
|
||||||
|
|
||||||
### UI Design
|
|
||||||
|
|
||||||
The project is in the process of migrating from antd & styled-components to HeroUI. Please use HeroUI to build UI components. The use of antd and styled-components is prohibited.
|
|
||||||
|
|
||||||
HeroUI Docs: https://www.heroui.com/docs/guide/introduction
|
|
||||||
|
|
||||||
## Logging Standards
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
|
### Logging
|
||||||
```typescript
|
```typescript
|
||||||
// Main process
|
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
const logger = loggerService.withContext('moduleName')
|
const logger = loggerService.withContext('moduleName')
|
||||||
|
// Renderer: loggerService.initWindowSource('windowName') first
|
||||||
// Renderer process (set window source first)
|
|
||||||
loggerService.initWindowSource('windowName')
|
|
||||||
const logger = loggerService.withContext('moduleName')
|
|
||||||
|
|
||||||
// Logging
|
|
||||||
logger.info('message', CONTEXT)
|
logger.info('message', CONTEXT)
|
||||||
logger.error('message', new Error('error'), CONTEXT)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Log Levels (highest to lowest)
|
|
||||||
|
|
||||||
- `error` - Critical errors causing crash/unusable functionality
|
|
||||||
- `warn` - Potential issues that don't affect core functionality
|
|
||||||
- `info` - Application lifecycle and key user actions
|
|
||||||
- `verbose` - Detailed flow information for feature tracing
|
|
||||||
- `debug` - Development diagnostic info (not for production)
|
|
||||||
- `silly` - Extreme debugging, low-level information
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src'),
|
'@renderer': resolve('src/renderer/src'),
|
||||||
'@shared': resolve('packages/shared'),
|
'@shared': resolve('packages/shared'),
|
||||||
|
'@types': resolve('src/renderer/src/types'),
|
||||||
'@logger': resolve('src/renderer/src/services/LoggerService'),
|
'@logger': resolve('src/renderer/src/services/LoggerService'),
|
||||||
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
||||||
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
|
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
|
||||||
|
|||||||
+16
-4
@@ -27,6 +27,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "dotenv electron-vite dev",
|
"dev": "dotenv electron-vite dev",
|
||||||
|
"dev:main": "dotenv electron-vite dev --watch",
|
||||||
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"build:check": "yarn lint && yarn test",
|
"build:check": "yarn lint && yarn test",
|
||||||
@@ -43,15 +44,18 @@
|
|||||||
"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": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts",
|
||||||
|
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
|
||||||
|
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
|
||||||
|
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
|
||||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||||
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
||||||
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
||||||
"check:i18n": "tsx scripts/check-i18n.ts",
|
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
|
||||||
"sync:i18n": "tsx scripts/sync-i18n.ts",
|
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
|
||||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||||
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||||
"update:languages": "tsx scripts/update-languages.ts",
|
"update:languages": "tsx scripts/update-languages.ts",
|
||||||
@@ -75,11 +79,15 @@
|
|||||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-code": "patch:@anthropic-ai/claude-code@npm%3A1.0.118#~/.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.patch",
|
||||||
"@libsql/client": "0.14.0",
|
"@libsql/client": "0.14.0",
|
||||||
"@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",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-validator": "^7.2.1",
|
||||||
"font-list": "^2.0.0",
|
"font-list": "^2.0.0",
|
||||||
"graceful-fs": "^4.2.11",
|
"graceful-fs": "^4.2.11",
|
||||||
"jsdom": "26.1.0",
|
"jsdom": "26.1.0",
|
||||||
@@ -151,6 +159,7 @@
|
|||||||
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
||||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||||
"@playwright/test": "^1.52.0",
|
"@playwright/test": "^1.52.0",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@reduxjs/toolkit": "^2.2.5",
|
"@reduxjs/toolkit": "^2.2.5",
|
||||||
"@shikijs/markdown-it": "^3.12.0",
|
"@shikijs/markdown-it": "^3.12.0",
|
||||||
"@swc/plugin-styled-components": "^8.0.4",
|
"@swc/plugin-styled-components": "^8.0.4",
|
||||||
@@ -238,9 +247,11 @@
|
|||||||
"docx": "^9.0.2",
|
"docx": "^9.0.2",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
|
"drizzle-kit": "^0.31.4",
|
||||||
"electron": "37.4.0",
|
"electron": "37.4.0",
|
||||||
"electron-builder": "26.0.15",
|
"electron-builder": "26.0.15",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
|
"electron-reload": "^2.0.0-alpha.1",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"electron-updater": "6.6.4",
|
"electron-updater": "6.6.4",
|
||||||
"electron-vite": "4.0.0",
|
"electron-vite": "4.0.0",
|
||||||
@@ -325,6 +336,7 @@
|
|||||||
"string-width": "^7.2.0",
|
"string-width": "^7.2.0",
|
||||||
"striptags": "^3.2.0",
|
"striptags": "^3.2.0",
|
||||||
"styled-components": "^6.1.11",
|
"styled-components": "^6.1.11",
|
||||||
|
"swr": "^2.3.6",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"tiny-pinyin": "^1.3.2",
|
"tiny-pinyin": "^1.3.2",
|
||||||
@@ -335,7 +347,7 @@
|
|||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.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",
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export enum IpcChannel {
|
|||||||
// Python
|
// Python
|
||||||
Python_Execute = 'python:execute',
|
Python_Execute = 'python:execute',
|
||||||
|
|
||||||
|
// agent messages
|
||||||
|
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
|
||||||
|
|
||||||
//copilot
|
//copilot
|
||||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `migrations` (
|
||||||
|
`version` integer PRIMARY KEY NOT NULL,
|
||||||
|
`tag` text NOT NULL,
|
||||||
|
`executed_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE `agents` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`accessible_paths` text,
|
||||||
|
`instructions` text,
|
||||||
|
`model` text NOT NULL,
|
||||||
|
`plan_model` text,
|
||||||
|
`small_model` text,
|
||||||
|
`mcps` text,
|
||||||
|
`allowed_tools` text,
|
||||||
|
`configuration` text,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`agent_type` text NOT NULL,
|
||||||
|
`agent_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`accessible_paths` text,
|
||||||
|
`instructions` text,
|
||||||
|
`model` text NOT NULL,
|
||||||
|
`plan_model` text,
|
||||||
|
`small_model` text,
|
||||||
|
`mcps` text,
|
||||||
|
`allowed_tools` text,
|
||||||
|
`configuration` text,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `session_messages` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`session_id` text NOT NULL,
|
||||||
|
`role` text NOT NULL,
|
||||||
|
`content` text NOT NULL,
|
||||||
|
`metadata` text,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `session_messages` ADD `agent_session_id` text DEFAULT '';
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "35efb412-0230-4767-9c76-7b7c4d40369f",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"agents": {
|
||||||
|
"name": "agents",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"name": "accessible_paths",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"name": "instructions",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plan_model": {
|
||||||
|
"name": "plan_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"small_model": {
|
||||||
|
"name": "small_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mcps": {
|
||||||
|
"name": "mcps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_tools": {
|
||||||
|
"name": "allowed_tools",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"name": "configuration",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session_messages": {
|
||||||
|
"name": "session_messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"name": "session_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"migrations": {
|
||||||
|
"name": "migrations",
|
||||||
|
"columns": {
|
||||||
|
"version": {
|
||||||
|
"name": "version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"name": "tag",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"executed_at": {
|
||||||
|
"name": "executed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_type": {
|
||||||
|
"name": "agent_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_id": {
|
||||||
|
"name": "agent_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"name": "accessible_paths",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"name": "instructions",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plan_model": {
|
||||||
|
"name": "plan_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"small_model": {
|
||||||
|
"name": "small_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mcps": {
|
||||||
|
"name": "mcps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_tools": {
|
||||||
|
"name": "allowed_tools",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"name": "configuration",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
|
||||||
|
"prevId": "35efb412-0230-4767-9c76-7b7c4d40369f",
|
||||||
|
"tables": {
|
||||||
|
"agents": {
|
||||||
|
"name": "agents",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"name": "accessible_paths",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"name": "instructions",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plan_model": {
|
||||||
|
"name": "plan_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"small_model": {
|
||||||
|
"name": "small_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mcps": {
|
||||||
|
"name": "mcps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_tools": {
|
||||||
|
"name": "allowed_tools",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"name": "configuration",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session_messages": {
|
||||||
|
"name": "session_messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"name": "session_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_session_id": {
|
||||||
|
"name": "agent_session_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"migrations": {
|
||||||
|
"name": "migrations",
|
||||||
|
"columns": {
|
||||||
|
"version": {
|
||||||
|
"name": "version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"name": "tag",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"executed_at": {
|
||||||
|
"name": "executed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_type": {
|
||||||
|
"name": "agent_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"agent_id": {
|
||||||
|
"name": "agent_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"name": "accessible_paths",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"name": "instructions",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plan_model": {
|
||||||
|
"name": "plan_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"small_model": {
|
||||||
|
"name": "small_model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mcps": {
|
||||||
|
"name": "mcps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_tools": {
|
||||||
|
"name": "allowed_tools",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"name": "configuration",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1758091173882,
|
||||||
|
"tag": "0000_confused_wendigo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1758187378775,
|
||||||
|
"tag": "0001_woozy_captain_flint",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ import * as path from 'path'
|
|||||||
|
|
||||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||||
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
||||||
const baseLocale = 'zh-cn'
|
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
||||||
const baseFileName = `${baseLocale}.json`
|
const baseFileName = `${baseLocale}.json`
|
||||||
|
const baseLocalePath = path.join(__dirname, '../src/renderer/src/i18n/locales', baseFileName)
|
||||||
|
|
||||||
type I18NValue = string | { [key: string]: I18NValue }
|
type I18NValue = string | { [key: string]: I18NValue }
|
||||||
type I18N = { [key: string]: I18NValue }
|
type I18N = { [key: string]: I18NValue }
|
||||||
@@ -105,6 +106,9 @@ const translateRecursively = async (originObj: I18N, systemPrompt: string): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
|
if (!fs.existsSync(baseLocalePath)) {
|
||||||
|
throw new Error(`${baseLocalePath} not found.`)
|
||||||
|
}
|
||||||
const localeFiles = fs
|
const localeFiles = fs
|
||||||
.readdirSync(localesDir)
|
.readdirSync(localesDir)
|
||||||
.filter((file) => file.endsWith('.json') && file !== baseFileName)
|
.filter((file) => file.endsWith('.json') && file !== baseFileName)
|
||||||
|
|||||||
+27
-13
@@ -35,6 +35,9 @@ const allX64 = {
|
|||||||
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const claudeCodeVenderPath = '@anthropic-ai/claude-code/vendor'
|
||||||
|
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
|
||||||
|
|
||||||
const platformToArch = {
|
const platformToArch = {
|
||||||
mac: 'darwin',
|
mac: 'darwin',
|
||||||
windows: 'win32',
|
windows: 'win32',
|
||||||
@@ -46,9 +49,6 @@ exports.default = async function (context) {
|
|||||||
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
|
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
|
||||||
const platform = context.packager.platform.name
|
const platform = context.packager.platform.name
|
||||||
|
|
||||||
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
|
|
||||||
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
|
|
||||||
|
|
||||||
const downloadPackages = async (packages) => {
|
const downloadPackages = async (packages) => {
|
||||||
console.log('downloading packages ......')
|
console.log('downloading packages ......')
|
||||||
const downloadPromises = []
|
const downloadPromises = []
|
||||||
@@ -67,25 +67,39 @@ exports.default = async function (context) {
|
|||||||
await Promise.all(downloadPromises)
|
await Promise.all(downloadPromises)
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeFilters = async (packages, filtersToExclude, filtersToInclude) => {
|
const changeFilters = async (filtersToExclude, filtersToInclude) => {
|
||||||
await downloadPackages(packages)
|
|
||||||
// remove filters for the target architecture (allow inclusion)
|
// remove filters for the target architecture (allow inclusion)
|
||||||
|
|
||||||
let filters = context.packager.config.files[0].filter
|
let filters = context.packager.config.files[0].filter
|
||||||
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
|
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
|
||||||
|
|
||||||
// add filters for other architectures (exclude them)
|
// add filters for other architectures (exclude them)
|
||||||
filters.push(...filtersToExclude)
|
filters.push(...filtersToExclude)
|
||||||
|
|
||||||
context.packager.config.files[0].filter = filters
|
context.packager.config.files[0].filter = filters
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arch === Arch.arm64) {
|
await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64)
|
||||||
await changeFilters(allArm64, x64Filters, arm64Filters)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arch === Arch.x64) {
|
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
|
||||||
await changeFilters(allX64, arm64Filters, x64Filters)
|
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
|
||||||
return
|
const excludeClaudeCodeRipgrepFilters = claudeCodeVenders
|
||||||
|
.filter((f) => f !== `${archType}-${platformToArch[platform]}`)
|
||||||
|
.map((f) => '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + f + '/**')
|
||||||
|
const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin']
|
||||||
|
|
||||||
|
const includeClaudeCodeFilters = [
|
||||||
|
'!node_modules/' + claudeCodeVenderPath + '/' + `${archType}-${platformToArch[platform]}/**`
|
||||||
|
]
|
||||||
|
|
||||||
|
if (arch === Arch.arm64) {
|
||||||
|
await changeFilters(
|
||||||
|
[...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
|
||||||
|
[...arm64Filters, ...includeClaudeCodeFilters]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await changeFilters(
|
||||||
|
[...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
|
||||||
|
[...x64Filters, ...includeClaudeCodeFilters]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as path from 'path'
|
|||||||
import { sortedObjectByKeys } from './sort'
|
import { sortedObjectByKeys } from './sort'
|
||||||
|
|
||||||
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||||
const baseLocale = 'zh-cn'
|
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
||||||
const baseFileName = `${baseLocale}.json`
|
const baseFileName = `${baseLocale}.json`
|
||||||
const baseFilePath = path.join(translationsDir, baseFileName)
|
const baseFilePath = path.join(translationsDir, baseFileName)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { sortedObjectByKeys } from './sort'
|
|||||||
|
|
||||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||||
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
||||||
const baseLocale = 'zh-cn'
|
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
||||||
const baseFileName = `${baseLocale}.json`
|
const baseFileName = `${baseLocale}.json`
|
||||||
const baseFilePath = path.join(localesDir, baseFileName)
|
const baseFilePath = path.join(localesDir, baseFileName)
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { authMiddleware } from './middleware/auth'
|
import { authMiddleware } from './middleware/auth'
|
||||||
import { errorHandler } from './middleware/error'
|
import { errorHandler } from './middleware/error'
|
||||||
import { setupOpenAPIDocumentation } from './middleware/openapi'
|
import { setupOpenAPIDocumentation } from './middleware/openapi'
|
||||||
|
import { agentsRoutes } from './routes/agents'
|
||||||
import { chatRoutes } from './routes/chat'
|
import { chatRoutes } from './routes/chat'
|
||||||
import { mcpRoutes } from './routes/mcp'
|
import { mcpRoutes } from './routes/mcp'
|
||||||
|
import { messagesRoutes } from './routes/messages'
|
||||||
import { modelsRoutes } from './routes/models'
|
import { modelsRoutes } from './routes/models'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServer')
|
const logger = loggerService.withContext('ApiServer')
|
||||||
@@ -101,10 +103,7 @@ app.get('/', (_req, res) => {
|
|||||||
name: 'Cherry Studio API',
|
name: 'Cherry Studio API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
health: 'GET /health',
|
health: 'GET /health'
|
||||||
models: 'GET /v1/models',
|
|
||||||
chat: 'POST /v1/chat/completions',
|
|
||||||
mcp: 'GET /v1/mcps'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -116,7 +115,9 @@ apiRouter.use(express.json())
|
|||||||
// Mount routes
|
// Mount routes
|
||||||
apiRouter.use('/chat', chatRoutes)
|
apiRouter.use('/chat', chatRoutes)
|
||||||
apiRouter.use('/mcps', mcpRoutes)
|
apiRouter.use('/mcps', mcpRoutes)
|
||||||
|
apiRouter.use('/messages', messagesRoutes)
|
||||||
apiRouter.use('/models', modelsRoutes)
|
apiRouter.use('/models', modelsRoutes)
|
||||||
|
apiRouter.use('/agents', agentsRoutes)
|
||||||
app.use('/v1', apiRouter)
|
app.use('/v1', apiRouter)
|
||||||
|
|
||||||
// Setup OpenAPI documentation
|
// Setup OpenAPI documentation
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { AgentModelValidationError, agentService } from '@main/services/agents'
|
||||||
|
import { ListAgentsResponse,type ReplaceAgentRequest, type UpdateAgentRequest } from '@types'
|
||||||
|
import { Request, Response } from 'express'
|
||||||
|
|
||||||
|
import type { ValidationRequest } from '../validators/zodValidator'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ApiServerAgentsHandlers')
|
||||||
|
|
||||||
|
const modelValidationErrorBody = (error: AgentModelValidationError) => ({
|
||||||
|
error: {
|
||||||
|
message: `Invalid ${error.context.field}: ${error.detail.message}`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: error.detail.code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /v1/agents:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new agent
|
||||||
|
* description: Creates a new autonomous agent with the specified configuration
|
||||||
|
* tags: [Agents]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/CreateAgentRequest'
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Agent created successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Validation error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
export const createAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
logger.info('Creating new agent')
|
||||||
|
logger.debug('Agent data:', req.body)
|
||||||
|
|
||||||
|
const agent = await agentService.createAgent(req.body)
|
||||||
|
|
||||||
|
logger.info(`Agent created successfully: ${agent.id}`)
|
||||||
|
return res.status(201).json(agent)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof AgentModelValidationError) {
|
||||||
|
logger.warn('Agent model validation error during create:', {
|
||||||
|
agentType: error.context.agentType,
|
||||||
|
field: error.context.field,
|
||||||
|
model: error.context.model,
|
||||||
|
detail: error.detail
|
||||||
|
})
|
||||||
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Error creating agent:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Failed to create agent: ${error.message}`,
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'agent_creation_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /v1/agents:
|
||||||
|
* get:
|
||||||
|
* summary: List all agents
|
||||||
|
* description: Retrieves a paginated list of all agents
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 100
|
||||||
|
* default: 20
|
||||||
|
* description: Number of agents to return
|
||||||
|
* - in: query
|
||||||
|
* name: offset
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* default: 0
|
||||||
|
* description: Number of agents to skip
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of agents
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* data:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* total:
|
||||||
|
* type: integer
|
||||||
|
* description: Total number of agents
|
||||||
|
* limit:
|
||||||
|
* type: integer
|
||||||
|
* description: Number of agents returned
|
||||||
|
* offset:
|
||||||
|
* type: integer
|
||||||
|
* description: Number of agents skipped
|
||||||
|
* 400:
|
||||||
|
* description: Validation error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
export const listAgents = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||||
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||||
|
|
||||||
|
logger.info(`Listing agents with limit=${limit}, offset=${offset}`)
|
||||||
|
|
||||||
|
const result = await agentService.listAgents({ limit, offset })
|
||||||
|
|
||||||
|
logger.info(`Retrieved ${result.agents.length} agents (total: ${result.total})`)
|
||||||
|
return res.json({
|
||||||
|
data: result.agents,
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
} satisfies ListAgentsResponse)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error listing agents:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to list agents',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'agent_list_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /v1/agents/{agentId}:
|
||||||
|
* get:
|
||||||
|
* summary: Get agent by ID
|
||||||
|
* description: Retrieves a specific agent by its ID
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Agent details
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
export const getAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { agentId } = req.params
|
||||||
|
logger.info(`Getting agent: ${agentId}`)
|
||||||
|
|
||||||
|
const agent = await agentService.getAgent(agentId)
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
logger.warn(`Agent not found: ${agentId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Agent not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'agent_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Agent retrieved successfully: ${agentId}`)
|
||||||
|
return res.json(agent)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error getting agent:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to get agent',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'agent_get_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /v1/agents/{agentId}:
|
||||||
|
* put:
|
||||||
|
* summary: Update agent
|
||||||
|
* description: Updates an existing agent with the provided data
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/CreateAgentRequest'
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Agent updated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Validation error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
export const updateAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
const { agentId } = req.params
|
||||||
|
try {
|
||||||
|
logger.info(`Updating agent: ${agentId}`)
|
||||||
|
logger.debug('Update data:', req.body)
|
||||||
|
|
||||||
|
const { validatedBody } = req as ValidationRequest
|
||||||
|
const replacePayload = (validatedBody ?? {}) as ReplaceAgentRequest
|
||||||
|
|
||||||
|
const agent = await agentService.updateAgent(agentId, replacePayload, { replace: true })
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
logger.warn(`Agent not found for update: ${agentId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Agent not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'agent_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Agent updated successfully: ${agentId}`)
|
||||||
|
return res.json(agent)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof AgentModelValidationError) {
|
||||||
|
logger.warn('Agent model validation error during update:', {
|
||||||
|
agentId,
|
||||||
|
agentType: error.context.agentType,
|
||||||
|
field: error.context.field,
|
||||||
|
model: error.context.model,
|
||||||
|
detail: error.detail
|
||||||
|
})
|
||||||
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Error updating agent:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to update agent: ' + error.message,
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'agent_update_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /v1/agents/{agentId}:
|
||||||
|
* patch:
|
||||||
|
* summary: Partially update agent
|
||||||
|
* description: Partially updates an existing agent with only the provided fields
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* description: Agent name
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* description: Agent description
|
||||||
|
* avatar:
|
||||||
|
* type: string
|
||||||
|
* description: Agent avatar URL
|
||||||
|
* instructions:
|
||||||
|
* type: string
|
||||||
|
* description: System prompt/instructions
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* description: Main model ID
|
||||||
|
* plan_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional planning model ID
|
||||||
|
* small_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional small/fast model ID
|
||||||
|
* built_in_tools:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Built-in tool IDs
|
||||||
|
* mcps:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: MCP tool IDs
|
||||||
|
* knowledges:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Knowledge base IDs
|
||||||
|
* configuration:
|
||||||
|
* type: object
|
||||||
|
* description: Extensible settings
|
||||||
|
* accessible_paths:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Accessible directory paths
|
||||||
|
* permission_mode:
|
||||||
|
* type: string
|
||||||
|
* enum: [readOnly, acceptEdits, bypassPermissions]
|
||||||
|
* description: Permission mode
|
||||||
|
* max_steps:
|
||||||
|
* type: integer
|
||||||
|
* description: Maximum steps the agent can take
|
||||||
|
* description: Only include the fields you want to update
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Agent updated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Validation error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
export const patchAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
const { agentId } = req.params
|
||||||
|
try {
|
||||||
|
logger.info(`Partially updating agent: ${agentId}`)
|
||||||
|
logger.debug('Partial update data:', req.body)
|
||||||
|
|
||||||
|
const { validatedBody } = req as ValidationRequest
|
||||||
|
const updatePayload = (validatedBody ?? {}) as UpdateAgentRequest
|
||||||
|
|
||||||
|
const agent = await agentService.updateAgent(agentId, updatePayload)
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
logger.warn(`Agent not found for partial update: ${agentId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Agent not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'agent_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Agent partially updated successfully: ${agentId}`)
|
||||||
|
return res.json(agent)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof AgentModelValidationError) {
|
||||||
|
logger.warn('Agent model validation error during partial update:', {
|
||||||
|
agentId,
|
||||||
|
agentType: error.context.agentType,
|
||||||
|
field: error.context.field,
|
||||||
|
model: error.context.model,
|
||||||
|
detail: error.detail
|
||||||
|
})
|
||||||
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Error partially updating agent:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Failed to partially update agent: ${error.message}`,
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'agent_patch_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /v1/agents/{agentId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete agent
|
||||||
|
* description: Deletes an agent and all associated sessions and logs
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* responses:
|
||||||
|
* 204:
|
||||||
|
* description: Agent deleted successfully
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
export const deleteAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { agentId } = req.params
|
||||||
|
logger.info(`Deleting agent: ${agentId}`)
|
||||||
|
|
||||||
|
const deleted = await agentService.deleteAgent(agentId)
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
logger.warn(`Agent not found for deletion: ${agentId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Agent not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'agent_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Agent deleted successfully: ${agentId}`)
|
||||||
|
return res.status(204).send()
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error deleting agent:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to delete agent',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'agent_delete_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * as agentHandlers from './agents'
|
||||||
|
export * as messageHandlers from './messages'
|
||||||
|
export * as sessionHandlers from './sessions'
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { Request, Response } from 'express'
|
||||||
|
|
||||||
|
import { agentService, sessionMessageService, sessionService } from '../../../../services/agents'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ApiServerMessagesHandlers')
|
||||||
|
|
||||||
|
// Helper function to verify agent and session exist and belong together
|
||||||
|
const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
|
||||||
|
const agentExists = await agentService.agentExists(agentId)
|
||||||
|
if (!agentExists) {
|
||||||
|
throw { status: 404, code: 'agent_not_found', message: 'Agent not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await sessionService.getSession(agentId, sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw { status: 404, code: 'session_not_found', message: 'Session not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.agent_id !== agentId) {
|
||||||
|
throw { status: 404, code: 'session_not_found', message: 'Session not found for this agent' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMessage = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { agentId, sessionId } = req.params
|
||||||
|
|
||||||
|
const session = await verifyAgentAndSession(agentId, sessionId)
|
||||||
|
|
||||||
|
const messageData = req.body
|
||||||
|
|
||||||
|
logger.info(`Creating streaming message for session: ${sessionId}`)
|
||||||
|
logger.debug('Streaming message data:', messageData)
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const { stream, completion } = await sessionMessageService.createSessionMessage(
|
||||||
|
session,
|
||||||
|
messageData,
|
||||||
|
abortController
|
||||||
|
)
|
||||||
|
const reader = stream.getReader()
|
||||||
|
|
||||||
|
// Track stream lifecycle so we keep the SSE connection open until persistence finishes
|
||||||
|
let responseEnded = false
|
||||||
|
let streamFinished = false
|
||||||
|
|
||||||
|
const finalizeResponse = () => {
|
||||||
|
if (responseEnded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamFinished) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseEnded = true
|
||||||
|
try {
|
||||||
|
// res.write('data: {"type":"finish"}\n\n')
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error writing final sentinel to SSE stream:', { error: writeError as Error })
|
||||||
|
}
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Disconnect Detection for Server-Sent Events (SSE)
|
||||||
|
*
|
||||||
|
* We monitor multiple HTTP events to reliably detect when a client disconnects
|
||||||
|
* from the streaming response. This is crucial for:
|
||||||
|
* - Aborting long-running Claude Code processes
|
||||||
|
* - Cleaning up resources and preventing memory leaks
|
||||||
|
* - Avoiding orphaned processes
|
||||||
|
*
|
||||||
|
* Event Priority & Behavior:
|
||||||
|
* 1. res.on('close') - Most common for SSE client disconnects (browser tab close, curl Ctrl+C)
|
||||||
|
* 2. req.on('aborted') - Explicit request abortion
|
||||||
|
* 3. req.on('close') - Request object closure (less common with SSE)
|
||||||
|
*
|
||||||
|
* When any disconnect event fires, we:
|
||||||
|
* - Abort the Claude Code SDK process via abortController
|
||||||
|
* - Clean up event listeners to prevent memory leaks
|
||||||
|
* - Mark the response as ended to prevent further writes
|
||||||
|
*/
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
if (responseEnded) return
|
||||||
|
logger.info(`Client disconnected from streaming message for session: ${sessionId}`)
|
||||||
|
responseEnded = true
|
||||||
|
abortController.abort('Client disconnected')
|
||||||
|
reader.cancel('Client disconnected').catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
req.on('close', handleDisconnect)
|
||||||
|
req.on('aborted', handleDisconnect)
|
||||||
|
res.on('close', handleDisconnect)
|
||||||
|
|
||||||
|
const pumpStream = async () => {
|
||||||
|
try {
|
||||||
|
while (!responseEnded) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify(value)}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamFinished = true
|
||||||
|
finalizeResponse()
|
||||||
|
} catch (error) {
|
||||||
|
if (responseEnded) return
|
||||||
|
logger.error('Error reading agent stream:', { error })
|
||||||
|
try {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
message: (error as Error).message || 'Stream processing error',
|
||||||
|
type: 'stream_error',
|
||||||
|
code: 'stream_processing_failed'
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error writing stream error to SSE:', { error: writeError })
|
||||||
|
}
|
||||||
|
responseEnded = true
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pumpStream().catch((error) => {
|
||||||
|
logger.error('Pump stream failure:', { error })
|
||||||
|
})
|
||||||
|
|
||||||
|
completion
|
||||||
|
.then(() => {
|
||||||
|
streamFinished = true
|
||||||
|
finalizeResponse()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (responseEnded) return
|
||||||
|
logger.error(`Streaming message error for session: ${sessionId}:`, error)
|
||||||
|
try {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
message: (error as { message?: string })?.message || 'Stream processing error',
|
||||||
|
type: 'stream_error',
|
||||||
|
code: 'stream_processing_failed'
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error writing completion error to SSE stream:', { error: writeError })
|
||||||
|
}
|
||||||
|
responseEnded = true
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set a timeout to prevent hanging indefinitely
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
if (!responseEnded) {
|
||||||
|
logger.error(`Streaming message timeout for session: ${sessionId}`)
|
||||||
|
try {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
message: 'Stream timeout',
|
||||||
|
type: 'timeout_error',
|
||||||
|
code: 'stream_timeout'
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error writing timeout to SSE stream:', { error: writeError })
|
||||||
|
}
|
||||||
|
abortController.abort('stream timeout')
|
||||||
|
reader.cancel('stream timeout').catch(() => {})
|
||||||
|
responseEnded = true
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
10 * 60 * 1000
|
||||||
|
) // 10 minutes timeout
|
||||||
|
|
||||||
|
// Clear timeout when response ends
|
||||||
|
res.on('close', () => clearTimeout(timeout))
|
||||||
|
res.on('finish', () => clearTimeout(timeout))
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error in streaming message handler:', error)
|
||||||
|
|
||||||
|
// Send error as SSE if possible
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorResponse = {
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
message: error.status ? error.message : 'Failed to create streaming message',
|
||||||
|
type: error.status ? 'not_found' : 'internal_error',
|
||||||
|
code: error.status ? error.code : 'stream_creation_failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error writing initial error to SSE stream:', { error: writeError })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import {
|
||||||
|
AgentModelValidationError,
|
||||||
|
sessionMessageService,
|
||||||
|
sessionService
|
||||||
|
} from '@main/services/agents'
|
||||||
|
import {
|
||||||
|
CreateSessionResponse,
|
||||||
|
ListAgentSessionsResponse,
|
||||||
|
type ReplaceSessionRequest,
|
||||||
|
UpdateSessionResponse
|
||||||
|
} from '@types'
|
||||||
|
import { Request, Response } from 'express'
|
||||||
|
|
||||||
|
import type { ValidationRequest } from '../validators/zodValidator'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ApiServerSessionsHandlers')
|
||||||
|
|
||||||
|
const modelValidationErrorBody = (error: AgentModelValidationError) => ({
|
||||||
|
error: {
|
||||||
|
message: `Invalid ${error.context.field}: ${error.detail.message}`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: error.detail.code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
const { agentId } = req.params
|
||||||
|
try {
|
||||||
|
const sessionData = req.body
|
||||||
|
|
||||||
|
logger.info(`Creating new session for agent: ${agentId}`)
|
||||||
|
logger.debug('Session data:', sessionData)
|
||||||
|
|
||||||
|
const session = (await sessionService.createSession(agentId, sessionData)) satisfies CreateSessionResponse
|
||||||
|
|
||||||
|
logger.info(`Session created successfully: ${session.id}`)
|
||||||
|
return res.status(201).json(session)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof AgentModelValidationError) {
|
||||||
|
logger.warn('Session model validation error during create:', {
|
||||||
|
agentId,
|
||||||
|
agentType: error.context.agentType,
|
||||||
|
field: error.context.field,
|
||||||
|
model: error.context.model,
|
||||||
|
detail: error.detail
|
||||||
|
})
|
||||||
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Error creating session:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Failed to create session: ${error.message}`,
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_creation_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listSessions = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { agentId } = req.params
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||||
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||||
|
const status = req.query.status as any
|
||||||
|
|
||||||
|
logger.info(`Listing sessions for agent: ${agentId} with limit=${limit}, offset=${offset}, status=${status}`)
|
||||||
|
|
||||||
|
const result = await sessionService.listSessions(agentId, { limit, offset })
|
||||||
|
|
||||||
|
logger.info(`Retrieved ${result.sessions.length} sessions (total: ${result.total}) for agent: ${agentId}`)
|
||||||
|
return res.json({
|
||||||
|
data: result.sessions,
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error listing sessions:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to list sessions',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_list_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { agentId, sessionId } = req.params
|
||||||
|
logger.info(`Getting session: ${sessionId} for agent: ${agentId}`)
|
||||||
|
|
||||||
|
const session = await sessionService.getSession(agentId, sessionId)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
logger.warn(`Session not found: ${sessionId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Verify session belongs to the agent
|
||||||
|
// logger.warn(`Session ${sessionId} does not belong to agent ${agentId}`)
|
||||||
|
// return res.status(404).json({
|
||||||
|
// error: {
|
||||||
|
// message: 'Session not found for this agent',
|
||||||
|
// type: 'not_found',
|
||||||
|
// code: 'session_not_found'
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Fetch session messages
|
||||||
|
logger.info(`Fetching messages for session: ${sessionId}`)
|
||||||
|
const { messages } = await sessionMessageService.listSessionMessages(sessionId)
|
||||||
|
|
||||||
|
// Add messages to session
|
||||||
|
const sessionWithMessages = {
|
||||||
|
...session,
|
||||||
|
messages: messages
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Session retrieved successfully: ${sessionId} with ${messages.length} messages`)
|
||||||
|
return res.json(sessionWithMessages)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error getting session:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to get session',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_get_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
const { agentId, sessionId } = req.params
|
||||||
|
try {
|
||||||
|
logger.info(`Updating session: ${sessionId} for agent: ${agentId}`)
|
||||||
|
logger.debug('Update data:', req.body)
|
||||||
|
|
||||||
|
// First check if session exists and belongs to agent
|
||||||
|
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||||
|
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||||
|
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found for this agent',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { validatedBody } = req as ValidationRequest
|
||||||
|
const replacePayload = (validatedBody ?? {}) as ReplaceSessionRequest
|
||||||
|
|
||||||
|
const session = await sessionService.updateSession(agentId, sessionId, replacePayload)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
logger.warn(`Session not found for update: ${sessionId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Session updated successfully: ${sessionId}`)
|
||||||
|
return res.json(session satisfies UpdateSessionResponse)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof AgentModelValidationError) {
|
||||||
|
logger.warn('Session model validation error during update:', {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
agentType: error.context.agentType,
|
||||||
|
field: error.context.field,
|
||||||
|
model: error.context.model,
|
||||||
|
detail: error.detail
|
||||||
|
})
|
||||||
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Error updating session:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Failed to update session: ${error.message}`,
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_update_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const patchSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
const { agentId, sessionId } = req.params
|
||||||
|
try {
|
||||||
|
logger.info(`Patching session: ${sessionId} for agent: ${agentId}`)
|
||||||
|
logger.debug('Patch data:', req.body)
|
||||||
|
|
||||||
|
// First check if session exists and belongs to agent
|
||||||
|
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||||
|
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||||
|
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found for this agent',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSession = { ...existingSession, ...req.body }
|
||||||
|
const session = await sessionService.updateSession(agentId, sessionId, updateSession)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
logger.warn(`Session not found for patch: ${sessionId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Session patched successfully: ${sessionId}`)
|
||||||
|
return res.json(session)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof AgentModelValidationError) {
|
||||||
|
logger.warn('Session model validation error during patch:', {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
agentType: error.context.agentType,
|
||||||
|
field: error.context.field,
|
||||||
|
model: error.context.model,
|
||||||
|
detail: error.detail
|
||||||
|
})
|
||||||
|
return res.status(400).json(modelValidationErrorBody(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Error patching session:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Failed to patch session, ${error.message}`,
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_patch_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteSession = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { agentId, sessionId } = req.params
|
||||||
|
logger.info(`Deleting session: ${sessionId} for agent: ${agentId}`)
|
||||||
|
|
||||||
|
// First check if session exists and belongs to agent
|
||||||
|
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||||
|
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||||
|
logger.warn(`Session ${sessionId} not found for agent ${agentId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found for this agent',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await sessionService.deleteSession(agentId, sessionId)
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
logger.warn(`Session not found for deletion: ${sessionId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Session deleted successfully: ${sessionId}`)
|
||||||
|
return res.status(204).send()
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error deleting session:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to delete session',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_delete_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience endpoints for sessions without agent context
|
||||||
|
export const listAllSessions = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||||
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||||
|
const status = req.query.status as any
|
||||||
|
|
||||||
|
logger.info(`Listing all sessions with limit=${limit}, offset=${offset}, status=${status}`)
|
||||||
|
|
||||||
|
const result = await sessionService.listSessions(undefined, { limit, offset })
|
||||||
|
|
||||||
|
logger.info(`Retrieved ${result.sessions.length} sessions (total: ${result.total})`)
|
||||||
|
return res.json({
|
||||||
|
data: result.sessions,
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
} satisfies ListAgentSessionsResponse)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error listing all sessions:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to list sessions',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_list_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSessionById = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params
|
||||||
|
logger.info(`Getting session: ${sessionId}`)
|
||||||
|
|
||||||
|
const session = await sessionService.getSessionById(sessionId)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
logger.warn(`Session not found: ${sessionId}`)
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Session not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'session_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Session retrieved successfully: ${sessionId}`)
|
||||||
|
return res.json(session)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error getting session:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to get session',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'session_get_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,927 @@
|
|||||||
|
import express from 'express'
|
||||||
|
|
||||||
|
import { agentHandlers, messageHandlers, sessionHandlers } from './handlers'
|
||||||
|
import { checkAgentExists, handleValidationErrors } from './middleware'
|
||||||
|
import {
|
||||||
|
validateAgent,
|
||||||
|
validateAgentId,
|
||||||
|
validateAgentReplace,
|
||||||
|
validateAgentUpdate,
|
||||||
|
validatePagination,
|
||||||
|
validateSession,
|
||||||
|
validateSessionId,
|
||||||
|
validateSessionMessage,
|
||||||
|
validateSessionReplace,
|
||||||
|
validateSessionUpdate
|
||||||
|
} from './validators'
|
||||||
|
|
||||||
|
// Create main agents router
|
||||||
|
const agentsRouter = express.Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* components:
|
||||||
|
* schemas:
|
||||||
|
* PermissionMode:
|
||||||
|
* type: string
|
||||||
|
* enum: [default, acceptEdits, bypassPermissions, plan]
|
||||||
|
* description: Permission mode for agent operations
|
||||||
|
*
|
||||||
|
* AgentType:
|
||||||
|
* type: string
|
||||||
|
* enum: [claude-code]
|
||||||
|
* description: Type of agent
|
||||||
|
*
|
||||||
|
* AgentConfiguration:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* permission_mode:
|
||||||
|
* $ref: '#/components/schemas/PermissionMode'
|
||||||
|
* default: default
|
||||||
|
* max_turns:
|
||||||
|
* type: integer
|
||||||
|
* default: 10
|
||||||
|
* description: Maximum number of interaction turns
|
||||||
|
* additionalProperties: true
|
||||||
|
*
|
||||||
|
* AgentBase:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* description: Agent name
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* description: Agent description
|
||||||
|
* accessible_paths:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of directory paths the agent can access
|
||||||
|
* instructions:
|
||||||
|
* type: string
|
||||||
|
* description: System prompt/instructions
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* description: Main model ID
|
||||||
|
* plan_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional planning model ID
|
||||||
|
* small_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional small/fast model ID
|
||||||
|
* mcps:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of MCP tool IDs
|
||||||
|
* allowed_tools:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of allowed tool IDs (whitelist)
|
||||||
|
* configuration:
|
||||||
|
* $ref: '#/components/schemas/AgentConfiguration'
|
||||||
|
* required:
|
||||||
|
* - model
|
||||||
|
* - accessible_paths
|
||||||
|
*
|
||||||
|
* AgentEntity:
|
||||||
|
* allOf:
|
||||||
|
* - $ref: '#/components/schemas/AgentBase'
|
||||||
|
* - type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: Unique agent identifier
|
||||||
|
* type:
|
||||||
|
* $ref: '#/components/schemas/AgentType'
|
||||||
|
* created_at:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: ISO timestamp of creation
|
||||||
|
* updated_at:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: ISO timestamp of last update
|
||||||
|
* required:
|
||||||
|
* - id
|
||||||
|
* - type
|
||||||
|
* - created_at
|
||||||
|
* - updated_at
|
||||||
|
* CreateAgentRequest:
|
||||||
|
* allOf:
|
||||||
|
* - $ref: '#/components/schemas/AgentBase'
|
||||||
|
* - type: object
|
||||||
|
* properties:
|
||||||
|
* type:
|
||||||
|
* $ref: '#/components/schemas/AgentType'
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* minLength: 1
|
||||||
|
* description: Agent name (required)
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* minLength: 1
|
||||||
|
* description: Main model ID (required)
|
||||||
|
* required:
|
||||||
|
* - type
|
||||||
|
* - name
|
||||||
|
* - model
|
||||||
|
*
|
||||||
|
* UpdateAgentRequest:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* description: Agent name
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* description: Agent description
|
||||||
|
* accessible_paths:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of directory paths the agent can access
|
||||||
|
* instructions:
|
||||||
|
* type: string
|
||||||
|
* description: System prompt/instructions
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* description: Main model ID
|
||||||
|
* plan_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional planning model ID
|
||||||
|
* small_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional small/fast model ID
|
||||||
|
* mcps:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of MCP tool IDs
|
||||||
|
* allowed_tools:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of allowed tool IDs (whitelist)
|
||||||
|
* configuration:
|
||||||
|
* $ref: '#/components/schemas/AgentConfiguration'
|
||||||
|
* description: Partial update - all fields are optional
|
||||||
|
*
|
||||||
|
* ReplaceAgentRequest:
|
||||||
|
* $ref: '#/components/schemas/AgentBase'
|
||||||
|
*
|
||||||
|
* SessionEntity:
|
||||||
|
* allOf:
|
||||||
|
* - $ref: '#/components/schemas/AgentBase'
|
||||||
|
* - type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: Unique session identifier
|
||||||
|
* agent_id:
|
||||||
|
* type: string
|
||||||
|
* description: Primary agent ID for the session
|
||||||
|
* agent_type:
|
||||||
|
* $ref: '#/components/schemas/AgentType'
|
||||||
|
* created_at:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: ISO timestamp of creation
|
||||||
|
* updated_at:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: ISO timestamp of last update
|
||||||
|
* required:
|
||||||
|
* - id
|
||||||
|
* - agent_id
|
||||||
|
* - agent_type
|
||||||
|
* - created_at
|
||||||
|
* - updated_at
|
||||||
|
*
|
||||||
|
* CreateSessionRequest:
|
||||||
|
* allOf:
|
||||||
|
* - $ref: '#/components/schemas/AgentBase'
|
||||||
|
* - type: object
|
||||||
|
* properties:
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* minLength: 1
|
||||||
|
* description: Main model ID (required)
|
||||||
|
* required:
|
||||||
|
* - model
|
||||||
|
*
|
||||||
|
* UpdateSessionRequest:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* description: Session name
|
||||||
|
* description:
|
||||||
|
* type: string
|
||||||
|
* description: Session description
|
||||||
|
* accessible_paths:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of directory paths the agent can access
|
||||||
|
* instructions:
|
||||||
|
* type: string
|
||||||
|
* description: System prompt/instructions
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* description: Main model ID
|
||||||
|
* plan_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional planning model ID
|
||||||
|
* small_model:
|
||||||
|
* type: string
|
||||||
|
* description: Optional small/fast model ID
|
||||||
|
* mcps:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of MCP tool IDs
|
||||||
|
* allowed_tools:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Array of allowed tool IDs (whitelist)
|
||||||
|
* configuration:
|
||||||
|
* $ref: '#/components/schemas/AgentConfiguration'
|
||||||
|
* description: Partial update - all fields are optional
|
||||||
|
*
|
||||||
|
* ReplaceSessionRequest:
|
||||||
|
* allOf:
|
||||||
|
* - $ref: '#/components/schemas/AgentBase'
|
||||||
|
* - type: object
|
||||||
|
* properties:
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* minLength: 1
|
||||||
|
* description: Main model ID (required)
|
||||||
|
* required:
|
||||||
|
* - model
|
||||||
|
*
|
||||||
|
* CreateSessionMessageRequest:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* content:
|
||||||
|
* type: string
|
||||||
|
* minLength: 1
|
||||||
|
* description: Message content
|
||||||
|
* required:
|
||||||
|
* - content
|
||||||
|
*
|
||||||
|
* PaginationQuery:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* limit:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 100
|
||||||
|
* default: 20
|
||||||
|
* description: Number of items to return
|
||||||
|
* offset:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* default: 0
|
||||||
|
* description: Number of items to skip
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* enum: [idle, running, completed, failed, stopped]
|
||||||
|
* description: Filter by session status
|
||||||
|
*
|
||||||
|
* ListAgentsResponse:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* agents:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* total:
|
||||||
|
* type: integer
|
||||||
|
* description: Total number of agents
|
||||||
|
* limit:
|
||||||
|
* type: integer
|
||||||
|
* description: Number of items returned
|
||||||
|
* offset:
|
||||||
|
* type: integer
|
||||||
|
* description: Number of items skipped
|
||||||
|
* required:
|
||||||
|
* - agents
|
||||||
|
* - total
|
||||||
|
* - limit
|
||||||
|
* - offset
|
||||||
|
*
|
||||||
|
* ListSessionsResponse:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessions:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/SessionEntity'
|
||||||
|
* total:
|
||||||
|
* type: integer
|
||||||
|
* description: Total number of sessions
|
||||||
|
* limit:
|
||||||
|
* type: integer
|
||||||
|
* description: Number of items returned
|
||||||
|
* offset:
|
||||||
|
* type: integer
|
||||||
|
* description: Number of items skipped
|
||||||
|
* required:
|
||||||
|
* - sessions
|
||||||
|
* - total
|
||||||
|
* - limit
|
||||||
|
* - offset
|
||||||
|
*
|
||||||
|
* ErrorResponse:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* error:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* description: Error message
|
||||||
|
* type:
|
||||||
|
* type: string
|
||||||
|
* description: Error type
|
||||||
|
* code:
|
||||||
|
* type: string
|
||||||
|
* description: Error code
|
||||||
|
* required:
|
||||||
|
* - message
|
||||||
|
* - type
|
||||||
|
* - code
|
||||||
|
* required:
|
||||||
|
* - error
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new agent
|
||||||
|
* tags: [Agents]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/CreateAgentRequest'
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Agent created successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
// Agent CRUD routes
|
||||||
|
agentsRouter.post('/', validateAgent, handleValidationErrors, agentHandlers.createAgent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents:
|
||||||
|
* get:
|
||||||
|
* summary: List all agents with pagination
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 100
|
||||||
|
* default: 20
|
||||||
|
* description: Number of agents to return
|
||||||
|
* - in: query
|
||||||
|
* name: offset
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* default: 0
|
||||||
|
* description: Number of agents to skip
|
||||||
|
* - in: query
|
||||||
|
* name: status
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [idle, running, completed, failed, stopped]
|
||||||
|
* description: Filter by agent status
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of agents
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ListAgentsResponse'
|
||||||
|
*/
|
||||||
|
agentsRouter.get('/', validatePagination, handleValidationErrors, agentHandlers.listAgents)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}:
|
||||||
|
* get:
|
||||||
|
* summary: Get agent by ID
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Agent details
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
agentsRouter.get('/:agentId', validateAgentId, handleValidationErrors, agentHandlers.getAgent)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}:
|
||||||
|
* put:
|
||||||
|
* summary: Replace agent (full update)
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ReplaceAgentRequest'
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Agent updated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
agentsRouter.put('/:agentId', validateAgentId, validateAgentReplace, handleValidationErrors, agentHandlers.updateAgent)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}:
|
||||||
|
* patch:
|
||||||
|
* summary: Update agent (partial update)
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/UpdateAgentRequest'
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Agent updated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/AgentEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
agentsRouter.patch('/:agentId', validateAgentId, validateAgentUpdate, handleValidationErrors, agentHandlers.patchAgent)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete agent
|
||||||
|
* tags: [Agents]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* responses:
|
||||||
|
* 204:
|
||||||
|
* description: Agent deleted successfully
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
agentsRouter.delete('/:agentId', validateAgentId, handleValidationErrors, agentHandlers.deleteAgent)
|
||||||
|
|
||||||
|
// Create sessions router with agent context
|
||||||
|
const createSessionsRouter = (): express.Router => {
|
||||||
|
const sessionsRouter = express.Router({ mergeParams: true })
|
||||||
|
|
||||||
|
// Session CRUD routes (nested under agent)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}/sessions:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new session for an agent
|
||||||
|
* tags: [Sessions]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/CreateSessionRequest'
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Session created successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/SessionEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
sessionsRouter.post('/', validateSession, handleValidationErrors, sessionHandlers.createSession)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}/sessions:
|
||||||
|
* get:
|
||||||
|
* summary: List sessions for an agent
|
||||||
|
* tags: [Sessions]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 100
|
||||||
|
* default: 20
|
||||||
|
* description: Number of sessions to return
|
||||||
|
* - in: query
|
||||||
|
* name: offset
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* default: 0
|
||||||
|
* description: Number of sessions to skip
|
||||||
|
* - in: query
|
||||||
|
* name: status
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [idle, running, completed, failed, stopped]
|
||||||
|
* description: Filter by session status
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of sessions
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ListSessionsResponse'
|
||||||
|
* 404:
|
||||||
|
* description: Agent not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
sessionsRouter.get('/', validatePagination, handleValidationErrors, sessionHandlers.listSessions)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}/sessions/{sessionId}:
|
||||||
|
* get:
|
||||||
|
* summary: Get session by ID
|
||||||
|
* tags: [Sessions]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Session ID
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session details
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/SessionEntity'
|
||||||
|
* 404:
|
||||||
|
* description: Agent or session not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
sessionsRouter.get('/:sessionId', validateSessionId, handleValidationErrors, sessionHandlers.getSession)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}/sessions/{sessionId}:
|
||||||
|
* put:
|
||||||
|
* summary: Replace session (full update)
|
||||||
|
* tags: [Sessions]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Session ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ReplaceSessionRequest'
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session updated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/SessionEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* 404:
|
||||||
|
* description: Agent or session not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
sessionsRouter.put(
|
||||||
|
'/:sessionId',
|
||||||
|
validateSessionId,
|
||||||
|
validateSessionReplace,
|
||||||
|
handleValidationErrors,
|
||||||
|
sessionHandlers.updateSession
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}/sessions/{sessionId}:
|
||||||
|
* patch:
|
||||||
|
* summary: Update session (partial update)
|
||||||
|
* tags: [Sessions]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Session ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/UpdateSessionRequest'
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session updated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/SessionEntity'
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* 404:
|
||||||
|
* description: Agent or session not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
sessionsRouter.patch(
|
||||||
|
'/:sessionId',
|
||||||
|
validateSessionId,
|
||||||
|
validateSessionUpdate,
|
||||||
|
handleValidationErrors,
|
||||||
|
sessionHandlers.patchSession
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}/sessions/{sessionId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete session
|
||||||
|
* tags: [Sessions]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Session ID
|
||||||
|
* responses:
|
||||||
|
* 204:
|
||||||
|
* description: Session deleted successfully
|
||||||
|
* 404:
|
||||||
|
* description: Agent or session not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
sessionsRouter.delete('/:sessionId', validateSessionId, handleValidationErrors, sessionHandlers.deleteSession)
|
||||||
|
|
||||||
|
return sessionsRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create messages router with agent and session context
|
||||||
|
const createMessagesRouter = (): express.Router => {
|
||||||
|
const messagesRouter = express.Router({ mergeParams: true })
|
||||||
|
|
||||||
|
// Message CRUD routes (nested under agent/session)
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/agents/{agentId}/sessions/{sessionId}/messages:
|
||||||
|
* post:
|
||||||
|
* summary: Create a new message in a session
|
||||||
|
* tags: [Messages]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: agentId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Agent ID
|
||||||
|
* - in: path
|
||||||
|
* name: sessionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Session ID
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/CreateSessionMessageRequest'
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Message created successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: number
|
||||||
|
* description: Message ID
|
||||||
|
* session_id:
|
||||||
|
* type: string
|
||||||
|
* description: Session ID
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* enum: [assistant, user, system, tool]
|
||||||
|
* description: Message role
|
||||||
|
* content:
|
||||||
|
* type: object
|
||||||
|
* description: Message content (AI SDK format)
|
||||||
|
* agent_session_id:
|
||||||
|
* type: string
|
||||||
|
* description: Agent session ID for resuming
|
||||||
|
* metadata:
|
||||||
|
* type: object
|
||||||
|
* description: Additional metadata
|
||||||
|
* created_at:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* updated_at:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* 404:
|
||||||
|
* description: Agent or session not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessage)
|
||||||
|
return messagesRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount nested resources with clear hierarchy
|
||||||
|
const sessionsRouter = createSessionsRouter()
|
||||||
|
const messagesRouter = createMessagesRouter()
|
||||||
|
|
||||||
|
// Mount sessions under specific agent
|
||||||
|
agentsRouter.use('/:agentId/sessions', validateAgentId, checkAgentExists, handleValidationErrors, sessionsRouter)
|
||||||
|
|
||||||
|
// Mount messages under specific agent/session
|
||||||
|
agentsRouter.use(
|
||||||
|
'/:agentId/sessions/:sessionId/messages',
|
||||||
|
validateAgentId,
|
||||||
|
validateSessionId,
|
||||||
|
handleValidationErrors,
|
||||||
|
messagesRouter
|
||||||
|
)
|
||||||
|
|
||||||
|
// Export main router and convenience router
|
||||||
|
export const agentsRoutes = agentsRouter
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
|
||||||
|
import { agentService } from '../../../../services/agents'
|
||||||
|
import { loggerService } from '../../../../services/LoggerService'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ApiServerMiddleware')
|
||||||
|
|
||||||
|
// Since Zod validators handle their own errors, this is now a pass-through
|
||||||
|
export const handleValidationErrors = (_req: Request, _res: Response, next: any): void => {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware to check if agent exists
|
||||||
|
export const checkAgentExists = async (req: Request, res: Response, next: any): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { agentId } = req.params
|
||||||
|
const exists = await agentService.agentExists(agentId)
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Agent not found',
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'agent_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking agent existence:', error as Error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to validate agent',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'agent_validation_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './common'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
AgentIdParamSchema,
|
||||||
|
CreateAgentRequestSchema,
|
||||||
|
ReplaceAgentRequestSchema,
|
||||||
|
UpdateAgentRequestSchema
|
||||||
|
} from '@types'
|
||||||
|
|
||||||
|
import { createZodValidator } from './zodValidator'
|
||||||
|
|
||||||
|
export const validateAgent = createZodValidator({
|
||||||
|
body: CreateAgentRequestSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateAgentReplace = createZodValidator({
|
||||||
|
body: ReplaceAgentRequestSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateAgentUpdate = createZodValidator({
|
||||||
|
body: UpdateAgentRequestSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateAgentId = createZodValidator({
|
||||||
|
params: AgentIdParamSchema
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { PaginationQuerySchema } from '@types'
|
||||||
|
|
||||||
|
import { createZodValidator } from './zodValidator'
|
||||||
|
|
||||||
|
export const validatePagination = createZodValidator({
|
||||||
|
query: PaginationQuerySchema
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './agents'
|
||||||
|
export * from './common'
|
||||||
|
export * from './messages'
|
||||||
|
export * from './sessions'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { CreateSessionMessageRequestSchema } from '@types'
|
||||||
|
|
||||||
|
import { createZodValidator } from './zodValidator'
|
||||||
|
|
||||||
|
export const validateSessionMessage = createZodValidator({
|
||||||
|
body: CreateSessionMessageRequestSchema
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
CreateSessionRequestSchema,
|
||||||
|
ReplaceSessionRequestSchema,
|
||||||
|
SessionIdParamSchema,
|
||||||
|
UpdateSessionRequestSchema
|
||||||
|
} from '@types'
|
||||||
|
|
||||||
|
import { createZodValidator } from './zodValidator'
|
||||||
|
|
||||||
|
export const validateSession = createZodValidator({
|
||||||
|
body: CreateSessionRequestSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateSessionReplace = createZodValidator({
|
||||||
|
body: ReplaceSessionRequestSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateSessionUpdate = createZodValidator({
|
||||||
|
body: UpdateSessionRequestSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validateSessionId = createZodValidator({
|
||||||
|
params: SessionIdParamSchema
|
||||||
|
})
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextFunction,Request, Response } from 'express'
|
||||||
|
import { ZodError, ZodType } from 'zod'
|
||||||
|
|
||||||
|
export interface ValidationRequest extends Request {
|
||||||
|
validatedBody?: any
|
||||||
|
validatedParams?: any
|
||||||
|
validatedQuery?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZodValidationConfig {
|
||||||
|
body?: ZodType
|
||||||
|
params?: ZodType
|
||||||
|
query?: ZodType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createZodValidator = (config: ZodValidationConfig) => {
|
||||||
|
return (req: ValidationRequest, res: Response, next: NextFunction): void => {
|
||||||
|
try {
|
||||||
|
if (config.body && req.body) {
|
||||||
|
req.validatedBody = config.body.parse(req.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.params && req.params) {
|
||||||
|
req.validatedParams = config.params.parse(req.params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.query && req.query) {
|
||||||
|
req.validatedQuery = config.query.parse(req.query)
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const validationErrors = error.issues.map((err) => ({
|
||||||
|
type: 'field',
|
||||||
|
value: err.input,
|
||||||
|
msg: err.message,
|
||||||
|
path: err.path.map(p => String(p)).join('.'),
|
||||||
|
location: getLocationFromPath(err.path, config)
|
||||||
|
}))
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: 'Validation failed',
|
||||||
|
type: 'validation_error',
|
||||||
|
details: validationErrors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Internal validation error',
|
||||||
|
type: 'internal_error',
|
||||||
|
code: 'validation_processing_failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationFromPath(path: (string | number | symbol)[], config: ZodValidationConfig): string {
|
||||||
|
if (config.body && path.length > 0) return 'body'
|
||||||
|
if (config.params && path.length > 0) return 'params'
|
||||||
|
if (config.query && path.length > 0) return 'query'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
@@ -1,15 +1,105 @@
|
|||||||
import express, { Request, Response } from 'express'
|
import express, { Request, Response } from 'express'
|
||||||
import OpenAI from 'openai'
|
|
||||||
import { ChatCompletionCreateParams } from 'openai/resources'
|
import { ChatCompletionCreateParams } from 'openai/resources'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import { chatCompletionService } from '../services/chat-completion'
|
import {
|
||||||
import { validateModelId } from '../utils'
|
ChatCompletionModelError,
|
||||||
|
chatCompletionService,
|
||||||
|
ChatCompletionValidationError
|
||||||
|
} from '../services/chat-completion'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerChatRoutes')
|
const logger = loggerService.withContext('ApiServerChatRoutes')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
interface ErrorResponseBody {
|
||||||
|
error: {
|
||||||
|
message: string
|
||||||
|
type: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapChatCompletionError = (error: unknown): { status: number; body: ErrorResponseBody } => {
|
||||||
|
if (error instanceof ChatCompletionValidationError) {
|
||||||
|
logger.warn('Chat completion validation error:', {
|
||||||
|
errors: error.errors
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
error: {
|
||||||
|
message: error.errors.join('; '),
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: 'validation_failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ChatCompletionModelError) {
|
||||||
|
logger.warn('Chat completion model error:', error.error)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
error: {
|
||||||
|
message: error.error.message,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: error.error.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
let statusCode = 500
|
||||||
|
let errorType = 'server_error'
|
||||||
|
let errorCode = 'internal_error'
|
||||||
|
|
||||||
|
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
||||||
|
statusCode = 401
|
||||||
|
errorType = 'authentication_error'
|
||||||
|
errorCode = 'invalid_api_key'
|
||||||
|
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
||||||
|
statusCode = 429
|
||||||
|
errorType = 'rate_limit_error'
|
||||||
|
errorCode = 'rate_limit_exceeded'
|
||||||
|
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
||||||
|
statusCode = 502
|
||||||
|
errorType = 'server_error'
|
||||||
|
errorCode = 'upstream_error'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Chat completion error:', { error })
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: statusCode,
|
||||||
|
body: {
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Internal server error',
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Chat completion unknown error:', { error })
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
error: {
|
||||||
|
message: 'Internal server error',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'internal_error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /v1/chat/completions:
|
* /v1/chat/completions:
|
||||||
@@ -60,7 +150,7 @@ const router = express.Router()
|
|||||||
* type: integer
|
* type: integer
|
||||||
* total_tokens:
|
* total_tokens:
|
||||||
* type: integer
|
* type: integer
|
||||||
* text/plain:
|
* text/event-stream:
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Server-sent events stream (when stream=true)
|
* description: Server-sent events stream (when stream=true)
|
||||||
@@ -110,63 +200,22 @@ router.post('/completions', async (req: Request, res: Response) => {
|
|||||||
temperature: request.temperature
|
temperature: request.temperature
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate request
|
const isStreaming = !!request.stream
|
||||||
const validation = chatCompletionService.validateRequest(request)
|
|
||||||
if (!validation.isValid) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: {
|
|
||||||
message: validation.errors.join('; '),
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
code: 'validation_failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate model ID and get provider
|
if (isStreaming) {
|
||||||
const modelValidation = await validateModelId(request.model)
|
const { stream } = await chatCompletionService.processStreamingCompletion(request)
|
||||||
if (!modelValidation.valid) {
|
|
||||||
const error = modelValidation.error!
|
|
||||||
logger.warn(`Model validation failed for '${request.model}':`, error)
|
|
||||||
return res.status(400).json({
|
|
||||||
error: {
|
|
||||||
message: error.message,
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
code: error.code
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = modelValidation.provider!
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
const modelId = modelValidation.modelId!
|
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||||
|
|
||||||
logger.info('Model validation successful:', {
|
|
||||||
provider: provider.id,
|
|
||||||
providerType: provider.type,
|
|
||||||
modelId: modelId,
|
|
||||||
fullModelId: request.model
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create OpenAI client
|
|
||||||
const client = new OpenAI({
|
|
||||||
baseURL: provider.apiHost,
|
|
||||||
apiKey: provider.apiKey
|
|
||||||
})
|
|
||||||
request.model = modelId
|
|
||||||
|
|
||||||
// Handle streaming
|
|
||||||
if (request.stream) {
|
|
||||||
const streamResponse = await client.chat.completions.create(request)
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
||||||
res.setHeader('Cache-Control', 'no-cache')
|
|
||||||
res.setHeader('Connection', 'keep-alive')
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
res.flushHeaders()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of streamResponse as any) {
|
for await (const chunk of stream) {
|
||||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
}
|
}
|
||||||
res.write('data: [DONE]\n\n')
|
res.write('data: [DONE]\n\n')
|
||||||
res.end()
|
|
||||||
} catch (streamError: any) {
|
} catch (streamError: any) {
|
||||||
logger.error('Stream error:', streamError)
|
logger.error('Stream error:', streamError)
|
||||||
res.write(
|
res.write(
|
||||||
@@ -178,47 +227,17 @@ router.post('/completions', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
})}\n\n`
|
})}\n\n`
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-streaming
|
const { response } = await chatCompletionService.processCompletion(request)
|
||||||
const response = await client.chat.completions.create(request)
|
|
||||||
return res.json(response)
|
return res.json(response)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
logger.error('Chat completion error:', error)
|
const { status, body } = mapChatCompletionError(error)
|
||||||
|
return res.status(status).json(body)
|
||||||
let statusCode = 500
|
|
||||||
let errorType = 'server_error'
|
|
||||||
let errorCode = 'internal_error'
|
|
||||||
let errorMessage = 'Internal server error'
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
errorMessage = error.message
|
|
||||||
|
|
||||||
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
|
||||||
statusCode = 401
|
|
||||||
errorType = 'authentication_error'
|
|
||||||
errorCode = 'invalid_api_key'
|
|
||||||
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
|
||||||
statusCode = 429
|
|
||||||
errorType = 'rate_limit_error'
|
|
||||||
errorCode = 'rate_limit_exceeded'
|
|
||||||
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
|
||||||
statusCode = 502
|
|
||||||
errorType = 'server_error'
|
|
||||||
errorCode = 'upstream_error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
error: {
|
|
||||||
message: errorMessage,
|
|
||||||
type: errorType,
|
|
||||||
code: errorCode
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
|
||||||
|
import express, { Request, Response } from 'express'
|
||||||
|
|
||||||
|
import { loggerService } from '../../services/LoggerService'
|
||||||
|
import { messagesService } from '../services/messages'
|
||||||
|
import { validateModelId } from '../utils'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /v1/messages:
|
||||||
|
* post:
|
||||||
|
* summary: Create message
|
||||||
|
* description: Create a message response using Anthropic's API format
|
||||||
|
* tags: [Messages]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - model
|
||||||
|
* - max_tokens
|
||||||
|
* - messages
|
||||||
|
* properties:
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* description: Model ID in format "provider:model_id"
|
||||||
|
* example: "my-anthropic:claude-3-5-sonnet-20241022"
|
||||||
|
* max_tokens:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* description: Maximum number of tokens to generate
|
||||||
|
* example: 1024
|
||||||
|
* messages:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* enum: [user, assistant]
|
||||||
|
* content:
|
||||||
|
* oneOf:
|
||||||
|
* - type: string
|
||||||
|
* - type: array
|
||||||
|
* system:
|
||||||
|
* type: string
|
||||||
|
* description: System message
|
||||||
|
* temperature:
|
||||||
|
* type: number
|
||||||
|
* minimum: 0
|
||||||
|
* maximum: 1
|
||||||
|
* description: Sampling temperature
|
||||||
|
* top_p:
|
||||||
|
* type: number
|
||||||
|
* minimum: 0
|
||||||
|
* maximum: 1
|
||||||
|
* description: Nucleus sampling
|
||||||
|
* top_k:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* description: Top-k sampling
|
||||||
|
* stream:
|
||||||
|
* type: boolean
|
||||||
|
* description: Whether to stream the response
|
||||||
|
* tools:
|
||||||
|
* type: array
|
||||||
|
* description: Available tools for the model
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Message response
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* type:
|
||||||
|
* type: string
|
||||||
|
* example: message
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* example: assistant
|
||||||
|
* content:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* model:
|
||||||
|
* type: string
|
||||||
|
* stop_reason:
|
||||||
|
* type: string
|
||||||
|
* stop_sequence:
|
||||||
|
* type: string
|
||||||
|
* usage:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* input_tokens:
|
||||||
|
* type: integer
|
||||||
|
* output_tokens:
|
||||||
|
* type: integer
|
||||||
|
* text/event-stream:
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Server-sent events stream (when stream=true)
|
||||||
|
* 400:
|
||||||
|
* description: Bad request
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* type:
|
||||||
|
* type: string
|
||||||
|
* example: error
|
||||||
|
* error:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* type:
|
||||||
|
* type: string
|
||||||
|
* message:
|
||||||
|
* type: string
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
* 429:
|
||||||
|
* description: Rate limit exceeded
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
*/
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const request: MessageCreateParams = req.body
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return res.status(400).json({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
message: 'Request body is required'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Anthropic message request:', {
|
||||||
|
model: request.model,
|
||||||
|
messageCount: request.messages?.length || 0,
|
||||||
|
stream: request.stream,
|
||||||
|
max_tokens: request.max_tokens,
|
||||||
|
temperature: request.temperature
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validate model ID and get provider
|
||||||
|
const modelValidation = await validateModelId(request.model)
|
||||||
|
if (!modelValidation.valid) {
|
||||||
|
const error = modelValidation.error!
|
||||||
|
logger.warn(`Model validation failed for '${request.model}':`, error)
|
||||||
|
return res.status(400).json({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = modelValidation.provider!
|
||||||
|
|
||||||
|
// Ensure provider is Anthropic type
|
||||||
|
if (provider.type !== 'anthropic') {
|
||||||
|
return res.status(400).json({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
message: `Invalid provider type '${provider.type}' for messages endpoint. Expected 'anthropic' provider.`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = modelValidation.modelId!
|
||||||
|
request.model = modelId
|
||||||
|
|
||||||
|
logger.info('Model validation successful:', {
|
||||||
|
provider: provider.id,
|
||||||
|
providerType: provider.type,
|
||||||
|
modelId: modelId,
|
||||||
|
fullModelId: request.model
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
const validation = messagesService.validateRequest(request)
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
message: validation.errors.join('; ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle streaming
|
||||||
|
if (request.stream) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
res.flushHeaders()
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
|
||||||
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
}
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
} catch (streamError: any) {
|
||||||
|
logger.error('Stream error:', streamError)
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'api_error',
|
||||||
|
message: 'Stream processing error'
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle non-streaming
|
||||||
|
const response = await messagesService.processMessage(request, provider)
|
||||||
|
return res.json(response)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Anthropic message error:', error)
|
||||||
|
|
||||||
|
let statusCode = 500
|
||||||
|
let errorType = 'api_error'
|
||||||
|
let errorMessage = 'Internal server error'
|
||||||
|
|
||||||
|
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
|
||||||
|
const anthropicError = error?.error
|
||||||
|
|
||||||
|
if (anthropicStatus) {
|
||||||
|
statusCode = anthropicStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anthropicError?.type) {
|
||||||
|
errorType = anthropicError.type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anthropicError?.message) {
|
||||||
|
errorMessage = anthropicError.message
|
||||||
|
} else if (error instanceof Error && error.message) {
|
||||||
|
errorMessage = error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anthropicStatus && error instanceof Error) {
|
||||||
|
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
||||||
|
statusCode = 401
|
||||||
|
errorType = 'authentication_error'
|
||||||
|
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
||||||
|
statusCode = 429
|
||||||
|
errorType = 'rate_limit_error'
|
||||||
|
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
||||||
|
statusCode = 502
|
||||||
|
errorType = 'api_error'
|
||||||
|
} else if (error.message.includes('validation') || error.message.includes('invalid')) {
|
||||||
|
statusCode = 400
|
||||||
|
errorType = 'invalid_request_error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType,
|
||||||
|
message: errorMessage,
|
||||||
|
requestId: error?.request_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export { router as messagesRoutes }
|
||||||
@@ -1,73 +1,130 @@
|
|||||||
|
import { ApiModelsFilterSchema, ApiModelsResponse } from '@types'
|
||||||
import express, { Request, Response } from 'express'
|
import express, { Request, Response } from 'express'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import { chatCompletionService } from '../services/chat-completion'
|
import { modelsService } from '../services/models'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerModelsRoutes')
|
const logger = loggerService.withContext('ApiServerModelsRoutes')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express
|
||||||
|
.Router()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /v1/models:
|
* /v1/models:
|
||||||
* get:
|
* get:
|
||||||
* summary: List available models
|
* summary: List available models
|
||||||
* description: Returns a list of available AI models from all configured providers
|
* description: Returns a list of available AI models from all configured providers with optional filtering
|
||||||
* tags: [Models]
|
* tags: [Models]
|
||||||
* responses:
|
* parameters:
|
||||||
* 200:
|
* - in: query
|
||||||
* description: List of available models
|
* name: providerType
|
||||||
* content:
|
* schema:
|
||||||
* application/json:
|
* type: string
|
||||||
* schema:
|
* enum: [openai, openai-response, anthropic, gemini]
|
||||||
* type: object
|
* description: Filter models by provider type
|
||||||
* properties:
|
* - in: query
|
||||||
* object:
|
* name: offset
|
||||||
* type: string
|
* schema:
|
||||||
* example: list
|
* type: integer
|
||||||
* data:
|
* minimum: 0
|
||||||
* type: array
|
* default: 0
|
||||||
* items:
|
* description: Pagination offset
|
||||||
* $ref: '#/components/schemas/Model'
|
* - in: query
|
||||||
* 503:
|
* name: limit
|
||||||
* description: Service unavailable
|
* schema:
|
||||||
* content:
|
* type: integer
|
||||||
* application/json:
|
* minimum: 1
|
||||||
* schema:
|
* description: Maximum number of models to return
|
||||||
* $ref: '#/components/schemas/Error'
|
* responses:
|
||||||
*/
|
* 200:
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
* description: List of available models
|
||||||
try {
|
* content:
|
||||||
logger.info('Models list request received')
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* object:
|
||||||
|
* type: string
|
||||||
|
* example: list
|
||||||
|
* data:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/Model'
|
||||||
|
* total:
|
||||||
|
* type: integer
|
||||||
|
* description: Total number of models (when using pagination)
|
||||||
|
* offset:
|
||||||
|
* type: integer
|
||||||
|
* description: Current offset (when using pagination)
|
||||||
|
* limit:
|
||||||
|
* type: integer
|
||||||
|
* description: Current limit (when using pagination)
|
||||||
|
* 400:
|
||||||
|
* description: Invalid query parameters
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 503:
|
||||||
|
* description: Service unavailable
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
.get('/', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
logger.info('Models list request received', { query: req.query })
|
||||||
|
|
||||||
const models = await chatCompletionService.getModels()
|
// Validate query parameters using Zod schema
|
||||||
|
const filterResult = ApiModelsFilterSchema.safeParse(req.query)
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (!filterResult.success) {
|
||||||
logger.warn(
|
logger.warn('Invalid query parameters:', filterResult.error.issues)
|
||||||
'No models available from providers. This may be because no OpenAI providers are configured or enabled.'
|
return res.status(400).json({
|
||||||
)
|
error: {
|
||||||
}
|
message: 'Invalid query parameters',
|
||||||
|
type: 'invalid_request_error',
|
||||||
logger.info(`Returning ${models.length} models (OpenAI providers only)`)
|
code: 'invalid_parameters',
|
||||||
logger.debug(
|
details: filterResult.error.issues.map((issue) => ({
|
||||||
'Model IDs:',
|
field: issue.path.join('.'),
|
||||||
models.map((m) => m.id)
|
message: issue.message
|
||||||
)
|
}))
|
||||||
|
}
|
||||||
return res.json({
|
})
|
||||||
object: 'list',
|
|
||||||
data: models
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error fetching models:', error)
|
|
||||||
return res.status(503).json({
|
|
||||||
error: {
|
|
||||||
message: 'Failed to retrieve models from available providers',
|
|
||||||
type: 'service_unavailable',
|
|
||||||
code: 'models_unavailable'
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
const filter = filterResult.data
|
||||||
})
|
const response = await modelsService.getModels(filter)
|
||||||
|
|
||||||
|
if (response.data.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
'No models available from providers. This may be because no OpenAI/Anthropic providers are configured or enabled.',
|
||||||
|
{ filter }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Returning ${response.data.length} models`, {
|
||||||
|
filter,
|
||||||
|
total: response.total
|
||||||
|
})
|
||||||
|
logger.debug(
|
||||||
|
'Model IDs:',
|
||||||
|
response.data.map((m) => m.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json(response satisfies ApiModelsResponse)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error fetching models:', error)
|
||||||
|
return res.status(503).json({
|
||||||
|
error: {
|
||||||
|
message: 'Failed to retrieve models from available providers',
|
||||||
|
type: 'service_unavailable',
|
||||||
|
code: 'models_unavailable'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export { router as modelsRoutes }
|
export { router as modelsRoutes }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
|
import { agentService } from '../services/agents'
|
||||||
import { loggerService } from '../services/LoggerService'
|
import { loggerService } from '../services/LoggerService'
|
||||||
import { app } from './app'
|
import { app } from './app'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
@@ -18,6 +19,11 @@ export class ApiServer {
|
|||||||
// Load config
|
// Load config
|
||||||
const { port, host, apiKey } = await config.load()
|
const { port, host, apiKey } = await config.load()
|
||||||
|
|
||||||
|
// Initialize AgentService
|
||||||
|
logger.info('Initializing AgentService...')
|
||||||
|
await agentService.initialize()
|
||||||
|
logger.info('AgentService initialized successfully')
|
||||||
|
|
||||||
// Create server with Express app
|
// Create server with Express app
|
||||||
this.server = createServer(app)
|
this.server = createServer(app)
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,131 @@
|
|||||||
|
import { Provider } from '@types'
|
||||||
import OpenAI from 'openai'
|
import OpenAI from 'openai'
|
||||||
import { ChatCompletionCreateParams } from 'openai/resources'
|
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import {
|
import { ModelValidationError, validateModelId } from '../utils'
|
||||||
getProviderByModel,
|
|
||||||
getRealProviderModel,
|
|
||||||
listAllAvailableModels,
|
|
||||||
OpenAICompatibleModel,
|
|
||||||
transformModelToOpenAI,
|
|
||||||
validateProvider
|
|
||||||
} from '../utils'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('ChatCompletionService')
|
const logger = loggerService.withContext('ChatCompletionService')
|
||||||
|
|
||||||
export interface ModelData extends OpenAICompatibleModel {
|
|
||||||
provider_id: string
|
|
||||||
model_id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ChatCompletionValidationError extends Error {
|
||||||
|
constructor(public readonly errors: string[]) {
|
||||||
|
super(`Request validation failed: ${errors.join('; ')}`)
|
||||||
|
this.name = 'ChatCompletionValidationError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatCompletionModelError extends Error {
|
||||||
|
constructor(public readonly error: ModelValidationError) {
|
||||||
|
super(`Model validation failed: ${error.message}`)
|
||||||
|
this.name = 'ChatCompletionModelError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrepareRequestResult =
|
||||||
|
| { status: 'validation_error'; errors: string[] }
|
||||||
|
| { status: 'model_error'; error: ModelValidationError }
|
||||||
|
| {
|
||||||
|
status: 'ok'
|
||||||
|
provider: Provider
|
||||||
|
modelId: string
|
||||||
|
client: OpenAI
|
||||||
|
providerRequest: ChatCompletionCreateParams
|
||||||
|
}
|
||||||
|
|
||||||
export class ChatCompletionService {
|
export class ChatCompletionService {
|
||||||
async getModels(): Promise<ModelData[]> {
|
async resolveProviderContext(model: string): Promise<
|
||||||
try {
|
| { ok: false; error: ModelValidationError }
|
||||||
logger.info('Getting available models from providers')
|
| { ok: true; provider: Provider; modelId: string; client: OpenAI }
|
||||||
|
> {
|
||||||
|
const modelValidation = await validateModelId(model)
|
||||||
|
if (!modelValidation.valid) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: modelValidation.error!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const models = await listAllAvailableModels()
|
const provider = modelValidation.provider!
|
||||||
|
|
||||||
// Use Map to deduplicate models by their full ID (provider:model_id)
|
if (provider.type !== 'openai') {
|
||||||
const uniqueModels = new Map<string, ModelData>()
|
return {
|
||||||
|
ok: false,
|
||||||
for (const model of models) {
|
error: {
|
||||||
const openAIModel = transformModelToOpenAI(model)
|
type: 'unsupported_provider_type',
|
||||||
const fullModelId = openAIModel.id // This is already in format "provider:model_id"
|
message: `Provider '${provider.id}' of type '${provider.type}' is not supported for OpenAI chat completions`,
|
||||||
|
code: 'unsupported_provider_type'
|
||||||
// Only add if not already present (first occurrence wins)
|
|
||||||
if (!uniqueModels.has(fullModelId)) {
|
|
||||||
uniqueModels.set(fullModelId, {
|
|
||||||
...openAIModel,
|
|
||||||
provider_id: model.provider,
|
|
||||||
model_id: model.id,
|
|
||||||
name: model.name
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
logger.debug(`Skipping duplicate model: ${fullModelId}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelData = Array.from(uniqueModels.values())
|
const modelId = modelValidation.modelId!
|
||||||
|
|
||||||
logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`)
|
const client = new OpenAI({
|
||||||
|
baseURL: provider.apiHost,
|
||||||
|
apiKey: provider.apiKey
|
||||||
|
})
|
||||||
|
|
||||||
if (models.length > modelData.length) {
|
return {
|
||||||
logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`)
|
ok: true,
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareRequest(request: ChatCompletionCreateParams, stream: boolean): Promise<PrepareRequestResult> {
|
||||||
|
const requestValidation = this.validateRequest(request)
|
||||||
|
if (!requestValidation.isValid) {
|
||||||
|
return {
|
||||||
|
status: 'validation_error',
|
||||||
|
errors: requestValidation.errors
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return modelData
|
const providerContext = await this.resolveProviderContext(request.model!)
|
||||||
} catch (error: any) {
|
if (!providerContext.ok) {
|
||||||
logger.error('Error getting models:', error)
|
return {
|
||||||
return []
|
status: 'model_error',
|
||||||
|
error: providerContext.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { provider, modelId, client } = providerContext
|
||||||
|
|
||||||
|
logger.info('Model validation successful:', {
|
||||||
|
provider: provider.id,
|
||||||
|
providerType: provider.type,
|
||||||
|
modelId,
|
||||||
|
fullModelId: request.model
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
client,
|
||||||
|
providerRequest: stream
|
||||||
|
? {
|
||||||
|
...request,
|
||||||
|
model: modelId,
|
||||||
|
stream: true as const
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...request,
|
||||||
|
model: modelId,
|
||||||
|
stream: false as const
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validateRequest(request: ChatCompletionCreateParams): ValidationResult {
|
validateRequest(request: ChatCompletionCreateParams): ValidationResult {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
// Validate model
|
|
||||||
if (!request.model) {
|
|
||||||
errors.push('Model is required')
|
|
||||||
} else if (typeof request.model !== 'string') {
|
|
||||||
errors.push('Model must be a string')
|
|
||||||
} else if (!request.model.includes(':')) {
|
|
||||||
errors.push('Model must be in format "provider:model_id"')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate messages
|
// Validate messages
|
||||||
if (!request.messages) {
|
if (!request.messages) {
|
||||||
errors.push('Messages array is required')
|
errors.push('Messages array is required')
|
||||||
@@ -98,17 +146,6 @@ export class ChatCompletionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate optional parameters
|
// Validate optional parameters
|
||||||
if (request.temperature !== undefined) {
|
|
||||||
if (typeof request.temperature !== 'number' || request.temperature < 0 || request.temperature > 2) {
|
|
||||||
errors.push('Temperature must be a number between 0 and 2')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.max_tokens !== undefined) {
|
|
||||||
if (typeof request.max_tokens !== 'number' || request.max_tokens < 1) {
|
|
||||||
errors.push('max_tokens must be a positive number')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
@@ -116,7 +153,11 @@ export class ChatCompletionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async processCompletion(request: ChatCompletionCreateParams): Promise<OpenAI.Chat.Completions.ChatCompletion> {
|
async processCompletion(request: ChatCompletionCreateParams): Promise<{
|
||||||
|
provider: Provider
|
||||||
|
modelId: string
|
||||||
|
response: OpenAI.Chat.Completions.ChatCompletion
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
logger.info('Processing chat completion request:', {
|
logger.info('Processing chat completion request:', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
@@ -124,38 +165,16 @@ export class ChatCompletionService {
|
|||||||
stream: request.stream
|
stream: request.stream
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate request
|
const preparation = await this.prepareRequest(request, false)
|
||||||
const validation = this.validateRequest(request)
|
if (preparation.status === 'validation_error') {
|
||||||
if (!validation.isValid) {
|
throw new ChatCompletionValidationError(preparation.errors)
|
||||||
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get provider for the model
|
if (preparation.status === 'model_error') {
|
||||||
const provider = await getProviderByModel(request.model!)
|
throw new ChatCompletionModelError(preparation.error)
|
||||||
if (!provider) {
|
|
||||||
throw new Error(`Provider not found for model: ${request.model}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider
|
const { provider, modelId, client, providerRequest } = preparation
|
||||||
if (!validateProvider(provider)) {
|
|
||||||
throw new Error(`Provider validation failed for: ${provider.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract model ID from the full model string
|
|
||||||
const modelId = getRealProviderModel(request.model)
|
|
||||||
|
|
||||||
// Create OpenAI client for the provider
|
|
||||||
const client = new OpenAI({
|
|
||||||
baseURL: provider.apiHost,
|
|
||||||
apiKey: provider.apiKey
|
|
||||||
})
|
|
||||||
|
|
||||||
// Prepare request with the actual model ID
|
|
||||||
const providerRequest = {
|
|
||||||
...request,
|
|
||||||
model: modelId,
|
|
||||||
stream: false
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Sending request to provider:', {
|
logger.debug('Sending request to provider:', {
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
@@ -166,54 +185,40 @@ export class ChatCompletionService {
|
|||||||
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
||||||
|
|
||||||
logger.info('Successfully processed chat completion')
|
logger.info('Successfully processed chat completion')
|
||||||
return response
|
return {
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
response
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error processing chat completion:', error)
|
logger.error('Error processing chat completion:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async *processStreamingCompletion(
|
async processStreamingCompletion(
|
||||||
request: ChatCompletionCreateParams
|
request: ChatCompletionCreateParams
|
||||||
): AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk> {
|
): Promise<{
|
||||||
|
provider: Provider
|
||||||
|
modelId: string
|
||||||
|
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
logger.info('Processing streaming chat completion request:', {
|
logger.info('Processing streaming chat completion request:', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
messageCount: request.messages.length
|
messageCount: request.messages.length
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate request
|
const preparation = await this.prepareRequest(request, true)
|
||||||
const validation = this.validateRequest(request)
|
if (preparation.status === 'validation_error') {
|
||||||
if (!validation.isValid) {
|
throw new ChatCompletionValidationError(preparation.errors)
|
||||||
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get provider for the model
|
if (preparation.status === 'model_error') {
|
||||||
const provider = await getProviderByModel(request.model!)
|
throw new ChatCompletionModelError(preparation.error)
|
||||||
if (!provider) {
|
|
||||||
throw new Error(`Provider not found for model: ${request.model}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider
|
const { provider, modelId, client, providerRequest } = preparation
|
||||||
if (!validateProvider(provider)) {
|
|
||||||
throw new Error(`Provider validation failed for: ${provider.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract model ID from the full model string
|
|
||||||
const modelId = getRealProviderModel(request.model)
|
|
||||||
|
|
||||||
// Create OpenAI client for the provider
|
|
||||||
const client = new OpenAI({
|
|
||||||
baseURL: provider.apiHost,
|
|
||||||
apiKey: provider.apiKey
|
|
||||||
})
|
|
||||||
|
|
||||||
// Prepare streaming request
|
|
||||||
const streamingRequest = {
|
|
||||||
...request,
|
|
||||||
model: modelId,
|
|
||||||
stream: true as const
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Sending streaming request to provider:', {
|
logger.debug('Sending streaming request to provider:', {
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
@@ -221,13 +226,17 @@ export class ChatCompletionService {
|
|||||||
apiHost: provider.apiHost
|
apiHost: provider.apiHost
|
||||||
})
|
})
|
||||||
|
|
||||||
const stream = await client.chat.completions.create(streamingRequest)
|
const streamRequest = providerRequest as ChatCompletionCreateParamsStreaming
|
||||||
|
const stream = (await client.chat.completions.create(streamRequest)) as AsyncIterable<
|
||||||
|
OpenAI.Chat.Completions.ChatCompletionChunk
|
||||||
|
>
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
logger.info('Successfully started streaming chat completion')
|
||||||
yield chunk
|
return {
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
stream
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Successfully completed streaming chat completion')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error processing streaming chat completion:', error)
|
logger.error('Error processing streaming chat completion:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import { Request, Response } from 'express'
|
|||||||
import { IncomingMessage, ServerResponse } from 'http'
|
import { IncomingMessage, ServerResponse } from 'http'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import { reduxService } from '../../services/ReduxService'
|
import { getMcpServerById, getMCPServersFromRedux } from '../utils/mcp'
|
||||||
import { getMcpServerById } from '../utils/mcp'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('MCPApiService')
|
const logger = loggerService.withContext('MCPApiService')
|
||||||
const transports: Record<string, StreamableHTTPServerTransport> = {}
|
const transports: Record<string, StreamableHTTPServerTransport> = {}
|
||||||
@@ -57,34 +56,10 @@ class MCPApiService extends EventEmitter {
|
|||||||
this.transport.onmessage = this.onMessage
|
this.transport.onmessage = this.onMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get servers directly from Redux store
|
|
||||||
*/
|
|
||||||
private async getServersFromRedux(): Promise<MCPServer[]> {
|
|
||||||
try {
|
|
||||||
logger.silly('Getting servers from Redux store')
|
|
||||||
|
|
||||||
// Try to get from cache first (faster)
|
|
||||||
const cachedServers = reduxService.selectSync<MCPServer[]>('state.mcp.servers')
|
|
||||||
if (cachedServers && Array.isArray(cachedServers)) {
|
|
||||||
logger.silly(`Found ${cachedServers.length} servers in Redux cache`)
|
|
||||||
return cachedServers
|
|
||||||
}
|
|
||||||
|
|
||||||
// If cache is not available, get fresh data
|
|
||||||
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
|
||||||
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
|
|
||||||
return servers || []
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Failed to get servers from Redux:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// get all activated servers
|
// get all activated servers
|
||||||
async getAllServers(req: Request): Promise<McpServersResp> {
|
async getAllServers(req: Request): Promise<McpServersResp> {
|
||||||
try {
|
try {
|
||||||
const servers = await this.getServersFromRedux()
|
const servers = await getMCPServersFromRedux()
|
||||||
logger.silly(`Returning ${servers.length} servers`)
|
logger.silly(`Returning ${servers.length} servers`)
|
||||||
const resp: McpServersResp = {
|
const resp: McpServersResp = {
|
||||||
servers: {}
|
servers: {}
|
||||||
@@ -111,7 +86,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
async getServerById(id: string): Promise<MCPServer | null> {
|
async getServerById(id: string): Promise<MCPServer | null> {
|
||||||
try {
|
try {
|
||||||
logger.silly(`getServerById called with id: ${id}`)
|
logger.silly(`getServerById called with id: ${id}`)
|
||||||
const servers = await this.getServersFromRedux()
|
const servers = await getMCPServersFromRedux()
|
||||||
const server = servers.find((s) => s.id === id)
|
const server = servers.find((s) => s.id === id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn(`Server with id ${id} not found`)
|
logger.warn(`Server with id ${id} not found`)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
|
import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
|
||||||
|
import { Provider } from '@types'
|
||||||
|
|
||||||
|
import { loggerService } from '../../services/LoggerService'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('MessagesService')
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessagesService {
|
||||||
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
validateRequest(request: MessageCreateParams): ValidationResult {
|
||||||
|
// TODO: Implement comprehensive request validation
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!request.model) {
|
||||||
|
errors.push('Model is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.max_tokens || request.max_tokens < 1) {
|
||||||
|
errors.push('max_tokens is required and must be at least 1')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
|
||||||
|
errors.push('messages is required and must be a non-empty array')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processMessage(request: MessageCreateParams, provider: Provider): Promise<Message> {
|
||||||
|
logger.info('Processing Anthropic message request:', {
|
||||||
|
model: request.model,
|
||||||
|
messageCount: request.messages.length,
|
||||||
|
stream: request.stream,
|
||||||
|
max_tokens: request.max_tokens
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create Anthropic client for the provider
|
||||||
|
const client = new Anthropic({
|
||||||
|
baseURL: provider.apiHost,
|
||||||
|
apiKey: provider.apiKey
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prepare request with the actual model ID
|
||||||
|
const anthropicRequest: MessageCreateParams = {
|
||||||
|
...request,
|
||||||
|
stream: false
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending request to Anthropic provider:', {
|
||||||
|
provider: provider.id,
|
||||||
|
apiHost: provider.apiHost
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await client.messages.create(anthropicRequest)
|
||||||
|
|
||||||
|
logger.info('Successfully processed Anthropic message')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async *processStreamingMessage(
|
||||||
|
request: MessageCreateParams,
|
||||||
|
provider: Provider
|
||||||
|
): AsyncIterable<RawMessageStreamEvent> {
|
||||||
|
logger.info('Processing streaming Anthropic message request:', {
|
||||||
|
model: request.model,
|
||||||
|
messageCount: request.messages.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create Anthropic client for the provider
|
||||||
|
const client = new Anthropic({
|
||||||
|
baseURL: provider.apiHost,
|
||||||
|
apiKey: provider.apiKey
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prepare streaming request
|
||||||
|
const streamingRequest: MessageCreateParams = {
|
||||||
|
...request,
|
||||||
|
stream: true
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending streaming request to Anthropic provider:', {
|
||||||
|
provider: provider.id,
|
||||||
|
apiHost: provider.apiHost
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = client.messages.stream(streamingRequest)
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
yield chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Successfully completed streaming Anthropic message')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const messagesService = new MessagesService()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
|
||||||
|
import { loggerService } from '../../services/LoggerService'
|
||||||
|
import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ModelsService')
|
||||||
|
|
||||||
|
// Re-export for backward compatibility
|
||||||
|
|
||||||
|
export type ModelsFilter = ApiModelsFilter
|
||||||
|
|
||||||
|
export class ModelsService {
|
||||||
|
async getModels(filter: ModelsFilter): Promise<ApiModelsResponse> {
|
||||||
|
try {
|
||||||
|
logger.debug('Getting available models from providers', { filter })
|
||||||
|
|
||||||
|
const models = await listAllAvailableModels()
|
||||||
|
const providers = await getAvailableProviders()
|
||||||
|
|
||||||
|
// Use Map to deduplicate models by their full ID (provider:model_id)
|
||||||
|
const uniqueModels = new Map<string, ApiModel>()
|
||||||
|
|
||||||
|
for (const model of models) {
|
||||||
|
const openAIModel = transformModelToOpenAI(model, providers)
|
||||||
|
const fullModelId = openAIModel.id // This is already in format "provider:model_id"
|
||||||
|
|
||||||
|
// Only add if not already present (first occurrence wins)
|
||||||
|
if (!uniqueModels.has(fullModelId)) {
|
||||||
|
uniqueModels.set(fullModelId, openAIModel)
|
||||||
|
} else {
|
||||||
|
logger.debug(`Skipping duplicate model: ${fullModelId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelData = Array.from(uniqueModels.values())
|
||||||
|
if (filter.providerType) {
|
||||||
|
// Apply filters
|
||||||
|
const providerType = filter.providerType
|
||||||
|
modelData = modelData.filter((model) => {
|
||||||
|
// Find the provider for this model and check its type
|
||||||
|
return model.provider_type === providerType
|
||||||
|
})
|
||||||
|
logger.debug(`Filtered by provider type '${providerType}': ${modelData.length} models`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = modelData.length
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
const offset = filter?.offset || 0
|
||||||
|
const limit = filter?.limit
|
||||||
|
|
||||||
|
if (limit !== undefined) {
|
||||||
|
modelData = modelData.slice(offset, offset + limit)
|
||||||
|
logger.debug(
|
||||||
|
`Applied pagination: offset=${offset}, limit=${limit}, showing ${modelData.length} of ${total} models`
|
||||||
|
)
|
||||||
|
} else if (offset > 0) {
|
||||||
|
modelData = modelData.slice(offset)
|
||||||
|
logger.debug(`Applied offset: offset=${offset}, showing ${modelData.length} of ${total} models`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Successfully retrieved ${modelData.length} models from ${models.length} total models`)
|
||||||
|
|
||||||
|
if (models.length > total) {
|
||||||
|
logger.debug(`Filtered out ${models.length - total} models after deduplication and filtering`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiModelsResponse = {
|
||||||
|
object: 'list',
|
||||||
|
data: modelData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pagination metadata if applicable
|
||||||
|
if (filter?.limit !== undefined || filter?.offset !== undefined) {
|
||||||
|
response.total = total
|
||||||
|
response.offset = offset
|
||||||
|
if (filter?.limit !== undefined) {
|
||||||
|
response.limit = filter.limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error getting models:', error)
|
||||||
|
return {
|
||||||
|
object: 'list',
|
||||||
|
data: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const modelsService = new ModelsService()
|
||||||
@@ -1,34 +1,41 @@
|
|||||||
|
import { CacheService } from '@main/services/CacheService'
|
||||||
import { loggerService } from '@main/services/LoggerService'
|
import { loggerService } from '@main/services/LoggerService'
|
||||||
import { reduxService } from '@main/services/ReduxService'
|
import { reduxService } from '@main/services/ReduxService'
|
||||||
import { Model, Provider } from '@types'
|
import { ApiModel, Model, Provider } from '@types'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerUtils')
|
const logger = loggerService.withContext('ApiServerUtils')
|
||||||
|
|
||||||
// OpenAI compatible model format
|
// Cache configuration
|
||||||
export interface OpenAICompatibleModel {
|
const PROVIDERS_CACHE_KEY = 'api-server:providers'
|
||||||
id: string
|
const PROVIDERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||||
object: 'model'
|
|
||||||
created: number
|
|
||||||
owned_by: string
|
|
||||||
provider?: string
|
|
||||||
provider_model_id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAvailableProviders(): Promise<Provider[]> {
|
export async function getAvailableProviders(): Promise<Provider[]> {
|
||||||
try {
|
try {
|
||||||
// Wait for store to be ready before accessing providers
|
// Try to get from cache first (faster)
|
||||||
|
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
||||||
|
if (cachedSupportedProviders) {
|
||||||
|
logger.debug(`Found ${cachedSupportedProviders.length} supported providers (from cache)`)
|
||||||
|
return cachedSupportedProviders
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cache is not available, get fresh data from Redux
|
||||||
const providers = await reduxService.select('state.llm.providers')
|
const providers = await reduxService.select('state.llm.providers')
|
||||||
if (!providers || !Array.isArray(providers)) {
|
if (!providers || !Array.isArray(providers)) {
|
||||||
logger.warn('No providers found in Redux store, returning empty array')
|
logger.warn('No providers found in Redux store, returning empty array')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only support OpenAI type providers for API server
|
// Support OpenAI and Anthropic type providers for API server
|
||||||
const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai')
|
const supportedProviders = providers.filter(
|
||||||
|
(p: Provider) => p.enabled && (p.type === 'openai' || p.type === 'anthropic')
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(`Filtered to ${openAIProviders.length} OpenAI providers from ${providers.length} total providers`)
|
// Cache the filtered results
|
||||||
|
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
|
||||||
|
|
||||||
return openAIProviders
|
logger.info(`Filtered to ${supportedProviders.length} supported providers from ${providers.length} total providers`)
|
||||||
|
|
||||||
|
return supportedProviders
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get providers from Redux store:', error)
|
logger.error('Failed to get providers from Redux store:', error)
|
||||||
return []
|
return []
|
||||||
@@ -181,13 +188,18 @@ export async function validateModelId(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformModelToOpenAI(model: Model): OpenAICompatibleModel {
|
export function transformModelToOpenAI(model: Model, providers: Provider[]): ApiModel {
|
||||||
|
const provider = providers.find((p) => p.id === model.provider)
|
||||||
|
const providerDisplayName = provider?.name
|
||||||
return {
|
return {
|
||||||
id: `${model.provider}:${model.id}`,
|
id: `${model.provider}:${model.id}`,
|
||||||
object: 'model',
|
object: 'model',
|
||||||
|
name: model.name,
|
||||||
created: Math.floor(Date.now() / 1000),
|
created: Math.floor(Date.now() / 1000),
|
||||||
owned_by: model.owned_by || model.provider,
|
owned_by: model.owned_by || providerDisplayName || model.provider,
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
|
provider_name: providerDisplayName,
|
||||||
|
provider_type: provider?.type,
|
||||||
provider_model_id: model.id
|
provider_model_id: model.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,10 +227,10 @@ export function validateProvider(provider: Provider): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only support OpenAI type providers
|
// Support OpenAI and Anthropic type providers
|
||||||
if (provider.type !== 'openai') {
|
if (provider.type !== 'openai' && provider.type !== 'anthropic') {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Provider type '${provider.type}' not supported, only 'openai' type is currently supported: ${provider.id}`
|
`Provider type '${provider.type}' not supported, only 'openai' and 'anthropic' types are currently supported: ${provider.id}`
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CacheService } from '@main/services/CacheService'
|
||||||
import mcpService from '@main/services/MCPService'
|
import mcpService from '@main/services/MCPService'
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
|
||||||
@@ -8,6 +9,10 @@ import { reduxService } from '../../services/ReduxService'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('MCPApiService')
|
const logger = loggerService.withContext('MCPApiService')
|
||||||
|
|
||||||
|
// Cache configuration
|
||||||
|
const MCP_SERVERS_CACHE_KEY = 'api-server:mcp-servers'
|
||||||
|
const MCP_SERVERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
const cachedServers: Record<string, Server> = {}
|
const cachedServers: Record<string, Server> = {}
|
||||||
|
|
||||||
async function handleListToolsRequest(request: any, extra: any): Promise<ListToolsResult> {
|
async function handleListToolsRequest(request: any, extra: any): Promise<ListToolsResult> {
|
||||||
@@ -33,18 +38,33 @@ async function handleCallToolRequest(request: any, extra: any): Promise<any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getMcpServerConfigById(id: string): Promise<MCPServer | undefined> {
|
async function getMcpServerConfigById(id: string): Promise<MCPServer | undefined> {
|
||||||
const servers = await getServersFromRedux()
|
const servers = await getMCPServersFromRedux()
|
||||||
return servers.find((s) => s.id === id || s.name === id)
|
return servers.find((s) => s.id === id || s.name === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get servers directly from Redux store
|
* Get servers directly from Redux store
|
||||||
*/
|
*/
|
||||||
async function getServersFromRedux(): Promise<MCPServer[]> {
|
export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
||||||
try {
|
try {
|
||||||
|
logger.silly('Getting servers from Redux store')
|
||||||
|
|
||||||
|
// Try to get from cache first (faster)
|
||||||
|
const cachedServers = CacheService.get<MCPServer[]>(MCP_SERVERS_CACHE_KEY)
|
||||||
|
if (cachedServers) {
|
||||||
|
logger.silly(`Found ${cachedServers.length} servers (from cache)`)
|
||||||
|
return cachedServers
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cache is not available, get fresh data from Redux
|
||||||
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
||||||
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
|
const serverList = servers || []
|
||||||
return servers || []
|
|
||||||
|
// Cache the results
|
||||||
|
CacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL)
|
||||||
|
|
||||||
|
logger.silly(`Fetched ${serverList.length} servers from Redux store`)
|
||||||
|
return serverList
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get servers from Redux:', error)
|
logger.error('Failed to get servers from Redux:', error)
|
||||||
return []
|
return []
|
||||||
@@ -54,7 +74,7 @@ async function getServersFromRedux(): Promise<MCPServer[]> {
|
|||||||
export async function getMcpServerById(id: string): Promise<Server> {
|
export async function getMcpServerById(id: string): Promise<Server> {
|
||||||
const server = cachedServers[id]
|
const server = cachedServers[id]
|
||||||
if (!server) {
|
if (!server) {
|
||||||
const servers = await getServersFromRedux()
|
const servers = await getMCPServersFromRedux()
|
||||||
const mcpServer = servers.find((s) => s.id === id || s.name === id)
|
const mcpServer = servers.find((s) => s.id === id || s.name === id)
|
||||||
if (!mcpServer) {
|
if (!mcpServer) {
|
||||||
throw new Error(`Server not found: ${id}`)
|
throw new Error(`Server not found: ${id}`)
|
||||||
|
|||||||
+13
-3
@@ -10,9 +10,13 @@ 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'
|
||||||
|
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
import { registerIpc } from './ipc'
|
import { registerIpc } from './ipc'
|
||||||
|
import { agentService } from './services/agents'
|
||||||
|
import { apiServerService } from './services/ApiServerService'
|
||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
import mcpService from './services/MCPService'
|
import mcpService from './services/MCPService'
|
||||||
import { nodeTraceService } from './services/NodeTraceService'
|
import { nodeTraceService } from './services/NodeTraceService'
|
||||||
@@ -26,8 +30,6 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
|||||||
import { registerShortcuts } from './services/ShortcutService'
|
import { registerShortcuts } from './services/ShortcutService'
|
||||||
import { TrayService } from './services/TrayService'
|
import { TrayService } from './services/TrayService'
|
||||||
import { windowService } from './services/WindowService'
|
import { windowService } from './services/WindowService'
|
||||||
import process from 'node:process'
|
|
||||||
import { apiServerService } from './services/ApiServerService'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('MainEntry')
|
const logger = loggerService.withContext('MainEntry')
|
||||||
|
|
||||||
@@ -147,6 +149,14 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
//start selection assistant service
|
//start selection assistant service
|
||||||
initSelectionService()
|
initSelectionService()
|
||||||
|
|
||||||
|
// Initialize Agent Service
|
||||||
|
try {
|
||||||
|
await agentService.initialize()
|
||||||
|
logger.info('Agent service initialized successfully')
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Failed to initialize Agent service:', error)
|
||||||
|
}
|
||||||
|
|
||||||
// Start API server if enabled
|
// Start API server if enabled
|
||||||
try {
|
try {
|
||||||
const config = await apiServerService.getCurrentConfig()
|
const config = await apiServerService.getCurrentConfig()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import checkDiskSpace from 'check-disk-space'
|
|||||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||||
import fontList from 'font-list'
|
import fontList from 'font-list'
|
||||||
|
|
||||||
|
import { agentMessageRepository } from './services/agents/database'
|
||||||
import { apiServerService } from './services/ApiServerService'
|
import { apiServerService } from './services/ApiServerService'
|
||||||
import appService from './services/AppService'
|
import appService from './services/AppService'
|
||||||
import AppUpdater from './services/AppUpdater'
|
import AppUpdater from './services/AppUpdater'
|
||||||
@@ -199,6 +200,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannel.AgentMessage_PersistExchange, async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
return await agentMessageRepository.persistExchange(payload)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to persist agent session messages', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
//only for mac
|
//only for mac
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# Agent Message Architecture Design Document
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the architecture for handling agent messages in Cherry Studio, including how agent-specific messages are generated, transformed to AI SDK format, stored, and sent to the UI. The system is designed to be agent-agnostic, allowing multiple agent types (Claude Code, OpenAI, etc.) to integrate seamlessly.
|
||||||
|
|
||||||
|
## Core Design Principles
|
||||||
|
|
||||||
|
1. **Agent Agnosticism**: The core message handling system should work with any agent type without modification
|
||||||
|
2. **Data Preservation**: All raw agent data must be preserved alongside transformed UI-friendly formats
|
||||||
|
3. **Streaming First**: Support real-time streaming of agent responses to the UI
|
||||||
|
4. **Type Safety**: Strong TypeScript interfaces ensure consistency across the pipeline
|
||||||
|
|
||||||
|
## Architecture Components
|
||||||
|
|
||||||
|
### 1. Agent Service Layer
|
||||||
|
|
||||||
|
Each agent (e.g., ClaudeCodeService) implements the `AgentServiceInterface`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AgentServiceInterface {
|
||||||
|
invoke(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Responsibilities:
|
||||||
|
- Spawn and manage agent-specific processes (e.g., Claude Code CLI)
|
||||||
|
- Parse agent-specific output formats (e.g., SDKMessage for Claude Code)
|
||||||
|
- Transform agent messages to AI SDK format
|
||||||
|
- Emit standardized `AgentStreamEvent` objects
|
||||||
|
|
||||||
|
### 2. Agent Stream Events
|
||||||
|
|
||||||
|
The standardized event interface that all agents emit:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AgentStreamEvent {
|
||||||
|
type: 'chunk' | 'error' | 'complete'
|
||||||
|
chunk?: UIMessageChunk // AI SDK format for UI
|
||||||
|
rawAgentMessage?: any // Agent-specific raw message
|
||||||
|
error?: Error
|
||||||
|
agentResult?: any // Complete agent-specific result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Session Message Service
|
||||||
|
|
||||||
|
The `SessionMessageService` acts as the orchestration layer:
|
||||||
|
|
||||||
|
#### Responsibilities:
|
||||||
|
- Manages session lifecycle and persistence
|
||||||
|
- Collects streaming chunks and raw agent messages
|
||||||
|
- Stores structured data in the database
|
||||||
|
- Forwards events to the API layer
|
||||||
|
|
||||||
|
### 4. Database Storage
|
||||||
|
|
||||||
|
Session messages are stored with complete structured data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SessionMessageContent {
|
||||||
|
aiSDKChunks: UIMessageChunk[] // UI-friendly format
|
||||||
|
rawAgentMessages: any[] // Original agent messages
|
||||||
|
agentResult?: any // Complete agent result
|
||||||
|
agentType: string // Agent identifier
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[User Input] --> B[API Handler]
|
||||||
|
B --> C[SessionMessageService]
|
||||||
|
C --> D[Agent Service]
|
||||||
|
D --> E[Agent Process]
|
||||||
|
E --> F[Raw Agent Output]
|
||||||
|
F --> G[Transform to AI SDK]
|
||||||
|
G --> H[Emit AgentStreamEvent]
|
||||||
|
H --> I[SessionMessageService]
|
||||||
|
I --> J[Store in Database]
|
||||||
|
I --> K[Forward to Client]
|
||||||
|
K --> L[UI Rendering]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Transformation Process
|
||||||
|
|
||||||
|
### Step 1: Raw Agent Message Generation
|
||||||
|
|
||||||
|
Each agent generates messages in its native format:
|
||||||
|
|
||||||
|
**Claude Code Example:**
|
||||||
|
```typescript
|
||||||
|
// SDKMessage from Claude Code CLI
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: 'msg_123',
|
||||||
|
session_id: 'session_456',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Hello, I can help...' },
|
||||||
|
{ type: 'tool_use', id: 'tool_1', name: 'read_file', input: {...} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Transformation to AI SDK Format
|
||||||
|
|
||||||
|
The agent service transforms native messages to AI SDK `UIMessageChunk`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In ClaudeCodeService
|
||||||
|
const emitChunks = (sdkMessage: SDKMessage) => {
|
||||||
|
// Transform to AI SDK format
|
||||||
|
const chunks = transformSDKMessageToUIChunk(sdkMessage)
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
stream.emit('data', {
|
||||||
|
type: 'chunk',
|
||||||
|
chunk, // AI SDK format
|
||||||
|
rawAgentMessage: sdkMessage // Preserve original
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transformed AI SDK Chunk:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'text-delta',
|
||||||
|
id: 'msg_123',
|
||||||
|
delta: 'Hello, I can help...',
|
||||||
|
providerMetadata: {
|
||||||
|
claudeCode: {
|
||||||
|
originalSDKMessage: {...},
|
||||||
|
uuid: 'msg_123',
|
||||||
|
session_id: 'session_456'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Session Message Processing
|
||||||
|
|
||||||
|
The SessionMessageService collects and processes events:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Collect streaming data
|
||||||
|
const streamedChunks: UIMessageChunk[] = []
|
||||||
|
const rawAgentMessages: any[] = []
|
||||||
|
|
||||||
|
claudeStream.on('data', async (event: AgentStreamEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'chunk':
|
||||||
|
streamedChunks.push(event.chunk)
|
||||||
|
if (event.rawAgentMessage) {
|
||||||
|
rawAgentMessages.push(event.rawAgentMessage)
|
||||||
|
}
|
||||||
|
// Forward to client
|
||||||
|
sessionStream.emit('data', { type: 'chunk', chunk: event.chunk })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
// Store complete structured data
|
||||||
|
const content = {
|
||||||
|
aiSDKChunks: streamedChunks,
|
||||||
|
rawAgentMessages: rawAgentMessages,
|
||||||
|
agentResult: event.agentResult,
|
||||||
|
agentType: event.agentResult?.agentType || 'unknown'
|
||||||
|
}
|
||||||
|
// Save to database...
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Client Streaming
|
||||||
|
|
||||||
|
The API handler converts events to Server-Sent Events (SSE):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In API handler
|
||||||
|
messageStream.on('data', (event: any) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'chunk':
|
||||||
|
// Send AI SDK chunk as SSE
|
||||||
|
res.write(`data: ${JSON.stringify(event.chunk)}\n\n`)
|
||||||
|
break
|
||||||
|
case 'complete':
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Agent Types
|
||||||
|
|
||||||
|
To add support for a new agent (e.g., OpenAI):
|
||||||
|
|
||||||
|
### 1. Create Agent Service
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class OpenAIService implements AgentServiceInterface {
|
||||||
|
invokeStream(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream {
|
||||||
|
const stream = new OpenAIStream()
|
||||||
|
|
||||||
|
// Call OpenAI API
|
||||||
|
const openaiResponse = await openai.chat.completions.create({
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transform OpenAI format to AI SDK
|
||||||
|
for await (const chunk of openaiResponse) {
|
||||||
|
const aiSDKChunk = transformOpenAIToAISDK(chunk)
|
||||||
|
stream.emit('data', {
|
||||||
|
type: 'chunk',
|
||||||
|
chunk: aiSDKChunk,
|
||||||
|
rawAgentMessage: chunk // Preserve OpenAI format
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Transform Function
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function transformOpenAIToAISDK(openaiChunk: OpenAIChunk): UIMessageChunk {
|
||||||
|
return {
|
||||||
|
type: 'text-delta',
|
||||||
|
id: openaiChunk.id,
|
||||||
|
delta: openaiChunk.choices[0].delta.content,
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
original: openaiChunk,
|
||||||
|
model: openaiChunk.model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register Agent Type
|
||||||
|
|
||||||
|
Update the agent type enum and factory:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AgentType = 'claude-code' | 'openai' | 'anthropic-api'
|
||||||
|
|
||||||
|
function createAgentService(type: AgentType): AgentServiceInterface {
|
||||||
|
switch (type) {
|
||||||
|
case 'claude-code':
|
||||||
|
return new ClaudeCodeService()
|
||||||
|
case 'openai':
|
||||||
|
return new OpenAIService()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of This Architecture
|
||||||
|
|
||||||
|
1. **Extensibility**: Easy to add new agent types without modifying core logic
|
||||||
|
2. **Data Integrity**: Raw agent data is never lost during transformation
|
||||||
|
3. **Debugging**: Complete message history available for troubleshooting
|
||||||
|
4. **Performance**: Streaming support for real-time responses
|
||||||
|
5. **Type Safety**: Strong interfaces prevent runtime errors
|
||||||
|
6. **UI Consistency**: All agents provide data in standard AI SDK format
|
||||||
|
|
||||||
|
## Key Interfaces Reference
|
||||||
|
|
||||||
|
### AgentStreamEvent
|
||||||
|
```typescript
|
||||||
|
interface AgentStreamEvent {
|
||||||
|
type: 'chunk' | 'error' | 'complete'
|
||||||
|
chunk?: UIMessageChunk
|
||||||
|
rawAgentMessage?: any
|
||||||
|
error?: Error
|
||||||
|
agentResult?: any
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionMessageEntity
|
||||||
|
```typescript
|
||||||
|
interface SessionMessageEntity {
|
||||||
|
id: number
|
||||||
|
session_id: string
|
||||||
|
parent_id?: number
|
||||||
|
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||||
|
type: string
|
||||||
|
content: string | SessionMessageContent
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionMessageContent
|
||||||
|
```typescript
|
||||||
|
interface SessionMessageContent {
|
||||||
|
aiSDKChunks: UIMessageChunk[]
|
||||||
|
rawAgentMessages: any[]
|
||||||
|
agentResult?: any
|
||||||
|
agentType: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- Test each transform function independently
|
||||||
|
- Verify event emission sequences
|
||||||
|
- Validate data structure preservation
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Test complete flow from input to database
|
||||||
|
- Verify streaming behavior
|
||||||
|
- Test error handling and recovery
|
||||||
|
|
||||||
|
### Agent-Specific Tests
|
||||||
|
- Validate agent-specific transformations
|
||||||
|
- Test edge cases for each agent type
|
||||||
|
- Verify metadata preservation
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Message Replay**: Ability to replay sessions from stored raw messages
|
||||||
|
2. **Format Migration**: Tools to migrate between agent formats
|
||||||
|
3. **Analytics**: Aggregate metrics from raw agent data
|
||||||
|
4. **Caching**: Cache transformed chunks for performance
|
||||||
|
5. **Compression**: Compress raw messages for storage efficiency
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This architecture provides a robust, extensible foundation for handling messages from multiple AI agents while maintaining data integrity and providing a consistent interface for the UI. The separation of concerns between agent-specific logic and core message handling ensures the system can evolve to support new agents and features without breaking existing functionality.
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { type Client, createClient } from '@libsql/client'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { ModelValidationError, validateModelId } from '@main/apiServer/utils'
|
||||||
|
import { AgentType, objectKeys, Provider } from '@types'
|
||||||
|
import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import { MigrationService } from './database/MigrationService'
|
||||||
|
import * as schema from './database/schema'
|
||||||
|
import { dbPath } from './drizzle.config'
|
||||||
|
import { AgentModelField, AgentModelValidationError } from './errors'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('BaseService')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base service class providing shared database connection and utilities
|
||||||
|
* for all agent-related services.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Programmatic schema management (no CLI dependencies)
|
||||||
|
* - Automatic table creation and migration
|
||||||
|
* - Schema version tracking and compatibility checks
|
||||||
|
* - Transaction-based operations for safety
|
||||||
|
* - Development vs production mode handling
|
||||||
|
* - Connection retry logic with exponential backoff
|
||||||
|
*/
|
||||||
|
export abstract class BaseService {
|
||||||
|
protected static client: Client | null = null
|
||||||
|
protected static db: LibSQLDatabase<typeof schema> | null = null
|
||||||
|
protected static isInitialized = false
|
||||||
|
protected static initializationPromise: Promise<void> | null = null
|
||||||
|
protected jsonFields: string[] = ['built_in_tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database with retry logic and proper error handling
|
||||||
|
*/
|
||||||
|
protected static async initialize(): Promise<void> {
|
||||||
|
// Return existing initialization if in progress
|
||||||
|
if (BaseService.initializationPromise) {
|
||||||
|
return BaseService.initializationPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BaseService.isInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseService.initializationPromise = BaseService.performInitialization()
|
||||||
|
return BaseService.initializationPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async performInitialization(): Promise<void> {
|
||||||
|
const maxRetries = 3
|
||||||
|
let lastError: Error
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
logger.info(`Initializing Agent database at: ${dbPath} (attempt ${attempt}/${maxRetries})`)
|
||||||
|
|
||||||
|
// Ensure the database directory exists
|
||||||
|
const dbDir = path.dirname(dbPath)
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
logger.info(`Creating database directory: ${dbDir}`)
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseService.client = createClient({
|
||||||
|
url: `file:${dbPath}`
|
||||||
|
})
|
||||||
|
|
||||||
|
BaseService.db = drizzle(BaseService.client, { schema })
|
||||||
|
|
||||||
|
// Run database migrations
|
||||||
|
const migrationService = new MigrationService(BaseService.db, BaseService.client)
|
||||||
|
await migrationService.runMigrations()
|
||||||
|
|
||||||
|
BaseService.isInitialized = true
|
||||||
|
logger.info('Agent database initialized successfully')
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error
|
||||||
|
logger.warn(`Database initialization attempt ${attempt} failed:`, lastError)
|
||||||
|
|
||||||
|
// Clean up on failure
|
||||||
|
if (BaseService.client) {
|
||||||
|
try {
|
||||||
|
BaseService.client.close()
|
||||||
|
} catch (closeError) {
|
||||||
|
logger.warn('Failed to close client during cleanup:', closeError as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BaseService.client = null
|
||||||
|
BaseService.db = null
|
||||||
|
|
||||||
|
// Wait before retrying (exponential backoff)
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = Math.pow(2, attempt) * 1000 // 2s, 4s, 8s
|
||||||
|
logger.info(`Retrying in ${delay}ms...`)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries failed
|
||||||
|
BaseService.initializationPromise = null
|
||||||
|
logger.error('Failed to initialize Agent database after all retries:', lastError!)
|
||||||
|
throw lastError!
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ensureInitialized(): void {
|
||||||
|
if (!BaseService.isInitialized || !BaseService.db || !BaseService.client) {
|
||||||
|
throw new Error('Database not initialized. Call initialize() first.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get database(): LibSQLDatabase<typeof schema> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
return BaseService.db!
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get rawClient(): Client {
|
||||||
|
this.ensureInitialized()
|
||||||
|
return BaseService.client!
|
||||||
|
}
|
||||||
|
|
||||||
|
protected serializeJsonFields(data: any): any {
|
||||||
|
const serialized = { ...data }
|
||||||
|
|
||||||
|
for (const field of this.jsonFields) {
|
||||||
|
if (serialized[field] !== undefined) {
|
||||||
|
serialized[field] =
|
||||||
|
Array.isArray(serialized[field]) || typeof serialized[field] === 'object'
|
||||||
|
? JSON.stringify(serialized[field])
|
||||||
|
: serialized[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized
|
||||||
|
}
|
||||||
|
|
||||||
|
protected deserializeJsonFields(data: any): any {
|
||||||
|
if (!data) return data
|
||||||
|
|
||||||
|
const deserialized = { ...data }
|
||||||
|
|
||||||
|
for (const field of this.jsonFields) {
|
||||||
|
if (deserialized[field] && typeof deserialized[field] === 'string') {
|
||||||
|
try {
|
||||||
|
deserialized[field] = JSON.parse(deserialized[field])
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to parse JSON field ${field}:`, error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert null from db to undefined to satisfy type definition
|
||||||
|
for (const key of objectKeys(data)) {
|
||||||
|
if (deserialized[key] === null) {
|
||||||
|
deserialized[key] = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserialized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate, normalize, and ensure filesystem access for a set of absolute paths.
|
||||||
|
*
|
||||||
|
* - Requires every entry to be an absolute path and throws if not.
|
||||||
|
* - Normalizes each path and deduplicates while preserving order.
|
||||||
|
* - Creates missing directories (or parent directories for file-like paths).
|
||||||
|
*/
|
||||||
|
protected ensurePathsExist(paths?: string[]): string[] {
|
||||||
|
if (!paths?.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedPaths: string[] = []
|
||||||
|
const seenPaths = new Set<string>()
|
||||||
|
|
||||||
|
for (const rawPath of paths) {
|
||||||
|
if (!rawPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.isAbsolute(rawPath)) {
|
||||||
|
throw new Error(`Accessible path must be absolute: ${rawPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to provide consistent values to downstream consumers.
|
||||||
|
const resolvedPath = path.normalize(rawPath)
|
||||||
|
|
||||||
|
let stats: fs.Stats | null = null
|
||||||
|
try {
|
||||||
|
// Attempt to stat the path to understand whether it already exists and if it is a file.
|
||||||
|
if (fs.existsSync(resolvedPath)) {
|
||||||
|
stats = fs.statSync(resolvedPath)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to inspect accessible path', {
|
||||||
|
path: rawPath,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikeFile =
|
||||||
|
(stats && stats.isFile()) || (!stats && path.extname(resolvedPath) !== '' && !resolvedPath.endsWith(path.sep))
|
||||||
|
|
||||||
|
// For file-like targets create the parent directory; otherwise ensure the directory itself.
|
||||||
|
const directoryToEnsure = looksLikeFile ? path.dirname(resolvedPath) : resolvedPath
|
||||||
|
|
||||||
|
if (!fs.existsSync(directoryToEnsure)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(directoryToEnsure, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create accessible path directory', {
|
||||||
|
path: directoryToEnsure,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the first occurrence only to avoid duplicates while keeping caller order stable.
|
||||||
|
if (!seenPaths.has(resolvedPath)) {
|
||||||
|
seenPaths.add(resolvedPath)
|
||||||
|
sanitizedPaths.push(resolvedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizedPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force re-initialization (for development/testing)
|
||||||
|
*/
|
||||||
|
protected async validateAgentModels(
|
||||||
|
agentType: AgentType,
|
||||||
|
models: Partial<Record<AgentModelField, string | undefined>>
|
||||||
|
): Promise<void> {
|
||||||
|
const entries = Object.entries(models) as [AgentModelField, string | undefined][]
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [field, rawValue] of entries) {
|
||||||
|
if (rawValue === undefined || rawValue === null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelValue = rawValue
|
||||||
|
const validation = await validateModelId(modelValue)
|
||||||
|
|
||||||
|
if (!validation.valid || !validation.provider) {
|
||||||
|
const detail: ModelValidationError = validation.error ?? {
|
||||||
|
type: 'invalid_format',
|
||||||
|
message: 'Unknown model validation error',
|
||||||
|
code: 'validation_error'
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AgentModelValidationError({ agentType, field, model: modelValue }, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validation.provider.apiKey) {
|
||||||
|
throw new AgentModelValidationError(
|
||||||
|
{ agentType, field, model: modelValue },
|
||||||
|
{
|
||||||
|
type: 'invalid_format',
|
||||||
|
message: `Provider '${validation.provider.id}' is missing an API key`,
|
||||||
|
code: 'provider_api_key_missing'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// different agent types may have different provider requirements
|
||||||
|
const agentTypeProviderRequirements: Record<AgentType, Provider['type']> = {
|
||||||
|
'claude-code': 'anthropic'
|
||||||
|
}
|
||||||
|
for (const [ak, pk] of Object.entries(agentTypeProviderRequirements)) {
|
||||||
|
if (agentType === ak && validation.provider.type !== pk) {
|
||||||
|
throw new AgentModelValidationError(
|
||||||
|
{ agentType, field, model: modelValue },
|
||||||
|
{
|
||||||
|
type: 'unsupported_provider_type',
|
||||||
|
message: `Provider type '${validation.provider.type}' is not supported for agent type '${agentType}'. Expected '${pk}'`,
|
||||||
|
code: 'unsupported_provider_type'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async reinitialize(): Promise<void> {
|
||||||
|
BaseService.isInitialized = false
|
||||||
|
BaseService.initializationPromise = null
|
||||||
|
|
||||||
|
if (BaseService.client) {
|
||||||
|
try {
|
||||||
|
BaseService.client.close()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to close client during reinitialize:', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseService.client = null
|
||||||
|
BaseService.db = null
|
||||||
|
|
||||||
|
await BaseService.initialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Agents Service
|
||||||
|
|
||||||
|
Simplified Drizzle ORM implementation for agent and session management in Cherry Studio.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Native Drizzle migrations** - Uses built-in migrate() function
|
||||||
|
- **Zero CLI dependencies** in production
|
||||||
|
- **Auto-initialization** with retry logic
|
||||||
|
- **Full TypeScript** type safety
|
||||||
|
- **Model validation** to ensure models exist and provider configuration matches the agent type
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
- `agents.schema.ts` - Agent definitions
|
||||||
|
- `sessions.schema.ts` - Session and message tables
|
||||||
|
- `migrations.schema.ts` - Migration tracking
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { agentService } from './services'
|
||||||
|
|
||||||
|
// Create agent - fully typed
|
||||||
|
const agent = await agentService.createAgent({
|
||||||
|
type: 'custom',
|
||||||
|
name: 'My Agent',
|
||||||
|
model: 'anthropic:claude-3-5-sonnet-20241022'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model Validation
|
||||||
|
|
||||||
|
- Model identifiers must use the `provider:model_id` format (for example `anthropic:claude-3-5-sonnet-20241022`).
|
||||||
|
- `model`, `plan_model`, and `small_model` are validated against the configured providers before the database is touched.
|
||||||
|
- Invalid configurations return a `400 invalid_request_error` response and the create/update operation is aborted.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply schema changes
|
||||||
|
yarn agents:generate
|
||||||
|
|
||||||
|
# Quick development sync
|
||||||
|
yarn agents:push
|
||||||
|
|
||||||
|
# Database tools
|
||||||
|
yarn agents:studio # Open Drizzle Studio
|
||||||
|
yarn agents:health # Health check
|
||||||
|
yarn agents:drop # Reset database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Edit schema** in `/database/schema/`
|
||||||
|
2. **Generate migration** with `yarn agents:generate`
|
||||||
|
3. **Test changes** with `yarn agents:health`
|
||||||
|
4. **Deploy** - migrations apply automatically
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- `AgentService` - Agent CRUD operations
|
||||||
|
- `SessionService` - Session management
|
||||||
|
- `SessionMessageService` - Message logging
|
||||||
|
- `BaseService` - Database utilities
|
||||||
|
- `schemaSyncer` - Migration handler
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check status
|
||||||
|
yarn agents:health
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
yarn agents:migrate
|
||||||
|
|
||||||
|
# Reset completely
|
||||||
|
yarn agents:reset --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The simplified migration system reduced complexity from 463 to ~30 lines while maintaining all functionality through Drizzle's native migration system.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Agents Service Refactor TODO (interface-level)
|
||||||
|
|
||||||
|
- [x] **SessionMessageService.createSessionMessage**
|
||||||
|
- Replace the current `EventEmitter` that emits `UIMessageChunk` with a readable stream of `TextStreamPart` objects (same shape produced by `/api/messages` in `messageThunk`).
|
||||||
|
- Update `startSessionMessageStream` to call a new adapter (`claudeToTextStreamPart(chunk)`) that maps Claude Code chunk payloads to `{ type: 'text-delta' | 'tool-call' | ... }` parts used by `AiSdkToChunkAdapter`.
|
||||||
|
- Add a secondary return value (promise) resolving to the persisted `ModelMessage[]` once streaming completes, so the renderer thunk can await save confirmation.
|
||||||
|
|
||||||
|
- [x] **main -> renderer transport**
|
||||||
|
- Update the existing SSE handler in `src/main/apiServer/routes/agents/handlers/messages.ts` (e.g., `createMessage`) to forward the new `TextStreamPart` stream over HTTP, preserving the current agent endpoint contract.
|
||||||
|
- Keep abort handling compatible with the current HTTP server (honor `AbortController` on the request to terminate the stream).
|
||||||
|
|
||||||
|
- [x] **renderer thunk integration**
|
||||||
|
- Introduce a thin IPC contract (e.g., `AgentMessagePersistence`) surfaced by `src/main/services/agents/database/index.ts` so the renderer thunk can request session-message writes without going through `SessionMessageService`.
|
||||||
|
- Define explicit entry points on the main side:
|
||||||
|
- `persistUserMessage({ sessionId, agentSessionId, payload, createdAt?, metadata? })`
|
||||||
|
- `persistAssistantMessage({ sessionId, agentSessionId, payload, createdAt?, metadata? })`
|
||||||
|
- `persistExchange({ sessionId, agentSessionId, user, assistant })` which runs the above in a single transaction and returns both records.
|
||||||
|
- Export these helpers via an `agentMessageRepository` object so both IPC handlers and legacy services share the same persistence path.
|
||||||
|
- Normalize persisted payloads to `{ message, blocks }` matching the renderer schema instead of AI-SDK `ModelMessage` chunks.
|
||||||
|
- Extend `messageThunk.sendMessage` to call the agent transport when the topic corresponds to a session, pipe chunks through `createStreamProcessor` + `AiSdkToChunkAdapter`, and invoke the new persistence interface once streaming resolves.
|
||||||
|
- Replace `useSession().createSessionMessage` optimistic insert with dispatching the thunk so Redux/Dexie persistence happens via the shared save helpers.
|
||||||
|
|
||||||
|
- [x] **persistence alignment**
|
||||||
|
- Remove `persistUserMessage` / `persistAssistantMessage` calls from `SessionMessageService`; instead expose a `SessionMessageRepository` in `main` that the thunk invokes via existing Dexie helpers.
|
||||||
|
- On renderer side, persist agent exchanges via IPC after streaming completes, storing `{ message, blocks }` payloads while skipping Dexie writes for agent sessions so the single source of truth remains `session_messages`.
|
||||||
|
|
||||||
|
- [x] **Blocks renderer**
|
||||||
|
- Replace `AgentSessionMessages` simple `<div>` render with the shared `Blocks` component (`src/renderer/src/pages/home/Messages/Blocks`) wired to the Redux store.
|
||||||
|
- Adjust `useSession` to only fetch metadata (e.g., session info) and rely on store selectors for message list.
|
||||||
|
|
||||||
|
- [x] **API client clean-up**
|
||||||
|
- Remove `AgentApiClient.createMessage` direct POST once thunk is in place; calls should go through renderer thunk -> stream -> final persistence.
|
||||||
|
|
||||||
|
- [ ] **Regression tests**
|
||||||
|
- Add integration test to assert agent sessions render incremental text the same way as standard assistant messages.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { type Client } from '@libsql/client'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { getResourcePath } from '@main/utils'
|
||||||
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import * as schema from './schema'
|
||||||
|
import { migrations, type NewMigration } from './schema/migrations.schema'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('MigrationService')
|
||||||
|
|
||||||
|
interface MigrationJournal {
|
||||||
|
version: string
|
||||||
|
dialect: string
|
||||||
|
entries: Array<{
|
||||||
|
idx: number
|
||||||
|
version: string
|
||||||
|
when: number
|
||||||
|
tag: string
|
||||||
|
breakpoints: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MigrationService {
|
||||||
|
private db: LibSQLDatabase<typeof schema>
|
||||||
|
private client: Client
|
||||||
|
private migrationDir: string
|
||||||
|
|
||||||
|
constructor(db: LibSQLDatabase<typeof schema>, client: Client) {
|
||||||
|
this.db = db
|
||||||
|
this.client = client
|
||||||
|
this.migrationDir = path.join(getResourcePath(), 'database', 'drizzle')
|
||||||
|
}
|
||||||
|
|
||||||
|
async runMigrations(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info('Starting migration check...')
|
||||||
|
|
||||||
|
const hasMigrationsTable = await this.migrationsTableExists()
|
||||||
|
|
||||||
|
if (!hasMigrationsTable) {
|
||||||
|
logger.info('Migrations table not found; assuming fresh database state')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read migration journal
|
||||||
|
const journal = await this.readMigrationJournal()
|
||||||
|
if (!journal.entries.length) {
|
||||||
|
logger.info('No migrations found in journal')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get applied migrations
|
||||||
|
const appliedMigrations = hasMigrationsTable ? await this.getAppliedMigrations() : []
|
||||||
|
const appliedVersions = new Set(appliedMigrations.map((m) => Number(m.version)))
|
||||||
|
|
||||||
|
const latestAppliedVersion = appliedMigrations.reduce(
|
||||||
|
(max, migration) => Math.max(max, Number(migration.version)),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const latestJournalVersion = journal.entries.reduce((max, entry) => Math.max(max, entry.idx), 0)
|
||||||
|
|
||||||
|
logger.info(`Latest applied migration: v${latestAppliedVersion}, latest available: v${latestJournalVersion}`)
|
||||||
|
|
||||||
|
// Find pending migrations (compare journal idx with stored version, which is the same value)
|
||||||
|
const pendingMigrations = journal.entries
|
||||||
|
.filter((entry) => !appliedVersions.has(entry.idx))
|
||||||
|
.sort((a, b) => a.idx - b.idx)
|
||||||
|
|
||||||
|
if (pendingMigrations.length === 0) {
|
||||||
|
logger.info('Database is up to date')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${pendingMigrations.length} pending migrations`)
|
||||||
|
|
||||||
|
// Execute pending migrations
|
||||||
|
for (const migration of pendingMigrations) {
|
||||||
|
await this.executeMigration(migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('All migrations completed successfully')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Migration failed:', { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrationsTableExists(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const table = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`)
|
||||||
|
return table.rows.length > 0
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check migrations table status:', { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readMigrationJournal(): Promise<MigrationJournal> {
|
||||||
|
const journalPath = path.join(this.migrationDir, 'meta', '_journal.json')
|
||||||
|
|
||||||
|
if (!fs.existsSync(journalPath)) {
|
||||||
|
logger.warn('Migration journal not found:', { journalPath })
|
||||||
|
return { version: '7', dialect: 'sqlite', entries: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const journalContent = fs.readFileSync(journalPath, 'utf-8')
|
||||||
|
return JSON.parse(journalContent)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to read migration journal:', { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAppliedMigrations(): Promise<schema.Migration[]> {
|
||||||
|
try {
|
||||||
|
return await this.db.select().from(migrations)
|
||||||
|
} catch (error) {
|
||||||
|
// This should not happen since we ensure the table exists in runMigrations()
|
||||||
|
logger.error('Failed to query applied migrations:', { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeMigration(migration: MigrationJournal['entries'][0]): Promise<void> {
|
||||||
|
const sqlFilePath = path.join(this.migrationDir, `${migration.tag}.sql`)
|
||||||
|
|
||||||
|
if (!fs.existsSync(sqlFilePath)) {
|
||||||
|
throw new Error(`Migration SQL file not found: ${sqlFilePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Executing migration ${migration.tag}...`)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Read and execute SQL
|
||||||
|
const sqlContent = fs.readFileSync(sqlFilePath, 'utf-8')
|
||||||
|
await this.client.executeMultiple(sqlContent)
|
||||||
|
|
||||||
|
// Record migration as applied (store journal idx as version for tracking)
|
||||||
|
const newMigration: NewMigration = {
|
||||||
|
version: migration.idx,
|
||||||
|
tag: migration.tag,
|
||||||
|
executedAt: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.migrationsTableExists())) {
|
||||||
|
throw new Error('Migrations table missing after executing migration; cannot record progress')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.insert(migrations).values(newMigration)
|
||||||
|
|
||||||
|
const executionTime = Date.now() - startTime
|
||||||
|
logger.info(`Migration ${migration.tag} completed in ${executionTime}ms`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Migration ${migration.tag} failed:`, { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Database Module
|
||||||
|
*
|
||||||
|
* This module provides centralized access to Drizzle ORM schemas
|
||||||
|
* for type-safe database operations.
|
||||||
|
*
|
||||||
|
* Schema evolution is handled by Drizzle Kit migrations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Drizzle ORM schemas
|
||||||
|
export * from './schema'
|
||||||
|
|
||||||
|
// Repository helpers
|
||||||
|
export * from './sessionMessageRepository'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Drizzle ORM schema for agents table
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const agentsTable = sqliteTable('agents', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
type: text('type').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access
|
||||||
|
|
||||||
|
instructions: text('instructions'),
|
||||||
|
|
||||||
|
model: text('model').notNull(), // Main model ID (required)
|
||||||
|
plan_model: text('plan_model'), // Optional plan/thinking model ID
|
||||||
|
small_model: text('small_model'), // Optional small/fast model ID
|
||||||
|
|
||||||
|
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||||
|
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||||
|
|
||||||
|
configuration: text('configuration'), // JSON, extensible settings
|
||||||
|
|
||||||
|
created_at: text('created_at').notNull(),
|
||||||
|
updated_at: text('updated_at').notNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Indexes for agents table
|
||||||
|
export const agentsNameIdx = index('idx_agents_name').on(agentsTable.name)
|
||||||
|
export const agentsTypeIdx = index('idx_agents_type').on(agentsTable.type)
|
||||||
|
export const agentsCreatedAtIdx = index('idx_agents_created_at').on(agentsTable.created_at)
|
||||||
|
|
||||||
|
export type AgentRow = typeof agentsTable.$inferSelect
|
||||||
|
export type InsertAgentRow = typeof agentsTable.$inferInsert
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Drizzle ORM schema exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './agents.schema'
|
||||||
|
export * from './messages.schema'
|
||||||
|
export * from './migrations.schema'
|
||||||
|
export * from './sessions.schema'
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
import { sessionsTable } from './sessions.schema'
|
||||||
|
|
||||||
|
// session_messages table to log all messages, thoughts, actions, observations in a session
|
||||||
|
export const sessionMessagesTable = sqliteTable('session_messages', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
session_id: text('session_id').notNull(),
|
||||||
|
role: text('role').notNull(), // 'user', 'agent', 'system', 'tool'
|
||||||
|
content: text('content').notNull(), // JSON structured data
|
||||||
|
agent_session_id: text('agent_session_id').default(''),
|
||||||
|
metadata: text('metadata'), // JSON metadata (optional)
|
||||||
|
created_at: text('created_at').notNull(),
|
||||||
|
updated_at: text('updated_at').notNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Indexes for session_messages table
|
||||||
|
export const sessionMessagesSessionIdIdx = index('idx_session_messages_session_id').on(sessionMessagesTable.session_id)
|
||||||
|
export const sessionMessagesCreatedAtIdx = index('idx_session_messages_created_at').on(sessionMessagesTable.created_at)
|
||||||
|
export const sessionMessagesUpdatedAtIdx = index('idx_session_messages_updated_at').on(sessionMessagesTable.updated_at)
|
||||||
|
|
||||||
|
// Foreign keys for session_messages table
|
||||||
|
export const sessionMessagesFkSession = foreignKey({
|
||||||
|
columns: [sessionMessagesTable.session_id],
|
||||||
|
foreignColumns: [sessionsTable.id],
|
||||||
|
name: 'fk_session_messages_session_id'
|
||||||
|
}).onDelete('cascade')
|
||||||
|
|
||||||
|
export type SessionMessageRow = typeof sessionMessagesTable.$inferSelect
|
||||||
|
export type InsertSessionMessageRow = typeof sessionMessagesTable.$inferInsert
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Migration tracking schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const migrations = sqliteTable('migrations', {
|
||||||
|
version: integer('version').primaryKey(),
|
||||||
|
tag: text('tag').notNull(),
|
||||||
|
executedAt: integer('executed_at').notNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Migration = typeof migrations.$inferSelect
|
||||||
|
export type NewMigration = typeof migrations.$inferInsert
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Drizzle ORM schema for sessions and session_logs tables
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { foreignKey, index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
import { agentsTable } from './agents.schema'
|
||||||
|
|
||||||
|
export const sessionsTable = sqliteTable('sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agent_type: text('agent_type').notNull(),
|
||||||
|
agent_id: text('agent_id').notNull(), // Primary agent ID for the session
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access
|
||||||
|
|
||||||
|
instructions: text('instructions'),
|
||||||
|
|
||||||
|
model: text('model').notNull(), // Main model ID (required)
|
||||||
|
plan_model: text('plan_model'), // Optional plan/thinking model ID
|
||||||
|
small_model: text('small_model'), // Optional small/fast model ID
|
||||||
|
|
||||||
|
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||||
|
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||||
|
|
||||||
|
configuration: text('configuration'), // JSON, extensible settings
|
||||||
|
|
||||||
|
created_at: text('created_at').notNull(),
|
||||||
|
updated_at: text('updated_at').notNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Foreign keys for sessions table
|
||||||
|
export const sessionsFkAgent = foreignKey({
|
||||||
|
columns: [sessionsTable.agent_id],
|
||||||
|
foreignColumns: [agentsTable.id],
|
||||||
|
name: 'fk_session_agent_id'
|
||||||
|
}).onDelete('cascade')
|
||||||
|
|
||||||
|
// Indexes for sessions table
|
||||||
|
export const sessionsCreatedAtIdx = index('idx_sessions_created_at').on(sessionsTable.created_at)
|
||||||
|
export const sessionsMainAgentIdIdx = index('idx_sessions_agent_id').on(sessionsTable.agent_id)
|
||||||
|
export const sessionsModelIdx = index('idx_sessions_model').on(sessionsTable.model)
|
||||||
|
|
||||||
|
export type SessionRow = typeof sessionsTable.$inferSelect
|
||||||
|
export type InsertSessionRow = typeof sessionsTable.$inferInsert
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import type {
|
||||||
|
AgentMessageAssistantPersistPayload,
|
||||||
|
AgentMessagePersistExchangePayload,
|
||||||
|
AgentMessagePersistExchangeResult,
|
||||||
|
AgentMessageUserPersistPayload,
|
||||||
|
AgentPersistedMessage,
|
||||||
|
AgentSessionMessageEntity
|
||||||
|
} from '@types'
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import type { InsertSessionMessageRow } from './schema'
|
||||||
|
import { sessionMessagesTable } from './schema'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('AgentMessageRepository')
|
||||||
|
|
||||||
|
type TxClient = any
|
||||||
|
|
||||||
|
export type PersistUserMessageParams = AgentMessageUserPersistPayload & {
|
||||||
|
sessionId: string
|
||||||
|
agentSessionId?: string
|
||||||
|
tx?: TxClient
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersistAssistantMessageParams = AgentMessageAssistantPersistPayload & {
|
||||||
|
sessionId: string
|
||||||
|
agentSessionId: string
|
||||||
|
tx?: TxClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersistExchangeParams = AgentMessagePersistExchangePayload & {
|
||||||
|
tx?: TxClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersistExchangeResult = AgentMessagePersistExchangeResult
|
||||||
|
|
||||||
|
class AgentMessageRepository extends BaseService {
|
||||||
|
private static instance: AgentMessageRepository | null = null
|
||||||
|
|
||||||
|
static getInstance(): AgentMessageRepository {
|
||||||
|
if (!AgentMessageRepository.instance) {
|
||||||
|
AgentMessageRepository.instance = new AgentMessageRepository()
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentMessageRepository.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeMessage(payload: AgentPersistedMessage): string {
|
||||||
|
return JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeMetadata(metadata?: Record<string, unknown>): string | undefined {
|
||||||
|
if (!metadata) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(metadata)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to serialize session message metadata', error as Error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deserialize(row: any): AgentSessionMessageEntity {
|
||||||
|
if (!row) return row
|
||||||
|
|
||||||
|
const deserialized = { ...row }
|
||||||
|
|
||||||
|
if (typeof deserialized.content === 'string') {
|
||||||
|
try {
|
||||||
|
deserialized.content = JSON.parse(deserialized.content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to parse session message content JSON', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof deserialized.metadata === 'string') {
|
||||||
|
try {
|
||||||
|
deserialized.metadata = JSON.parse(deserialized.metadata)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to parse session message metadata JSON', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserialized
|
||||||
|
}
|
||||||
|
|
||||||
|
private getWriter(tx?: TxClient): TxClient {
|
||||||
|
return tx ?? this.database
|
||||||
|
}
|
||||||
|
|
||||||
|
async persistUserMessage(params: PersistUserMessageParams): Promise<AgentSessionMessageEntity> {
|
||||||
|
await AgentMessageRepository.initialize()
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const writer = this.getWriter(params.tx)
|
||||||
|
const now = params.createdAt ?? params.payload.message.createdAt ?? new Date().toISOString()
|
||||||
|
|
||||||
|
const insertData: InsertSessionMessageRow = {
|
||||||
|
session_id: params.sessionId,
|
||||||
|
role: params.payload.message.role,
|
||||||
|
content: this.serializeMessage(params.payload),
|
||||||
|
agent_session_id: params.agentSessionId ?? '',
|
||||||
|
metadata: this.serializeMetadata(params.metadata),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
|
||||||
|
|
||||||
|
return this.deserialize(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
async persistAssistantMessage(params: PersistAssistantMessageParams): Promise<AgentSessionMessageEntity> {
|
||||||
|
await AgentMessageRepository.initialize()
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const writer = this.getWriter(params.tx)
|
||||||
|
const now = params.createdAt ?? params.payload.message.createdAt ?? new Date().toISOString()
|
||||||
|
|
||||||
|
const insertData: InsertSessionMessageRow = {
|
||||||
|
session_id: params.sessionId,
|
||||||
|
role: params.payload.message.role,
|
||||||
|
content: this.serializeMessage(params.payload),
|
||||||
|
agent_session_id: params.agentSessionId,
|
||||||
|
metadata: this.serializeMetadata(params.metadata),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
|
||||||
|
|
||||||
|
return this.deserialize(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
async persistExchange(params: PersistExchangeParams): Promise<PersistExchangeResult> {
|
||||||
|
await AgentMessageRepository.initialize()
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const { sessionId, agentSessionId, user, assistant } = params
|
||||||
|
|
||||||
|
const result = await this.database.transaction(async (tx) => {
|
||||||
|
const exchangeResult: PersistExchangeResult = {}
|
||||||
|
|
||||||
|
if (user?.payload) {
|
||||||
|
if (!user.payload.message?.role) {
|
||||||
|
throw new Error('User message payload missing role')
|
||||||
|
}
|
||||||
|
exchangeResult.userMessage = await this.persistUserMessage({
|
||||||
|
sessionId,
|
||||||
|
agentSessionId,
|
||||||
|
payload: user.payload,
|
||||||
|
metadata: user.metadata,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
tx
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assistant?.payload) {
|
||||||
|
if (!assistant.payload.message?.role) {
|
||||||
|
throw new Error('Assistant message payload missing role')
|
||||||
|
}
|
||||||
|
exchangeResult.assistantMessage = await this.persistAssistantMessage({
|
||||||
|
sessionId,
|
||||||
|
agentSessionId,
|
||||||
|
payload: assistant.payload,
|
||||||
|
metadata: assistant.metadata,
|
||||||
|
createdAt: assistant.createdAt,
|
||||||
|
tx
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return exchangeResult
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentMessageRepository = AgentMessageRepository.getInstance()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Drizzle Kit configuration for agents database
|
||||||
|
*/
|
||||||
|
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
function getDbPath() {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return path.join(os.homedir(), '.cherrystudio', 'data', 'agents.db')
|
||||||
|
}
|
||||||
|
return path.join(app.getPath('userData'), 'agents.db')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedDbPath = getDbPath()
|
||||||
|
|
||||||
|
export const dbPath = resolvedDbPath
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
schema: './src/main/services/agents/database/schema/index.ts',
|
||||||
|
out: './resources/database/drizzle',
|
||||||
|
dbCredentials: {
|
||||||
|
url: `file:${resolvedDbPath}`
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ModelValidationError } from '@main/apiServer/utils'
|
||||||
|
import { AgentType } from '@types'
|
||||||
|
|
||||||
|
export type AgentModelField = 'model' | 'plan_model' | 'small_model'
|
||||||
|
|
||||||
|
export interface AgentModelValidationContext {
|
||||||
|
agentType: AgentType
|
||||||
|
field: AgentModelField
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AgentModelValidationError extends Error {
|
||||||
|
readonly context: AgentModelValidationContext
|
||||||
|
readonly detail: ModelValidationError
|
||||||
|
|
||||||
|
constructor(context: AgentModelValidationContext, detail: ModelValidationError) {
|
||||||
|
super(`Validation failed for ${context.agentType}.${context.field}: ${detail.message}`)
|
||||||
|
this.name = 'AgentModelValidationError'
|
||||||
|
this.context = context
|
||||||
|
this.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Agents Service Module
|
||||||
|
*
|
||||||
|
* This module provides a complete autonomous agent management system with:
|
||||||
|
* - Agent lifecycle management (CRUD operations)
|
||||||
|
* - Session handling with conversation history
|
||||||
|
* - Comprehensive logging and audit trails
|
||||||
|
* - Database operations with Drizzle ORM and migration support
|
||||||
|
* - RESTful API endpoints for external integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
// === Core Services ===
|
||||||
|
// Main service classes and singleton instances
|
||||||
|
export * from './services'
|
||||||
|
|
||||||
|
// === Error Types ===
|
||||||
|
export { type AgentModelField, AgentModelValidationError } from './errors'
|
||||||
|
|
||||||
|
// === Base Infrastructure ===
|
||||||
|
// Shared database utilities and base service class
|
||||||
|
export { BaseService } from './BaseService'
|
||||||
|
|
||||||
|
// === Database Layer ===
|
||||||
|
// Drizzle ORM schemas, migrations, and database utilities
|
||||||
|
export * as Database from './database'
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Agent-agnostic streaming interface
|
||||||
|
// This interface should be implemented by all agent services
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
|
||||||
|
import { GetAgentSessionResponse } from '@types'
|
||||||
|
import type { TextStreamPart } from 'ai'
|
||||||
|
|
||||||
|
// Generic agent stream event that works with any agent type
|
||||||
|
export interface AgentStreamEvent {
|
||||||
|
type: 'chunk' | 'error' | 'complete' | 'cancelled'
|
||||||
|
chunk?: TextStreamPart<any> // Standard AI SDK chunk for UI consumption
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent stream interface that all agents should implement
|
||||||
|
export interface AgentStream extends EventEmitter {
|
||||||
|
emit(event: 'data', data: AgentStreamEvent): boolean
|
||||||
|
on(event: 'data', listener: (data: AgentStreamEvent) => void): this
|
||||||
|
once(event: 'data', listener: (data: AgentStreamEvent) => void): this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base agent service interface
|
||||||
|
export interface AgentServiceInterface {
|
||||||
|
invoke(
|
||||||
|
prompt: string,
|
||||||
|
session: GetAgentSessionResponse,
|
||||||
|
abortController: AbortController,
|
||||||
|
lastAgentSessionId?: string
|
||||||
|
): Promise<AgentStream>
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { getDataPath } from '@main/utils'
|
||||||
|
import {
|
||||||
|
AgentBaseSchema,
|
||||||
|
AgentEntity,
|
||||||
|
CreateAgentRequest,
|
||||||
|
CreateAgentResponse,
|
||||||
|
GetAgentResponse,
|
||||||
|
ListOptions,
|
||||||
|
UpdateAgentRequest,
|
||||||
|
UpdateAgentResponse
|
||||||
|
} from '@types'
|
||||||
|
import { count, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
|
||||||
|
import { AgentModelField } from '../errors'
|
||||||
|
import { builtinTools } from './claudecode/tools'
|
||||||
|
|
||||||
|
export class AgentService extends BaseService {
|
||||||
|
private static instance: AgentService | null = null
|
||||||
|
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
|
||||||
|
|
||||||
|
static getInstance(): AgentService {
|
||||||
|
if (!AgentService.instance) {
|
||||||
|
AgentService.instance = new AgentService()
|
||||||
|
}
|
||||||
|
return AgentService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await BaseService.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent Methods
|
||||||
|
async createAgent(req: CreateAgentRequest): Promise<CreateAgentResponse> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const id = `agent_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
if (!req.accessible_paths || req.accessible_paths.length === 0) {
|
||||||
|
const defaultPath = path.join(getDataPath(), 'agents', id)
|
||||||
|
req.accessible_paths = [defaultPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.accessible_paths !== undefined) {
|
||||||
|
req.accessible_paths = this.ensurePathsExist(req.accessible_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.validateAgentModels(req.type, {
|
||||||
|
model: req.model,
|
||||||
|
plan_model: req.plan_model,
|
||||||
|
small_model: req.small_model
|
||||||
|
})
|
||||||
|
|
||||||
|
const serializedReq = this.serializeJsonFields(req)
|
||||||
|
|
||||||
|
const insertData: InsertAgentRow = {
|
||||||
|
id,
|
||||||
|
type: req.type,
|
||||||
|
name: req.name || 'New Agent',
|
||||||
|
description: req.description,
|
||||||
|
instructions: req.instructions || 'You are a helpful assistant.',
|
||||||
|
model: req.model,
|
||||||
|
plan_model: req.plan_model,
|
||||||
|
small_model: req.small_model,
|
||||||
|
configuration: serializedReq.configuration,
|
||||||
|
accessible_paths: serializedReq.accessible_paths,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.database.insert(agentsTable).values(insertData)
|
||||||
|
const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
|
||||||
|
if (!result[0]) {
|
||||||
|
throw new Error('Failed to create agent')
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = this.deserializeJsonFields(result[0]) as AgentEntity
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAgent(id: string): Promise<GetAgentResponse | null> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
|
||||||
|
|
||||||
|
if (!result[0]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse
|
||||||
|
if (agent.type === 'claude-code') {
|
||||||
|
agent.built_in_tools = builtinTools
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAgents(options: ListOptions = {}): Promise<{ agents: AgentEntity[]; total: number }> {
|
||||||
|
this.ensureInitialized() // Build query with pagination
|
||||||
|
|
||||||
|
const totalResult = await this.database.select({ count: count() }).from(agentsTable)
|
||||||
|
|
||||||
|
const baseQuery = this.database.select().from(agentsTable).orderBy(agentsTable.created_at)
|
||||||
|
|
||||||
|
const result =
|
||||||
|
options.limit !== undefined
|
||||||
|
? options.offset !== undefined
|
||||||
|
? await baseQuery.limit(options.limit).offset(options.offset)
|
||||||
|
: await baseQuery.limit(options.limit)
|
||||||
|
: await baseQuery
|
||||||
|
|
||||||
|
const agents = result.map((row) => this.deserializeJsonFields(row)) as GetAgentResponse[]
|
||||||
|
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
if (agent.type === 'claude-code') {
|
||||||
|
agent.built_in_tools = builtinTools
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { agents, total: totalResult[0].count }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAgent(
|
||||||
|
id: string,
|
||||||
|
updates: UpdateAgentRequest,
|
||||||
|
options: { replace?: boolean } = {}
|
||||||
|
): Promise<UpdateAgentResponse | null> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
// Check if agent exists
|
||||||
|
const existing = await this.getAgent(id)
|
||||||
|
if (!existing) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
if (updates.accessible_paths !== undefined) {
|
||||||
|
updates.accessible_paths = this.ensurePathsExist(updates.accessible_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelUpdates: Partial<Record<AgentModelField, string | undefined>> = {}
|
||||||
|
for (const field of this.modelFields) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, field)) {
|
||||||
|
modelUpdates[field] = updates[field as keyof UpdateAgentRequest] as string | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(modelUpdates).length > 0) {
|
||||||
|
await this.validateAgentModels(existing.type, modelUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedUpdates = this.serializeJsonFields(updates)
|
||||||
|
|
||||||
|
const updateData: Partial<AgentRow> = {
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
const replaceableFields = Object.keys(AgentBaseSchema.shape) as (keyof AgentRow)[]
|
||||||
|
const shouldReplace = options.replace ?? false
|
||||||
|
|
||||||
|
for (const field of replaceableFields) {
|
||||||
|
if (shouldReplace || Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
|
||||||
|
const value = serializedUpdates[field as keyof typeof serializedUpdates]
|
||||||
|
;(updateData as Record<string, unknown>)[field] = value ?? null
|
||||||
|
} else if (shouldReplace) {
|
||||||
|
;(updateData as Record<string, unknown>)[field] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.database.update(agentsTable).set(updateData).where(eq(agentsTable.id, id))
|
||||||
|
return await this.getAgent(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAgent(id: string): Promise<boolean> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database.delete(agentsTable).where(eq(agentsTable.id, id))
|
||||||
|
|
||||||
|
return result.rowsAffected > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async agentExists(id: string): Promise<boolean> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database
|
||||||
|
.select({ id: agentsTable.id })
|
||||||
|
.from(agentsTable)
|
||||||
|
.where(eq(agentsTable.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return result.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentService = AgentService.getInstance()
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import type {
|
||||||
|
AgentSessionMessageEntity,
|
||||||
|
CreateSessionMessageRequest,
|
||||||
|
GetAgentSessionResponse,
|
||||||
|
ListOptions
|
||||||
|
} from '@types'
|
||||||
|
import { ModelMessage, TextStreamPart } from 'ai'
|
||||||
|
import { desc, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import { sessionMessagesTable } from '../database/schema'
|
||||||
|
import { AgentStreamEvent } from '../interfaces/AgentStreamInterface'
|
||||||
|
import ClaudeCodeService from './claudecode'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('SessionMessageService')
|
||||||
|
|
||||||
|
type SessionStreamResult = {
|
||||||
|
stream: ReadableStream<TextStreamPart<Record<string, any>>>
|
||||||
|
completion: Promise<{
|
||||||
|
userMessage?: AgentSessionMessageEntity
|
||||||
|
assistantMessage?: AgentSessionMessageEntity
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure errors emitted through SSE are serializable
|
||||||
|
function serializeError(error: unknown): { message: string; name?: string; stack?: string } {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return { message: error }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextStreamAccumulator {
|
||||||
|
private textBuffer = ''
|
||||||
|
private totalText = ''
|
||||||
|
private readonly toolCalls = new Map<string, { toolName?: string; input?: unknown }>()
|
||||||
|
private readonly toolResults = new Map<string, unknown>()
|
||||||
|
|
||||||
|
add(part: TextStreamPart<Record<string, any>>): void {
|
||||||
|
switch (part.type) {
|
||||||
|
case 'text-start':
|
||||||
|
this.textBuffer = ''
|
||||||
|
break
|
||||||
|
case 'text-delta':
|
||||||
|
if (part.text) {
|
||||||
|
this.textBuffer += part.text
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'text-end': {
|
||||||
|
const blockText = (part.providerMetadata?.text?.value as string | undefined) ?? this.textBuffer
|
||||||
|
if (blockText) {
|
||||||
|
this.totalText += blockText
|
||||||
|
}
|
||||||
|
this.textBuffer = ''
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'tool-call':
|
||||||
|
if (part.toolCallId) {
|
||||||
|
this.toolCalls.set(part.toolCallId, {
|
||||||
|
toolName: part.toolName,
|
||||||
|
input: part.input ?? part.args ?? part.providerMetadata?.raw?.input
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'tool-result':
|
||||||
|
if (part.toolCallId) {
|
||||||
|
this.toolResults.set(part.toolCallId, part.output ?? part.result ?? part.providerMetadata?.raw)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toModelMessage(role: ModelMessage['role'] = 'assistant'): ModelMessage {
|
||||||
|
const content = this.totalText || this.textBuffer || ''
|
||||||
|
|
||||||
|
const toolInvocations = Array.from(this.toolCalls.entries()).map(([toolCallId, info]) => ({
|
||||||
|
toolCallId,
|
||||||
|
toolName: info.toolName,
|
||||||
|
args: info.input,
|
||||||
|
result: this.toolResults.get(toolCallId)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const message: Record<string, unknown> = {
|
||||||
|
role,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolInvocations.length > 0) {
|
||||||
|
message.toolInvocations = toolInvocations
|
||||||
|
}
|
||||||
|
|
||||||
|
return message as ModelMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionMessageService extends BaseService {
|
||||||
|
private static instance: SessionMessageService | null = null
|
||||||
|
private cc: ClaudeCodeService = new ClaudeCodeService()
|
||||||
|
|
||||||
|
static getInstance(): SessionMessageService {
|
||||||
|
if (!SessionMessageService.instance) {
|
||||||
|
SessionMessageService.instance = new SessionMessageService()
|
||||||
|
}
|
||||||
|
return SessionMessageService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await BaseService.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async sessionMessageExists(id: number): Promise<boolean> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database
|
||||||
|
.select({ id: sessionMessagesTable.id })
|
||||||
|
.from(sessionMessagesTable)
|
||||||
|
.where(eq(sessionMessagesTable.id, id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return result.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessionMessages(
|
||||||
|
sessionId: string,
|
||||||
|
options: ListOptions = {}
|
||||||
|
): Promise<{ messages: AgentSessionMessageEntity[] }> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
// Get messages with pagination
|
||||||
|
const baseQuery = this.database
|
||||||
|
.select()
|
||||||
|
.from(sessionMessagesTable)
|
||||||
|
.where(eq(sessionMessagesTable.session_id, sessionId))
|
||||||
|
.orderBy(sessionMessagesTable.created_at)
|
||||||
|
|
||||||
|
const result =
|
||||||
|
options.limit !== undefined
|
||||||
|
? options.offset !== undefined
|
||||||
|
? await baseQuery.limit(options.limit).offset(options.offset)
|
||||||
|
: await baseQuery.limit(options.limit)
|
||||||
|
: await baseQuery
|
||||||
|
|
||||||
|
const messages = result.map((row) => this.deserializeSessionMessage(row)) as AgentSessionMessageEntity[]
|
||||||
|
|
||||||
|
return { messages }
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSessionMessage(
|
||||||
|
session: GetAgentSessionResponse,
|
||||||
|
messageData: CreateSessionMessageRequest,
|
||||||
|
abortController: AbortController
|
||||||
|
): Promise<SessionStreamResult> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
return await this.startSessionMessageStream(session, messageData, abortController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startSessionMessageStream(
|
||||||
|
session: GetAgentSessionResponse,
|
||||||
|
req: CreateSessionMessageRequest,
|
||||||
|
abortController: AbortController
|
||||||
|
): Promise<SessionStreamResult> {
|
||||||
|
const agentSessionId = await this.getLastAgentSessionId(session.id)
|
||||||
|
let newAgentSessionId = ''
|
||||||
|
logger.debug('Session Message stream message data:', { message: req, session_id: agentSessionId })
|
||||||
|
|
||||||
|
if (session.agent_type !== 'claude-code') {
|
||||||
|
// TODO: Implement support for other agent types
|
||||||
|
logger.error('Unsupported agent type for streaming:', { agent_type: session.agent_type })
|
||||||
|
throw new Error('Unsupported agent type for streaming')
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeStream = await this.cc.invoke(req.content, session, abortController, agentSessionId)
|
||||||
|
const accumulator = new TextStreamAccumulator()
|
||||||
|
|
||||||
|
let resolveCompletion!: (value: {
|
||||||
|
userMessage?: AgentSessionMessageEntity
|
||||||
|
assistantMessage?: AgentSessionMessageEntity
|
||||||
|
}) => void
|
||||||
|
let rejectCompletion!: (reason?: unknown) => void
|
||||||
|
|
||||||
|
const completion = new Promise<{
|
||||||
|
userMessage?: AgentSessionMessageEntity
|
||||||
|
assistantMessage?: AgentSessionMessageEntity
|
||||||
|
}>((resolve, reject) => {
|
||||||
|
resolveCompletion = resolve
|
||||||
|
rejectCompletion = reject
|
||||||
|
})
|
||||||
|
|
||||||
|
let finished = false
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
claudeStream.removeAllListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new ReadableStream<TextStreamPart<Record<string, any>>>({
|
||||||
|
start: (controller) => {
|
||||||
|
claudeStream.on('data', async (event: AgentStreamEvent) => {
|
||||||
|
if (finished) return
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'chunk': {
|
||||||
|
const chunk = event.chunk as TextStreamPart<Record<string, any>> | undefined
|
||||||
|
if (!chunk) {
|
||||||
|
logger.warn('Received agent chunk event without chunk payload')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.type === 'start' && chunk.messageId) {
|
||||||
|
newAgentSessionId = chunk.messageId
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator.add(chunk)
|
||||||
|
controller.enqueue(chunk)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'error': {
|
||||||
|
const stderrMessage = (event as any)?.data?.stderr as string | undefined
|
||||||
|
const underlyingError = event.error ?? (stderrMessage ? new Error(stderrMessage) : undefined)
|
||||||
|
cleanup()
|
||||||
|
const streamError = underlyingError ?? new Error('Stream error')
|
||||||
|
controller.error(streamError)
|
||||||
|
rejectCompletion(serializeError(streamError))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'complete': {
|
||||||
|
cleanup()
|
||||||
|
controller.close()
|
||||||
|
resolveCompletion({})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancelled': {
|
||||||
|
cleanup()
|
||||||
|
controller.close()
|
||||||
|
resolveCompletion({})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn('Unknown event type from Claude Code service:', {
|
||||||
|
type: event.type
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
cleanup()
|
||||||
|
controller.error(error)
|
||||||
|
rejectCompletion(serializeError(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cancel: (reason) => {
|
||||||
|
cleanup()
|
||||||
|
abortController.abort(typeof reason === 'string' ? reason : 'stream cancelled')
|
||||||
|
resolveCompletion({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { stream, completion }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLastAgentSessionId(sessionId: string): Promise<string> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.database
|
||||||
|
.select({ agent_session_id: sessionMessagesTable.agent_session_id })
|
||||||
|
.from(sessionMessagesTable)
|
||||||
|
.where(eq(sessionMessagesTable.session_id, sessionId))
|
||||||
|
.orderBy(desc(sessionMessagesTable.created_at))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return result[0]?.agent_session_id || ''
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get last agent session ID', {
|
||||||
|
sessionId,
|
||||||
|
error
|
||||||
|
})
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deserializeSessionMessage(data: any): AgentSessionMessageEntity {
|
||||||
|
if (!data) return data
|
||||||
|
|
||||||
|
const deserialized = { ...data }
|
||||||
|
|
||||||
|
// Parse content JSON
|
||||||
|
if (deserialized.content && typeof deserialized.content === 'string') {
|
||||||
|
try {
|
||||||
|
deserialized.content = JSON.parse(deserialized.content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to parse content JSON:`, error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse metadata JSON
|
||||||
|
if (deserialized.metadata && typeof deserialized.metadata === 'string') {
|
||||||
|
try {
|
||||||
|
deserialized.metadata = JSON.parse(deserialized.metadata)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to parse metadata JSON:`, error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionMessageService = SessionMessageService.getInstance()
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
AgentBaseSchema,
|
||||||
|
type AgentEntity,
|
||||||
|
type AgentSessionEntity,
|
||||||
|
type CreateSessionRequest,
|
||||||
|
type CreateSessionResponse,
|
||||||
|
type GetAgentSessionResponse,
|
||||||
|
type ListOptions,
|
||||||
|
type UpdateSessionRequest,
|
||||||
|
UpdateSessionResponse
|
||||||
|
} from '@types'
|
||||||
|
import { and, count, eq, type SQL } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { BaseService } from '../BaseService'
|
||||||
|
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
|
||||||
|
import { AgentModelField } from '../errors'
|
||||||
|
|
||||||
|
export class SessionService extends BaseService {
|
||||||
|
private static instance: SessionService | null = null
|
||||||
|
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
|
||||||
|
|
||||||
|
static getInstance(): SessionService {
|
||||||
|
if (!SessionService.instance) {
|
||||||
|
SessionService.instance = new SessionService()
|
||||||
|
}
|
||||||
|
return SessionService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await BaseService.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(agentId: string, req: CreateSessionRequest): Promise<CreateSessionResponse> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
// Validate agent exists - we'll need to import AgentService for this check
|
||||||
|
// For now, we'll skip this validation to avoid circular dependencies
|
||||||
|
// The database foreign key constraint will handle this
|
||||||
|
|
||||||
|
const agents = await this.database.select().from(agentsTable).where(eq(agentsTable.id, agentId)).limit(1)
|
||||||
|
if (!agents[0]) {
|
||||||
|
throw new Error('Agent not found')
|
||||||
|
}
|
||||||
|
const agent = this.deserializeJsonFields(agents[0]) as AgentEntity
|
||||||
|
|
||||||
|
const id = `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// inherit configuration from agent by default, can be overridden by sessionData
|
||||||
|
const sessionData: Partial<CreateSessionRequest> = {
|
||||||
|
...agent,
|
||||||
|
...req
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.validateAgentModels(agent.type, {
|
||||||
|
model: sessionData.model,
|
||||||
|
plan_model: sessionData.plan_model,
|
||||||
|
small_model: sessionData.small_model
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sessionData.accessible_paths !== undefined) {
|
||||||
|
sessionData.accessible_paths = this.ensurePathsExist(sessionData.accessible_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedData = this.serializeJsonFields(sessionData)
|
||||||
|
|
||||||
|
const insertData: InsertSessionRow = {
|
||||||
|
id,
|
||||||
|
agent_id: agentId,
|
||||||
|
agent_type: agent.type,
|
||||||
|
name: serializedData.name || null,
|
||||||
|
description: serializedData.description || null,
|
||||||
|
accessible_paths: serializedData.accessible_paths || null,
|
||||||
|
instructions: serializedData.instructions || null,
|
||||||
|
model: serializedData.model || null,
|
||||||
|
plan_model: serializedData.plan_model || null,
|
||||||
|
small_model: serializedData.small_model || null,
|
||||||
|
mcps: serializedData.mcps || null,
|
||||||
|
configuration: serializedData.configuration || null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.database.insert(sessionsTable).values(insertData)
|
||||||
|
|
||||||
|
const result = await this.database.select().from(sessionsTable).where(eq(sessionsTable.id, id)).limit(1)
|
||||||
|
|
||||||
|
if (!result[0]) {
|
||||||
|
throw new Error('Failed to create session')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.deserializeJsonFields(result[0]) as AgentSessionEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(agentId: string, id: string): Promise<GetAgentSessionResponse | null> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database
|
||||||
|
.select()
|
||||||
|
.from(sessionsTable)
|
||||||
|
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!result[0]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionById(id: string): Promise<GetAgentSessionResponse | null> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database.select().from(sessionsTable).where(eq(sessionsTable.id, id)).limit(1)
|
||||||
|
|
||||||
|
if (!result[0]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(
|
||||||
|
agentId?: string,
|
||||||
|
options: ListOptions = {}
|
||||||
|
): Promise<{ sessions: AgentSessionEntity[]; total: number }> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
// Build where conditions
|
||||||
|
const whereConditions: SQL[] = []
|
||||||
|
if (agentId) {
|
||||||
|
whereConditions.push(eq(sessionsTable.agent_id, agentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 1
|
||||||
|
? and(...whereConditions)
|
||||||
|
: whereConditions.length === 1
|
||||||
|
? whereConditions[0]
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const totalResult = await this.database.select({ count: count() }).from(sessionsTable).where(whereClause)
|
||||||
|
|
||||||
|
const total = totalResult[0].count
|
||||||
|
|
||||||
|
// Build list query with pagination
|
||||||
|
const baseQuery = this.database.select().from(sessionsTable).where(whereClause).orderBy(sessionsTable.created_at)
|
||||||
|
|
||||||
|
const result =
|
||||||
|
options.limit !== undefined
|
||||||
|
? options.offset !== undefined
|
||||||
|
? await baseQuery.limit(options.limit).offset(options.offset)
|
||||||
|
: await baseQuery.limit(options.limit)
|
||||||
|
: await baseQuery
|
||||||
|
|
||||||
|
const sessions = result.map((row) => this.deserializeJsonFields(row)) as GetAgentSessionResponse[]
|
||||||
|
|
||||||
|
return { sessions, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSession(
|
||||||
|
agentId: string,
|
||||||
|
id: string,
|
||||||
|
updates: UpdateSessionRequest
|
||||||
|
): Promise<UpdateSessionResponse | null> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
// Check if session exists
|
||||||
|
const existing = await this.getSession(agentId, id)
|
||||||
|
if (!existing) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate agent exists if changing main_agent_id
|
||||||
|
// We'll skip this validation for now to avoid circular dependencies
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
if (updates.accessible_paths !== undefined) {
|
||||||
|
updates.accessible_paths = this.ensurePathsExist(updates.accessible_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelUpdates: Partial<Record<AgentModelField, string | undefined>> = {}
|
||||||
|
for (const field of this.modelFields) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, field)) {
|
||||||
|
modelUpdates[field] = updates[field as keyof UpdateSessionRequest] as string | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(modelUpdates).length > 0) {
|
||||||
|
await this.validateAgentModels(existing.agent_type, modelUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedUpdates = this.serializeJsonFields(updates)
|
||||||
|
|
||||||
|
const updateData: Partial<SessionRow> = {
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
const replaceableFields = Object.keys(AgentBaseSchema.shape) as (keyof SessionRow)[]
|
||||||
|
|
||||||
|
for (const field of replaceableFields) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(serializedUpdates, field)) {
|
||||||
|
const value = serializedUpdates[field as keyof typeof serializedUpdates]
|
||||||
|
;(updateData as Record<string, unknown>)[field] = value ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.database.update(sessionsTable).set(updateData).where(eq(sessionsTable.id, id))
|
||||||
|
|
||||||
|
return await this.getSession(agentId, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(agentId: string, id: string): Promise<boolean> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database
|
||||||
|
.delete(sessionsTable)
|
||||||
|
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
|
||||||
|
|
||||||
|
return result.rowsAffected > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async sessionExists(agentId: string, id: string): Promise<boolean> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database
|
||||||
|
.select({ id: sessionsTable.id })
|
||||||
|
.from(sessionsTable)
|
||||||
|
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return result.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionService = SessionService.getInstance()
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// src/main/services/agents/services/claudecode/index.ts
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-code'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { config as apiConfigService } from '@main/apiServer/config'
|
||||||
|
import { validateModelId } from '@main/apiServer/utils'
|
||||||
|
|
||||||
|
import { GetAgentSessionResponse } from '../..'
|
||||||
|
import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||||
|
import { transformSDKMessageToStreamParts } from './transform'
|
||||||
|
|
||||||
|
const require_ = createRequire(import.meta.url)
|
||||||
|
const logger = loggerService.withContext('ClaudeCodeService')
|
||||||
|
|
||||||
|
class ClaudeCodeStream extends EventEmitter implements AgentStream {
|
||||||
|
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
|
||||||
|
declare on: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
|
||||||
|
declare once: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClaudeCodeService implements AgentServiceInterface {
|
||||||
|
private claudeExecutablePath: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Resolve Claude Code CLI robustly (works in dev and in asar)
|
||||||
|
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-code/cli.js')
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoke(
|
||||||
|
prompt: string,
|
||||||
|
session: GetAgentSessionResponse,
|
||||||
|
abortController: AbortController,
|
||||||
|
lastAgentSessionId?: string
|
||||||
|
): Promise<AgentStream> {
|
||||||
|
const aiStream = new ClaudeCodeStream()
|
||||||
|
|
||||||
|
// Validate session accessible paths and make sure it exists as a directory
|
||||||
|
const cwd = session.accessible_paths[0]
|
||||||
|
if (!cwd) {
|
||||||
|
aiStream.emit('data', {
|
||||||
|
type: 'error',
|
||||||
|
error: new Error('No accessible paths defined for the agent session')
|
||||||
|
})
|
||||||
|
return aiStream
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate model info
|
||||||
|
const modelInfo = await validateModelId(session.model)
|
||||||
|
if (!modelInfo.valid) {
|
||||||
|
aiStream.emit('data', {
|
||||||
|
type: 'error',
|
||||||
|
error: new Error(`Invalid model ID '${session.model}': ${JSON.stringify(modelInfo.error)}`)
|
||||||
|
})
|
||||||
|
return aiStream
|
||||||
|
}
|
||||||
|
if (modelInfo.provider?.type !== 'anthropic' || modelInfo.provider.apiKey === '') {
|
||||||
|
aiStream.emit('data', {
|
||||||
|
type: 'error',
|
||||||
|
error: new Error(`Invalid provider type '${modelInfo.provider?.type}'. Expected 'anthropic' provider type.`)
|
||||||
|
})
|
||||||
|
return aiStream
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: use cherry studio api server config instead of direct provider config to provide more flexibility (e.g. custom headers, proxy, statistics, etc).
|
||||||
|
const apiConfig = await apiConfigService.get()
|
||||||
|
// process.env.ANTHROPIC_AUTH_TOKEN = apiConfig.apiKey
|
||||||
|
// process.env.ANTHROPIC_BASE_URL = `http://${apiConfig.host}:${apiConfig.port}`
|
||||||
|
process.env.ANTHROPIC_AUTH_TOKEN = modelInfo.provider.apiKey
|
||||||
|
process.env.ANTHROPIC_BASE_URL = modelInfo.provider.apiHost
|
||||||
|
|
||||||
|
// Build SDK options from parameters
|
||||||
|
const options: Options = {
|
||||||
|
abortController,
|
||||||
|
cwd,
|
||||||
|
pathToClaudeCodeExecutable: this.claudeExecutablePath,
|
||||||
|
stderr: (chunk: string) => {
|
||||||
|
logger.info('claude stderr', { chunk })
|
||||||
|
},
|
||||||
|
appendSystemPrompt: session.instructions,
|
||||||
|
permissionMode: session.configuration?.permission_mode,
|
||||||
|
maxTurns: session.configuration?.max_turns
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.accessible_paths.length > 1) {
|
||||||
|
options.additionalDirectories = session.accessible_paths.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.mcps && session.mcps.length > 0) {
|
||||||
|
// mcp configs
|
||||||
|
const mcpList: Record<string, McpHttpServerConfig> = {}
|
||||||
|
for (const mcpId of session.mcps) {
|
||||||
|
mcpList[mcpId] = {
|
||||||
|
type: 'http',
|
||||||
|
url: `http://${apiConfig.host}:${apiConfig.port}/v1/mcps/${mcpId}/mcp`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiConfig.apiKey}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.mcpServers = mcpList
|
||||||
|
options.strictMcpConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastAgentSessionId) {
|
||||||
|
options.resume = lastAgentSessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Starting Claude Code SDK query', {
|
||||||
|
prompt,
|
||||||
|
options
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start async processing
|
||||||
|
this.processSDKQuery(prompt, options, aiStream)
|
||||||
|
|
||||||
|
return aiStream
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *userMessages(prompt: string) {
|
||||||
|
{
|
||||||
|
yield {
|
||||||
|
type: 'user' as const,
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
session_id: '',
|
||||||
|
message: {
|
||||||
|
role: 'user' as const,
|
||||||
|
content: prompt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process SDK query and emit stream events
|
||||||
|
*/
|
||||||
|
private async processSDKQuery(prompt: string, options: Options, stream: ClaudeCodeStream): Promise<void> {
|
||||||
|
const jsonOutput: SDKMessage[] = []
|
||||||
|
let hasCompleted = false
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process streaming responses using SDK query
|
||||||
|
for await (const message of query({
|
||||||
|
prompt: this.userMessages(prompt),
|
||||||
|
options
|
||||||
|
})) {
|
||||||
|
if (hasCompleted) break
|
||||||
|
|
||||||
|
jsonOutput.push(message)
|
||||||
|
logger.silly('claude response', { message })
|
||||||
|
if (message.type === 'assistant' || message.type === 'user') {
|
||||||
|
logger.silly('message content', {
|
||||||
|
message: JSON.stringify({ role: message.message.role, content: message.message.content })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform SDKMessage to UIMessageChunks
|
||||||
|
const chunks = transformSDKMessageToStreamParts(message)
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
stream.emit('data', {
|
||||||
|
type: 'chunk',
|
||||||
|
chunk
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successfully completed
|
||||||
|
hasCompleted = true
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
logger.debug('SDK query completed successfully', {
|
||||||
|
duration,
|
||||||
|
messageCount: jsonOutput.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit completion event
|
||||||
|
stream.emit('data', {
|
||||||
|
type: 'complete'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (hasCompleted) return
|
||||||
|
hasCompleted = true
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
// Check if this is an abort error
|
||||||
|
const errorObj = error as any
|
||||||
|
const isAborted =
|
||||||
|
errorObj?.name === 'AbortError' ||
|
||||||
|
errorObj?.message?.includes('aborted') ||
|
||||||
|
options.abortController?.signal.aborted
|
||||||
|
|
||||||
|
if (isAborted) {
|
||||||
|
logger.info('SDK query aborted by client disconnect', { duration })
|
||||||
|
// Simply cleanup and return - don't emit error events
|
||||||
|
stream.emit('data', {
|
||||||
|
type: 'cancelled',
|
||||||
|
error: new Error('Request aborted by client')
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original error handling for non-abort errors
|
||||||
|
logger.error('SDK query error:', {
|
||||||
|
error: errorObj instanceof Error ? errorObj.message : String(errorObj),
|
||||||
|
duration,
|
||||||
|
messageCount: jsonOutput.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit error event
|
||||||
|
stream.emit('data', {
|
||||||
|
type: 'error',
|
||||||
|
error: errorObj instanceof Error ? errorObj : new Error(String(errorObj))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClaudeCodeService
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// ported from https://github.com/ben-vargas/ai-sdk-provider-claude-code/blob/main/src/map-claude-code-finish-reason.ts#L22
|
||||||
|
import type { LanguageModelV2FinishReason } from '@ai-sdk/provider'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Claude Code SDK result subtypes to AI SDK finish reasons.
|
||||||
|
*
|
||||||
|
* @param subtype - The result subtype from Claude Code SDK
|
||||||
|
* @returns The corresponding AI SDK finish reason
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const finishReason = mapClaudeCodeFinishReason('error_max_turns');
|
||||||
|
* // Returns: 'length'
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* Mappings:
|
||||||
|
* - 'success' -> 'stop' (normal completion)
|
||||||
|
* - 'error_max_turns' -> 'length' (hit turn limit)
|
||||||
|
* - 'error_during_execution' -> 'error' (execution error)
|
||||||
|
* - default -> 'stop' (unknown subtypes treated as normal completion)
|
||||||
|
*/
|
||||||
|
export function mapClaudeCodeFinishReason(subtype?: string): LanguageModelV2FinishReason {
|
||||||
|
switch (subtype) {
|
||||||
|
case 'success':
|
||||||
|
return 'stop'
|
||||||
|
case 'error_max_turns':
|
||||||
|
return 'length'
|
||||||
|
case 'error_during_execution':
|
||||||
|
return 'error'
|
||||||
|
default:
|
||||||
|
return 'stop'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Tool } from '@types'
|
||||||
|
|
||||||
|
// https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude
|
||||||
|
export const builtinTools: Tool[] = [
|
||||||
|
{ id: 'Bash', name: 'Bash', description: 'Executes shell commands in your environment', requirePermissions: true },
|
||||||
|
{ id: 'Edit', name: 'Edit', description: 'Makes targeted edits to specific files', requirePermissions: true },
|
||||||
|
{ id: 'Glob', name: 'Glob', description: 'Finds files based on pattern matching', requirePermissions: false },
|
||||||
|
{ id: 'Grep', name: 'Grep', description: 'Searches for patterns in file contents', requirePermissions: false },
|
||||||
|
{
|
||||||
|
id: 'MultiEdit',
|
||||||
|
name: 'MultiEdit',
|
||||||
|
description: 'Performs multiple edits on a single file atomically',
|
||||||
|
requirePermissions: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'NotebookEdit',
|
||||||
|
name: 'NotebookEdit',
|
||||||
|
description: 'Modifies Jupyter notebook cells',
|
||||||
|
requirePermissions: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'NotebookRead',
|
||||||
|
name: 'NotebookRead',
|
||||||
|
description: 'Reads and displays Jupyter notebook contents',
|
||||||
|
requirePermissions: false
|
||||||
|
},
|
||||||
|
{ id: 'Read', name: 'Read', description: 'Reads the contents of files', requirePermissions: false },
|
||||||
|
{
|
||||||
|
id: 'Task',
|
||||||
|
name: 'Task',
|
||||||
|
description: 'Runs a sub-agent to handle complex, multi-step tasks',
|
||||||
|
requirePermissions: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'TodoWrite',
|
||||||
|
name: 'TodoWrite',
|
||||||
|
description: 'Creates and manages structured task lists',
|
||||||
|
requirePermissions: false
|
||||||
|
},
|
||||||
|
{ id: 'WebFetch', name: 'WebFetch', description: 'Fetches content from a specified URL', requirePermissions: true },
|
||||||
|
{
|
||||||
|
id: 'WebSearch',
|
||||||
|
name: 'WebSearch',
|
||||||
|
description: 'Performs web searches with domain filtering',
|
||||||
|
requirePermissions: true
|
||||||
|
},
|
||||||
|
{ id: 'Write', name: 'Write', description: 'Creates or overwrites files', requirePermissions: true }
|
||||||
|
]
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// This file is used to transform claude code json response to aisdk streaming format
|
||||||
|
|
||||||
|
import type { LanguageModelV2Usage } from '@ai-sdk/provider'
|
||||||
|
import { SDKMessage } from '@anthropic-ai/claude-code'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import type { ProviderMetadata, TextStreamPart } from 'ai'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
import { mapClaudeCodeFinishReason } from './map-claude-code-finish-reason'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ClaudeCodeTransform')
|
||||||
|
|
||||||
|
type AgentStreamPart = TextStreamPart<Record<string, any>>
|
||||||
|
|
||||||
|
const contentBlockState = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
type: 'text' | 'tool-call'
|
||||||
|
toolCallId?: string
|
||||||
|
toolName?: string
|
||||||
|
input?: string
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
// Helper function to generate unique IDs for text blocks
|
||||||
|
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
||||||
|
|
||||||
|
// Main transform function
|
||||||
|
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage): AgentStreamPart[] {
|
||||||
|
const chunks: AgentStreamPart[] = []
|
||||||
|
logger.debug('Transforming SDKMessage to stream parts', sdkMessage)
|
||||||
|
switch (sdkMessage.type) {
|
||||||
|
case 'assistant':
|
||||||
|
case 'user':
|
||||||
|
chunks.push(...handleUserOrAssistantMessage(sdkMessage))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stream_event':
|
||||||
|
chunks.push(...handleStreamEvent(sdkMessage))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'system':
|
||||||
|
chunks.push(...handleSystemMessage(sdkMessage))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'result':
|
||||||
|
chunks.push(...handleResultMessage(sdkMessage))
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn('Unknown SDKMessage type:', { type: (sdkMessage as any).type })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => {
|
||||||
|
return {
|
||||||
|
anthropic: {
|
||||||
|
uuid: message.uuid || generateMessageId(),
|
||||||
|
session_id: message.session_id
|
||||||
|
},
|
||||||
|
raw: message as Record<string, any>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTextChunks(id: string, text: string, message: SDKMessage): AgentStreamPart[] {
|
||||||
|
const providerMetadata = sdkMessageToProviderMetadata(message)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'text-start',
|
||||||
|
id,
|
||||||
|
providerMetadata
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text-delta',
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
providerMetadata
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text-end',
|
||||||
|
id,
|
||||||
|
providerMetadata: {
|
||||||
|
...providerMetadata,
|
||||||
|
text: {
|
||||||
|
value: text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assistant' | 'user' }>): AgentStreamPart[] {
|
||||||
|
const chunks: AgentStreamPart[] = []
|
||||||
|
const messageId = message.uuid?.toString() || generateMessageId()
|
||||||
|
|
||||||
|
// handle normal text content
|
||||||
|
if (typeof message.message.content === 'string') {
|
||||||
|
const textContent = message.message.content
|
||||||
|
if (textContent) {
|
||||||
|
chunks.push(...generateTextChunks(messageId, textContent, message))
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(message.message.content)) {
|
||||||
|
for (const block of message.message.content) {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'text':
|
||||||
|
chunks.push(...generateTextChunks(messageId, block.text, message))
|
||||||
|
break
|
||||||
|
case 'tool_use':
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: block.id,
|
||||||
|
toolName: block.name,
|
||||||
|
input: block.input,
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: sdkMessageToProviderMetadata(message)
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'tool_result':
|
||||||
|
// chunks.push({
|
||||||
|
// type: 'tool-result',
|
||||||
|
// toolCallId: block.tool_use_id,
|
||||||
|
// output: block.content,
|
||||||
|
// providerMetadata: sdkMessageToProviderMetadata(message)
|
||||||
|
// })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.warn('Unknown content block type in user/assistant message:', {
|
||||||
|
type: block.type
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle stream events (real-time streaming)
|
||||||
|
function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }>): AgentStreamPart[] {
|
||||||
|
const chunks: AgentStreamPart[] = []
|
||||||
|
const event = message.event
|
||||||
|
const blockKey = `${message.uuid ?? message.session_id ?? 'session'}:${event.index}`
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'message_start':
|
||||||
|
// No specific UI chunk needed for message start in this protocol
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'content_block_start':
|
||||||
|
const contentBlockType = event.content_block.type
|
||||||
|
switch (contentBlockType) {
|
||||||
|
case 'text': {
|
||||||
|
contentBlockState.set(blockKey, { type: 'text' })
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-start',
|
||||||
|
id: String(event.index),
|
||||||
|
providerMetadata: {
|
||||||
|
...sdkMessageToProviderMetadata(message),
|
||||||
|
anthropic: {
|
||||||
|
uuid: message.uuid,
|
||||||
|
session_id: message.session_id,
|
||||||
|
content_block_index: event.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'tool_use': {
|
||||||
|
contentBlockState.set(blockKey, {
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: event.content_block.id,
|
||||||
|
toolName: event.content_block.name,
|
||||||
|
input: ''
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: event.content_block.id,
|
||||||
|
toolName: event.content_block.name,
|
||||||
|
input: event.content_block.input,
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: sdkMessageToProviderMetadata(message)
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'content_block_delta':
|
||||||
|
switch (event.delta.type) {
|
||||||
|
case 'text_delta': {
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-delta',
|
||||||
|
id: String(event.index),
|
||||||
|
text: event.delta.text,
|
||||||
|
providerMetadata: {
|
||||||
|
...sdkMessageToProviderMetadata(message),
|
||||||
|
anthropic: {
|
||||||
|
uuid: message.uuid,
|
||||||
|
session_id: message.session_id,
|
||||||
|
content_block_index: event.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// case 'thinking_delta': {
|
||||||
|
// chunks.push({
|
||||||
|
// type: 'reasoning-delta',
|
||||||
|
// id: String(event.index),
|
||||||
|
// text: event.delta.thinking,
|
||||||
|
// });
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
// case 'signature_delta': {
|
||||||
|
// if (blockType === 'thinking') {
|
||||||
|
// chunks.push({
|
||||||
|
// type: 'reasoning-delta',
|
||||||
|
// id: String(event.index),
|
||||||
|
// text: '',
|
||||||
|
// providerMetadata: {
|
||||||
|
// ...sdkMessageToProviderMetadata(message),
|
||||||
|
// anthropic: {
|
||||||
|
// uuid: message.uuid,
|
||||||
|
// session_id: message.session_id,
|
||||||
|
// content_block_index: event.index,
|
||||||
|
// signature: event.delta.signature
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
case 'input_json_delta': {
|
||||||
|
const contentBlock = contentBlockState.get(blockKey)
|
||||||
|
if (contentBlock && contentBlock.type === 'tool-call') {
|
||||||
|
contentBlockState.set(blockKey, {
|
||||||
|
...contentBlock,
|
||||||
|
input: `${contentBlock.input ?? ''}${event.delta.partial_json ?? ''}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'content_block_stop': {
|
||||||
|
const contentBlock = contentBlockState.get(blockKey)
|
||||||
|
if (contentBlock?.type === 'text') {
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-end',
|
||||||
|
id: String(event.index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
contentBlockState.delete(blockKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'message_delta':
|
||||||
|
// Handle usage updates or other message-level deltas
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'message_stop':
|
||||||
|
// This could signal the end of the message
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn('Unknown stream event type:', { type: (event as any).type })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle system messages
|
||||||
|
function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>): AgentStreamPart[] {
|
||||||
|
const chunks: AgentStreamPart[] = []
|
||||||
|
logger.debug('Received system message', {
|
||||||
|
subtype: message.subtype
|
||||||
|
})
|
||||||
|
switch (message.subtype) {
|
||||||
|
case 'init': {
|
||||||
|
chunks.push({
|
||||||
|
type: 'start'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle result messages (completion with usage stats)
|
||||||
|
function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>): AgentStreamPart[] {
|
||||||
|
const chunks: AgentStreamPart[] = []
|
||||||
|
|
||||||
|
let usage: LanguageModelV2Usage | undefined
|
||||||
|
if ('usage' in message) {
|
||||||
|
usage = {
|
||||||
|
inputTokens:
|
||||||
|
(message.usage.cache_creation_input_tokens ?? 0) +
|
||||||
|
(message.usage.cache_read_input_tokens ?? 0) +
|
||||||
|
(message.usage.input_tokens ?? 0),
|
||||||
|
outputTokens: message.usage.output_tokens ?? 0,
|
||||||
|
totalTokens:
|
||||||
|
(message.usage.cache_creation_input_tokens ?? 0) +
|
||||||
|
(message.usage.cache_read_input_tokens ?? 0) +
|
||||||
|
(message.usage.input_tokens ?? 0) +
|
||||||
|
(message.usage.output_tokens ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message.subtype === 'success') {
|
||||||
|
chunks.push({
|
||||||
|
type: 'finish',
|
||||||
|
totalUsage: usage,
|
||||||
|
finishReason: mapClaudeCodeFinishReason(message.subtype),
|
||||||
|
providerMetadata: {
|
||||||
|
...sdkMessageToProviderMetadata(message),
|
||||||
|
usage: message.usage,
|
||||||
|
durationMs: message.duration_ms,
|
||||||
|
costUsd: message.total_cost_usd,
|
||||||
|
raw: message
|
||||||
|
}
|
||||||
|
} as AgentStreamPart)
|
||||||
|
} else {
|
||||||
|
chunks.push({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
message: `${message.subtype}: Process failed after ${message.num_turns} turns`
|
||||||
|
}
|
||||||
|
} as AgentStreamPart)
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience function to transform a stream of SDKMessages
|
||||||
|
export function* transformSDKMessageStream(sdkMessages: SDKMessage[]): Generator<AgentStreamPart> {
|
||||||
|
for (const sdkMessage of sdkMessages) {
|
||||||
|
const chunks = transformSDKMessageToStreamParts(sdkMessage)
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
yield chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async version for async iterables
|
||||||
|
export async function* transformSDKMessageStreamAsync(
|
||||||
|
sdkMessages: AsyncIterable<SDKMessage>
|
||||||
|
): AsyncGenerator<AgentStreamPart> {
|
||||||
|
for await (const sdkMessage of sdkMessages) {
|
||||||
|
const chunks = transformSDKMessageToStreamParts(sdkMessage)
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
yield chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Agent Services Module
|
||||||
|
*
|
||||||
|
* This module provides service classes for managing agents, sessions, and session messages.
|
||||||
|
* All services extend BaseService and provide database operations with proper error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Service classes
|
||||||
|
export { AgentService } from './AgentService'
|
||||||
|
export { SessionMessageService } from './SessionMessageService'
|
||||||
|
export { SessionService } from './SessionService'
|
||||||
|
|
||||||
|
// Service instances (singletons)
|
||||||
|
export { agentService } from './AgentService'
|
||||||
|
export { sessionMessageService } from './SessionMessageService'
|
||||||
|
export { sessionService } from './SessionService'
|
||||||
|
|
||||||
|
// Type definitions for service requests and responses
|
||||||
|
export type { AgentEntity, AgentSessionEntity, CreateAgentRequest, UpdateAgentRequest } from '@types'
|
||||||
|
export type {
|
||||||
|
AgentSessionMessageEntity,
|
||||||
|
CreateSessionRequest,
|
||||||
|
GetAgentSessionResponse,
|
||||||
|
ListOptions as SessionListOptions,
|
||||||
|
UpdateSessionRequest
|
||||||
|
} from '@types'
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
|
|||||||
import TabsContainer from './components/Tab/TabContainer'
|
import TabsContainer from './components/Tab/TabContainer'
|
||||||
import NavigationHandler from './handler/NavigationHandler'
|
import NavigationHandler from './handler/NavigationHandler'
|
||||||
import { useNavbarPosition } from './hooks/useSettings'
|
import { useNavbarPosition } from './hooks/useSettings'
|
||||||
import AgentsPage from './pages/agents/AgentsPage'
|
import AssistantPresetsPage from './pages/assistantPresets/AssistantPresetsPage'
|
||||||
import CodeToolsPage from './pages/code/CodeToolsPage'
|
import CodeToolsPage from './pages/code/CodeToolsPage'
|
||||||
import FilesPage from './pages/files/FilesPage'
|
import FilesPage from './pages/files/FilesPage'
|
||||||
import HomePage from './pages/home/HomePage'
|
import HomePage from './pages/home/HomePage'
|
||||||
@@ -29,7 +29,7 @@ const Router: FC = () => {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/agents" element={<AgentsPage />} />
|
<Route path="/assistantPresets" element={<AssistantPresetsPage />} />
|
||||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||||
<Route path="/translate" element={<TranslatePage />} />
|
<Route path="/translate" element={<TranslatePage />} />
|
||||||
<Route path="/files" element={<FilesPage />} />
|
<Route path="/files" element={<FilesPage />} />
|
||||||
|
|||||||
@@ -32,16 +32,19 @@ export class AiSdkToChunkAdapter {
|
|||||||
private accumulate: boolean | undefined
|
private accumulate: boolean | undefined
|
||||||
private isFirstChunk = true
|
private isFirstChunk = true
|
||||||
private enableWebSearch: boolean = false
|
private enableWebSearch: boolean = false
|
||||||
|
private onSessionUpdate?: (sessionId: string) => void
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private onChunk: (chunk: Chunk) => void,
|
private onChunk: (chunk: Chunk) => void,
|
||||||
mcpTools: MCPTool[] = [],
|
mcpTools: MCPTool[] = [],
|
||||||
accumulate?: boolean,
|
accumulate?: boolean,
|
||||||
enableWebSearch?: boolean
|
enableWebSearch?: boolean,
|
||||||
|
onSessionUpdate?: (sessionId: string) => void
|
||||||
) {
|
) {
|
||||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||||
this.accumulate = accumulate
|
this.accumulate = accumulate
|
||||||
this.enableWebSearch = enableWebSearch || false
|
this.enableWebSearch = enableWebSearch || false
|
||||||
|
this.onSessionUpdate = onSessionUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +111,15 @@ export class AiSdkToChunkAdapter {
|
|||||||
chunk: TextStreamPart<any>,
|
chunk: TextStreamPart<any>,
|
||||||
final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string }
|
final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string }
|
||||||
) {
|
) {
|
||||||
|
const sessionId =
|
||||||
|
(chunk.providerMetadata as any)?.anthropic?.session_id ??
|
||||||
|
(chunk.providerMetadata as any)?.anthropic?.sessionId ??
|
||||||
|
(chunk.providerMetadata as any)?.raw?.session_id
|
||||||
|
|
||||||
|
if (typeof sessionId === 'string' && sessionId) {
|
||||||
|
this.onSessionUpdate?.(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk)
|
logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk)
|
||||||
switch (chunk.type) {
|
switch (chunk.type) {
|
||||||
// === 文本相关事件 ===
|
// === 文本相关事件 ===
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { formatAgentServerError } from '@renderer/utils/error'
|
||||||
|
import {
|
||||||
|
AddAgentForm,
|
||||||
|
AgentServerErrorSchema,
|
||||||
|
ApiModelsFilter,
|
||||||
|
ApiModelsResponse,
|
||||||
|
ApiModelsResponseSchema,
|
||||||
|
CreateAgentRequest,
|
||||||
|
CreateAgentResponse,
|
||||||
|
CreateAgentResponseSchema,
|
||||||
|
CreateSessionForm,
|
||||||
|
CreateSessionRequest,
|
||||||
|
CreateSessionResponse,
|
||||||
|
CreateSessionResponseSchema,
|
||||||
|
GetAgentResponse,
|
||||||
|
GetAgentResponseSchema,
|
||||||
|
GetAgentSessionResponse,
|
||||||
|
ListAgentSessionsResponse,
|
||||||
|
ListAgentSessionsResponseSchema,
|
||||||
|
type ListAgentsResponse,
|
||||||
|
ListAgentsResponseSchema,
|
||||||
|
objectEntries,
|
||||||
|
objectKeys,
|
||||||
|
UpdateAgentForm,
|
||||||
|
UpdateAgentRequest,
|
||||||
|
UpdateAgentResponse,
|
||||||
|
UpdateAgentResponseSchema,
|
||||||
|
UpdateSessionForm,
|
||||||
|
UpdateSessionRequest,
|
||||||
|
UpdateSessionResponse,
|
||||||
|
UpdateSessionResponseSchema
|
||||||
|
} from '@types'
|
||||||
|
import axios, { Axios, AxiosRequestConfig, isAxiosError } from 'axios'
|
||||||
|
import { ZodError } from 'zod'
|
||||||
|
|
||||||
|
type ApiVersion = 'v1'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('AgentApiClient')
|
||||||
|
|
||||||
|
// const logger = loggerService.withContext('AgentClient')
|
||||||
|
const processError = (error: unknown, fallbackMessage: string) => {
|
||||||
|
logger.error(fallbackMessage, error as Error)
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
const result = AgentServerErrorSchema.safeParse(error.response?.data)
|
||||||
|
if (result.success) {
|
||||||
|
return new Error(formatAgentServerError(result.data))
|
||||||
|
}
|
||||||
|
} else if (error instanceof ZodError) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return new Error(fallbackMessage, { cause: error })
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AgentApiClient {
|
||||||
|
private axios: Axios
|
||||||
|
private apiVersion: ApiVersion = 'v1'
|
||||||
|
constructor(config: AxiosRequestConfig, apiVersion?: ApiVersion) {
|
||||||
|
if (!config.baseURL || !config.headers?.Authorization) {
|
||||||
|
throw new Error('Please pass in baseUrl and Authroization header.')
|
||||||
|
}
|
||||||
|
if (config.baseURL.endsWith('/')) {
|
||||||
|
throw new Error('baseURL should not end with /')
|
||||||
|
}
|
||||||
|
this.axios = axios.create(config)
|
||||||
|
if (apiVersion) {
|
||||||
|
this.apiVersion = apiVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public agentPaths = {
|
||||||
|
base: `/${this.apiVersion}/agents`,
|
||||||
|
withId: (id: string) => `/${this.apiVersion}/agents/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSessionPaths = (agentId: string) => ({
|
||||||
|
base: `/${this.apiVersion}/agents/${agentId}/sessions`,
|
||||||
|
withId: (id: string) => `/${this.apiVersion}/agents/${agentId}/sessions/${id}`
|
||||||
|
})
|
||||||
|
|
||||||
|
public getSessionMessagesPath = (agentId: string, sessionId: string) =>
|
||||||
|
`/${this.apiVersion}/agents/${agentId}/sessions/${sessionId}/messages`
|
||||||
|
|
||||||
|
public getModelsPath = (props?: ApiModelsFilter) => {
|
||||||
|
const base = `/${this.apiVersion}/models`
|
||||||
|
if (!props) return base
|
||||||
|
if (objectKeys(props).length > 0) {
|
||||||
|
const params = objectEntries(props)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('&')
|
||||||
|
return `${base}?${params}`
|
||||||
|
} else {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listAgents(): Promise<ListAgentsResponse> {
|
||||||
|
const url = this.agentPaths.base
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(url)
|
||||||
|
const result = ListAgentsResponseSchema.safeParse(response.data)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Not a valid Agents array.')
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to list agents.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAgent(form: AddAgentForm): Promise<CreateAgentResponse> {
|
||||||
|
const url = this.agentPaths.base
|
||||||
|
try {
|
||||||
|
const payload = form satisfies CreateAgentRequest
|
||||||
|
const response = await this.axios.post(url, payload)
|
||||||
|
const data = CreateAgentResponseSchema.parse(response.data)
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to create agent.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAgent(id: string): Promise<GetAgentResponse> {
|
||||||
|
const url = this.agentPaths.withId(id)
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(url)
|
||||||
|
const data = GetAgentResponseSchema.parse(response.data)
|
||||||
|
if (data.id !== id) {
|
||||||
|
throw new Error('Agent ID mismatch in response')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to get agent.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAgent(id: string): Promise<void> {
|
||||||
|
const url = this.agentPaths.withId(id)
|
||||||
|
try {
|
||||||
|
await this.axios.delete(url)
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to delete agent.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateAgent(form: UpdateAgentForm): Promise<UpdateAgentResponse> {
|
||||||
|
const url = this.agentPaths.withId(form.id)
|
||||||
|
try {
|
||||||
|
const payload = form satisfies UpdateAgentRequest
|
||||||
|
const response = await this.axios.patch(url, payload)
|
||||||
|
const data = UpdateAgentResponseSchema.parse(response.data)
|
||||||
|
if (data.id !== form.id) {
|
||||||
|
throw new Error('Agent ID mismatch in response')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to updateAgent.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listSessions(agentId: string): Promise<ListAgentSessionsResponse> {
|
||||||
|
const url = this.getSessionPaths(agentId).base
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(url)
|
||||||
|
const result = ListAgentSessionsResponseSchema.safeParse(response.data)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Not a valid Sessions array.')
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to list sessions.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSession(agentId: string, session: CreateSessionForm): Promise<CreateSessionResponse> {
|
||||||
|
const url = this.getSessionPaths(agentId).base
|
||||||
|
try {
|
||||||
|
const payload = session satisfies CreateSessionRequest
|
||||||
|
const response = await this.axios.post(url, payload)
|
||||||
|
const data = CreateSessionResponseSchema.parse(response.data)
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to add session.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSession(agentId: string, sessionId: string): Promise<GetAgentSessionResponse> {
|
||||||
|
const url = this.getSessionPaths(agentId).withId(sessionId)
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(url)
|
||||||
|
// const data = GetAgentSessionResponseSchema.parse(response.data)
|
||||||
|
// TODO: enable validation
|
||||||
|
const data = response.data
|
||||||
|
if (sessionId !== data.id) {
|
||||||
|
throw new Error('Session ID mismatch in response')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to get session.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteSession(agentId: string, sessionId: string): Promise<void> {
|
||||||
|
const url = this.getSessionPaths(agentId).withId(sessionId)
|
||||||
|
try {
|
||||||
|
await this.axios.delete(url)
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to delete session.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateSession(agentId: string, session: UpdateSessionForm): Promise<UpdateSessionResponse> {
|
||||||
|
const url = this.getSessionPaths(agentId).withId(session.id)
|
||||||
|
try {
|
||||||
|
const payload = session satisfies UpdateSessionRequest
|
||||||
|
const response = await this.axios.patch(url, payload)
|
||||||
|
const data = UpdateSessionResponseSchema.parse(response.data)
|
||||||
|
if (session.id !== data.id) {
|
||||||
|
throw new Error('Session ID mismatch in response')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to update session.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getModels(props?: ApiModelsFilter): Promise<ApiModelsResponse> {
|
||||||
|
const url = this.getModelsPath(props)
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(url)
|
||||||
|
const data = ApiModelsResponseSchema.parse(response.data)
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
throw processError(error, 'Failed to get models.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
*:focus {
|
*:focus {
|
||||||
outline: none;
|
outline-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface ContextMenuProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: Why does this component name look like a generic component but is not customizable at all?
|
||||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {
|
const ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
|
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import { useAgents } from '@renderer/hooks/useAgents'
|
|
||||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||||
|
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import { useSystemAgents } from '@renderer/pages/agents'
|
import { useSystemAssistantPresets } from '@renderer/pages/assistantPresets'
|
||||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import { Agent, Assistant } from '@renderer/types'
|
import { Assistant, AssistantPreset } from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
||||||
import { take } from 'lodash'
|
import { take } from 'lodash'
|
||||||
@@ -25,30 +25,30 @@ interface Props {
|
|||||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { agents: userAgents } = useAgents()
|
const { presets: userPresets } = useAssistantPresets()
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const { defaultAssistant } = useDefaultAssistant()
|
const { defaultAssistant } = useDefaultAssistant()
|
||||||
const { assistants, addAssistant } = useAssistants()
|
const { assistants, addAssistant } = useAssistants()
|
||||||
const inputRef = useRef<InputRef>(null)
|
const inputRef = useRef<InputRef>(null)
|
||||||
const systemAgents = useSystemAgents()
|
const systemPresets = useSystemAssistantPresets()
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
const agents = useMemo(() => {
|
const presets = useMemo(() => {
|
||||||
const allAgents = [...userAgents, ...systemAgents] as Agent[]
|
const allPresets = [...userPresets, ...systemPresets] as AssistantPreset[]
|
||||||
const list = [defaultAssistant, ...allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))]
|
const list = [defaultAssistant, ...allPresets.filter((preset) => !assistants.map((a) => a.id).includes(preset.id))]
|
||||||
const filtered = searchText
|
const filtered = searchText
|
||||||
? list.filter(
|
? list.filter(
|
||||||
(agent) =>
|
(preset) =>
|
||||||
agent.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
|
preset.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
|
||||||
agent.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
|
preset.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
|
||||||
)
|
)
|
||||||
: list
|
: list
|
||||||
|
|
||||||
if (searchText.trim()) {
|
if (searchText.trim()) {
|
||||||
const newAgent: Agent = {
|
const newAgent: AssistantPreset = {
|
||||||
id: 'new',
|
id: 'new',
|
||||||
name: searchText.trim(),
|
name: searchText.trim(),
|
||||||
prompt: '',
|
prompt: '',
|
||||||
@@ -59,15 +59,15 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
return [newAgent, ...filtered]
|
return [newAgent, ...filtered]
|
||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
|
}, [assistants, defaultAssistant, searchText, systemPresets, userPresets])
|
||||||
|
|
||||||
// 重置选中索引当搜索或列表内容变更时
|
// 重置选中索引当搜索或列表内容变更时
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
}, [agents.length, searchText])
|
}, [presets.length, searchText])
|
||||||
|
|
||||||
const onCreateAssistant = useCallback(
|
const onCreateAssistant = useCallback(
|
||||||
async (agent: Agent) => {
|
async (preset: AssistantPreset) => {
|
||||||
if (loadingRef.current) {
|
if (loadingRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -75,11 +75,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
loadingRef.current = true
|
loadingRef.current = true
|
||||||
let assistant: Assistant
|
let assistant: Assistant
|
||||||
|
|
||||||
if (agent.id === 'default') {
|
if (preset.id === 'default') {
|
||||||
assistant = { ...agent, id: uuid() }
|
assistant = { ...preset, id: uuid() }
|
||||||
addAssistant(assistant)
|
addAssistant(assistant)
|
||||||
} else {
|
} else {
|
||||||
assistant = await createAssistantFromAgent(agent)
|
assistant = await createAssistantFromAgent(preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||||
@@ -93,28 +93,28 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const displayedAgents = take(agents, 100)
|
const displayedPresets = take(presets, 100)
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
|
setSelectedIndex((prev) => (prev >= displayedPresets.length - 1 ? 0 : prev + 1))
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
|
setSelectedIndex((prev) => (prev <= 0 ? displayedPresets.length - 1 : prev - 1))
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
case 'NumpadEnter':
|
case 'NumpadEnter':
|
||||||
// 如果焦点在输入框且有搜索内容,则默认选择第一项
|
// 如果焦点在输入框且有搜索内容,则默认选择第一项
|
||||||
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
|
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onCreateAssistant(displayedAgents[selectedIndex])
|
onCreateAssistant(displayedPresets[selectedIndex])
|
||||||
}
|
}
|
||||||
// 否则选择当前选中项
|
// 否则选择当前选中项
|
||||||
else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
|
else if (selectedIndex >= 0 && selectedIndex < displayedPresets.length) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onCreateAssistant(displayedAgents[selectedIndex])
|
onCreateAssistant(displayedPresets[selectedIndex])
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -122,14 +122,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
|
}, [open, selectedIndex, presets, searchText, onCreateAssistant])
|
||||||
|
|
||||||
// 确保选中项在可视区域
|
// 确保选中项在可视区域
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
const agentItems = containerRef.current.querySelectorAll('.agent-item')
|
const presetItems = containerRef.current.querySelectorAll('.agent-item')
|
||||||
if (agentItems[selectedIndex]) {
|
if (presetItems[selectedIndex]) {
|
||||||
agentItems[selectedIndex].scrollIntoView({
|
presetItems[selectedIndex].scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'nearest'
|
block: 'nearest'
|
||||||
})
|
})
|
||||||
@@ -193,19 +193,19 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||||
<Container ref={containerRef}>
|
<Container ref={containerRef}>
|
||||||
{take(agents, 100).map((agent, index) => (
|
{take(presets, 100).map((preset, index) => (
|
||||||
<AgentItem
|
<AgentItem
|
||||||
key={agent.id}
|
key={preset.id}
|
||||||
onClick={() => onCreateAssistant(agent)}
|
onClick={() => onCreateAssistant(preset)}
|
||||||
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
|
className={`agent-item ${preset.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
|
||||||
onMouseEnter={() => setSelectedIndex(index)}>
|
onMouseEnter={() => setSelectedIndex(index)}>
|
||||||
<HStack alignItems="center" gap={5} style={{ overflow: 'hidden', maxWidth: '100%' }}>
|
<HStack alignItems="center" gap={5} style={{ overflow: 'hidden', maxWidth: '100%' }}>
|
||||||
<EmojiIcon emoji={agent.emoji || ''} />
|
<EmojiIcon emoji={preset.emoji || ''} />
|
||||||
<span className="text-nowrap">{agent.name}</span>
|
<span className="text-nowrap">{preset.name}</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
{preset.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
||||||
{agent.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
|
{preset.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
|
||||||
{agent.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
|
{preset.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
|
||||||
</AgentItem>
|
</AgentItem>
|
||||||
))}
|
))}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
cn,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
Select,
|
||||||
|
SelectedItemProps,
|
||||||
|
SelectItem,
|
||||||
|
Textarea,
|
||||||
|
useDisclosure
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
|
||||||
|
import { getModelLogo } from '@renderer/config/models'
|
||||||
|
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
||||||
|
import { useModels } from '@renderer/hooks/agents/useModels'
|
||||||
|
import { AddAgentForm, AgentEntity, AgentType, BaseAgentForm, isAgentType, UpdateAgentForm } from '@renderer/types'
|
||||||
|
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '../../ErrorBoundary'
|
||||||
|
import { BaseOption, ModelOption, Option, renderOption } from './shared'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('AddAgentPopup')
|
||||||
|
|
||||||
|
interface AgentTypeOption extends BaseOption {
|
||||||
|
type: 'type'
|
||||||
|
key: AgentEntity['type']
|
||||||
|
name: AgentEntity['name']
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option = AgentTypeOption | ModelOption
|
||||||
|
|
||||||
|
const buildAgentForm = (existing?: AgentEntity): BaseAgentForm => ({
|
||||||
|
type: existing?.type ?? 'claude-code',
|
||||||
|
name: existing?.name ?? 'Claude Code',
|
||||||
|
description: existing?.description,
|
||||||
|
instructions: existing?.instructions,
|
||||||
|
model: existing?.model ?? 'claude-4-sonnet',
|
||||||
|
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
interface BaseProps {
|
||||||
|
agent?: AgentEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerProps extends BaseProps {
|
||||||
|
trigger: { content: ReactNode; className?: string }
|
||||||
|
isOpen?: never
|
||||||
|
onClose?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateProps extends BaseProps {
|
||||||
|
trigger?: never
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = TriggerProps | StateProps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal component for creating or editing an agent.
|
||||||
|
*
|
||||||
|
* Either trigger or isOpen and onClose is given.
|
||||||
|
* @param agent - Optional agent entity for editing mode.
|
||||||
|
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
|
||||||
|
* @param isOpen - Optional controlled modal open state. From useDisclosure.
|
||||||
|
* @param onClose - Optional callback when modal closes. From useDisclosure.
|
||||||
|
* @returns Modal component for agent creation/editing
|
||||||
|
*/
|
||||||
|
export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => {
|
||||||
|
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const loadingRef = useRef(false)
|
||||||
|
// const { setTimeoutTimer } = useTimer()
|
||||||
|
const { addAgent, updateAgent } = useAgents()
|
||||||
|
// hard-coded. We only support anthropic for now.
|
||||||
|
const { models } = useModels({ providerType: 'anthropic' })
|
||||||
|
const isEditing = (agent?: AgentEntity) => agent !== undefined
|
||||||
|
|
||||||
|
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setForm(buildAgentForm(agent))
|
||||||
|
}
|
||||||
|
}, [agent, isOpen])
|
||||||
|
|
||||||
|
// add supported agents type here.
|
||||||
|
const agentConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'type',
|
||||||
|
key: 'claude-code',
|
||||||
|
label: 'Claude Code',
|
||||||
|
name: 'Claude Code',
|
||||||
|
avatar: ClaudeIcon
|
||||||
|
}
|
||||||
|
] as const satisfies AgentTypeOption[],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const agentOptions: AgentTypeOption[] = useMemo(
|
||||||
|
() =>
|
||||||
|
agentConfig.map(
|
||||||
|
(option) =>
|
||||||
|
({
|
||||||
|
...option,
|
||||||
|
rendered: <Option option={option} />
|
||||||
|
}) as const satisfies SelectedItemProps
|
||||||
|
),
|
||||||
|
[agentConfig]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onAgentTypeChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const prevConfig = agentConfig.find((config) => config.key === form.type)
|
||||||
|
let newName: string | undefined = form.name
|
||||||
|
if (prevConfig && prevConfig.name === form.name) {
|
||||||
|
const newConfig = agentConfig.find((config) => config.key === e.target.value)
|
||||||
|
if (newConfig) {
|
||||||
|
newName = newConfig.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: e.target.value as AgentType,
|
||||||
|
name: newName
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[agentConfig, form.name, form.type]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onNameChange = useCallback((name: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDescChange = useCallback((description: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onInstChange = useCallback((instructions: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
instructions
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addAccessiblePath = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const selected = await window.api.file.selectFolder()
|
||||||
|
if (!selected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setForm((prev) => {
|
||||||
|
if (prev.accessible_paths.includes(selected)) {
|
||||||
|
window.toast.warning(t('agent.session.accessible_paths.duplicate'))
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
accessible_paths: [...prev.accessible_paths, selected]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to select accessible path:', error as Error)
|
||||||
|
window.toast.error(t('agent.session.accessible_paths.select_failed'))
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const removeAccessiblePath = useCallback((path: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
accessible_paths: prev.accessible_paths.filter((item) => item !== path)
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const modelOptions = useMemo(() => {
|
||||||
|
// mocked data. not final version
|
||||||
|
return (models ?? []).map((model) => ({
|
||||||
|
type: 'model',
|
||||||
|
key: model.id,
|
||||||
|
label: model.name,
|
||||||
|
avatar: getModelLogo(model.id),
|
||||||
|
providerId: model.provider,
|
||||||
|
providerName: model.provider_name
|
||||||
|
})) satisfies ModelOption[]
|
||||||
|
}, [models])
|
||||||
|
|
||||||
|
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
model: e.target.value
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (loadingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.current = true
|
||||||
|
|
||||||
|
// Additional validation check besides native HTML validation to ensure security
|
||||||
|
if (!isAgentType(form.type)) {
|
||||||
|
window.toast.error(t('agent.add.error.invalid_agent'))
|
||||||
|
loadingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.model) {
|
||||||
|
window.toast.error(t('error.model.not_exists'))
|
||||||
|
loadingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.accessible_paths.length === 0) {
|
||||||
|
window.toast.error(t('agent.session.accessible_paths.required'))
|
||||||
|
loadingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing(agent)) {
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error('Agent is required for editing mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
id: agent.id,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
instructions: form.instructions,
|
||||||
|
model: form.model,
|
||||||
|
accessible_paths: [...form.accessible_paths]
|
||||||
|
} satisfies UpdateAgentForm
|
||||||
|
|
||||||
|
updateAgent(updatePayload)
|
||||||
|
logger.debug('Updated agent', updatePayload)
|
||||||
|
} else {
|
||||||
|
const newAgent = {
|
||||||
|
type: form.type,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
instructions: form.instructions,
|
||||||
|
model: form.model,
|
||||||
|
accessible_paths: [...form.accessible_paths]
|
||||||
|
} satisfies AddAgentForm
|
||||||
|
addAgent(newAgent)
|
||||||
|
logger.debug('Added agent', newAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.current = false
|
||||||
|
|
||||||
|
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
[
|
||||||
|
form.type,
|
||||||
|
form.model,
|
||||||
|
form.name,
|
||||||
|
form.description,
|
||||||
|
form.instructions,
|
||||||
|
form.accessible_paths,
|
||||||
|
agent,
|
||||||
|
onClose,
|
||||||
|
t,
|
||||||
|
updateAgent,
|
||||||
|
addAgent
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
|
||||||
|
encapsulated component. This is because the Modal component needs to bind the onOpen
|
||||||
|
event handler to the Button for proper focus management.
|
||||||
|
|
||||||
|
Or just use external isOpen/onOpen/onClose to control modal state.
|
||||||
|
*/}
|
||||||
|
|
||||||
|
{trigger && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpen()
|
||||||
|
}}
|
||||||
|
className={cn('w-full', trigger.className)}>
|
||||||
|
{trigger.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>{isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}</ModalHeader>
|
||||||
|
<Form onSubmit={onSubmit} className="w-full">
|
||||||
|
<ModalBody className="w-full">
|
||||||
|
<Select
|
||||||
|
isRequired
|
||||||
|
isDisabled={isEditing(agent)}
|
||||||
|
selectionMode="single"
|
||||||
|
selectedKeys={[form.type]}
|
||||||
|
onChange={onAgentTypeChange}
|
||||||
|
items={agentOptions}
|
||||||
|
label={t('agent.add.type.label')}
|
||||||
|
placeholder={t('agent.add.type.placeholder')}
|
||||||
|
renderValue={renderOption}>
|
||||||
|
{(option) => (
|
||||||
|
<SelectItem key={option.key} textValue={option.label}>
|
||||||
|
<Option option={option} />
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
|
||||||
|
{/* FIXME: Model type definition is string. It cannot be related to provider. Just mock a model now. */}
|
||||||
|
<Select
|
||||||
|
isRequired
|
||||||
|
selectionMode="single"
|
||||||
|
selectedKeys={form.model ? [form.model] : []}
|
||||||
|
onChange={onModelChange}
|
||||||
|
items={modelOptions}
|
||||||
|
label={t('common.model')}
|
||||||
|
placeholder={t('common.placeholders.select.model')}
|
||||||
|
renderValue={renderOption}>
|
||||||
|
{(option) => (
|
||||||
|
<SelectItem key={option.key} textValue={option.label}>
|
||||||
|
<Option option={option} />
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
label={t('common.description')}
|
||||||
|
value={form.description ?? ''}
|
||||||
|
onValueChange={onDescChange}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{t('agent.session.accessible_paths.label')}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="flat" onPress={addAccessiblePath}>
|
||||||
|
{t('agent.session.accessible_paths.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.accessible_paths.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.accessible_paths.map((path) => (
|
||||||
|
<div
|
||||||
|
key={path}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
|
||||||
|
<span className="truncate text-sm" title={path}>
|
||||||
|
{path}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-foreground-400">{t('agent.session.accessible_paths.empty')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter className="w-full">
|
||||||
|
<Button onPress={onClose}>{t('common.close')}</Button>
|
||||||
|
<Button color="primary" type="submit" isLoading={loadingRef.current}>
|
||||||
|
{isEditing(agent) ? t('common.confirm') : t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
cn,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
Select,
|
||||||
|
SelectedItemProps,
|
||||||
|
SelectedItems,
|
||||||
|
SelectItem,
|
||||||
|
Textarea,
|
||||||
|
useDisclosure
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { getModelLogo } from '@renderer/config/models'
|
||||||
|
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||||
|
import { useModels } from '@renderer/hooks/agents/useModels'
|
||||||
|
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||||
|
import { AgentEntity, AgentSessionEntity, BaseSessionForm, CreateSessionForm, UpdateSessionForm } from '@renderer/types'
|
||||||
|
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '../../ErrorBoundary'
|
||||||
|
import { BaseOption, ModelOption, Option } from './shared'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('SessionAgentPopup')
|
||||||
|
|
||||||
|
type Option = ModelOption
|
||||||
|
|
||||||
|
const buildSessionForm = (existing?: AgentSessionEntity, agent?: AgentEntity): BaseSessionForm => ({
|
||||||
|
name: existing?.name ?? agent?.name ?? 'Claude Code',
|
||||||
|
description: existing?.description ?? agent?.description,
|
||||||
|
instructions: existing?.instructions ?? agent?.instructions,
|
||||||
|
model: existing?.model ?? agent?.model ?? '',
|
||||||
|
accessible_paths: existing?.accessible_paths
|
||||||
|
? [...existing.accessible_paths]
|
||||||
|
: agent?.accessible_paths
|
||||||
|
? [...agent.accessible_paths]
|
||||||
|
: []
|
||||||
|
})
|
||||||
|
|
||||||
|
interface BaseProps {
|
||||||
|
agentId: string
|
||||||
|
session?: AgentSessionEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerProps extends BaseProps {
|
||||||
|
trigger: { content: ReactNode; className?: string }
|
||||||
|
isOpen?: never
|
||||||
|
onClose?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateProps extends BaseProps {
|
||||||
|
trigger?: never
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = TriggerProps | StateProps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal component for creating or editing a Session.
|
||||||
|
*
|
||||||
|
* Either trigger or isOpen and onClose is given.
|
||||||
|
* @param agentId - The ID of agent which the session is related.
|
||||||
|
* @param session - Optional session entity for editing mode.
|
||||||
|
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
|
||||||
|
* @param isOpen - Optional controlled modal open state. From useDisclosure.
|
||||||
|
* @param onClose - Optional callback when modal closes. From useDisclosure.
|
||||||
|
* @returns Modal component for agent creation/editing
|
||||||
|
*/
|
||||||
|
export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpen: _isOpen, onClose: _onClose }) => {
|
||||||
|
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const loadingRef = useRef(false)
|
||||||
|
// const { setTimeoutTimer } = useTimer()
|
||||||
|
const { createSession, updateSession } = useSessions(agentId)
|
||||||
|
// Only support claude code for now
|
||||||
|
const { models } = useModels({ providerType: 'anthropic' })
|
||||||
|
const { agent } = useAgent(agentId)
|
||||||
|
const isEditing = (session?: AgentSessionEntity) => session !== undefined
|
||||||
|
|
||||||
|
const [form, setForm] = useState<BaseSessionForm>(() => buildSessionForm(session, agent ?? undefined))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setForm(buildSessionForm(session, agent ?? undefined))
|
||||||
|
}
|
||||||
|
}, [session, agent, isOpen])
|
||||||
|
|
||||||
|
const Item = useCallback(({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />, [])
|
||||||
|
|
||||||
|
const renderOption = useCallback(
|
||||||
|
(items: SelectedItems<BaseOption>) => items.map((item) => <Item key={item.key} item={item} />),
|
||||||
|
[Item]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onNameChange = useCallback((name: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDescChange = useCallback((description: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onInstChange = useCallback((instructions: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
instructions
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const modelOptions = useMemo(() => {
|
||||||
|
// mocked data. not final version
|
||||||
|
return (models ?? []).map((model) => ({
|
||||||
|
type: 'model',
|
||||||
|
key: model.id,
|
||||||
|
label: model.name,
|
||||||
|
avatar: getModelLogo(model.id),
|
||||||
|
providerId: model.provider,
|
||||||
|
providerName: model.provider_name
|
||||||
|
})) satisfies ModelOption[]
|
||||||
|
}, [models])
|
||||||
|
|
||||||
|
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
model: e.target.value
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (loadingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.current = true
|
||||||
|
|
||||||
|
// Additional validation check besides native HTML validation to ensure security
|
||||||
|
if (!form.model) {
|
||||||
|
window.toast.error(t('error.model.not_exists'))
|
||||||
|
loadingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.accessible_paths.length === 0) {
|
||||||
|
window.toast.error(t('agent.session.accessible_paths.required'))
|
||||||
|
loadingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing(session)) {
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Agent is required for editing mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
id: session.id,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
instructions: form.instructions,
|
||||||
|
model: form.model,
|
||||||
|
accessible_paths: [...form.accessible_paths]
|
||||||
|
} satisfies UpdateSessionForm
|
||||||
|
|
||||||
|
updateSession(updatePayload)
|
||||||
|
logger.debug('Updated agent', updatePayload)
|
||||||
|
} else {
|
||||||
|
const newSession = {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
instructions: form.instructions,
|
||||||
|
model: form.model,
|
||||||
|
accessible_paths: [...form.accessible_paths]
|
||||||
|
} satisfies CreateSessionForm
|
||||||
|
createSession(newSession)
|
||||||
|
logger.debug('Added agent', newSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.current = false
|
||||||
|
|
||||||
|
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
[
|
||||||
|
form.model,
|
||||||
|
form.name,
|
||||||
|
form.description,
|
||||||
|
form.instructions,
|
||||||
|
form.accessible_paths,
|
||||||
|
session,
|
||||||
|
onClose,
|
||||||
|
t,
|
||||||
|
updateSession,
|
||||||
|
createSession
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
|
||||||
|
encapsulated component. This is because the Modal component needs to bind the onOpen
|
||||||
|
event handler to the Button for proper focus management.
|
||||||
|
|
||||||
|
Or just use external isOpen/onOpen/onClose to control modal state.
|
||||||
|
*/}
|
||||||
|
|
||||||
|
{trigger && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpen()
|
||||||
|
}}
|
||||||
|
className={cn('w-full', trigger.className)}>
|
||||||
|
{trigger.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>
|
||||||
|
{isEditing(session) ? t('agent.session.edit.title') : t('agent.session.add.title')}
|
||||||
|
</ModalHeader>
|
||||||
|
<Form onSubmit={onSubmit} className="w-full">
|
||||||
|
<ModalBody className="w-full">
|
||||||
|
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
|
||||||
|
{/* FIXME: Model type definition is string. It cannot be related to provider. Just mock a model now. */}
|
||||||
|
<Select
|
||||||
|
isRequired
|
||||||
|
selectionMode="single"
|
||||||
|
selectedKeys={form.model ? [form.model] : []}
|
||||||
|
onChange={onModelChange}
|
||||||
|
items={modelOptions}
|
||||||
|
label={t('common.model')}
|
||||||
|
placeholder={t('common.placeholders.select.model')}
|
||||||
|
renderValue={renderOption}>
|
||||||
|
{(option) => (
|
||||||
|
<SelectItem key={option.key} textValue={option.label}>
|
||||||
|
<Option option={option} />
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
label={t('common.description')}
|
||||||
|
value={form.description ?? ''}
|
||||||
|
onValueChange={onDescChange}
|
||||||
|
/>
|
||||||
|
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter className="w-full">
|
||||||
|
<Button onPress={onClose}>{t('common.close')}</Button>
|
||||||
|
<Button color="primary" type="submit" isLoading={loadingRef.current}>
|
||||||
|
{isEditing(session) ? t('common.confirm') : t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Avatar, SelectedItemProps, SelectedItems } from '@heroui/react'
|
||||||
|
import { getProviderLabel } from '@renderer/i18n/label'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export interface BaseOption {
|
||||||
|
type: 'type' | 'model'
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
// img src
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelOption extends BaseOption {
|
||||||
|
providerId?: string
|
||||||
|
providerName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelOption(option: BaseOption): option is ModelOption {
|
||||||
|
return option.type === 'model'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Item = ({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />
|
||||||
|
|
||||||
|
export const renderOption = (items: SelectedItems<BaseOption>) =>
|
||||||
|
items.map((item) => <Item key={item.key} item={item} />)
|
||||||
|
|
||||||
|
export const Option = ({ option }: { option?: BaseOption | null }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!option) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Avatar name="?" className="h-5 w-5" />
|
||||||
|
{t('common.invalid_value')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const providerLabel = (() => {
|
||||||
|
if (!isModelOption(option)) return null
|
||||||
|
if (option.providerName) return option.providerName
|
||||||
|
if (option.providerId) return getProviderLabel(option.providerId)
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Avatar src={option.avatar} className="h-5 w-5" />
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
{providerLabel ? <span className="truncate text-foreground-500">| {providerLabel}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined
|
|||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
case 'home':
|
case 'home':
|
||||||
return <Home size={14} />
|
return <Home size={14} />
|
||||||
case 'agents':
|
case 'assistantPresets':
|
||||||
return <Sparkle size={14} />
|
return <Sparkle size={14} />
|
||||||
case 'translate':
|
case 'translate':
|
||||||
return <Languages size={14} />
|
return <Languages size={14} />
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import ClaudeAvatar from '@renderer/assets/images/models/claude.png'
|
||||||
|
import { AgentBase, AgentEntity } from '@renderer/types'
|
||||||
|
|
||||||
|
// base agent config. no default config for now.
|
||||||
|
const DEFAULT_AGENT_CONFIG: Omit<AgentBase, 'model'> = {
|
||||||
|
accessible_paths: []
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// no default config for now.
|
||||||
|
export const DEFAULT_CLAUDE_CODE_CONFIG: Omit<AgentBase, 'model'> = {
|
||||||
|
...DEFAULT_AGENT_CONFIG
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const getAgentAvatar = (type: AgentEntity['type']): string => {
|
||||||
|
switch (type) {
|
||||||
|
case 'claude-code':
|
||||||
|
return ClaudeAvatar
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { UpdateAgentForm } from '@renderer/types'
|
||||||
|
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
|
||||||
|
import { useAgentClient } from './useAgentClient'
|
||||||
|
|
||||||
|
export const useAgent = (id: string | null) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const client = useAgentClient()
|
||||||
|
const key = id ? client.agentPaths.withId(id) : null
|
||||||
|
const fetcher = useCallback(async () => {
|
||||||
|
if (!id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const result = await client.getAgent(id)
|
||||||
|
return result
|
||||||
|
}, [client, id])
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(key, id ? fetcher : null)
|
||||||
|
|
||||||
|
const updateAgent = useCallback(
|
||||||
|
async (form: UpdateAgentForm) => {
|
||||||
|
try {
|
||||||
|
// may change to optimistic update
|
||||||
|
const result = await client.updateAgent(form)
|
||||||
|
mutate(result)
|
||||||
|
window.toast.success(t('common.update_success'))
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent: data,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
updateAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { AgentApiClient } from '@renderer/api/agent'
|
||||||
|
|
||||||
|
import { useSettings } from '../useSettings'
|
||||||
|
|
||||||
|
export const useAgentClient = () => {
|
||||||
|
const { apiServer } = useSettings()
|
||||||
|
const { host, port, apiKey } = apiServer
|
||||||
|
const client = new AgentApiClient({
|
||||||
|
baseURL: `http://${host}:${port}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { AddAgentForm, UpdateAgentForm } from '@renderer/types'
|
||||||
|
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
|
||||||
|
import { useAgentClient } from './useAgentClient'
|
||||||
|
|
||||||
|
export const useAgents = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const client = useAgentClient()
|
||||||
|
const key = client.agentPaths.base
|
||||||
|
const fetcher = useCallback(async () => {
|
||||||
|
const result = await client.listAgents()
|
||||||
|
return result.data
|
||||||
|
}, [client])
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
|
||||||
|
|
||||||
|
const addAgent = useCallback(
|
||||||
|
async (form: AddAgentForm) => {
|
||||||
|
try {
|
||||||
|
const result = await client.createAgent(form)
|
||||||
|
mutate((prev) => [...(prev ?? []), result])
|
||||||
|
window.toast.success(t('common.add_success'))
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.add.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateAgent = useCallback(
|
||||||
|
async (form: UpdateAgentForm) => {
|
||||||
|
try {
|
||||||
|
// may change to optimistic update
|
||||||
|
const result = await client.updateAgent(form)
|
||||||
|
mutate((prev) => prev?.map((a) => (a.id === result.id ? result : a)) ?? [])
|
||||||
|
window.toast.success(t('common.update_success'))
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteAgent = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await client.deleteAgent(id)
|
||||||
|
mutate((prev) => prev?.filter((a) => a.id !== id) ?? [])
|
||||||
|
window.toast.success(t('common.delete_success'))
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.delete.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getAgent = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const result = await client.getAgent(id)
|
||||||
|
mutate((prev) => prev?.map((a) => (a.id === result.id ? result : a)) ?? [])
|
||||||
|
},
|
||||||
|
[client, mutate]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
agents: data ?? [],
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
addAgent,
|
||||||
|
updateAgent,
|
||||||
|
deleteAgent,
|
||||||
|
getAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiModelsFilter } from '@renderer/types'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
|
||||||
|
import { useAgentClient } from './useAgentClient'
|
||||||
|
|
||||||
|
export const useModels = (filter?: ApiModelsFilter) => {
|
||||||
|
const client = useAgentClient()
|
||||||
|
const path = client.getModelsPath(filter)
|
||||||
|
const fetcher = useCallback(() => {
|
||||||
|
return client.getModels(filter)
|
||||||
|
}, [client, filter])
|
||||||
|
const { data, error, isLoading } = useSWR(path, fetcher)
|
||||||
|
return {
|
||||||
|
models: data?.data,
|
||||||
|
error,
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useAppDispatch } from '@renderer/store'
|
||||||
|
import { removeManyBlocks,upsertManyBlocks } from '@renderer/store/messageBlock'
|
||||||
|
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||||
|
import { AgentPersistedMessage, UpdateSessionForm } from '@renderer/types'
|
||||||
|
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
|
||||||
|
import { useAgentClient } from './useAgentClient'
|
||||||
|
|
||||||
|
export const useSession = (agentId: string, sessionId: string) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const client = useAgentClient()
|
||||||
|
const key = client.getSessionPaths(agentId).withId(sessionId)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
|
||||||
|
const blockIdsRef = useRef<string[]>([])
|
||||||
|
|
||||||
|
const fetcher = async () => {
|
||||||
|
const data = await client.getSession(agentId, sessionId)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const messages = data?.messages ?? []
|
||||||
|
if (!messages.length) {
|
||||||
|
dispatch(newMessagesActions.messagesReceived({ topicId: sessionTopicId, messages: [] }))
|
||||||
|
blockIdsRef.current = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedEntries = messages
|
||||||
|
.map((entity) => entity.content as AgentPersistedMessage | undefined)
|
||||||
|
.filter((entry): entry is AgentPersistedMessage => Boolean(entry))
|
||||||
|
|
||||||
|
const allBlocks = persistedEntries.flatMap((entry) => entry.blocks)
|
||||||
|
if (allBlocks.length > 0) {
|
||||||
|
dispatch(upsertManyBlocks(allBlocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
blockIdsRef.current = allBlocks.map((block) => block.id)
|
||||||
|
|
||||||
|
const messageRecords = persistedEntries.map((entry) => entry.message)
|
||||||
|
dispatch(newMessagesActions.messagesReceived({ topicId: sessionTopicId, messages: messageRecords }))
|
||||||
|
}, [data?.messages, dispatch, sessionTopicId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (blockIdsRef.current.length > 0) {
|
||||||
|
dispatch(removeManyBlocks(blockIdsRef.current))
|
||||||
|
}
|
||||||
|
dispatch(newMessagesActions.clearTopicMessages(sessionTopicId))
|
||||||
|
}
|
||||||
|
}, [dispatch, sessionTopicId])
|
||||||
|
|
||||||
|
const updateSession = useCallback(
|
||||||
|
async (form: UpdateSessionForm) => {
|
||||||
|
if (!agentId) return
|
||||||
|
try {
|
||||||
|
const result = await client.updateSession(agentId, form)
|
||||||
|
mutate(result)
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(t('agent.session.update.error.failed'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agentId, client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: data,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
updateSession,
|
||||||
|
mutate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { CreateSessionForm, UpdateSessionForm } from '@renderer/types'
|
||||||
|
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
|
||||||
|
import { useAgentClient } from './useAgentClient'
|
||||||
|
|
||||||
|
export const useSessions = (agentId: string) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const client = useAgentClient()
|
||||||
|
const key = client.getSessionPaths(agentId).base
|
||||||
|
|
||||||
|
const fetcher = async () => {
|
||||||
|
const data = await client.listSessions(agentId)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
|
||||||
|
|
||||||
|
const createSession = useCallback(
|
||||||
|
async (form: CreateSessionForm) => {
|
||||||
|
try {
|
||||||
|
const result = await client.createSession(agentId, form)
|
||||||
|
mutate((prev) => [...(prev ?? []), result])
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agentId, client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: including messages field
|
||||||
|
const getSession = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
const result = await client.getSession(agentId, id)
|
||||||
|
mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session)))
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agentId, client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteSession = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!agentId) return
|
||||||
|
try {
|
||||||
|
await client.deleteSession(agentId, id)
|
||||||
|
mutate((prev) => prev?.filter((session) => session.id !== id))
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agentId, client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateSession = useCallback(
|
||||||
|
async (form: UpdateSessionForm) => {
|
||||||
|
if (!agentId) return
|
||||||
|
try {
|
||||||
|
const result = await client.updateSession(agentId, form)
|
||||||
|
mutate((prev) => prev?.map((session) => (session.id === form.id ? result : session)))
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.update.error.failed')))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agentId, client, mutate, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: data ?? [],
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
deleteSession,
|
||||||
|
updateSession
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
|
||||||
import { addAgent, removeAgent, updateAgent, updateAgents, updateAgentSettings } from '@renderer/store/agents'
|
|
||||||
import { Agent, AssistantSettings } from '@renderer/types'
|
|
||||||
|
|
||||||
export function useAgents() {
|
|
||||||
const agents = useAppSelector((state) => state.agents.agents)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
return {
|
|
||||||
agents,
|
|
||||||
updateAgents: (agents: Agent[]) => dispatch(updateAgents(agents)),
|
|
||||||
addAgent: (agent: Agent) => dispatch(addAgent(agent)),
|
|
||||||
removeAgent: (id: string) => dispatch(removeAgent({ id }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgent(id: string) {
|
|
||||||
const agent = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as Agent)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
return {
|
|
||||||
agent,
|
|
||||||
updateAgent: (agent: Agent) => dispatch(updateAgent(agent)),
|
|
||||||
updateAgentSettings: (settings: Partial<AssistantSettings>) => {
|
|
||||||
dispatch(updateAgentSettings({ assistantId: agent.id, settings }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
|
import {
|
||||||
|
addAssistantPreset,
|
||||||
|
removeAssistantPreset,
|
||||||
|
setAssistantPresets,
|
||||||
|
updateAssistantPreset,
|
||||||
|
updateAssistantPresetSettings
|
||||||
|
} from '@renderer/store/agents'
|
||||||
|
import { AssistantPreset, AssistantSettings } from '@renderer/types'
|
||||||
|
|
||||||
|
export function useAssistantPresets() {
|
||||||
|
const presets = useAppSelector((state) => state.agents.agents)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
return {
|
||||||
|
presets,
|
||||||
|
setAssistantPresets: (presets: AssistantPreset[]) => dispatch(setAssistantPresets(presets)),
|
||||||
|
addAssistantPreset: (preset: AssistantPreset) => dispatch(addAssistantPreset(preset)),
|
||||||
|
removeAssistantPreset: (id: string) => dispatch(removeAssistantPreset({ id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssistantPreset(id: string) {
|
||||||
|
// FIXME: undefined is not handled
|
||||||
|
const preset = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as AssistantPreset)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
updateAssistantPreset: (preset: AssistantPreset) => dispatch(updateAssistantPreset(preset)),
|
||||||
|
updateAssistantPresetSettings: (settings: Partial<AssistantSettings>) => {
|
||||||
|
dispatch(updateAssistantPresetSettings({ assistantId: preset.id, settings }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,8 +32,8 @@ import { cloneDeep } from 'lodash'
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
|
||||||
import { useAgents } from './useAgents'
|
|
||||||
import { useAssistants } from './useAssistant'
|
import { useAssistants } from './useAssistant'
|
||||||
|
import { useAssistantPresets } from './useAssistantPresets'
|
||||||
import { useTimer } from './useTimer'
|
import { useTimer } from './useTimer'
|
||||||
|
|
||||||
export const useKnowledge = (baseId: string) => {
|
export const useKnowledge = (baseId: string) => {
|
||||||
@@ -346,7 +346,7 @@ export const useKnowledgeBases = () => {
|
|||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const bases = useSelector((state: RootState) => state.knowledge.bases)
|
const bases = useSelector((state: RootState) => state.knowledge.bases)
|
||||||
const { assistants, updateAssistants } = useAssistants()
|
const { assistants, updateAssistants } = useAssistants()
|
||||||
const { agents, updateAgents } = useAgents()
|
const { presets, setAssistantPresets } = useAssistantPresets()
|
||||||
|
|
||||||
const addKnowledgeBase = (base: KnowledgeBase) => {
|
const addKnowledgeBase = (base: KnowledgeBase) => {
|
||||||
dispatch(addBase(base))
|
dispatch(addBase(base))
|
||||||
@@ -373,7 +373,7 @@ export const useKnowledgeBases = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// remove agent knowledge_base
|
// remove agent knowledge_base
|
||||||
const _agents = agents.map((agent) => {
|
const _presets = presets.map((agent) => {
|
||||||
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
|
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
|
||||||
return {
|
return {
|
||||||
...agent,
|
...agent,
|
||||||
@@ -384,7 +384,7 @@ export const useKnowledgeBases = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
updateAssistants(_assistants)
|
updateAssistants(_assistants)
|
||||||
updateAgents(_agents)
|
setAssistantPresets(_presets)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
|
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ export const getRestoreProgressLabel = (key: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const titleKeyMap = {
|
const titleKeyMap = {
|
||||||
agents: 'title.agents',
|
// TODO: update i18n key
|
||||||
|
assistantPresets: 'title.agents',
|
||||||
apps: 'title.apps',
|
apps: 'title.apps',
|
||||||
code: 'title.code',
|
code: 'title.code',
|
||||||
files: 'title.files',
|
files: 'title.files',
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to add a agent",
|
||||||
|
"invalid_agent": "Invalid Agent"
|
||||||
|
},
|
||||||
|
"title": "Add Agent",
|
||||||
|
"type": {
|
||||||
|
"label": "Agent Type",
|
||||||
|
"placeholder": "Select an agent type"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Deleting the agent will forcibly stop and delete all sessions associated with the agent. Are you sure?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to delete the agent"
|
||||||
|
},
|
||||||
|
"title": "Delete Agent"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Edit Agent"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "Add a session"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to add a session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Are you sure to delete this session?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to delete the session"
|
||||||
|
},
|
||||||
|
"title": "Delete session"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Edit session"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to get the session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "Session",
|
||||||
|
"label_other": "Sessions",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to update the session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "Accessible directories",
|
||||||
|
"add": "Add directory",
|
||||||
|
"empty": "Select at least one directory that the agent can access.",
|
||||||
|
"required": "Please select at least one accessible directory.",
|
||||||
|
"duplicate": "This directory is already included.",
|
||||||
|
"select_failed": "Failed to select directory."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Failed to update the agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "Add to Assistant",
|
"button": "Add to Assistant",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
|
"add_success": "Added successfully",
|
||||||
"advanced_settings": "Advanced Settings",
|
"advanced_settings": "Advanced Settings",
|
||||||
|
"agent_one": "Agent",
|
||||||
|
"agent_other": "Agents",
|
||||||
"and": "and",
|
"and": "and",
|
||||||
"assistant": "Assistant",
|
"assistant": "Agent",
|
||||||
|
"assistant_one": "Assistant",
|
||||||
|
"assistant_other": "Assistants",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"browse": "Browse",
|
"browse": "Browse",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "Default",
|
"default": "Default",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_confirm": "Are you sure you want to delete?",
|
"delete_confirm": "Are you sure you want to delete?",
|
||||||
|
"delete_failed": "Failed to delete",
|
||||||
|
"delete_success": "Deleted successfully",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"detail": "Detail",
|
"detail": "Detail",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"error": "error",
|
"error": "error",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "Failed to create message",
|
||||||
|
"validation": "Verification failed"
|
||||||
|
},
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "Unsupported file type {{type}}"
|
"not_supported": "Unsupported file type {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "Go to settings",
|
"go_to_settings": "Go to settings",
|
||||||
"i_know": "I know",
|
"i_know": "I know",
|
||||||
"inspect": "Inspect",
|
"inspect": "Inspect",
|
||||||
|
"invalid_value": "Invalid Value",
|
||||||
"knowledge_base": "Knowledge Base",
|
"knowledge_base": "Knowledge Base",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"paste": "Paste",
|
"paste": "Paste",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "Select a model"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "Success",
|
"success": "Success",
|
||||||
"swap": "Swap",
|
"swap": "Swap",
|
||||||
"topics": "Topics",
|
"topics": "Topics",
|
||||||
|
"update_success": "Update successfully",
|
||||||
"upload_files": "Upload file",
|
"upload_files": "Upload file",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"you": "You"
|
"you": "You"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "Model Type",
|
"modelType": "Model Type",
|
||||||
"name": "Error name",
|
"name": "Error name",
|
||||||
"no_api_key": "API key is not configured",
|
"no_api_key": "API key is not configured",
|
||||||
|
"no_response": "No response",
|
||||||
"originalError": "Original Error",
|
"originalError": "Original Error",
|
||||||
"originalMessage": "Original Message",
|
"originalMessage": "Original Message",
|
||||||
"parameter": "Parameter",
|
"parameter": "Parameter",
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "添加 Agent 失败",
|
||||||
|
"invalid_agent": "无效的 Agent"
|
||||||
|
},
|
||||||
|
"title": "添加 Agent",
|
||||||
|
"type": {
|
||||||
|
"label": "Agent 类型",
|
||||||
|
"placeholder": "选择 Agent 类型"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "删除该 Agent 将强制终止并删除该 Agent 下的所有会话。您确定吗?",
|
||||||
|
"error": {
|
||||||
|
"failed": "删除 Agent 失败"
|
||||||
|
},
|
||||||
|
"title": "删除 Agent"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "编辑 Agent"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "添加会话"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "添加会话失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "确定要删除此会话吗?",
|
||||||
|
"error": {
|
||||||
|
"failed": "删除会话失败"
|
||||||
|
},
|
||||||
|
"title": "删除会话"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "编辑会话"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "获取会话失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "会话",
|
||||||
|
"label_other": "会话",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "更新会话失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "工作目录",
|
||||||
|
"add": "添加目录",
|
||||||
|
"empty": "请选择至少一个智能体可访问的目录。",
|
||||||
|
"required": "请至少选择一个可访问的目录。",
|
||||||
|
"duplicate": "该目录已添加。",
|
||||||
|
"select_failed": "选择目录失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "更新 Agent 失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "添加到助手",
|
"button": "添加到助手",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
|
"add_success": "添加成功",
|
||||||
"advanced_settings": "高级设置",
|
"advanced_settings": "高级设置",
|
||||||
|
"agent_one": "Agent",
|
||||||
|
"agent_other": "Agents",
|
||||||
"and": "和",
|
"and": "和",
|
||||||
"assistant": "智能体",
|
"assistant": "智能体",
|
||||||
|
"assistant_one": "助手",
|
||||||
|
"assistant_other": "助手",
|
||||||
"avatar": "头像",
|
"avatar": "头像",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"browse": "浏览",
|
"browse": "浏览",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "默认",
|
"default": "默认",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"delete_confirm": "确定要删除吗?",
|
"delete_confirm": "确定要删除吗?",
|
||||||
|
"delete_failed": "删除失败",
|
||||||
|
"delete_success": "删除成功",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"detail": "详情",
|
"detail": "详情",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "创建消息失败",
|
||||||
|
"validation": "验证失败"
|
||||||
|
},
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "不支持的文件类型 {{type}}"
|
"not_supported": "不支持的文件类型 {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "前往设置",
|
"go_to_settings": "前往设置",
|
||||||
"i_know": "我知道了",
|
"i_know": "我知道了",
|
||||||
"inspect": "检查",
|
"inspect": "检查",
|
||||||
|
"invalid_value": "无效值",
|
||||||
"knowledge_base": "知识库",
|
"knowledge_base": "知识库",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "无",
|
"none": "无",
|
||||||
"open": "打开",
|
"open": "打开",
|
||||||
"paste": "粘贴",
|
"paste": "粘贴",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "选择模型"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"prompt": "提示词",
|
"prompt": "提示词",
|
||||||
"provider": "提供商",
|
"provider": "提供商",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "成功",
|
"success": "成功",
|
||||||
"swap": "交换",
|
"swap": "交换",
|
||||||
"topics": "话题",
|
"topics": "话题",
|
||||||
|
"update_success": "更新成功",
|
||||||
"upload_files": "上传文件",
|
"upload_files": "上传文件",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "用户"
|
"you": "用户"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "模型类型",
|
"modelType": "模型类型",
|
||||||
"name": "错误名称",
|
"name": "错误名称",
|
||||||
"no_api_key": "API 密钥未配置",
|
"no_api_key": "API 密钥未配置",
|
||||||
|
"no_response": "无响应",
|
||||||
"originalError": "原错误",
|
"originalError": "原错误",
|
||||||
"originalMessage": "原消息",
|
"originalMessage": "原消息",
|
||||||
"parameter": "参数",
|
"parameter": "参数",
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "無法新增代理人",
|
||||||
|
"invalid_agent": "無效的 Agent"
|
||||||
|
},
|
||||||
|
"title": "新增代理",
|
||||||
|
"type": {
|
||||||
|
"label": "代理類型",
|
||||||
|
"placeholder": "選擇 Agent 類型"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "刪除該 Agent 將強制終止並刪除該 Agent 下的所有會話。您確定嗎?",
|
||||||
|
"error": {
|
||||||
|
"failed": "刪除代理程式失敗"
|
||||||
|
},
|
||||||
|
"title": "刪除 Agent"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "編輯 Agent"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "新增會議"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "無法新增工作階段"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "您確定要刪除此工作階段嗎?",
|
||||||
|
"error": {
|
||||||
|
"failed": "無法刪除工作階段"
|
||||||
|
},
|
||||||
|
"title": "刪除工作階段"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "編輯工作階段"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "無法取得工作階段"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "會議",
|
||||||
|
"label_other": "Sessions",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "無法更新工作階段"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "可存取的目錄",
|
||||||
|
"add": "新增目錄",
|
||||||
|
"empty": "選擇至少一個代理可以存取的目錄。",
|
||||||
|
"required": "請至少選擇一個可存取的目錄。",
|
||||||
|
"duplicate": "此目錄已包含在內。",
|
||||||
|
"select_failed": "無法選擇目錄。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "無法更新代理程式"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "新增到助手",
|
"button": "新增到助手",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
|
"add_success": "新增成功",
|
||||||
"advanced_settings": "進階設定",
|
"advanced_settings": "進階設定",
|
||||||
|
"agent_one": "代理人",
|
||||||
|
"agent_other": "代理人",
|
||||||
"and": "與",
|
"and": "與",
|
||||||
"assistant": "智慧代理人",
|
"assistant": "智慧代理人",
|
||||||
|
"assistant_one": "助手",
|
||||||
|
"assistant_other": "助手",
|
||||||
"avatar": "頭像",
|
"avatar": "頭像",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"browse": "瀏覽",
|
"browse": "瀏覽",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "預設",
|
"default": "預設",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"delete_confirm": "確定要刪除嗎?",
|
"delete_confirm": "確定要刪除嗎?",
|
||||||
|
"delete_failed": "刪除失敗",
|
||||||
|
"delete_success": "刪除成功",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"detail": "詳情",
|
"detail": "詳情",
|
||||||
"disabled": "已停用",
|
"disabled": "已停用",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"enabled": "已啟用",
|
"enabled": "已啟用",
|
||||||
"error": "錯誤",
|
"error": "錯誤",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "無法建立訊息",
|
||||||
|
"validation": "驗證失敗"
|
||||||
|
},
|
||||||
"expand": "展開",
|
"expand": "展開",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "不支持的文件類型 {{type}}"
|
"not_supported": "不支持的文件類型 {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "前往設定",
|
"go_to_settings": "前往設定",
|
||||||
"i_know": "我知道了",
|
"i_know": "我知道了",
|
||||||
"inspect": "檢查",
|
"inspect": "檢查",
|
||||||
|
"invalid_value": "無效值",
|
||||||
"knowledge_base": "知識庫",
|
"knowledge_base": "知識庫",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
"loading": "加載中...",
|
"loading": "加載中...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "無",
|
"none": "無",
|
||||||
"open": "開啟",
|
"open": "開啟",
|
||||||
"paste": "貼上",
|
"paste": "貼上",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "選擇模型"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "預覽",
|
"preview": "預覽",
|
||||||
"prompt": "提示詞",
|
"prompt": "提示詞",
|
||||||
"provider": "供應商",
|
"provider": "供應商",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "成功",
|
"success": "成功",
|
||||||
"swap": "交換",
|
"swap": "交換",
|
||||||
"topics": "話題",
|
"topics": "話題",
|
||||||
|
"update_success": "更新成功",
|
||||||
"upload_files": "上傳檔案",
|
"upload_files": "上傳檔案",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "您"
|
"you": "您"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "模型類型",
|
"modelType": "模型類型",
|
||||||
"name": "錯誤名稱",
|
"name": "錯誤名稱",
|
||||||
"no_api_key": "API 金鑰未設定",
|
"no_api_key": "API 金鑰未設定",
|
||||||
|
"no_response": "無回應",
|
||||||
"originalError": "原錯誤",
|
"originalError": "原錯誤",
|
||||||
"originalMessage": "原消息",
|
"originalMessage": "原消息",
|
||||||
"parameter": "參數",
|
"parameter": "參數",
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Αποτυχία προσθήκης πράκτορα",
|
||||||
|
"invalid_agent": "Μη έγκυρος Agent"
|
||||||
|
},
|
||||||
|
"title": "Προσθήκη Agent",
|
||||||
|
"type": {
|
||||||
|
"label": "Τύπος πράκτορα",
|
||||||
|
"placeholder": "Επιλέξτε τύπο Agent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Η διαγραφή αυτού του Agent θα τερματίσει βίαια και θα διαγράψει όλες τις συνεδρίες υπό αυτόν τον Agent. Είστε σίγουροι;",
|
||||||
|
"error": {
|
||||||
|
"failed": "Αποτυχία διαγραφής του πράκτορα"
|
||||||
|
},
|
||||||
|
"title": "Διαγραφή Agent"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Επεξεργαστής Agent"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "Προσθήκη συνεδρίας"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Αποτυχία προσθήκης συνεδρίας"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις αυτήν τη συνεδρία;",
|
||||||
|
"error": {
|
||||||
|
"failed": "Αποτυχία διαγραφής της συνεδρίας"
|
||||||
|
},
|
||||||
|
"title": "Διαγραφή συνεδρίας"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Συνεδρία επεξεργασίας"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Αποτυχία λήψης της συνεδρίας"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "Συνεδρία",
|
||||||
|
"label_other": "Συνεδρίες",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Αποτυχία ενημέρωσης της συνεδρίας"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "Προσβάσιμοι κατάλογοι",
|
||||||
|
"add": "Προσθήκη καταλόγου",
|
||||||
|
"empty": "Επιλέξτε τουλάχιστον έναν κατάλογο στον οποίο ο πράκτορας μπορεί να έχει πρόσβαση.",
|
||||||
|
"required": "Παρακαλώ επιλέξτε τουλάχιστον έναν προσβάσιμο κατάλογο.",
|
||||||
|
"duplicate": "Αυτός ο κατάλογος έχει ήδη συμπεριληφθεί.",
|
||||||
|
"select_failed": "Αποτυχία επιλογής καταλόγου."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Αποτυχία ενημέρωσης του πράκτορα"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "Προσθήκη στο Βοηθό",
|
"button": "Προσθήκη στο Βοηθό",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "Προσθέστε",
|
"add": "Προσθέστε",
|
||||||
|
"add_success": "Η προσθήκη ήταν επιτυχής",
|
||||||
"advanced_settings": "Προχωρημένες ρυθμίσεις",
|
"advanced_settings": "Προχωρημένες ρυθμίσεις",
|
||||||
|
"agent_one": "Πράκτορας",
|
||||||
|
"agent_other": "Πράκτορες",
|
||||||
"and": "και",
|
"and": "και",
|
||||||
"assistant": "Εξυπνιασμένη Ενότητα",
|
"assistant": "Εξυπνιασμένη Ενότητα",
|
||||||
|
"assistant_one": "βοηθός",
|
||||||
|
"assistant_other": "βοηθός",
|
||||||
"avatar": "Εικονίδιο",
|
"avatar": "Εικονίδιο",
|
||||||
"back": "Πίσω",
|
"back": "Πίσω",
|
||||||
"browse": "Περιήγηση",
|
"browse": "Περιήγηση",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "Προεπιλογή",
|
"default": "Προεπιλογή",
|
||||||
"delete": "Διαγραφή",
|
"delete": "Διαγραφή",
|
||||||
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
|
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
|
||||||
|
"delete_failed": "Αποτυχία διαγραφής",
|
||||||
|
"delete_success": "Η διαγραφή ήταν επιτυχής",
|
||||||
"description": "Περιγραφή",
|
"description": "Περιγραφή",
|
||||||
"detail": "Λεπτομέρειες",
|
"detail": "Λεπτομέρειες",
|
||||||
"disabled": "Απενεργοποιημένο",
|
"disabled": "Απενεργοποιημένο",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "Επεξεργασία",
|
"edit": "Επεξεργασία",
|
||||||
"enabled": "Ενεργοποιημένο",
|
"enabled": "Ενεργοποιημένο",
|
||||||
"error": "σφάλμα",
|
"error": "σφάλμα",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "Αποτυχία δημιουργίας μηνύματος",
|
||||||
|
"validation": "Η επαλήθευση απέτυχε"
|
||||||
|
},
|
||||||
"expand": "Επεκτάση",
|
"expand": "Επεκτάση",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
|
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "Πηγαίνετε στις ρυθμίσεις",
|
"go_to_settings": "Πηγαίνετε στις ρυθμίσεις",
|
||||||
"i_know": "Το έχω καταλάβει",
|
"i_know": "Το έχω καταλάβει",
|
||||||
"inspect": "Επιθεώρηση",
|
"inspect": "Επιθεώρηση",
|
||||||
|
"invalid_value": "Μη έγκυρη τιμή",
|
||||||
"knowledge_base": "Βάση Γνώσεων",
|
"knowledge_base": "Βάση Γνώσεων",
|
||||||
"language": "Γλώσσα",
|
"language": "Γλώσσα",
|
||||||
"loading": "Φόρτωση...",
|
"loading": "Φόρτωση...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "Χωρίς",
|
"none": "Χωρίς",
|
||||||
"open": "Άνοιγμα",
|
"open": "Άνοιγμα",
|
||||||
"paste": "Επικόλληση",
|
"paste": "Επικόλληση",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "Επιλέξτε μοντέλο"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "Προεπισκόπηση",
|
"preview": "Προεπισκόπηση",
|
||||||
"prompt": "Ενδεικτικός ρήματος",
|
"prompt": "Ενδεικτικός ρήματος",
|
||||||
"provider": "Παρέχων",
|
"provider": "Παρέχων",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "Επιτυχία",
|
"success": "Επιτυχία",
|
||||||
"swap": "Εναλλαγή",
|
"swap": "Εναλλαγή",
|
||||||
"topics": "Θέματα",
|
"topics": "Θέματα",
|
||||||
|
"update_success": "Επιτυχής ενημέρωση",
|
||||||
"upload_files": "Ανέβασμα αρχείου",
|
"upload_files": "Ανέβασμα αρχείου",
|
||||||
"warning": "Προσοχή",
|
"warning": "Προσοχή",
|
||||||
"you": "Εσείς"
|
"you": "Εσείς"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "Τύπος μοντέλου",
|
"modelType": "Τύπος μοντέλου",
|
||||||
"name": "Λάθος όνομα",
|
"name": "Λάθος όνομα",
|
||||||
"no_api_key": "Δεν έχετε ρυθμίσει το κλειδί API",
|
"no_api_key": "Δεν έχετε ρυθμίσει το κλειδί API",
|
||||||
|
"no_response": "Καμία απάντηση",
|
||||||
"originalError": "Αρχικό σφάλμα",
|
"originalError": "Αρχικό σφάλμα",
|
||||||
"originalMessage": "Αρχικό μήνυμα",
|
"originalMessage": "Αρχικό μήνυμα",
|
||||||
"parameter": "παράμετροι",
|
"parameter": "παράμετροι",
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Error al añadir agente",
|
||||||
|
"invalid_agent": "Agent inválido"
|
||||||
|
},
|
||||||
|
"title": "Agregar Agente",
|
||||||
|
"type": {
|
||||||
|
"label": "Tipo de agente",
|
||||||
|
"placeholder": "Seleccionar tipo de Agente"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Eliminar este Agente forzará la terminación y eliminación de todas las sesiones bajo este Agente. ¿Está seguro?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Error al eliminar el agente"
|
||||||
|
},
|
||||||
|
"title": "Eliminar Agent"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Agent de edición"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "Agregar una sesión"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Error al añadir una sesión"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "¿Estás seguro de eliminar esta sesión?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Error al eliminar la sesión"
|
||||||
|
},
|
||||||
|
"title": "Eliminar sesión"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Sesión de edición"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Error al obtener la sesión"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "Sesión",
|
||||||
|
"label_other": "Sesiones",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Error al actualizar la sesión"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "Directorios accesibles",
|
||||||
|
"add": "Agregar directorio",
|
||||||
|
"empty": "Selecciona al menos un directorio al que el agente pueda acceder.",
|
||||||
|
"required": "Por favor, seleccione al menos un directorio accesible.",
|
||||||
|
"duplicate": "Este directorio ya está incluido.",
|
||||||
|
"select_failed": "Error al seleccionar el directorio."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Error al actualizar el agente"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "Agregar al asistente",
|
"button": "Agregar al asistente",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "Agregar",
|
"add": "Agregar",
|
||||||
|
"add_success": "Añadido con éxito",
|
||||||
"advanced_settings": "Configuración avanzada",
|
"advanced_settings": "Configuración avanzada",
|
||||||
|
"agent_one": "Agente",
|
||||||
|
"agent_other": "Agentes",
|
||||||
"and": "y",
|
"and": "y",
|
||||||
"assistant": "Agente inteligente",
|
"assistant": "Agente inteligente",
|
||||||
|
"assistant_one": "Asistente",
|
||||||
|
"assistant_other": "Asistente",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"back": "Atrás",
|
"back": "Atrás",
|
||||||
"browse": "Examinar",
|
"browse": "Examinar",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "Predeterminado",
|
"default": "Predeterminado",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
|
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
|
||||||
|
"delete_failed": "Error al eliminar",
|
||||||
|
"delete_success": "Eliminación exitosa",
|
||||||
"description": "Descripción",
|
"description": "Descripción",
|
||||||
"detail": "Detalles",
|
"detail": "Detalles",
|
||||||
"disabled": "Desactivado",
|
"disabled": "Desactivado",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"enabled": "Activado",
|
"enabled": "Activado",
|
||||||
"error": "error",
|
"error": "error",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "Error al crear el mensaje",
|
||||||
|
"validation": "Fallo en la verificación"
|
||||||
|
},
|
||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "Tipo de archivo no compatible {{type}}"
|
"not_supported": "Tipo de archivo no compatible {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "Ir a la configuración",
|
"go_to_settings": "Ir a la configuración",
|
||||||
"i_know": "Entendido",
|
"i_know": "Entendido",
|
||||||
"inspect": "Inspeccionar",
|
"inspect": "Inspeccionar",
|
||||||
|
"invalid_value": "Valor inválido",
|
||||||
"knowledge_base": "Base de conocimiento",
|
"knowledge_base": "Base de conocimiento",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "无",
|
"none": "无",
|
||||||
"open": "Abrir",
|
"open": "Abrir",
|
||||||
"paste": "Pegar",
|
"paste": "Pegar",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "Seleccionar modelo"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "Vista previa",
|
"preview": "Vista previa",
|
||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"provider": "Proveedor",
|
"provider": "Proveedor",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "Éxito",
|
"success": "Éxito",
|
||||||
"swap": "Intercambiar",
|
"swap": "Intercambiar",
|
||||||
"topics": "Temas",
|
"topics": "Temas",
|
||||||
|
"update_success": "Actualización exitosa",
|
||||||
"upload_files": "Subir archivo",
|
"upload_files": "Subir archivo",
|
||||||
"warning": "Advertencia",
|
"warning": "Advertencia",
|
||||||
"you": "Usuario"
|
"you": "Usuario"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "Tipo de modelo",
|
"modelType": "Tipo de modelo",
|
||||||
"name": "Nombre de error",
|
"name": "Nombre de error",
|
||||||
"no_api_key": "La clave API no está configurada",
|
"no_api_key": "La clave API no está configurada",
|
||||||
|
"no_response": "Sin respuesta",
|
||||||
"originalError": "Error original",
|
"originalError": "Error original",
|
||||||
"originalMessage": "mensaje original",
|
"originalMessage": "mensaje original",
|
||||||
"parameter": "parámetro",
|
"parameter": "parámetro",
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Échec de l'ajout de l'agent",
|
||||||
|
"invalid_agent": "Agent invalide"
|
||||||
|
},
|
||||||
|
"title": "Ajouter un agent",
|
||||||
|
"type": {
|
||||||
|
"label": "Type d'agent",
|
||||||
|
"placeholder": "Sélectionner le type d'Agent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "La suppression de cet Agent entraînera la terminaison forcée et la suppression de toutes les sessions associées. Êtes-vous certain ?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Échec de la suppression de l'agent"
|
||||||
|
},
|
||||||
|
"title": "Supprimer l'Agent"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Éditer Agent"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "Ajouter une session"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Échec de l'ajout d'une session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Êtes-vous sûr de vouloir supprimer cette session ?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Échec de la suppression de la session"
|
||||||
|
},
|
||||||
|
"title": "Supprimer la session"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Session d'édition"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Échec de l'obtention de la session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "Session",
|
||||||
|
"label_other": "Séances",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Échec de la mise à jour de la session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "Répertoires accessibles",
|
||||||
|
"add": "Ajouter un répertoire",
|
||||||
|
"empty": "Sélectionnez au moins un répertoire auquel l'agent peut accéder.",
|
||||||
|
"required": "Veuillez sélectionner au moins un répertoire accessible.",
|
||||||
|
"duplicate": "Ce répertoire est déjà inclus.",
|
||||||
|
"select_failed": "Échec de la sélection du répertoire."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Échec de la mise à jour de l'agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "Ajouter à l'assistant",
|
"button": "Ajouter à l'assistant",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
|
"add_success": "Ajout réussi",
|
||||||
"advanced_settings": "Paramètres avancés",
|
"advanced_settings": "Paramètres avancés",
|
||||||
|
"agent_one": "Agent",
|
||||||
|
"agent_other": "Agents",
|
||||||
"and": "et",
|
"and": "et",
|
||||||
"assistant": "Intelligence artificielle",
|
"assistant": "Intelligence artificielle",
|
||||||
|
"assistant_one": "assistant",
|
||||||
|
"assistant_other": "assistant",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"back": "Retour",
|
"back": "Retour",
|
||||||
"browse": "Parcourir",
|
"browse": "Parcourir",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "Défaut",
|
"default": "Défaut",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
|
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
|
||||||
|
"delete_failed": "Échec de la suppression",
|
||||||
|
"delete_success": "Suppression réussie",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"detail": "détails",
|
"detail": "détails",
|
||||||
"disabled": "Désactivé",
|
"disabled": "Désactivé",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "Éditer",
|
"edit": "Éditer",
|
||||||
"enabled": "Activé",
|
"enabled": "Activé",
|
||||||
"error": "erreur",
|
"error": "erreur",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "Échec de la création du message",
|
||||||
|
"validation": "Échec de la vérification"
|
||||||
|
},
|
||||||
"expand": "Développer",
|
"expand": "Développer",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "Type de fichier non pris en charge {{type}}"
|
"not_supported": "Type de fichier non pris en charge {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "Aller aux paramètres",
|
"go_to_settings": "Aller aux paramètres",
|
||||||
"i_know": "J'ai compris",
|
"i_know": "J'ai compris",
|
||||||
"inspect": "Vérifier",
|
"inspect": "Vérifier",
|
||||||
|
"invalid_value": "valeur invalide",
|
||||||
"knowledge_base": "Base de connaissances",
|
"knowledge_base": "Base de connaissances",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"open": "Ouvrir",
|
"open": "Ouvrir",
|
||||||
"paste": "Coller",
|
"paste": "Coller",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "Choisir le modèle"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "Aperçu",
|
"preview": "Aperçu",
|
||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"provider": "Fournisseur",
|
"provider": "Fournisseur",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "Succès",
|
"success": "Succès",
|
||||||
"swap": "Échanger",
|
"swap": "Échanger",
|
||||||
"topics": "Sujets",
|
"topics": "Sujets",
|
||||||
|
"update_success": "Mise à jour réussie",
|
||||||
"upload_files": "Uploader des fichiers",
|
"upload_files": "Uploader des fichiers",
|
||||||
"warning": "Avertissement",
|
"warning": "Avertissement",
|
||||||
"you": "Vous"
|
"you": "Vous"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "Type de modèle",
|
"modelType": "Type de modèle",
|
||||||
"name": "Nom d'erreur",
|
"name": "Nom d'erreur",
|
||||||
"no_api_key": "La clé API n'est pas configurée",
|
"no_api_key": "La clé API n'est pas configurée",
|
||||||
|
"no_response": "Pas de réponse",
|
||||||
"originalError": "Erreur d'origine",
|
"originalError": "Erreur d'origine",
|
||||||
"originalMessage": "message original",
|
"originalMessage": "message original",
|
||||||
"parameter": "paramètre",
|
"parameter": "paramètre",
|
||||||
|
|||||||
@@ -1,4 +1,64 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "エージェントの追加に失敗しました",
|
||||||
|
"invalid_agent": "無効なエージェント"
|
||||||
|
},
|
||||||
|
"title": "エージェントを追加",
|
||||||
|
"type": {
|
||||||
|
"label": "エージェントタイプ",
|
||||||
|
"placeholder": "エージェントタイプを選択"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "このエージェントを削除すると、このエージェントのすべてのセッションが強制的に終了し、削除されます。本当によろしいですか?",
|
||||||
|
"error": {
|
||||||
|
"failed": "エージェントの削除に失敗しました"
|
||||||
|
},
|
||||||
|
"title": "エージェントを削除"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "編集エージェント"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "セッションを追加"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "セッションの追加に失敗しました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "このセッションを削除してもよろしいですか?",
|
||||||
|
"error": {
|
||||||
|
"failed": "セッションの削除に失敗しました"
|
||||||
|
},
|
||||||
|
"title": "セッションを削除"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "編集セッション"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "セッションの取得に失敗しました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "セッション",
|
||||||
|
"label_other": "セッション",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "セッションの更新に失敗しました"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "エージェントの更新に失敗しました"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "アシスタントに追加",
|
"button": "アシスタントに追加",
|
||||||
@@ -737,9 +797,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
|
"add_success": "追加成功",
|
||||||
"advanced_settings": "詳細設定",
|
"advanced_settings": "詳細設定",
|
||||||
|
"agent_one": "エージェント",
|
||||||
|
"agent_other": "エージェント",
|
||||||
"and": "と",
|
"and": "と",
|
||||||
"assistant": "アシスタント",
|
"assistant": "アシスタント",
|
||||||
|
"assistant_one": "助手",
|
||||||
|
"assistant_other": "助手",
|
||||||
"avatar": "アバター",
|
"avatar": "アバター",
|
||||||
"back": "戻る",
|
"back": "戻る",
|
||||||
"browse": "参照",
|
"browse": "参照",
|
||||||
@@ -756,6 +821,8 @@
|
|||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"delete_confirm": "削除してもよろしいですか?",
|
"delete_confirm": "削除してもよろしいですか?",
|
||||||
|
"delete_failed": "削除に失敗しました",
|
||||||
|
"delete_success": "削除に成功しました",
|
||||||
"description": "説明",
|
"description": "説明",
|
||||||
"detail": "詳細",
|
"detail": "詳細",
|
||||||
"disabled": "無効",
|
"disabled": "無効",
|
||||||
@@ -765,6 +832,10 @@
|
|||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"enabled": "有効",
|
"enabled": "有効",
|
||||||
"error": "エラー",
|
"error": "エラー",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "メッセージの作成に失敗しました",
|
||||||
|
"validation": "検証に失敗しました"
|
||||||
|
},
|
||||||
"expand": "展開",
|
"expand": "展開",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "サポートされていないファイルタイプ {{type}}"
|
"not_supported": "サポートされていないファイルタイプ {{type}}"
|
||||||
@@ -775,6 +846,7 @@
|
|||||||
"go_to_settings": "設定に移動",
|
"go_to_settings": "設定に移動",
|
||||||
"i_know": "わかりました",
|
"i_know": "わかりました",
|
||||||
"inspect": "検査",
|
"inspect": "検査",
|
||||||
|
"invalid_value": "無効な値",
|
||||||
"knowledge_base": "ナレッジベース",
|
"knowledge_base": "ナレッジベース",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -786,6 +858,11 @@
|
|||||||
"none": "無",
|
"none": "無",
|
||||||
"open": "開く",
|
"open": "開く",
|
||||||
"paste": "貼り付け",
|
"paste": "貼り付け",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "モデルを選択"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "プレビュー",
|
"preview": "プレビュー",
|
||||||
"prompt": "プロンプト",
|
"prompt": "プロンプト",
|
||||||
"provider": "プロバイダー",
|
"provider": "プロバイダー",
|
||||||
@@ -812,6 +889,7 @@
|
|||||||
"success": "成功",
|
"success": "成功",
|
||||||
"swap": "交換",
|
"swap": "交換",
|
||||||
"topics": "トピック",
|
"topics": "トピック",
|
||||||
|
"update_success": "更新成功",
|
||||||
"upload_files": "ファイルをアップロードする",
|
"upload_files": "ファイルをアップロードする",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "あなた"
|
"you": "あなた"
|
||||||
@@ -884,6 +962,7 @@
|
|||||||
"modelType": "モデルの種類",
|
"modelType": "モデルの種類",
|
||||||
"name": "エラー名",
|
"name": "エラー名",
|
||||||
"no_api_key": "APIキーが設定されていません",
|
"no_api_key": "APIキーが設定されていません",
|
||||||
|
"no_response": "応答なし",
|
||||||
"originalError": "元のエラー",
|
"originalError": "元のエラー",
|
||||||
"originalMessage": "元のメッセージ",
|
"originalMessage": "元のメッセージ",
|
||||||
"parameter": "パラメータ",
|
"parameter": "パラメータ",
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Falha ao adicionar agente",
|
||||||
|
"invalid_agent": "Agent inválido"
|
||||||
|
},
|
||||||
|
"title": "Adicionar Agente",
|
||||||
|
"type": {
|
||||||
|
"label": "Tipo de Agente",
|
||||||
|
"placeholder": "Selecionar tipo de Agente"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Excluir este Agente forçará a terminação e exclusão de todas as sessões sob ele. Tem certeza?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Falha ao excluir o agente"
|
||||||
|
},
|
||||||
|
"title": "删除代理"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Agent Editor"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "Adicionar uma sessão"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Falha ao adicionar uma sessão"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Tem certeza de que deseja excluir esta sessão?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Falha ao excluir a sessão"
|
||||||
|
},
|
||||||
|
"title": "Excluir sessão"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Sessão de edição"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Falha ao obter a sessão"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "Sessão",
|
||||||
|
"label_other": "Sessões",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Falha ao atualizar a sessão"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "Diretórios acessíveis",
|
||||||
|
"add": "Adicionar diretório",
|
||||||
|
"empty": "Selecione pelo menos um diretório ao qual o agente possa acessar.",
|
||||||
|
"required": "Por favor, selecione pelo menos um diretório acessível.",
|
||||||
|
"duplicate": "Este diretório já está incluído.",
|
||||||
|
"select_failed": "Falha ao selecionar o diretório."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Falha ao atualizar o agente"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "Adicionar ao Assistente",
|
"button": "Adicionar ao Assistente",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
|
"add_success": "Adicionado com sucesso",
|
||||||
"advanced_settings": "Configurações Avançadas",
|
"advanced_settings": "Configurações Avançadas",
|
||||||
|
"agent_one": "Agente",
|
||||||
|
"agent_other": "Agentes",
|
||||||
"and": "e",
|
"and": "e",
|
||||||
"assistant": "Agente Inteligente",
|
"assistant": "Agente Inteligente",
|
||||||
|
"assistant_one": "assistente",
|
||||||
|
"assistant_other": "assistente",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"back": "Voltar",
|
"back": "Voltar",
|
||||||
"browse": "Navegar",
|
"browse": "Navegar",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "Padrão",
|
"default": "Padrão",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"delete_confirm": "Tem certeza de que deseja excluir?",
|
"delete_confirm": "Tem certeza de que deseja excluir?",
|
||||||
|
"delete_failed": "Falha ao excluir",
|
||||||
|
"delete_success": "Excluído com sucesso",
|
||||||
"description": "Descrição",
|
"description": "Descrição",
|
||||||
"detail": "detalhes",
|
"detail": "detalhes",
|
||||||
"disabled": "Desativado",
|
"disabled": "Desativado",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"enabled": "Ativado",
|
"enabled": "Ativado",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "Falha ao criar mensagem",
|
||||||
|
"validation": "Falha na verificação"
|
||||||
|
},
|
||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "Tipo de arquivo não suportado {{type}}"
|
"not_supported": "Tipo de arquivo não suportado {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "Ir para configurações",
|
"go_to_settings": "Ir para configurações",
|
||||||
"i_know": "Entendi",
|
"i_know": "Entendi",
|
||||||
"inspect": "Verificar",
|
"inspect": "Verificar",
|
||||||
|
"invalid_value": "Valor inválido",
|
||||||
"knowledge_base": "Base de Conhecimento",
|
"knowledge_base": "Base de Conhecimento",
|
||||||
"language": "Língua",
|
"language": "Língua",
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "Nenhum",
|
"none": "Nenhum",
|
||||||
"open": "Abrir",
|
"open": "Abrir",
|
||||||
"paste": "Colar",
|
"paste": "Colar",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "Selecionar modelo"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "Pré-visualização",
|
"preview": "Pré-visualização",
|
||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"provider": "Fornecedor",
|
"provider": "Fornecedor",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "Sucesso",
|
"success": "Sucesso",
|
||||||
"swap": "Trocar",
|
"swap": "Trocar",
|
||||||
"topics": "Tópicos",
|
"topics": "Tópicos",
|
||||||
|
"update_success": "Atualização bem-sucedida",
|
||||||
"upload_files": "Carregar arquivo",
|
"upload_files": "Carregar arquivo",
|
||||||
"warning": "Aviso",
|
"warning": "Aviso",
|
||||||
"you": "Você"
|
"you": "Você"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "Tipo de modelo",
|
"modelType": "Tipo de modelo",
|
||||||
"name": "Nome do erro",
|
"name": "Nome do erro",
|
||||||
"no_api_key": "A chave da API não foi configurada",
|
"no_api_key": "A chave da API não foi configurada",
|
||||||
|
"no_response": "Sem resposta",
|
||||||
"originalError": "Erro original",
|
"originalError": "Erro original",
|
||||||
"originalMessage": "Mensagem original",
|
"originalMessage": "Mensagem original",
|
||||||
"parameter": "parâmetro",
|
"parameter": "parâmetro",
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
{
|
{
|
||||||
|
"agent": {
|
||||||
|
"add": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Не удалось добавить агента",
|
||||||
|
"invalid_agent": "Недействительный агент"
|
||||||
|
},
|
||||||
|
"title": "Добавить агента",
|
||||||
|
"type": {
|
||||||
|
"label": "Тип агента",
|
||||||
|
"placeholder": "Выбор типа агента"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Удаление этого агента приведёт к принудительному завершению и удалению всех сессий, связанных с ним. Вы уверены?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Не удалось удалить агента"
|
||||||
|
},
|
||||||
|
"title": "Удалить агента"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Редактировать агент"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"add": {
|
||||||
|
"title": "Добавить сеанс"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Не удалось добавить сеанс"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"content": "Вы уверены, что хотите удалить этот сеанс?",
|
||||||
|
"error": {
|
||||||
|
"failed": "Не удалось удалить сеанс"
|
||||||
|
},
|
||||||
|
"title": "Удалить сеанс"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Сессия редактирования"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Не удалось получить сеанс"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label_one": "Сессия",
|
||||||
|
"label_other": "Сессии",
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Не удалось обновить сеанс"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accessible_paths": {
|
||||||
|
"label": "Доступные директории",
|
||||||
|
"add": "Добавить каталог",
|
||||||
|
"empty": "Выберите хотя бы один каталог, к которому агент имеет доступ.",
|
||||||
|
"required": "Пожалуйста, выберите хотя бы один доступный каталог.",
|
||||||
|
"duplicate": "Этот каталог уже включён.",
|
||||||
|
"select_failed": "Не удалось выбрать каталог."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"error": {
|
||||||
|
"failed": "Не удалось обновить агента"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add": {
|
"add": {
|
||||||
"button": "Добавить в ассистента",
|
"button": "Добавить в ассистента",
|
||||||
@@ -737,9 +805,14 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
|
"add_success": "Успешно добавлено",
|
||||||
"advanced_settings": "Дополнительные настройки",
|
"advanced_settings": "Дополнительные настройки",
|
||||||
|
"agent_one": "Агент",
|
||||||
|
"agent_other": "Агенты",
|
||||||
"and": "и",
|
"and": "и",
|
||||||
"assistant": "Ассистент",
|
"assistant": "Ассистент",
|
||||||
|
"assistant_one": "Помощник",
|
||||||
|
"assistant_other": "ассистент",
|
||||||
"avatar": "Аватар",
|
"avatar": "Аватар",
|
||||||
"back": "Назад",
|
"back": "Назад",
|
||||||
"browse": "Обзор",
|
"browse": "Обзор",
|
||||||
@@ -756,6 +829,8 @@
|
|||||||
"default": "По умолчанию",
|
"default": "По умолчанию",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"delete_confirm": "Вы уверены, что хотите удалить?",
|
"delete_confirm": "Вы уверены, что хотите удалить?",
|
||||||
|
"delete_failed": "Не удалось удалить",
|
||||||
|
"delete_success": "Удаление выполнено успешно",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"detail": "Подробности",
|
"detail": "Подробности",
|
||||||
"disabled": "Отключено",
|
"disabled": "Отключено",
|
||||||
@@ -765,6 +840,10 @@
|
|||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"enabled": "Включено",
|
"enabled": "Включено",
|
||||||
"error": "ошибка",
|
"error": "ошибка",
|
||||||
|
"errors": {
|
||||||
|
"create_message": "Не удалось создать сообщение",
|
||||||
|
"validation": "Ошибка проверки"
|
||||||
|
},
|
||||||
"expand": "Развернуть",
|
"expand": "Развернуть",
|
||||||
"file": {
|
"file": {
|
||||||
"not_supported": "Неподдерживаемый тип файла {{type}}"
|
"not_supported": "Неподдерживаемый тип файла {{type}}"
|
||||||
@@ -775,6 +854,7 @@
|
|||||||
"go_to_settings": "Перейти в настройки",
|
"go_to_settings": "Перейти в настройки",
|
||||||
"i_know": "Я понял",
|
"i_know": "Я понял",
|
||||||
"inspect": "Осмотреть",
|
"inspect": "Осмотреть",
|
||||||
|
"invalid_value": "недопустимое значение",
|
||||||
"knowledge_base": "База знаний",
|
"knowledge_base": "База знаний",
|
||||||
"language": "Язык",
|
"language": "Язык",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -786,6 +866,11 @@
|
|||||||
"none": "без",
|
"none": "без",
|
||||||
"open": "Открыть",
|
"open": "Открыть",
|
||||||
"paste": "Вставить",
|
"paste": "Вставить",
|
||||||
|
"placeholders": {
|
||||||
|
"select": {
|
||||||
|
"model": "Выбор модели"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": "Предварительный просмотр",
|
"preview": "Предварительный просмотр",
|
||||||
"prompt": "Промпт",
|
"prompt": "Промпт",
|
||||||
"provider": "Провайдер",
|
"provider": "Провайдер",
|
||||||
@@ -812,6 +897,7 @@
|
|||||||
"success": "Успешно",
|
"success": "Успешно",
|
||||||
"swap": "Поменять местами",
|
"swap": "Поменять местами",
|
||||||
"topics": "Топики",
|
"topics": "Топики",
|
||||||
|
"update_success": "Обновление выполнено успешно",
|
||||||
"upload_files": "Загрузить файл",
|
"upload_files": "Загрузить файл",
|
||||||
"warning": "Предупреждение",
|
"warning": "Предупреждение",
|
||||||
"you": "Вы"
|
"you": "Вы"
|
||||||
@@ -884,6 +970,7 @@
|
|||||||
"modelType": "Тип модели",
|
"modelType": "Тип модели",
|
||||||
"name": "Название ошибки",
|
"name": "Название ошибки",
|
||||||
"no_api_key": "Ключ API не настроен",
|
"no_api_key": "Ключ API не настроен",
|
||||||
|
"no_response": "Нет ответа",
|
||||||
"originalError": "Исходная ошибка",
|
"originalError": "Исходная ошибка",
|
||||||
"originalMessage": "исходное сообщение",
|
"originalMessage": "исходное сообщение",
|
||||||
"parameter": "параметр",
|
"parameter": "параметр",
|
||||||
|
|||||||
+42
-42
@@ -4,10 +4,10 @@ import { HStack } from '@renderer/components/Layout'
|
|||||||
import ListItem from '@renderer/components/ListItem'
|
import ListItem from '@renderer/components/ListItem'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||||
import { useAgents } from '@renderer/hooks/useAgents'
|
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
|
||||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||||
import { Agent } from '@renderer/types'
|
import { AssistantPreset } from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import { Button, Empty, Flex, Input } from 'antd'
|
import { Button, Empty, Flex, Input } from 'antd'
|
||||||
import { omit } from 'lodash'
|
import { omit } from 'lodash'
|
||||||
@@ -17,65 +17,65 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { groupByCategories, useSystemAgents } from '.'
|
import { groupByCategories, useSystemAssistantPresets } from '.'
|
||||||
import { groupTranslations } from './agentGroupTranslations'
|
import { groupTranslations } from './assistantPresetGroupTranslations'
|
||||||
import AddAgentPopup from './components/AddAgentPopup'
|
import AddAssistantPresetPopup from './components/AddAssistantPresetPopup'
|
||||||
import AgentCard from './components/AgentCard'
|
import AssistantPresetCard from './components/AssistantPresetCard'
|
||||||
import { AgentGroupIcon } from './components/AgentGroupIcon'
|
import { AssistantPresetGroupIcon } from './components/AssistantPresetGroupIcon'
|
||||||
import ImportAgentPopup from './components/ImportAgentPopup'
|
import ImportAssistantPresetPopup from './components/ImportAssistantPresetPopup'
|
||||||
|
|
||||||
const AgentsPage: FC = () => {
|
const AssistantPresetsPage: FC = () => {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const [activeGroup, setActiveGroup] = useState('我的')
|
const [activeGroup, setActiveGroup] = useState('我的')
|
||||||
const [agentGroups, setAgentGroups] = useState<Record<string, Agent[]>>({})
|
const [agentGroups, setAgentGroups] = useState<Record<string, AssistantPreset[]>>({})
|
||||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false)
|
const [isSearchExpanded, setIsSearchExpanded] = useState(false)
|
||||||
const systemAgents = useSystemAgents()
|
const systemPresets = useSystemAssistantPresets()
|
||||||
const { agents: userAgents } = useAgents()
|
const { presets: userPresets } = useAssistantPresets()
|
||||||
const { isTopNavbar } = useNavbarPosition()
|
const { isTopNavbar } = useNavbarPosition()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const systemAgentsGroupList = groupByCategories(systemAgents)
|
const systemAgentsGroupList = groupByCategories(systemPresets)
|
||||||
const agentsGroupList = {
|
const agentsGroupList = {
|
||||||
我的: userAgents,
|
我的: userPresets,
|
||||||
精选: [],
|
精选: [],
|
||||||
...systemAgentsGroupList
|
...systemAgentsGroupList
|
||||||
} as Record<string, Agent[]>
|
} as Record<string, AssistantPreset[]>
|
||||||
setAgentGroups(agentsGroupList)
|
setAgentGroups(agentsGroupList)
|
||||||
}, [systemAgents, userAgents])
|
}, [systemPresets, userPresets])
|
||||||
|
|
||||||
const filteredAgents = useMemo(() => {
|
const filteredPresets = useMemo(() => {
|
||||||
// 搜索框为空直接返回「我的」分组下的 agent
|
// 搜索框为空直接返回「我的」分组下的 agent
|
||||||
if (!search.trim()) {
|
if (!search.trim()) {
|
||||||
return agentGroups[activeGroup] || []
|
return agentGroups[activeGroup] || []
|
||||||
}
|
}
|
||||||
const uniqueAgents = new Map<string, Agent>()
|
const uniquePresets = new Map<string, AssistantPreset>()
|
||||||
Object.entries(agentGroups).forEach(([, agents]) => {
|
Object.entries(agentGroups).forEach(([, agents]) => {
|
||||||
agents.forEach((agent) => {
|
agents.forEach((agent) => {
|
||||||
if (
|
if (
|
||||||
agent.name.toLowerCase().includes(search.toLowerCase()) ||
|
agent.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
agent.description?.toLowerCase().includes(search.toLowerCase())
|
agent.description?.toLowerCase().includes(search.toLowerCase())
|
||||||
) {
|
) {
|
||||||
uniqueAgents.set(agent.id, agent)
|
uniquePresets.set(agent.id, agent)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return Array.from(uniqueAgents.values())
|
return Array.from(uniquePresets.values())
|
||||||
}, [agentGroups, activeGroup, search])
|
}, [agentGroups, activeGroup, search])
|
||||||
|
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
const onAddAgentConfirm = useCallback(
|
const onAddPresetConfirm = useCallback(
|
||||||
(agent: Agent) => {
|
(preset: AssistantPreset) => {
|
||||||
window.modal.confirm({
|
window.modal.confirm({
|
||||||
title: agent.name,
|
title: preset.name,
|
||||||
content: (
|
content: (
|
||||||
<Flex gap={16} vertical style={{ width: 'calc(100% + 12px)' }}>
|
<Flex gap={16} vertical style={{ width: 'calc(100% + 12px)' }}>
|
||||||
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
|
{preset.description && <AgentDescription>{preset.description}</AgentDescription>}
|
||||||
|
|
||||||
{agent.prompt && (
|
{preset.prompt && (
|
||||||
<AgentPrompt className="markdown">
|
<AgentPrompt className="markdown">
|
||||||
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
|
<ReactMarkdown>{preset.prompt}</ReactMarkdown>
|
||||||
</AgentPrompt>
|
</AgentPrompt>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -87,16 +87,16 @@ const AgentsPage: FC = () => {
|
|||||||
centered: true,
|
centered: true,
|
||||||
okButtonProps: { type: 'primary' },
|
okButtonProps: { type: 'primary' },
|
||||||
okText: t('agents.add.button'),
|
okText: t('agents.add.button'),
|
||||||
onOk: () => createAssistantFromAgent(agent)
|
onOk: () => createAssistantFromAgent(preset)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const getAgentFromSystemAgent = useCallback((agent: (typeof systemAgents)[number]) => {
|
const getPresetFromSystemPreset = useCallback((preset: (typeof systemPresets)[number]) => {
|
||||||
return {
|
return {
|
||||||
...omit(agent, 'group'),
|
...omit(preset, 'group'),
|
||||||
name: agent.name,
|
name: preset.name,
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
topics: [],
|
topics: [],
|
||||||
type: 'agent'
|
type: 'agent'
|
||||||
@@ -161,14 +161,14 @@ const AgentsPage: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddAgent = () => {
|
const handleAddAgent = () => {
|
||||||
AddAgentPopup.show().then(() => {
|
AddAssistantPresetPopup.show().then(() => {
|
||||||
handleSearchClear()
|
handleSearchClear()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportAgent = async () => {
|
const handleImportAgent = async () => {
|
||||||
try {
|
try {
|
||||||
await ImportAgentPopup.show()
|
await ImportAssistantPresetPopup.show()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.toast.error(error instanceof Error ? error.message : t('message.agents.import.error'))
|
window.toast.error(error instanceof Error ? error.message : t('message.agents.import.error'))
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ const AgentsPage: FC = () => {
|
|||||||
title={
|
title={
|
||||||
<Flex gap={16} align="center" justify="space-between">
|
<Flex gap={16} align="center" justify="space-between">
|
||||||
<Flex gap={10} align="center">
|
<Flex gap={10} align="center">
|
||||||
<AgentGroupIcon groupName={group} />
|
<AssistantPresetGroupIcon groupName={group} />
|
||||||
{getLocalizedGroupName(group)}
|
{getLocalizedGroupName(group)}
|
||||||
</Flex>
|
</Flex>
|
||||||
{
|
{
|
||||||
@@ -229,19 +229,19 @@ const AgentsPage: FC = () => {
|
|||||||
<AgentsListTitle>
|
<AgentsListTitle>
|
||||||
{search.trim() ? (
|
{search.trim() ? (
|
||||||
<>
|
<>
|
||||||
<AgentGroupIcon groupName="搜索" size={24} />
|
<AssistantPresetGroupIcon groupName="搜索" size={24} />
|
||||||
{search.trim()}{' '}
|
{search.trim()}{' '}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AgentGroupIcon groupName={activeGroup} size={24} />
|
<AssistantPresetGroupIcon groupName={activeGroup} size={24} />
|
||||||
{getLocalizedGroupName(activeGroup)}
|
{getLocalizedGroupName(activeGroup)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{
|
{
|
||||||
<CustomTag color="#A0A0A0" size={10}>
|
<CustomTag color="#A0A0A0" size={10}>
|
||||||
{filteredAgents.length}
|
{filteredPresets.length}
|
||||||
</CustomTag>
|
</CustomTag>
|
||||||
}
|
}
|
||||||
</AgentsListTitle>
|
</AgentsListTitle>
|
||||||
@@ -282,13 +282,13 @@ const AgentsPage: FC = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</AgentsListHeader>
|
</AgentsListHeader>
|
||||||
|
|
||||||
{filteredAgents.length > 0 ? (
|
{filteredPresets.length > 0 ? (
|
||||||
<AgentsList>
|
<AgentsList>
|
||||||
{filteredAgents.map((agent, index) => (
|
{filteredPresets.map((agent, index) => (
|
||||||
<AgentCard
|
<AssistantPresetCard
|
||||||
key={agent.id || index}
|
key={agent.id || index}
|
||||||
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent))}
|
onClick={() => onAddPresetConfirm(getPresetFromSystemPreset(agent))}
|
||||||
agent={agent}
|
preset={agent}
|
||||||
activegroup={activeGroup}
|
activegroup={activeGroup}
|
||||||
getLocalizedGroupName={getLocalizedGroupName}
|
getLocalizedGroupName={getLocalizedGroupName}
|
||||||
/>
|
/>
|
||||||
@@ -390,4 +390,4 @@ const EmptyView = styled.div`
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
`
|
`
|
||||||
|
|
||||||
export default AgentsPage
|
export default AssistantPresetsPage
|
||||||
+1
@@ -1,3 +1,4 @@
|
|||||||
|
// FIXME: Just use i18next!
|
||||||
export type GroupTranslations = {
|
export type GroupTranslations = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
'el-GR': string
|
'el-GR': string
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user