Compare commits
239 Commits
v1.6.2
...
refactor/a
| 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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -69,3 +69,5 @@ playwright-report
|
||||
test-results
|
||||
|
||||
YOUR_MEMORY_FILE_PATH
|
||||
|
||||
.sessions/
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -34,10 +34,10 @@
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"i18n-ally.displayLanguage": "zh-cn",
|
||||
// "i18n-ally.displayLanguage": "zh-cn", // 界面显示语言
|
||||
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
||||
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
||||
"i18n-ally.fullReloadOnChanged": true, // 界面显示语言
|
||||
"i18n-ally.fullReloadOnChanged": true,
|
||||
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
||||
// "i18n-ally.namespace": true, // 开启命名空间
|
||||
|
||||
18
.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.patch
vendored
Normal file
18
.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.patch
vendored
Normal file
@@ -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,
|
||||
153
CLAUDE.md
153
CLAUDE.md
@@ -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
|
||||
|
||||
### 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
|
||||
- **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.
|
||||
## Project Architecture
|
||||
|
||||
### 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
|
||||
- **Debug Mode**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
|
||||
|
||||
### Testing & Quality
|
||||
|
||||
- **Run Tests**: `yarn test` - Runs all tests (Vitest)
|
||||
- **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
|
||||
### Key Components
|
||||
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
|
||||
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
|
||||
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
|
||||
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
|
||||
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
|
||||
|
||||
### Logging
|
||||
```typescript
|
||||
// Main process
|
||||
import { loggerService } from '@logger'
|
||||
const logger = loggerService.withContext('moduleName')
|
||||
|
||||
// Renderer process (set window source first)
|
||||
loggerService.initWindowSource('windowName')
|
||||
const logger = loggerService.withContext('moduleName')
|
||||
|
||||
// Logging
|
||||
// Renderer: loggerService.initWindowSource('windowName') first
|
||||
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: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('packages/shared'),
|
||||
'@types': resolve('src/renderer/src/types'),
|
||||
'@logger': resolve('src/renderer/src/services/LoggerService'),
|
||||
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
||||
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
|
||||
|
||||
20
package.json
20
package.json
@@ -27,6 +27,7 @@
|
||||
"scripts": {
|
||||
"start": "electron-vite preview",
|
||||
"dev": "dotenv electron-vite dev",
|
||||
"dev:main": "dotenv electron-vite dev --watch",
|
||||
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn lint && yarn test",
|
||||
@@ -43,15 +44,18 @@
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||
"generate:agents": "yarn workspace @cherry-studio/database agents",
|
||||
"agents:generate": "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",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=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:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
||||
"check:i18n": "tsx scripts/check-i18n.ts",
|
||||
"sync:i18n": "tsx scripts/sync-i18n.ts",
|
||||
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
|
||||
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
|
||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.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"
|
||||
},
|
||||
"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/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"express": "^5.1.0",
|
||||
"express-validator": "^7.2.1",
|
||||
"font-list": "^2.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"jsdom": "26.1.0",
|
||||
@@ -151,6 +159,7 @@
|
||||
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.12.0",
|
||||
"@swc/plugin-styled-components": "^8.0.4",
|
||||
@@ -238,9 +247,11 @@
|
||||
"docx": "^9.0.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"electron": "37.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-vite": "4.0.0",
|
||||
@@ -325,6 +336,7 @@
|
||||
"string-width": "^7.2.0",
|
||||
"striptags": "^3.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"swr": "^2.3.6",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
@@ -335,7 +347,7 @@
|
||||
"typescript": "~5.8.2",
|
||||
"undici": "6.21.2",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^10.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vitest": "^3.2.4",
|
||||
"webdav": "^5.8.0",
|
||||
|
||||
@@ -89,6 +89,9 @@ export enum IpcChannel {
|
||||
// Python
|
||||
Python_Execute = 'python:execute',
|
||||
|
||||
// agent messages
|
||||
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
|
||||
53
resources/database/drizzle/0000_confused_wendigo.sql
Normal file
53
resources/database/drizzle/0000_confused_wendigo.sql
Normal file
@@ -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
|
||||
);
|
||||
1
resources/database/drizzle/0001_woozy_captain_flint.sql
Normal file
1
resources/database/drizzle/0001_woozy_captain_flint.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `session_messages` ADD `agent_session_id` text DEFAULT '';
|
||||
331
resources/database/drizzle/meta/0000_snapshot.json
Normal file
331
resources/database/drizzle/meta/0000_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
339
resources/database/drizzle/meta/0001_snapshot.json
Normal file
339
resources/database/drizzle/meta/0001_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
20
resources/database/drizzle/meta/_journal.json
Normal file
20
resources/database/drizzle/meta/_journal.json
Normal file
@@ -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 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 baseLocalePath = path.join(__dirname, '../src/renderer/src/i18n/locales', baseFileName)
|
||||
|
||||
type I18NValue = string | { [key: string]: I18NValue }
|
||||
type I18N = { [key: string]: I18NValue }
|
||||
@@ -105,6 +106,9 @@ const translateRecursively = async (originObj: I18N, systemPrompt: string): Prom
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
if (!fs.existsSync(baseLocalePath)) {
|
||||
throw new Error(`${baseLocalePath} not found.`)
|
||||
}
|
||||
const localeFiles = fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => file.endsWith('.json') && file !== baseFileName)
|
||||
|
||||
@@ -35,6 +35,9 @@ const allX64 = {
|
||||
'@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 = {
|
||||
mac: 'darwin',
|
||||
windows: 'win32',
|
||||
@@ -46,9 +49,6 @@ exports.default = async function (context) {
|
||||
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
|
||||
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) => {
|
||||
console.log('downloading packages ......')
|
||||
const downloadPromises = []
|
||||
@@ -67,25 +67,39 @@ exports.default = async function (context) {
|
||||
await Promise.all(downloadPromises)
|
||||
}
|
||||
|
||||
const changeFilters = async (packages, filtersToExclude, filtersToInclude) => {
|
||||
await downloadPackages(packages)
|
||||
const changeFilters = async (filtersToExclude, filtersToInclude) => {
|
||||
// remove filters for the target architecture (allow inclusion)
|
||||
|
||||
let filters = context.packager.config.files[0].filter
|
||||
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
|
||||
|
||||
// add filters for other architectures (exclude them)
|
||||
filters.push(...filtersToExclude)
|
||||
|
||||
context.packager.config.files[0].filter = filters
|
||||
}
|
||||
|
||||
if (arch === Arch.arm64) {
|
||||
await changeFilters(allArm64, x64Filters, arm64Filters)
|
||||
return
|
||||
}
|
||||
await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64)
|
||||
|
||||
if (arch === Arch.x64) {
|
||||
await changeFilters(allX64, arm64Filters, x64Filters)
|
||||
return
|
||||
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
|
||||
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
|
||||
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'
|
||||
|
||||
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 baseFilePath = path.join(translationsDir, baseFileName)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { sortedObjectByKeys } from './sort'
|
||||
|
||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
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 baseFilePath = path.join(localesDir, baseFileName)
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
import { errorHandler } from './middleware/error'
|
||||
import { setupOpenAPIDocumentation } from './middleware/openapi'
|
||||
import { agentsRoutes } from './routes/agents'
|
||||
import { chatRoutes } from './routes/chat'
|
||||
import { mcpRoutes } from './routes/mcp'
|
||||
import { messagesRoutes } from './routes/messages'
|
||||
import { modelsRoutes } from './routes/models'
|
||||
|
||||
const logger = loggerService.withContext('ApiServer')
|
||||
@@ -101,10 +103,7 @@ app.get('/', (_req, res) => {
|
||||
name: 'Cherry Studio API',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
health: 'GET /health',
|
||||
models: 'GET /v1/models',
|
||||
chat: 'POST /v1/chat/completions',
|
||||
mcp: 'GET /v1/mcps'
|
||||
health: 'GET /health'
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -116,7 +115,9 @@ apiRouter.use(express.json())
|
||||
// Mount routes
|
||||
apiRouter.use('/chat', chatRoutes)
|
||||
apiRouter.use('/mcps', mcpRoutes)
|
||||
apiRouter.use('/messages', messagesRoutes)
|
||||
apiRouter.use('/models', modelsRoutes)
|
||||
apiRouter.use('/agents', agentsRoutes)
|
||||
app.use('/v1', apiRouter)
|
||||
|
||||
// Setup OpenAPI documentation
|
||||
|
||||
532
src/main/apiServer/routes/agents/handlers/agents.ts
Normal file
532
src/main/apiServer/routes/agents/handlers/agents.ts
Normal file
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3
src/main/apiServer/routes/agents/handlers/index.ts
Normal file
3
src/main/apiServer/routes/agents/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * as agentHandlers from './agents'
|
||||
export * as messageHandlers from './messages'
|
||||
export * as sessionHandlers from './sessions'
|
||||
230
src/main/apiServer/routes/agents/handlers/messages.ts
Normal file
230
src/main/apiServer/routes/agents/handlers/messages.ts
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
370
src/main/apiServer/routes/agents/handlers/sessions.ts
Normal file
370
src/main/apiServer/routes/agents/handlers/sessions.ts
Normal file
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
927
src/main/apiServer/routes/agents/index.ts
Normal file
927
src/main/apiServer/routes/agents/index.ts
Normal file
@@ -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
|
||||
41
src/main/apiServer/routes/agents/middleware/common.ts
Normal file
41
src/main/apiServer/routes/agents/middleware/common.ts
Normal file
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/main/apiServer/routes/agents/middleware/index.ts
Normal file
1
src/main/apiServer/routes/agents/middleware/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './common'
|
||||
24
src/main/apiServer/routes/agents/validators/agents.ts
Normal file
24
src/main/apiServer/routes/agents/validators/agents.ts
Normal file
@@ -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
|
||||
})
|
||||
7
src/main/apiServer/routes/agents/validators/common.ts
Normal file
7
src/main/apiServer/routes/agents/validators/common.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PaginationQuerySchema } from '@types'
|
||||
|
||||
import { createZodValidator } from './zodValidator'
|
||||
|
||||
export const validatePagination = createZodValidator({
|
||||
query: PaginationQuerySchema
|
||||
})
|
||||
4
src/main/apiServer/routes/agents/validators/index.ts
Normal file
4
src/main/apiServer/routes/agents/validators/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './agents'
|
||||
export * from './common'
|
||||
export * from './messages'
|
||||
export * from './sessions'
|
||||
7
src/main/apiServer/routes/agents/validators/messages.ts
Normal file
7
src/main/apiServer/routes/agents/validators/messages.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CreateSessionMessageRequestSchema } from '@types'
|
||||
|
||||
import { createZodValidator } from './zodValidator'
|
||||
|
||||
export const validateSessionMessage = createZodValidator({
|
||||
body: CreateSessionMessageRequestSchema
|
||||
})
|
||||
24
src/main/apiServer/routes/agents/validators/sessions.ts
Normal file
24
src/main/apiServer/routes/agents/validators/sessions.ts
Normal file
@@ -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
|
||||
})
|
||||
68
src/main/apiServer/routes/agents/validators/zodValidator.ts
Normal file
68
src/main/apiServer/routes/agents/validators/zodValidator.ts
Normal file
@@ -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 OpenAI from 'openai'
|
||||
import { ChatCompletionCreateParams } from 'openai/resources'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import { chatCompletionService } from '../services/chat-completion'
|
||||
import { validateModelId } from '../utils'
|
||||
import {
|
||||
ChatCompletionModelError,
|
||||
chatCompletionService,
|
||||
ChatCompletionValidationError
|
||||
} from '../services/chat-completion'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerChatRoutes')
|
||||
|
||||
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
|
||||
* /v1/chat/completions:
|
||||
@@ -60,7 +150,7 @@ const router = express.Router()
|
||||
* type: integer
|
||||
* total_tokens:
|
||||
* type: integer
|
||||
* text/plain:
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Server-sent events stream (when stream=true)
|
||||
@@ -110,63 +200,22 @@ router.post('/completions', async (req: Request, res: Response) => {
|
||||
temperature: request.temperature
|
||||
})
|
||||
|
||||
// Validate request
|
||||
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'
|
||||
}
|
||||
})
|
||||
}
|
||||
const isStreaming = !!request.stream
|
||||
|
||||
// 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({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: 'invalid_request_error',
|
||||
code: error.code
|
||||
}
|
||||
})
|
||||
}
|
||||
if (isStreaming) {
|
||||
const { stream } = await chatCompletionService.processStreamingCompletion(request)
|
||||
|
||||
const provider = modelValidation.provider!
|
||||
const modelId = modelValidation.modelId!
|
||||
|
||||
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('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 streamResponse as any) {
|
||||
for await (const chunk of stream) {
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
} catch (streamError: any) {
|
||||
logger.error('Stream error:', streamError)
|
||||
res.write(
|
||||
@@ -178,47 +227,17 @@ router.post('/completions', async (req: Request, res: Response) => {
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} finally {
|
||||
res.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle non-streaming
|
||||
const response = await client.chat.completions.create(request)
|
||||
const { response } = await chatCompletionService.processCompletion(request)
|
||||
return res.json(response)
|
||||
} catch (error: any) {
|
||||
logger.error('Chat completion error:', error)
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const { status, body } = mapChatCompletionError(error)
|
||||
return res.status(status).json(body)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
290
src/main/apiServer/routes/messages.ts
Normal file
290
src/main/apiServer/routes/messages.ts
Normal file
@@ -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 { loggerService } from '../../services/LoggerService'
|
||||
import { chatCompletionService } from '../services/chat-completion'
|
||||
import { modelsService } from '../services/models'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerModelsRoutes')
|
||||
|
||||
const router = express.Router()
|
||||
const router = express
|
||||
.Router()
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/models:
|
||||
* get:
|
||||
* summary: List available models
|
||||
* description: Returns a list of available AI models from all configured providers
|
||||
* tags: [Models]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of available models
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* object:
|
||||
* type: string
|
||||
* example: list
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Model'
|
||||
* 503:
|
||||
* description: Service unavailable
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Models list request received')
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/models:
|
||||
* get:
|
||||
* summary: List available models
|
||||
* description: Returns a list of available AI models from all configured providers with optional filtering
|
||||
* tags: [Models]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: providerType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [openai, openai-response, anthropic, gemini]
|
||||
* description: Filter models by provider type
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* description: Pagination offset
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: Maximum number of models to return
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of available models
|
||||
* content:
|
||||
* 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) {
|
||||
logger.warn(
|
||||
'No models available from providers. This may be because no OpenAI providers are configured or enabled.'
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`Returning ${models.length} models (OpenAI providers only)`)
|
||||
logger.debug(
|
||||
'Model IDs:',
|
||||
models.map((m) => m.id)
|
||||
)
|
||||
|
||||
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'
|
||||
if (!filterResult.success) {
|
||||
logger.warn('Invalid query parameters:', filterResult.error.issues)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid query parameters',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_parameters',
|
||||
details: filterResult.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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 }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { agentService } from '../services/agents'
|
||||
import { loggerService } from '../services/LoggerService'
|
||||
import { app } from './app'
|
||||
import { config } from './config'
|
||||
@@ -18,6 +19,11 @@ export class ApiServer {
|
||||
// Load config
|
||||
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
|
||||
this.server = createServer(app)
|
||||
|
||||
|
||||
@@ -1,83 +1,131 @@
|
||||
import { Provider } from '@types'
|
||||
import OpenAI from 'openai'
|
||||
import { ChatCompletionCreateParams } from 'openai/resources'
|
||||
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import {
|
||||
getProviderByModel,
|
||||
getRealProviderModel,
|
||||
listAllAvailableModels,
|
||||
OpenAICompatibleModel,
|
||||
transformModelToOpenAI,
|
||||
validateProvider
|
||||
} from '../utils'
|
||||
import { ModelValidationError, validateModelId } from '../utils'
|
||||
|
||||
const logger = loggerService.withContext('ChatCompletionService')
|
||||
|
||||
export interface ModelData extends OpenAICompatibleModel {
|
||||
provider_id: string
|
||||
model_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean
|
||||
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 {
|
||||
async getModels(): Promise<ModelData[]> {
|
||||
try {
|
||||
logger.info('Getting available models from providers')
|
||||
async resolveProviderContext(model: string): Promise<
|
||||
| { ok: false; error: ModelValidationError }
|
||||
| { 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)
|
||||
const uniqueModels = new Map<string, ModelData>()
|
||||
|
||||
for (const model of models) {
|
||||
const openAIModel = transformModelToOpenAI(model)
|
||||
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,
|
||||
provider_id: model.provider,
|
||||
model_id: model.id,
|
||||
name: model.name
|
||||
})
|
||||
} else {
|
||||
logger.debug(`Skipping duplicate model: ${fullModelId}`)
|
||||
if (provider.type !== 'openai') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: 'unsupported_provider_type',
|
||||
message: `Provider '${provider.id}' of type '${provider.type}' is not supported for OpenAI chat completions`,
|
||||
code: 'unsupported_provider_type'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`)
|
||||
return {
|
||||
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
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting models:', error)
|
||||
return []
|
||||
const providerContext = await this.resolveProviderContext(request.model!)
|
||||
if (!providerContext.ok) {
|
||||
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 {
|
||||
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
|
||||
if (!request.messages) {
|
||||
errors.push('Messages array is required')
|
||||
@@ -98,17 +146,6 @@ export class ChatCompletionService {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
logger.info('Processing chat completion request:', {
|
||||
model: request.model,
|
||||
@@ -124,38 +165,16 @@ export class ChatCompletionService {
|
||||
stream: request.stream
|
||||
})
|
||||
|
||||
// Validate request
|
||||
const validation = this.validateRequest(request)
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
||||
const preparation = await this.prepareRequest(request, false)
|
||||
if (preparation.status === 'validation_error') {
|
||||
throw new ChatCompletionValidationError(preparation.errors)
|
||||
}
|
||||
|
||||
// Get provider for the model
|
||||
const provider = await getProviderByModel(request.model!)
|
||||
if (!provider) {
|
||||
throw new Error(`Provider not found for model: ${request.model}`)
|
||||
if (preparation.status === 'model_error') {
|
||||
throw new ChatCompletionModelError(preparation.error)
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
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
|
||||
}
|
||||
const { provider, modelId, client, providerRequest } = preparation
|
||||
|
||||
logger.debug('Sending request to provider:', {
|
||||
provider: provider.id,
|
||||
@@ -166,54 +185,40 @@ export class ChatCompletionService {
|
||||
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
||||
|
||||
logger.info('Successfully processed chat completion')
|
||||
return response
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
response
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error processing chat completion:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async *processStreamingCompletion(
|
||||
async processStreamingCompletion(
|
||||
request: ChatCompletionCreateParams
|
||||
): AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk> {
|
||||
): Promise<{
|
||||
provider: Provider
|
||||
modelId: string
|
||||
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
||||
}> {
|
||||
try {
|
||||
logger.info('Processing streaming chat completion request:', {
|
||||
model: request.model,
|
||||
messageCount: request.messages.length
|
||||
})
|
||||
|
||||
// Validate request
|
||||
const validation = this.validateRequest(request)
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
||||
const preparation = await this.prepareRequest(request, true)
|
||||
if (preparation.status === 'validation_error') {
|
||||
throw new ChatCompletionValidationError(preparation.errors)
|
||||
}
|
||||
|
||||
// Get provider for the model
|
||||
const provider = await getProviderByModel(request.model!)
|
||||
if (!provider) {
|
||||
throw new Error(`Provider not found for model: ${request.model}`)
|
||||
if (preparation.status === 'model_error') {
|
||||
throw new ChatCompletionModelError(preparation.error)
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
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
|
||||
}
|
||||
const { provider, modelId, client, providerRequest } = preparation
|
||||
|
||||
logger.debug('Sending streaming request to provider:', {
|
||||
provider: provider.id,
|
||||
@@ -221,13 +226,17 @@ export class ChatCompletionService {
|
||||
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) {
|
||||
yield chunk
|
||||
logger.info('Successfully started streaming chat completion')
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
stream
|
||||
}
|
||||
|
||||
logger.info('Successfully completed streaming chat completion')
|
||||
} catch (error: any) {
|
||||
logger.error('Error processing streaming chat completion:', error)
|
||||
throw error
|
||||
|
||||
@@ -13,8 +13,7 @@ import { Request, Response } from 'express'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import { reduxService } from '../../services/ReduxService'
|
||||
import { getMcpServerById } from '../utils/mcp'
|
||||
import { getMcpServerById, getMCPServersFromRedux } from '../utils/mcp'
|
||||
|
||||
const logger = loggerService.withContext('MCPApiService')
|
||||
const transports: Record<string, StreamableHTTPServerTransport> = {}
|
||||
@@ -57,34 +56,10 @@ class MCPApiService extends EventEmitter {
|
||||
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
|
||||
async getAllServers(req: Request): Promise<McpServersResp> {
|
||||
try {
|
||||
const servers = await this.getServersFromRedux()
|
||||
const servers = await getMCPServersFromRedux()
|
||||
logger.silly(`Returning ${servers.length} servers`)
|
||||
const resp: McpServersResp = {
|
||||
servers: {}
|
||||
@@ -111,7 +86,7 @@ class MCPApiService extends EventEmitter {
|
||||
async getServerById(id: string): Promise<MCPServer | null> {
|
||||
try {
|
||||
logger.silly(`getServerById called with id: ${id}`)
|
||||
const servers = await this.getServersFromRedux()
|
||||
const servers = await getMCPServersFromRedux()
|
||||
const server = servers.find((s) => s.id === id)
|
||||
if (!server) {
|
||||
logger.warn(`Server with id ${id} not found`)
|
||||
|
||||
106
src/main/apiServer/services/messages.ts
Normal file
106
src/main/apiServer/services/messages.ts
Normal file
@@ -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()
|
||||
93
src/main/apiServer/services/models.ts
Normal file
93
src/main/apiServer/services/models.ts
Normal file
@@ -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 { reduxService } from '@main/services/ReduxService'
|
||||
import { Model, Provider } from '@types'
|
||||
import { ApiModel, Model, Provider } from '@types'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerUtils')
|
||||
|
||||
// OpenAI compatible model format
|
||||
export interface OpenAICompatibleModel {
|
||||
id: string
|
||||
object: 'model'
|
||||
created: number
|
||||
owned_by: string
|
||||
provider?: string
|
||||
provider_model_id?: string
|
||||
}
|
||||
// Cache configuration
|
||||
const PROVIDERS_CACHE_KEY = 'api-server:providers'
|
||||
const PROVIDERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export async function getAvailableProviders(): Promise<Provider[]> {
|
||||
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')
|
||||
if (!providers || !Array.isArray(providers)) {
|
||||
logger.warn('No providers found in Redux store, returning empty array')
|
||||
return []
|
||||
}
|
||||
|
||||
// Only support OpenAI type providers for API server
|
||||
const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai')
|
||||
// Support OpenAI and Anthropic type providers for API server
|
||||
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) {
|
||||
logger.error('Failed to get providers from Redux store:', error)
|
||||
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 {
|
||||
id: `${model.provider}:${model.id}`,
|
||||
object: 'model',
|
||||
name: model.name,
|
||||
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_name: providerDisplayName,
|
||||
provider_type: provider?.type,
|
||||
provider_model_id: model.id
|
||||
}
|
||||
}
|
||||
@@ -215,10 +227,10 @@ export function validateProvider(provider: Provider): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only support OpenAI type providers
|
||||
if (provider.type !== 'openai') {
|
||||
// Support OpenAI and Anthropic type providers
|
||||
if (provider.type !== 'openai' && provider.type !== 'anthropic') {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CacheService } from '@main/services/CacheService'
|
||||
import mcpService from '@main/services/MCPService'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
@@ -8,6 +9,10 @@ import { reduxService } from '../../services/ReduxService'
|
||||
|
||||
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> = {}
|
||||
|
||||
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> {
|
||||
const servers = await getServersFromRedux()
|
||||
const servers = await getMCPServersFromRedux()
|
||||
return servers.find((s) => s.id === id || s.name === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get servers directly from Redux store
|
||||
*/
|
||||
async function getServersFromRedux(): Promise<MCPServer[]> {
|
||||
export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
||||
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')
|
||||
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
|
||||
return servers || []
|
||||
const serverList = 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) {
|
||||
logger.error('Failed to get servers from Redux:', error)
|
||||
return []
|
||||
@@ -54,7 +74,7 @@ async function getServersFromRedux(): Promise<MCPServer[]> {
|
||||
export async function getMcpServerById(id: string): Promise<Server> {
|
||||
const server = cachedServers[id]
|
||||
if (!server) {
|
||||
const servers = await getServersFromRedux()
|
||||
const servers = await getMCPServersFromRedux()
|
||||
const mcpServer = servers.find((s) => s.id === id || s.name === id)
|
||||
if (!mcpServer) {
|
||||
throw new Error(`Server not found: ${id}`)
|
||||
|
||||
@@ -10,9 +10,13 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { isDev, isLinux, isWin } from './constant'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { agentService } from './services/agents'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
import { nodeTraceService } from './services/NodeTraceService'
|
||||
@@ -26,8 +30,6 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import process from 'node:process'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
|
||||
const logger = loggerService.withContext('MainEntry')
|
||||
|
||||
@@ -147,6 +149,14 @@ if (!app.requestSingleInstanceLock()) {
|
||||
//start selection assistant service
|
||||
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
|
||||
try {
|
||||
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 fontList from 'font-list'
|
||||
|
||||
import { agentMessageRepository } from './services/agents/database'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import appService from './services/AppService'
|
||||
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
|
||||
if (isMac) {
|
||||
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
||||
|
||||
341
src/main/services/agents/AGENT_MESSAGE_ARCHITECTURE.md
Normal file
341
src/main/services/agents/AGENT_MESSAGE_ARCHITECTURE.md
Normal file
@@ -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.
|
||||
311
src/main/services/agents/BaseService.ts
Normal file
311
src/main/services/agents/BaseService.ts
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
81
src/main/services/agents/README.md
Normal file
81
src/main/services/agents/README.md
Normal file
@@ -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.
|
||||
35
src/main/services/agents/TODO.md
Normal file
35
src/main/services/agents/TODO.md
Normal file
@@ -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.
|
||||
161
src/main/services/agents/database/MigrationService.ts
Normal file
161
src/main/services/agents/database/MigrationService.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/main/services/agents/database/index.ts
Normal file
14
src/main/services/agents/database/index.ts
Normal file
@@ -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'
|
||||
35
src/main/services/agents/database/schema/agents.schema.ts
Normal file
35
src/main/services/agents/database/schema/agents.schema.ts
Normal file
@@ -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
|
||||
8
src/main/services/agents/database/schema/index.ts
Normal file
8
src/main/services/agents/database/schema/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Drizzle ORM schema exports
|
||||
*/
|
||||
|
||||
export * from './agents.schema'
|
||||
export * from './messages.schema'
|
||||
export * from './migrations.schema'
|
||||
export * from './sessions.schema'
|
||||
30
src/main/services/agents/database/schema/messages.schema.ts
Normal file
30
src/main/services/agents/database/schema/messages.schema.ts
Normal file
@@ -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
|
||||
45
src/main/services/agents/database/schema/sessions.schema.ts
Normal file
45
src/main/services/agents/database/schema/sessions.schema.ts
Normal file
@@ -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
|
||||
181
src/main/services/agents/database/sessionMessageRepository.ts
Normal file
181
src/main/services/agents/database/sessionMessageRepository.ts
Normal file
@@ -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()
|
||||
31
src/main/services/agents/drizzle.config.ts
Normal file
31
src/main/services/agents/drizzle.config.ts
Normal file
@@ -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
|
||||
})
|
||||
23
src/main/services/agents/errors.ts
Normal file
23
src/main/services/agents/errors.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
25
src/main/services/agents/index.ts
Normal file
25
src/main/services/agents/index.ts
Normal file
@@ -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'
|
||||
31
src/main/services/agents/interfaces/AgentStreamInterface.ts
Normal file
31
src/main/services/agents/interfaces/AgentStreamInterface.ts
Normal file
@@ -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>
|
||||
}
|
||||
201
src/main/services/agents/services/AgentService.ts
Normal file
201
src/main/services/agents/services/AgentService.ts
Normal file
@@ -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()
|
||||
329
src/main/services/agents/services/SessionMessageService.ts
Normal file
329
src/main/services/agents/services/SessionMessageService.ts
Normal file
@@ -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()
|
||||
241
src/main/services/agents/services/SessionService.ts
Normal file
241
src/main/services/agents/services/SessionService.ts
Normal file
@@ -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()
|
||||
221
src/main/services/agents/services/claudecode/index.ts
Normal file
221
src/main/services/agents/services/claudecode/index.ts
Normal file
@@ -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'
|
||||
}
|
||||
}
|
||||
48
src/main/services/agents/services/claudecode/tools.ts
Normal file
48
src/main/services/agents/services/claudecode/tools.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Tool } from '@types'
|
||||
|
||||
// https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude
|
||||
export const builtinTools: Tool[] = [
|
||||
{ id: 'Bash', name: 'Bash', description: 'Executes shell commands in your environment', requirePermissions: true },
|
||||
{ id: 'Edit', name: 'Edit', description: 'Makes targeted edits to specific files', requirePermissions: true },
|
||||
{ id: 'Glob', name: 'Glob', description: 'Finds files based on pattern matching', requirePermissions: false },
|
||||
{ id: 'Grep', name: 'Grep', description: 'Searches for patterns in file contents', requirePermissions: false },
|
||||
{
|
||||
id: 'MultiEdit',
|
||||
name: 'MultiEdit',
|
||||
description: 'Performs multiple edits on a single file atomically',
|
||||
requirePermissions: true
|
||||
},
|
||||
{
|
||||
id: 'NotebookEdit',
|
||||
name: 'NotebookEdit',
|
||||
description: 'Modifies Jupyter notebook cells',
|
||||
requirePermissions: true
|
||||
},
|
||||
{
|
||||
id: 'NotebookRead',
|
||||
name: 'NotebookRead',
|
||||
description: 'Reads and displays Jupyter notebook contents',
|
||||
requirePermissions: false
|
||||
},
|
||||
{ id: 'Read', name: 'Read', description: 'Reads the contents of files', requirePermissions: false },
|
||||
{
|
||||
id: 'Task',
|
||||
name: 'Task',
|
||||
description: 'Runs a sub-agent to handle complex, multi-step tasks',
|
||||
requirePermissions: false
|
||||
},
|
||||
{
|
||||
id: 'TodoWrite',
|
||||
name: 'TodoWrite',
|
||||
description: 'Creates and manages structured task lists',
|
||||
requirePermissions: false
|
||||
},
|
||||
{ id: 'WebFetch', name: 'WebFetch', description: 'Fetches content from a specified URL', requirePermissions: true },
|
||||
{
|
||||
id: 'WebSearch',
|
||||
name: 'WebSearch',
|
||||
description: 'Performs web searches with domain filtering',
|
||||
requirePermissions: true
|
||||
},
|
||||
{ id: 'Write', name: 'Write', description: 'Creates or overwrites files', requirePermissions: true }
|
||||
]
|
||||
354
src/main/services/agents/services/claudecode/transform.ts
Normal file
354
src/main/services/agents/services/claudecode/transform.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/main/services/agents/services/index.ts
Normal file
26
src/main/services/agents/services/index.ts
Normal file
@@ -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 { readFile } from 'fs/promises'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => {
|
||||
// Delayed loading: The Sharp module is only loaded when the OCR functionality is actually needed, not at app startup
|
||||
const sharp = (await import('sharp')).default
|
||||
return sharp(buffer)
|
||||
.grayscale() // 转为灰度
|
||||
.normalize()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import TabsContainer from './components/Tab/TabContainer'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import { useNavbarPosition } from './hooks/useSettings'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AssistantPresetsPage from './pages/assistantPresets/AssistantPresetsPage'
|
||||
import CodeToolsPage from './pages/code/CodeToolsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
@@ -29,7 +29,7 @@ const Router: FC = () => {
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/assistantPresets" element={<AssistantPresetsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
|
||||
@@ -32,16 +32,19 @@ export class AiSdkToChunkAdapter {
|
||||
private accumulate: boolean | undefined
|
||||
private isFirstChunk = true
|
||||
private enableWebSearch: boolean = false
|
||||
private onSessionUpdate?: (sessionId: string) => void
|
||||
|
||||
constructor(
|
||||
private onChunk: (chunk: Chunk) => void,
|
||||
mcpTools: MCPTool[] = [],
|
||||
accumulate?: boolean,
|
||||
enableWebSearch?: boolean
|
||||
enableWebSearch?: boolean,
|
||||
onSessionUpdate?: (sessionId: string) => void
|
||||
) {
|
||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||
this.accumulate = accumulate
|
||||
this.enableWebSearch = enableWebSearch || false
|
||||
this.onSessionUpdate = onSessionUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +111,15 @@ export class AiSdkToChunkAdapter {
|
||||
chunk: TextStreamPart<any>,
|
||||
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)
|
||||
switch (chunk.type) {
|
||||
// === 文本相关事件 ===
|
||||
|
||||
237
src/renderer/src/api/agent.ts
Normal file
237
src/renderer/src/api/agent.ts
Normal file
@@ -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 {
|
||||
outline: none;
|
||||
outline-style: none;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -6,6 +6,7 @@ interface ContextMenuProps {
|
||||
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 { t } = useTranslation()
|
||||
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
|
||||
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 { 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 { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
||||
import { take } from 'lodash'
|
||||
@@ -25,30 +25,30 @@ interface Props {
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const { agents: userAgents } = useAgents()
|
||||
const { presets: userPresets } = useAssistantPresets()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { assistants, addAssistant } = useAssistants()
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const systemAgents = useSystemAgents()
|
||||
const systemPresets = useSystemAssistantPresets()
|
||||
const loadingRef = useRef(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const agents = useMemo(() => {
|
||||
const allAgents = [...userAgents, ...systemAgents] as Agent[]
|
||||
const list = [defaultAssistant, ...allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))]
|
||||
const presets = useMemo(() => {
|
||||
const allPresets = [...userPresets, ...systemPresets] as AssistantPreset[]
|
||||
const list = [defaultAssistant, ...allPresets.filter((preset) => !assistants.map((a) => a.id).includes(preset.id))]
|
||||
const filtered = searchText
|
||||
? list.filter(
|
||||
(agent) =>
|
||||
agent.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
|
||||
agent.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
|
||||
(preset) =>
|
||||
preset.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
|
||||
preset.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
|
||||
)
|
||||
: list
|
||||
|
||||
if (searchText.trim()) {
|
||||
const newAgent: Agent = {
|
||||
const newAgent: AssistantPreset = {
|
||||
id: 'new',
|
||||
name: searchText.trim(),
|
||||
prompt: '',
|
||||
@@ -59,15 +59,15 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
return [newAgent, ...filtered]
|
||||
}
|
||||
return filtered
|
||||
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
|
||||
}, [assistants, defaultAssistant, searchText, systemPresets, userPresets])
|
||||
|
||||
// 重置选中索引当搜索或列表内容变更时
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [agents.length, searchText])
|
||||
}, [presets.length, searchText])
|
||||
|
||||
const onCreateAssistant = useCallback(
|
||||
async (agent: Agent) => {
|
||||
async (preset: AssistantPreset) => {
|
||||
if (loadingRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -75,11 +75,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
loadingRef.current = true
|
||||
let assistant: Assistant
|
||||
|
||||
if (agent.id === 'default') {
|
||||
assistant = { ...agent, id: uuid() }
|
||||
if (preset.id === 'default') {
|
||||
assistant = { ...preset, id: uuid() }
|
||||
addAssistant(assistant)
|
||||
} else {
|
||||
assistant = await createAssistantFromAgent(agent)
|
||||
assistant = await createAssistantFromAgent(preset)
|
||||
}
|
||||
|
||||
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||
@@ -93,28 +93,28 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
if (!open) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const displayedAgents = take(agents, 100)
|
||||
const displayedPresets = take(presets, 100)
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
|
||||
setSelectedIndex((prev) => (prev >= displayedPresets.length - 1 ? 0 : prev + 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
|
||||
setSelectedIndex((prev) => (prev <= 0 ? displayedPresets.length - 1 : prev - 1))
|
||||
break
|
||||
case 'Enter':
|
||||
case 'NumpadEnter':
|
||||
// 如果焦点在输入框且有搜索内容,则默认选择第一项
|
||||
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
|
||||
e.preventDefault()
|
||||
onCreateAssistant(displayedAgents[selectedIndex])
|
||||
onCreateAssistant(displayedPresets[selectedIndex])
|
||||
}
|
||||
// 否则选择当前选中项
|
||||
else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
|
||||
else if (selectedIndex >= 0 && selectedIndex < displayedPresets.length) {
|
||||
e.preventDefault()
|
||||
onCreateAssistant(displayedAgents[selectedIndex])
|
||||
onCreateAssistant(displayedPresets[selectedIndex])
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -122,14 +122,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
|
||||
}, [open, selectedIndex, presets, searchText, onCreateAssistant])
|
||||
|
||||
// 确保选中项在可视区域
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const agentItems = containerRef.current.querySelectorAll('.agent-item')
|
||||
if (agentItems[selectedIndex]) {
|
||||
agentItems[selectedIndex].scrollIntoView({
|
||||
const presetItems = containerRef.current.querySelectorAll('.agent-item')
|
||||
if (presetItems[selectedIndex]) {
|
||||
presetItems[selectedIndex].scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
})
|
||||
@@ -193,19 +193,19 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
<Container ref={containerRef}>
|
||||
{take(agents, 100).map((agent, index) => (
|
||||
{take(presets, 100).map((preset, index) => (
|
||||
<AgentItem
|
||||
key={agent.id}
|
||||
onClick={() => onCreateAssistant(agent)}
|
||||
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
|
||||
key={preset.id}
|
||||
onClick={() => onCreateAssistant(preset)}
|
||||
className={`agent-item ${preset.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
|
||||
onMouseEnter={() => setSelectedIndex(index)}>
|
||||
<HStack alignItems="center" gap={5} style={{ overflow: 'hidden', maxWidth: '100%' }}>
|
||||
<EmojiIcon emoji={agent.emoji || ''} />
|
||||
<span className="text-nowrap">{agent.name}</span>
|
||||
<EmojiIcon emoji={preset.emoji || ''} />
|
||||
<span className="text-nowrap">{preset.name}</span>
|
||||
</HStack>
|
||||
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
||||
{agent.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
|
||||
{agent.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
|
||||
{preset.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
||||
{preset.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
|
||||
{preset.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
|
||||
</AgentItem>
|
||||
))}
|
||||
</Container>
|
||||
|
||||
392
src/renderer/src/components/Popups/agent/AgentModal.tsx
Normal file
392
src/renderer/src/components/Popups/agent/AgentModal.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
276
src/renderer/src/components/Popups/agent/SessionModal.tsx
Normal file
276
src/renderer/src/components/Popups/agent/SessionModal.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
51
src/renderer/src/components/Popups/agent/shared.tsx
Normal file
51
src/renderer/src/components/Popups/agent/shared.tsx
Normal file
@@ -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) {
|
||||
case 'home':
|
||||
return <Home size={14} />
|
||||
case 'agents':
|
||||
case 'assistantPresets':
|
||||
return <Sparkle size={14} />
|
||||
case 'translate':
|
||||
return <Languages size={14} />
|
||||
|
||||
21
src/renderer/src/config/agent.ts
Normal file
21
src/renderer/src/config/agent.ts
Normal file
@@ -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 ''
|
||||
}
|
||||
}
|
||||
42
src/renderer/src/hooks/agents/useAgent.ts
Normal file
42
src/renderer/src/hooks/agents/useAgent.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
15
src/renderer/src/hooks/agents/useAgentClient.ts
Normal file
15
src/renderer/src/hooks/agents/useAgentClient.ts
Normal file
@@ -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
|
||||
}
|
||||
76
src/renderer/src/hooks/agents/useAgents.ts
Normal file
76
src/renderer/src/hooks/agents/useAgents.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
19
src/renderer/src/hooks/agents/useModels.ts
Normal file
19
src/renderer/src/hooks/agents/useModels.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
78
src/renderer/src/hooks/agents/useSession.ts
Normal file
78
src/renderer/src/hooks/agents/useSession.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
80
src/renderer/src/hooks/agents/useSessions.ts
Normal file
80
src/renderer/src/hooks/agents/useSessions.ts
Normal file
@@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/renderer/src/hooks/useAssistantPresets.ts
Normal file
35
src/renderer/src/hooks/useAssistantPresets.ts
Normal file
@@ -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 { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { useAgents } from './useAgents'
|
||||
import { useAssistants } from './useAssistant'
|
||||
import { useAssistantPresets } from './useAssistantPresets'
|
||||
import { useTimer } from './useTimer'
|
||||
|
||||
export const useKnowledge = (baseId: string) => {
|
||||
@@ -346,7 +346,7 @@ export const useKnowledgeBases = () => {
|
||||
const dispatch = useDispatch()
|
||||
const bases = useSelector((state: RootState) => state.knowledge.bases)
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
const { agents, updateAgents } = useAgents()
|
||||
const { presets, setAssistantPresets } = useAssistantPresets()
|
||||
|
||||
const addKnowledgeBase = (base: KnowledgeBase) => {
|
||||
dispatch(addBase(base))
|
||||
@@ -373,7 +373,7 @@ export const useKnowledgeBases = () => {
|
||||
})
|
||||
|
||||
// remove agent knowledge_base
|
||||
const _agents = agents.map((agent) => {
|
||||
const _presets = presets.map((agent) => {
|
||||
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
|
||||
return {
|
||||
...agent,
|
||||
@@ -384,7 +384,7 @@ export const useKnowledgeBases = () => {
|
||||
})
|
||||
|
||||
updateAssistants(_assistants)
|
||||
updateAgents(_agents)
|
||||
setAssistantPresets(_presets)
|
||||
}
|
||||
|
||||
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
|
||||
|
||||
@@ -125,7 +125,8 @@ export const getRestoreProgressLabel = (key: string): string => {
|
||||
}
|
||||
|
||||
const titleKeyMap = {
|
||||
agents: 'title.agents',
|
||||
// TODO: update i18n key
|
||||
assistantPresets: 'title.agents',
|
||||
apps: 'title.apps',
|
||||
code: 'title.code',
|
||||
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": {
|
||||
"add": {
|
||||
"button": "Add to Assistant",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"add_success": "Added successfully",
|
||||
"advanced_settings": "Advanced Settings",
|
||||
"agent_one": "Agent",
|
||||
"agent_other": "Agents",
|
||||
"and": "and",
|
||||
"assistant": "Assistant",
|
||||
"assistant": "Agent",
|
||||
"assistant_one": "Assistant",
|
||||
"assistant_other": "Assistants",
|
||||
"avatar": "Avatar",
|
||||
"back": "Back",
|
||||
"browse": "Browse",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "Default",
|
||||
"delete": "Delete",
|
||||
"delete_confirm": "Are you sure you want to delete?",
|
||||
"delete_failed": "Failed to delete",
|
||||
"delete_success": "Deleted successfully",
|
||||
"description": "Description",
|
||||
"detail": "Detail",
|
||||
"disabled": "Disabled",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "Edit",
|
||||
"enabled": "Enabled",
|
||||
"error": "error",
|
||||
"errors": {
|
||||
"create_message": "Failed to create message",
|
||||
"validation": "Verification failed"
|
||||
},
|
||||
"expand": "Expand",
|
||||
"file": {
|
||||
"not_supported": "Unsupported file type {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "Go to settings",
|
||||
"i_know": "I know",
|
||||
"inspect": "Inspect",
|
||||
"invalid_value": "Invalid Value",
|
||||
"knowledge_base": "Knowledge Base",
|
||||
"language": "Language",
|
||||
"loading": "Loading...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "None",
|
||||
"open": "Open",
|
||||
"paste": "Paste",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "Select a model"
|
||||
}
|
||||
},
|
||||
"preview": "Preview",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Provider",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "Success",
|
||||
"swap": "Swap",
|
||||
"topics": "Topics",
|
||||
"update_success": "Update successfully",
|
||||
"upload_files": "Upload file",
|
||||
"warning": "Warning",
|
||||
"you": "You"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "Model Type",
|
||||
"name": "Error name",
|
||||
"no_api_key": "API key is not configured",
|
||||
"no_response": "No response",
|
||||
"originalError": "Original Error",
|
||||
"originalMessage": "Original Message",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "添加到助手",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "添加",
|
||||
"add_success": "添加成功",
|
||||
"advanced_settings": "高级设置",
|
||||
"agent_one": "Agent",
|
||||
"agent_other": "Agents",
|
||||
"and": "和",
|
||||
"assistant": "智能体",
|
||||
"assistant_one": "助手",
|
||||
"assistant_other": "助手",
|
||||
"avatar": "头像",
|
||||
"back": "返回",
|
||||
"browse": "浏览",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "默认",
|
||||
"delete": "删除",
|
||||
"delete_confirm": "确定要删除吗?",
|
||||
"delete_failed": "删除失败",
|
||||
"delete_success": "删除成功",
|
||||
"description": "描述",
|
||||
"detail": "详情",
|
||||
"disabled": "已禁用",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "编辑",
|
||||
"enabled": "已启用",
|
||||
"error": "错误",
|
||||
"errors": {
|
||||
"create_message": "创建消息失败",
|
||||
"validation": "验证失败"
|
||||
},
|
||||
"expand": "展开",
|
||||
"file": {
|
||||
"not_supported": "不支持的文件类型 {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "前往设置",
|
||||
"i_know": "我知道了",
|
||||
"inspect": "检查",
|
||||
"invalid_value": "无效值",
|
||||
"knowledge_base": "知识库",
|
||||
"language": "语言",
|
||||
"loading": "加载中...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "无",
|
||||
"open": "打开",
|
||||
"paste": "粘贴",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "选择模型"
|
||||
}
|
||||
},
|
||||
"preview": "预览",
|
||||
"prompt": "提示词",
|
||||
"provider": "提供商",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "成功",
|
||||
"swap": "交换",
|
||||
"topics": "话题",
|
||||
"update_success": "更新成功",
|
||||
"upload_files": "上传文件",
|
||||
"warning": "警告",
|
||||
"you": "用户"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "模型类型",
|
||||
"name": "错误名称",
|
||||
"no_api_key": "API 密钥未配置",
|
||||
"no_response": "无响应",
|
||||
"originalError": "原错误",
|
||||
"originalMessage": "原消息",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "新增到助手",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "新增",
|
||||
"add_success": "新增成功",
|
||||
"advanced_settings": "進階設定",
|
||||
"agent_one": "代理人",
|
||||
"agent_other": "代理人",
|
||||
"and": "與",
|
||||
"assistant": "智慧代理人",
|
||||
"assistant_one": "助手",
|
||||
"assistant_other": "助手",
|
||||
"avatar": "頭像",
|
||||
"back": "返回",
|
||||
"browse": "瀏覽",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "預設",
|
||||
"delete": "刪除",
|
||||
"delete_confirm": "確定要刪除嗎?",
|
||||
"delete_failed": "刪除失敗",
|
||||
"delete_success": "刪除成功",
|
||||
"description": "描述",
|
||||
"detail": "詳情",
|
||||
"disabled": "已停用",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "編輯",
|
||||
"enabled": "已啟用",
|
||||
"error": "錯誤",
|
||||
"errors": {
|
||||
"create_message": "無法建立訊息",
|
||||
"validation": "驗證失敗"
|
||||
},
|
||||
"expand": "展開",
|
||||
"file": {
|
||||
"not_supported": "不支持的文件類型 {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "前往設定",
|
||||
"i_know": "我知道了",
|
||||
"inspect": "檢查",
|
||||
"invalid_value": "無效值",
|
||||
"knowledge_base": "知識庫",
|
||||
"language": "語言",
|
||||
"loading": "加載中...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "無",
|
||||
"open": "開啟",
|
||||
"paste": "貼上",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "選擇模型"
|
||||
}
|
||||
},
|
||||
"preview": "預覽",
|
||||
"prompt": "提示詞",
|
||||
"provider": "供應商",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "成功",
|
||||
"swap": "交換",
|
||||
"topics": "話題",
|
||||
"update_success": "更新成功",
|
||||
"upload_files": "上傳檔案",
|
||||
"warning": "警告",
|
||||
"you": "您"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "模型類型",
|
||||
"name": "錯誤名稱",
|
||||
"no_api_key": "API 金鑰未設定",
|
||||
"no_response": "無回應",
|
||||
"originalError": "原錯誤",
|
||||
"originalMessage": "原消息",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "Προσθήκη στο Βοηθό",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Προσθέστε",
|
||||
"add_success": "Η προσθήκη ήταν επιτυχής",
|
||||
"advanced_settings": "Προχωρημένες ρυθμίσεις",
|
||||
"agent_one": "Πράκτορας",
|
||||
"agent_other": "Πράκτορες",
|
||||
"and": "και",
|
||||
"assistant": "Εξυπνιασμένη Ενότητα",
|
||||
"assistant_one": "βοηθός",
|
||||
"assistant_other": "βοηθός",
|
||||
"avatar": "Εικονίδιο",
|
||||
"back": "Πίσω",
|
||||
"browse": "Περιήγηση",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "Προεπιλογή",
|
||||
"delete": "Διαγραφή",
|
||||
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
|
||||
"delete_failed": "Αποτυχία διαγραφής",
|
||||
"delete_success": "Η διαγραφή ήταν επιτυχής",
|
||||
"description": "Περιγραφή",
|
||||
"detail": "Λεπτομέρειες",
|
||||
"disabled": "Απενεργοποιημένο",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "Επεξεργασία",
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"error": "σφάλμα",
|
||||
"errors": {
|
||||
"create_message": "Αποτυχία δημιουργίας μηνύματος",
|
||||
"validation": "Η επαλήθευση απέτυχε"
|
||||
},
|
||||
"expand": "Επεκτάση",
|
||||
"file": {
|
||||
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "Πηγαίνετε στις ρυθμίσεις",
|
||||
"i_know": "Το έχω καταλάβει",
|
||||
"inspect": "Επιθεώρηση",
|
||||
"invalid_value": "Μη έγκυρη τιμή",
|
||||
"knowledge_base": "Βάση Γνώσεων",
|
||||
"language": "Γλώσσα",
|
||||
"loading": "Φόρτωση...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "Χωρίς",
|
||||
"open": "Άνοιγμα",
|
||||
"paste": "Επικόλληση",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "Επιλέξτε μοντέλο"
|
||||
}
|
||||
},
|
||||
"preview": "Προεπισκόπηση",
|
||||
"prompt": "Ενδεικτικός ρήματος",
|
||||
"provider": "Παρέχων",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "Επιτυχία",
|
||||
"swap": "Εναλλαγή",
|
||||
"topics": "Θέματα",
|
||||
"update_success": "Επιτυχής ενημέρωση",
|
||||
"upload_files": "Ανέβασμα αρχείου",
|
||||
"warning": "Προσοχή",
|
||||
"you": "Εσείς"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "Τύπος μοντέλου",
|
||||
"name": "Λάθος όνομα",
|
||||
"no_api_key": "Δεν έχετε ρυθμίσει το κλειδί API",
|
||||
"no_response": "Καμία απάντηση",
|
||||
"originalError": "Αρχικό σφάλμα",
|
||||
"originalMessage": "Αρχικό μήνυμα",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "Agregar al asistente",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Agregar",
|
||||
"add_success": "Añadido con éxito",
|
||||
"advanced_settings": "Configuración avanzada",
|
||||
"agent_one": "Agente",
|
||||
"agent_other": "Agentes",
|
||||
"and": "y",
|
||||
"assistant": "Agente inteligente",
|
||||
"assistant_one": "Asistente",
|
||||
"assistant_other": "Asistente",
|
||||
"avatar": "Avatar",
|
||||
"back": "Atrás",
|
||||
"browse": "Examinar",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "Predeterminado",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
|
||||
"delete_failed": "Error al eliminar",
|
||||
"delete_success": "Eliminación exitosa",
|
||||
"description": "Descripción",
|
||||
"detail": "Detalles",
|
||||
"disabled": "Desactivado",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "Editar",
|
||||
"enabled": "Activado",
|
||||
"error": "error",
|
||||
"errors": {
|
||||
"create_message": "Error al crear el mensaje",
|
||||
"validation": "Fallo en la verificación"
|
||||
},
|
||||
"expand": "Expandir",
|
||||
"file": {
|
||||
"not_supported": "Tipo de archivo no compatible {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "Ir a la configuración",
|
||||
"i_know": "Entendido",
|
||||
"inspect": "Inspeccionar",
|
||||
"invalid_value": "Valor inválido",
|
||||
"knowledge_base": "Base de conocimiento",
|
||||
"language": "Idioma",
|
||||
"loading": "Cargando...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "无",
|
||||
"open": "Abrir",
|
||||
"paste": "Pegar",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "Seleccionar modelo"
|
||||
}
|
||||
},
|
||||
"preview": "Vista previa",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Proveedor",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "Éxito",
|
||||
"swap": "Intercambiar",
|
||||
"topics": "Temas",
|
||||
"update_success": "Actualización exitosa",
|
||||
"upload_files": "Subir archivo",
|
||||
"warning": "Advertencia",
|
||||
"you": "Usuario"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "Tipo de modelo",
|
||||
"name": "Nombre de error",
|
||||
"no_api_key": "La clave API no está configurada",
|
||||
"no_response": "Sin respuesta",
|
||||
"originalError": "Error original",
|
||||
"originalMessage": "mensaje original",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "Ajouter à l'assistant",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Ajouter",
|
||||
"add_success": "Ajout réussi",
|
||||
"advanced_settings": "Paramètres avancés",
|
||||
"agent_one": "Agent",
|
||||
"agent_other": "Agents",
|
||||
"and": "et",
|
||||
"assistant": "Intelligence artificielle",
|
||||
"assistant_one": "assistant",
|
||||
"assistant_other": "assistant",
|
||||
"avatar": "Avatar",
|
||||
"back": "Retour",
|
||||
"browse": "Parcourir",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "Défaut",
|
||||
"delete": "Supprimer",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
|
||||
"delete_failed": "Échec de la suppression",
|
||||
"delete_success": "Suppression réussie",
|
||||
"description": "Description",
|
||||
"detail": "détails",
|
||||
"disabled": "Désactivé",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "Éditer",
|
||||
"enabled": "Activé",
|
||||
"error": "erreur",
|
||||
"errors": {
|
||||
"create_message": "Échec de la création du message",
|
||||
"validation": "Échec de la vérification"
|
||||
},
|
||||
"expand": "Développer",
|
||||
"file": {
|
||||
"not_supported": "Type de fichier non pris en charge {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "Aller aux paramètres",
|
||||
"i_know": "J'ai compris",
|
||||
"inspect": "Vérifier",
|
||||
"invalid_value": "valeur invalide",
|
||||
"knowledge_base": "Base de connaissances",
|
||||
"language": "Langue",
|
||||
"loading": "Chargement...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "Aucun",
|
||||
"open": "Ouvrir",
|
||||
"paste": "Coller",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "Choisir le modèle"
|
||||
}
|
||||
},
|
||||
"preview": "Aperçu",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Fournisseur",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "Succès",
|
||||
"swap": "Échanger",
|
||||
"topics": "Sujets",
|
||||
"update_success": "Mise à jour réussie",
|
||||
"upload_files": "Uploader des fichiers",
|
||||
"warning": "Avertissement",
|
||||
"you": "Vous"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "Type de modèle",
|
||||
"name": "Nom d'erreur",
|
||||
"no_api_key": "La clé API n'est pas configurée",
|
||||
"no_response": "Pas de réponse",
|
||||
"originalError": "Erreur d'origine",
|
||||
"originalMessage": "message original",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "アシスタントに追加",
|
||||
@@ -737,9 +797,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "追加",
|
||||
"add_success": "追加成功",
|
||||
"advanced_settings": "詳細設定",
|
||||
"agent_one": "エージェント",
|
||||
"agent_other": "エージェント",
|
||||
"and": "と",
|
||||
"assistant": "アシスタント",
|
||||
"assistant_one": "助手",
|
||||
"assistant_other": "助手",
|
||||
"avatar": "アバター",
|
||||
"back": "戻る",
|
||||
"browse": "参照",
|
||||
@@ -756,6 +821,8 @@
|
||||
"default": "デフォルト",
|
||||
"delete": "削除",
|
||||
"delete_confirm": "削除してもよろしいですか?",
|
||||
"delete_failed": "削除に失敗しました",
|
||||
"delete_success": "削除に成功しました",
|
||||
"description": "説明",
|
||||
"detail": "詳細",
|
||||
"disabled": "無効",
|
||||
@@ -765,6 +832,10 @@
|
||||
"edit": "編集",
|
||||
"enabled": "有効",
|
||||
"error": "エラー",
|
||||
"errors": {
|
||||
"create_message": "メッセージの作成に失敗しました",
|
||||
"validation": "検証に失敗しました"
|
||||
},
|
||||
"expand": "展開",
|
||||
"file": {
|
||||
"not_supported": "サポートされていないファイルタイプ {{type}}"
|
||||
@@ -775,6 +846,7 @@
|
||||
"go_to_settings": "設定に移動",
|
||||
"i_know": "わかりました",
|
||||
"inspect": "検査",
|
||||
"invalid_value": "無効な値",
|
||||
"knowledge_base": "ナレッジベース",
|
||||
"language": "言語",
|
||||
"loading": "読み込み中...",
|
||||
@@ -786,6 +858,11 @@
|
||||
"none": "無",
|
||||
"open": "開く",
|
||||
"paste": "貼り付け",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "モデルを選択"
|
||||
}
|
||||
},
|
||||
"preview": "プレビュー",
|
||||
"prompt": "プロンプト",
|
||||
"provider": "プロバイダー",
|
||||
@@ -812,6 +889,7 @@
|
||||
"success": "成功",
|
||||
"swap": "交換",
|
||||
"topics": "トピック",
|
||||
"update_success": "更新成功",
|
||||
"upload_files": "ファイルをアップロードする",
|
||||
"warning": "警告",
|
||||
"you": "あなた"
|
||||
@@ -884,6 +962,7 @@
|
||||
"modelType": "モデルの種類",
|
||||
"name": "エラー名",
|
||||
"no_api_key": "APIキーが設定されていません",
|
||||
"no_response": "応答なし",
|
||||
"originalError": "元のエラー",
|
||||
"originalMessage": "元のメッセージ",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "Adicionar ao Assistente",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Adicionar",
|
||||
"add_success": "Adicionado com sucesso",
|
||||
"advanced_settings": "Configurações Avançadas",
|
||||
"agent_one": "Agente",
|
||||
"agent_other": "Agentes",
|
||||
"and": "e",
|
||||
"assistant": "Agente Inteligente",
|
||||
"assistant_one": "assistente",
|
||||
"assistant_other": "assistente",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
"browse": "Navegar",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "Padrão",
|
||||
"delete": "Excluir",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir?",
|
||||
"delete_failed": "Falha ao excluir",
|
||||
"delete_success": "Excluído com sucesso",
|
||||
"description": "Descrição",
|
||||
"detail": "detalhes",
|
||||
"disabled": "Desativado",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "Editar",
|
||||
"enabled": "Ativado",
|
||||
"error": "错误",
|
||||
"errors": {
|
||||
"create_message": "Falha ao criar mensagem",
|
||||
"validation": "Falha na verificação"
|
||||
},
|
||||
"expand": "Expandir",
|
||||
"file": {
|
||||
"not_supported": "Tipo de arquivo não suportado {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "Ir para configurações",
|
||||
"i_know": "Entendi",
|
||||
"inspect": "Verificar",
|
||||
"invalid_value": "Valor inválido",
|
||||
"knowledge_base": "Base de Conhecimento",
|
||||
"language": "Língua",
|
||||
"loading": "Carregando...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "Nenhum",
|
||||
"open": "Abrir",
|
||||
"paste": "Colar",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "Selecionar modelo"
|
||||
}
|
||||
},
|
||||
"preview": "Pré-visualização",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Fornecedor",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "Sucesso",
|
||||
"swap": "Trocar",
|
||||
"topics": "Tópicos",
|
||||
"update_success": "Atualização bem-sucedida",
|
||||
"upload_files": "Carregar arquivo",
|
||||
"warning": "Aviso",
|
||||
"you": "Você"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "Tipo de modelo",
|
||||
"name": "Nome do erro",
|
||||
"no_api_key": "A chave da API não foi configurada",
|
||||
"no_response": "Sem resposta",
|
||||
"originalError": "Erro original",
|
||||
"originalMessage": "Mensagem original",
|
||||
"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": {
|
||||
"add": {
|
||||
"button": "Добавить в ассистента",
|
||||
@@ -737,9 +805,14 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Добавить",
|
||||
"add_success": "Успешно добавлено",
|
||||
"advanced_settings": "Дополнительные настройки",
|
||||
"agent_one": "Агент",
|
||||
"agent_other": "Агенты",
|
||||
"and": "и",
|
||||
"assistant": "Ассистент",
|
||||
"assistant_one": "Помощник",
|
||||
"assistant_other": "ассистент",
|
||||
"avatar": "Аватар",
|
||||
"back": "Назад",
|
||||
"browse": "Обзор",
|
||||
@@ -756,6 +829,8 @@
|
||||
"default": "По умолчанию",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить?",
|
||||
"delete_failed": "Не удалось удалить",
|
||||
"delete_success": "Удаление выполнено успешно",
|
||||
"description": "Описание",
|
||||
"detail": "Подробности",
|
||||
"disabled": "Отключено",
|
||||
@@ -765,6 +840,10 @@
|
||||
"edit": "Редактировать",
|
||||
"enabled": "Включено",
|
||||
"error": "ошибка",
|
||||
"errors": {
|
||||
"create_message": "Не удалось создать сообщение",
|
||||
"validation": "Ошибка проверки"
|
||||
},
|
||||
"expand": "Развернуть",
|
||||
"file": {
|
||||
"not_supported": "Неподдерживаемый тип файла {{type}}"
|
||||
@@ -775,6 +854,7 @@
|
||||
"go_to_settings": "Перейти в настройки",
|
||||
"i_know": "Я понял",
|
||||
"inspect": "Осмотреть",
|
||||
"invalid_value": "недопустимое значение",
|
||||
"knowledge_base": "База знаний",
|
||||
"language": "Язык",
|
||||
"loading": "Загрузка...",
|
||||
@@ -786,6 +866,11 @@
|
||||
"none": "без",
|
||||
"open": "Открыть",
|
||||
"paste": "Вставить",
|
||||
"placeholders": {
|
||||
"select": {
|
||||
"model": "Выбор модели"
|
||||
}
|
||||
},
|
||||
"preview": "Предварительный просмотр",
|
||||
"prompt": "Промпт",
|
||||
"provider": "Провайдер",
|
||||
@@ -812,6 +897,7 @@
|
||||
"success": "Успешно",
|
||||
"swap": "Поменять местами",
|
||||
"topics": "Топики",
|
||||
"update_success": "Обновление выполнено успешно",
|
||||
"upload_files": "Загрузить файл",
|
||||
"warning": "Предупреждение",
|
||||
"you": "Вы"
|
||||
@@ -884,6 +970,7 @@
|
||||
"modelType": "Тип модели",
|
||||
"name": "Название ошибки",
|
||||
"no_api_key": "Ключ API не настроен",
|
||||
"no_response": "Нет ответа",
|
||||
"originalError": "Исходная ошибка",
|
||||
"originalMessage": "исходное сообщение",
|
||||
"parameter": "параметр",
|
||||
|
||||
@@ -4,10 +4,10 @@ import { HStack } from '@renderer/components/Layout'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
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 { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { AssistantPreset } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Input } from 'antd'
|
||||
import { omit } from 'lodash'
|
||||
@@ -17,65 +17,65 @@ import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { groupByCategories, useSystemAgents } from '.'
|
||||
import { groupTranslations } from './agentGroupTranslations'
|
||||
import AddAgentPopup from './components/AddAgentPopup'
|
||||
import AgentCard from './components/AgentCard'
|
||||
import { AgentGroupIcon } from './components/AgentGroupIcon'
|
||||
import ImportAgentPopup from './components/ImportAgentPopup'
|
||||
import { groupByCategories, useSystemAssistantPresets } from '.'
|
||||
import { groupTranslations } from './assistantPresetGroupTranslations'
|
||||
import AddAssistantPresetPopup from './components/AddAssistantPresetPopup'
|
||||
import AssistantPresetCard from './components/AssistantPresetCard'
|
||||
import { AssistantPresetGroupIcon } from './components/AssistantPresetGroupIcon'
|
||||
import ImportAssistantPresetPopup from './components/ImportAssistantPresetPopup'
|
||||
|
||||
const AgentsPage: FC = () => {
|
||||
const AssistantPresetsPage: FC = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchInput, setSearchInput] = 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 systemAgents = useSystemAgents()
|
||||
const { agents: userAgents } = useAgents()
|
||||
const systemPresets = useSystemAssistantPresets()
|
||||
const { presets: userPresets } = useAssistantPresets()
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
useEffect(() => {
|
||||
const systemAgentsGroupList = groupByCategories(systemAgents)
|
||||
const systemAgentsGroupList = groupByCategories(systemPresets)
|
||||
const agentsGroupList = {
|
||||
我的: userAgents,
|
||||
我的: userPresets,
|
||||
精选: [],
|
||||
...systemAgentsGroupList
|
||||
} as Record<string, Agent[]>
|
||||
} as Record<string, AssistantPreset[]>
|
||||
setAgentGroups(agentsGroupList)
|
||||
}, [systemAgents, userAgents])
|
||||
}, [systemPresets, userPresets])
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
const filteredPresets = useMemo(() => {
|
||||
// 搜索框为空直接返回「我的」分组下的 agent
|
||||
if (!search.trim()) {
|
||||
return agentGroups[activeGroup] || []
|
||||
}
|
||||
const uniqueAgents = new Map<string, Agent>()
|
||||
const uniquePresets = new Map<string, AssistantPreset>()
|
||||
Object.entries(agentGroups).forEach(([, agents]) => {
|
||||
agents.forEach((agent) => {
|
||||
if (
|
||||
agent.name.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])
|
||||
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const onAddAgentConfirm = useCallback(
|
||||
(agent: Agent) => {
|
||||
const onAddPresetConfirm = useCallback(
|
||||
(preset: AssistantPreset) => {
|
||||
window.modal.confirm({
|
||||
title: agent.name,
|
||||
title: preset.name,
|
||||
content: (
|
||||
<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">
|
||||
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
|
||||
<ReactMarkdown>{preset.prompt}</ReactMarkdown>
|
||||
</AgentPrompt>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -87,16 +87,16 @@ const AgentsPage: FC = () => {
|
||||
centered: true,
|
||||
okButtonProps: { type: 'primary' },
|
||||
okText: t('agents.add.button'),
|
||||
onOk: () => createAssistantFromAgent(agent)
|
||||
onOk: () => createAssistantFromAgent(preset)
|
||||
})
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const getAgentFromSystemAgent = useCallback((agent: (typeof systemAgents)[number]) => {
|
||||
const getPresetFromSystemPreset = useCallback((preset: (typeof systemPresets)[number]) => {
|
||||
return {
|
||||
...omit(agent, 'group'),
|
||||
name: agent.name,
|
||||
...omit(preset, 'group'),
|
||||
name: preset.name,
|
||||
id: uuid(),
|
||||
topics: [],
|
||||
type: 'agent'
|
||||
@@ -161,14 +161,14 @@ const AgentsPage: FC = () => {
|
||||
}
|
||||
|
||||
const handleAddAgent = () => {
|
||||
AddAgentPopup.show().then(() => {
|
||||
AddAssistantPresetPopup.show().then(() => {
|
||||
handleSearchClear()
|
||||
})
|
||||
}
|
||||
|
||||
const handleImportAgent = async () => {
|
||||
try {
|
||||
await ImportAgentPopup.show()
|
||||
await ImportAssistantPresetPopup.show()
|
||||
} catch (error) {
|
||||
window.toast.error(error instanceof Error ? error.message : t('message.agents.import.error'))
|
||||
}
|
||||
@@ -207,7 +207,7 @@ const AgentsPage: FC = () => {
|
||||
title={
|
||||
<Flex gap={16} align="center" justify="space-between">
|
||||
<Flex gap={10} align="center">
|
||||
<AgentGroupIcon groupName={group} />
|
||||
<AssistantPresetGroupIcon groupName={group} />
|
||||
{getLocalizedGroupName(group)}
|
||||
</Flex>
|
||||
{
|
||||
@@ -229,19 +229,19 @@ const AgentsPage: FC = () => {
|
||||
<AgentsListTitle>
|
||||
{search.trim() ? (
|
||||
<>
|
||||
<AgentGroupIcon groupName="搜索" size={24} />
|
||||
<AssistantPresetGroupIcon groupName="搜索" size={24} />
|
||||
{search.trim()}{' '}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AgentGroupIcon groupName={activeGroup} size={24} />
|
||||
<AssistantPresetGroupIcon groupName={activeGroup} size={24} />
|
||||
{getLocalizedGroupName(activeGroup)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{
|
||||
<CustomTag color="#A0A0A0" size={10}>
|
||||
{filteredAgents.length}
|
||||
{filteredPresets.length}
|
||||
</CustomTag>
|
||||
}
|
||||
</AgentsListTitle>
|
||||
@@ -282,13 +282,13 @@ const AgentsPage: FC = () => {
|
||||
</Flex>
|
||||
</AgentsListHeader>
|
||||
|
||||
{filteredAgents.length > 0 ? (
|
||||
{filteredPresets.length > 0 ? (
|
||||
<AgentsList>
|
||||
{filteredAgents.map((agent, index) => (
|
||||
<AgentCard
|
||||
{filteredPresets.map((agent, index) => (
|
||||
<AssistantPresetCard
|
||||
key={agent.id || index}
|
||||
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent))}
|
||||
agent={agent}
|
||||
onClick={() => onAddPresetConfirm(getPresetFromSystemPreset(agent))}
|
||||
preset={agent}
|
||||
activegroup={activeGroup}
|
||||
getLocalizedGroupName={getLocalizedGroupName}
|
||||
/>
|
||||
@@ -390,4 +390,4 @@ const EmptyView = styled.div`
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
export default AgentsPage
|
||||
export default AssistantPresetsPage
|
||||
@@ -1,3 +1,4 @@
|
||||
// FIXME: Just use i18next!
|
||||
export type GroupTranslations = {
|
||||
[key: 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