Compare commits
3 Commits
fix/react-
...
feat/messa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e79353fc | ||
|
|
93e972a5da | ||
|
|
12d08e4748 |
6
.github/workflows/auto-i18n.yml
vendored
6
.github/workflows/auto-i18n.yml
vendored
@@ -2,8 +2,8 @@ name: Auto I18N
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
||||||
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
|
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
|
||||||
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
|
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
|
|
||||||
- name: 📦 Setting Node.js
|
- name: 📦 Setting Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
17
.github/workflows/claude-translator.yml
vendored
17
.github/workflows/claude-translator.yml
vendored
@@ -16,13 +16,10 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
translate:
|
translate:
|
||||||
if: |
|
if: |
|
||||||
(github.event_name == 'issues')
|
(github.event_name == 'issues') ||
|
||||||
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
|
||||||
|| (
|
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
|
||||||
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
|
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
|
||||||
&& github.event.sender.type != 'Bot'
|
|
||||||
&& github.event.pull_request.head.repo.fork == false
|
|
||||||
)
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -32,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -45,7 +42,7 @@ jobs:
|
|||||||
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
|
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
|
||||||
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
|
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
|
||||||
allowed_non_write_users: "*"
|
allowed_non_write_users: "*"
|
||||||
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
|
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
|
||||||
prompt: |
|
prompt: |
|
||||||
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
|
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
|
||||||
@@ -108,5 +105,3 @@ jobs:
|
|||||||
|
|
||||||
使用以下命令获取完整信息:
|
使用以下命令获取完整信息:
|
||||||
gh issue view ${{ github.event.issue.number }} --json title,body,comments
|
gh issue view ${{ github.event.issue.number }} --json title,body,comments
|
||||||
env:
|
|
||||||
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}
|
|
||||||
|
|||||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/delete-branch.yml
vendored
2
.github/workflows/delete-branch.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||||
steps:
|
steps:
|
||||||
- name: Delete merged branch
|
- name: Delete merged branch
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
github.rest.git.deleteRef({
|
github.rest.git.deleteRef({
|
||||||
|
|||||||
26
.github/workflows/nightly-build.yml
vendored
26
.github/workflows/nightly-build.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
@@ -99,9 +99,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Mac
|
- name: Build Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
@@ -110,15 +110,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
@@ -128,9 +128,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Rename artifacts with nightly format
|
- name: Rename artifacts with nightly format
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
|||||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
@@ -86,9 +86,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Mac
|
- name: Build Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
@@ -98,15 +98,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
@@ -116,9 +116,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -71,5 +71,3 @@ playwright-report
|
|||||||
test-results
|
test-results
|
||||||
|
|
||||||
YOUR_MEMORY_FILE_PATH
|
YOUR_MEMORY_FILE_PATH
|
||||||
|
|
||||||
.sessions/
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
"no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()`
|
"no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()`
|
||||||
"no-unused-labels": "error",
|
"no-unused-labels": "error",
|
||||||
"no-unused-private-class-members": "error",
|
"no-unused-private-class-members": "error",
|
||||||
"no-unused-vars": ["warn", { "caughtErrors": "none" }],
|
"no-unused-vars": ["error", { "caughtErrors": "none" }],
|
||||||
"no-useless-backreference": "error",
|
"no-useless-backreference": "error",
|
||||||
"no-useless-catch": "error",
|
"no-useless-catch": "error",
|
||||||
"no-useless-escape": "error",
|
"no-useless-escape": "error",
|
||||||
|
|||||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -34,10 +34,10 @@
|
|||||||
"*.css": "tailwindcss"
|
"*.css": "tailwindcss"
|
||||||
},
|
},
|
||||||
"files.eol": "\n",
|
"files.eol": "\n",
|
||||||
// "i18n-ally.displayLanguage": "zh-cn", // 界面显示语言
|
"i18n-ally.displayLanguage": "zh-cn",
|
||||||
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
||||||
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
||||||
"i18n-ally.fullReloadOnChanged": true,
|
"i18n-ally.fullReloadOnChanged": true, // 界面显示语言
|
||||||
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
||||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
||||||
// "i18n-ally.namespace": true, // 开启命名空间
|
// "i18n-ally.namespace": true, // 开启命名空间
|
||||||
@@ -47,9 +47,5 @@
|
|||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/dist/**": true,
|
"**/dist/**": true,
|
||||||
".yarn/releases/**": true
|
".yarn/releases/**": true
|
||||||
},
|
}
|
||||||
"tailwindCSS.classAttributes": [
|
|
||||||
"className",
|
|
||||||
"classNames",
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch
vendored
Normal file
36
.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
|
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
|
||||||
|
--- a/dist/index.mjs
|
||||||
|
+++ b/dist/index.mjs
|
||||||
|
@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/get-model-path.ts
|
||||||
|
-function getModelPath(modelId) {
|
||||||
|
+function getModelPath(modelId, baseURL) {
|
||||||
|
+ if (baseURL?.includes('cherryin')) {
|
||||||
|
+ return `models/${modelId}`;
|
||||||
|
+ }
|
||||||
|
return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class {
|
||||||
|
rawValue: rawResponse
|
||||||
|
} = await postJsonToApi2({
|
||||||
|
url: `${this.config.baseURL}/${getModelPath(
|
||||||
|
- this.modelId
|
||||||
|
+ this.modelId,
|
||||||
|
+ this.config.baseURL
|
||||||
|
)}:generateContent`,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
body: args,
|
||||||
|
@@ -962,7 +966,8 @@ var GoogleGenerativeAILanguageModel = class {
|
||||||
|
);
|
||||||
|
const { responseHeaders, value: response } = await postJsonToApi2({
|
||||||
|
url: `${this.config.baseURL}/${getModelPath(
|
||||||
|
- this.modelId
|
||||||
|
+ this.modelId,
|
||||||
|
+ this.config.baseURL
|
||||||
|
)}:streamGenerateContent?alt=sse`,
|
||||||
|
headers,
|
||||||
|
body: args,
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
|
||||||
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
|
|
||||||
--- a/dist/index.mjs
|
|
||||||
+++ b/dist/index.mjs
|
|
||||||
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
|
||||||
|
|
||||||
// src/get-model-path.ts
|
|
||||||
function getModelPath(modelId) {
|
|
||||||
- return modelId.includes("/") ? modelId : `models/${modelId}`;
|
|
||||||
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/google-generative-ai-options.ts
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
diff --git a/sdk.mjs b/sdk.mjs
|
|
||||||
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
|
|
||||||
--- a/sdk.mjs
|
|
||||||
+++ b/sdk.mjs
|
|
||||||
@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
|
||||||
}
|
|
||||||
|
|
||||||
// ../src/transport/ProcessTransport.ts
|
|
||||||
-import { spawn } from "child_process";
|
|
||||||
+import { fork } from "child_process";
|
|
||||||
import { createInterface } from "readline";
|
|
||||||
|
|
||||||
// ../src/utils/fsOperations.ts
|
|
||||||
@@ -6473,14 +6473,11 @@ class ProcessTransport {
|
|
||||||
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
|
||||||
throw new ReferenceError(errorMessage);
|
|
||||||
}
|
|
||||||
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
|
|
||||||
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
|
|
||||||
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
|
|
||||||
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
|
|
||||||
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
|
|
||||||
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
|
|
||||||
- this.child = spawn(spawnCommand, spawnArgs, {
|
|
||||||
+ this.child = fork(pathToClaudeCodeExecutable, args, {
|
|
||||||
cwd,
|
|
||||||
- stdio: ["pipe", "pipe", stderrMode],
|
|
||||||
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
|
|
||||||
signal: this.abortController.signal,
|
|
||||||
env
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
diff --git a/dist/index.js b/dist/index.js
|
|
||||||
index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644
|
|
||||||
--- a/dist/index.js
|
|
||||||
+++ b/dist/index.js
|
|
||||||
@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (value.item.type === "message") {
|
|
||||||
- controller.enqueue({
|
|
||||||
- type: "text-end",
|
|
||||||
- id: value.item.id
|
|
||||||
- });
|
|
||||||
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
|
|
||||||
+ if (currentTextId) {
|
|
||||||
+ controller.enqueue({
|
|
||||||
+ type: "text-end",
|
|
||||||
+ id: currentTextId
|
|
||||||
+ });
|
|
||||||
+ }
|
|
||||||
currentTextId = null;
|
|
||||||
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
|
|
||||||
const activeReasoningPart = activeReasoning[value.item.id];
|
|
||||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
|
||||||
index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644
|
|
||||||
--- a/dist/index.mjs
|
|
||||||
+++ b/dist/index.mjs
|
|
||||||
@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (value.item.type === "message") {
|
|
||||||
- controller.enqueue({
|
|
||||||
- type: "text-end",
|
|
||||||
- id: value.item.id
|
|
||||||
- });
|
|
||||||
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
|
|
||||||
+ if (currentTextId) {
|
|
||||||
+ controller.enqueue({
|
|
||||||
+ type: "text-end",
|
|
||||||
+ id: currentTextId
|
|
||||||
+ });
|
|
||||||
+ }
|
|
||||||
currentTextId = null;
|
|
||||||
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
|
|
||||||
const activeReasoningPart = activeReasoning[value.item.id];
|
|
||||||
152
CLAUDE.md
152
CLAUDE.md
@@ -1,51 +1,127 @@
|
|||||||
# AI Assistant Guide
|
# CLAUDE.md
|
||||||
|
|
||||||
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.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Guiding Principles (MUST FOLLOW)
|
|
||||||
|
|
||||||
- **Keep it clear**: Write code that is easy to read, maintain, and explain.
|
|
||||||
- **Match the house style**: Reuse existing patterns, naming, and conventions.
|
|
||||||
- **Search smart**: Prefer `ast-grep` for semantic queries; fall back to `rg`/`grep` when needed.
|
|
||||||
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
|
|
||||||
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
|
|
||||||
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
|
|
||||||
- **Seek review**: Ask a human developer to review substantial changes before merging.
|
|
||||||
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
|
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
- **Install**: `yarn install` - Install all project dependencies
|
### Environment Setup
|
||||||
- **Development**: `yarn dev` - Runs Electron app in development mode with hot reload
|
|
||||||
- **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
|
|
||||||
- **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck)
|
|
||||||
- If having i18n sort issues, run `yarn sync:i18n` first to sync template
|
|
||||||
- If having formatting issues, run `yarn format` first
|
|
||||||
- **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes
|
|
||||||
- **Single Test**:
|
|
||||||
- `yarn test:main` - Run tests for main process only
|
|
||||||
- `yarn test:renderer` - Run tests for renderer process only
|
|
||||||
- **Lint**: `yarn lint` - Fix linting issues and run TypeScript type checking
|
|
||||||
- **Format**: `yarn format` - Auto-format code using Biome
|
|
||||||
|
|
||||||
## Project Architecture
|
- **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.
|
||||||
|
|
||||||
### Electron Structure
|
### Development
|
||||||
- **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.)
|
|
||||||
- **Renderer Process** (`src/renderer/`): React UI with Redux state management
|
|
||||||
- **Preload Scripts** (`src/preload/`): Secure IPC bridge
|
|
||||||
|
|
||||||
### Key Components
|
- **Start Development**: `yarn dev` - Runs Electron app in development mode
|
||||||
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
|
- **Debug Mode**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
|
||||||
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
|
|
||||||
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
|
### Testing & Quality
|
||||||
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
|
|
||||||
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
|
- **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
|
||||||
|
|
||||||
### Logging
|
|
||||||
```typescript
|
```typescript
|
||||||
|
// Main process
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
const logger = loggerService.withContext('moduleName')
|
const logger = loggerService.withContext('moduleName')
|
||||||
// Renderer: loggerService.initWindowSource('windowName') first
|
|
||||||
|
// Renderer process (set window source first)
|
||||||
|
loggerService.initWindowSource('windowName')
|
||||||
|
const logger = loggerService.withContext('moduleName')
|
||||||
|
|
||||||
|
// Logging
|
||||||
logger.info('message', CONTEXT)
|
logger.info('message', CONTEXT)
|
||||||
|
logger.error('message', new Error('error'), CONTEXT)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Log Levels (highest to lowest)
|
||||||
|
|
||||||
|
- `error` - Critical errors causing crash/unusable functionality
|
||||||
|
- `warn` - Potential issues that don't affect core functionality
|
||||||
|
- `info` - Application lifecycle and key user actions
|
||||||
|
- `verbose` - Detailed flow information for feature tracing
|
||||||
|
- `debug` - Development diagnostic info (not for production)
|
||||||
|
- `silly` - Extreme debugging, low-level information
|
||||||
|
|||||||
@@ -126,60 +126,58 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
|||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
<!--LANG:en-->
|
<!--LANG:en-->
|
||||||
What's New in v1.7.0-beta.2
|
🚀 New Features:
|
||||||
|
- Refactored AI core engine for more efficient and stable content generation
|
||||||
New Features:
|
- Added support for multiple AI model providers: CherryIN, AiOnly
|
||||||
- Session Settings: Manage session-specific settings and model configurations independently
|
- Added API server functionality for external application integration
|
||||||
- Notes Full-Text Search: Search across all notes with match highlighting
|
- Added PaddleOCR document recognition for enhanced document processing
|
||||||
- Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only)
|
- Added Anthropic OAuth authentication support
|
||||||
- Intel OV OCR: Hardware-accelerated OCR using Intel NPU
|
- Added data storage space limit notifications
|
||||||
- Auto-start API Server: Automatically starts when agents exist
|
- Added font settings for global and code fonts customization
|
||||||
|
- Added auto-copy feature after translation completion
|
||||||
Improvements:
|
- Added keyboard shortcuts: rename topic, edit last message, etc.
|
||||||
- Agent model selection now requires explicit user choice
|
- Added text attachment preview for viewing file contents in messages
|
||||||
- Added Mistral AI provider support
|
- Added custom window control buttons (minimize, maximize, close)
|
||||||
- Added NewAPI generic provider support
|
- Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads
|
||||||
- Improved navbar layout consistency across different modes
|
- Support for Qwen image recognition models (Qwen-Image)
|
||||||
- Enhanced chat component responsiveness
|
- Added iFlow CLI support
|
||||||
- Better code block display on small screens
|
- Converted knowledge base and web search to tool-calling approach for better flexibility
|
||||||
- Updated OVMS to 2025.3 official release
|
|
||||||
- Added Greek language support
|
|
||||||
|
|
||||||
Bug Fixes:
|
|
||||||
- Fixed GitHub Copilot gpt-5-codex streaming issues
|
|
||||||
- Fixed assistant creation failures
|
|
||||||
- Fixed translate auto-copy functionality
|
|
||||||
- Fixed miniapps external link opening
|
|
||||||
- Fixed message layout and overflow issues
|
|
||||||
- Fixed API key parsing to preserve spaces
|
|
||||||
- Fixed agent display in different navbar layouts
|
|
||||||
|
|
||||||
|
🎨 UI Improvements & Bug Fixes:
|
||||||
|
- Integrated HeroUI and Tailwind CSS framework
|
||||||
|
- Optimized message notification styles with unified toast component
|
||||||
|
- Moved free models to bottom with fixed position for easier access
|
||||||
|
- Refactored quick panel and input bar tools for smoother operation
|
||||||
|
- Optimized responsive design for navbar and sidebar
|
||||||
|
- Improved scrollbar component with horizontal scrolling support
|
||||||
|
- Fixed multiple translation issues: paste handling, file processing, state management
|
||||||
|
- Various UI optimizations and bug fixes
|
||||||
<!--LANG:zh-CN-->
|
<!--LANG:zh-CN-->
|
||||||
v1.7.0-beta.2 新特性
|
🚀 新功能:
|
||||||
|
- 重构 AI 核心引擎,提供更高效稳定的内容生成
|
||||||
|
- 新增多个 AI 模型提供商支持:CherryIN、AiOnly
|
||||||
|
- 新增 API 服务器功能,支持外部应用集成
|
||||||
|
- 新增 PaddleOCR 文档识别,增强文档处理能力
|
||||||
|
- 新增 Anthropic OAuth 认证支持
|
||||||
|
- 新增数据存储空间限制提醒
|
||||||
|
- 新增字体设置,支持全局字体和代码字体自定义
|
||||||
|
- 新增翻译完成后自动复制功能
|
||||||
|
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
|
||||||
|
- 新增文本附件预览,可查看消息中的文件内容
|
||||||
|
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
|
||||||
|
- 支持通义千问长文本(qwen-long)和文档分析(qwen-doc)模型,原生文件上传
|
||||||
|
- 支持通义千问图像识别模型(Qwen-Image)
|
||||||
|
- 新增 iFlow CLI 支持
|
||||||
|
- 知识库和网页搜索转换为工具调用方式,提升灵活性
|
||||||
|
|
||||||
新功能:
|
🎨 界面改进与问题修复:
|
||||||
- 会话设置:独立管理会话特定的设置和模型配置
|
- 集成 HeroUI 和 Tailwind CSS 框架
|
||||||
- 笔记全文搜索:跨所有笔记搜索并高亮匹配内容
|
- 优化消息通知样式,统一 toast 组件
|
||||||
- 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区)
|
- 免费模型移至底部固定位置,便于访问
|
||||||
- Intel OV OCR:使用 Intel NPU 的硬件加速 OCR
|
- 重构快捷面板和输入栏工具,操作更流畅
|
||||||
- 自动启动 API 服务器:当存在 Agent 时自动启动
|
- 优化导航栏和侧边栏响应式设计
|
||||||
|
- 改进滚动条组件,支持水平滚动
|
||||||
改进:
|
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
|
||||||
- Agent 模型选择现在需要用户显式选择
|
- 各种界面优化和问题修复
|
||||||
- 添加 Mistral AI 提供商支持
|
|
||||||
- 添加 NewAPI 通用提供商支持
|
|
||||||
- 改进不同模式下的导航栏布局一致性
|
|
||||||
- 增强聊天组件响应式设计
|
|
||||||
- 优化小屏幕代码块显示
|
|
||||||
- 更新 OVMS 至 2025.3 正式版
|
|
||||||
- 添加希腊语支持
|
|
||||||
|
|
||||||
问题修复:
|
|
||||||
- 修复 GitHub Copilot gpt-5-codex 流式传输问题
|
|
||||||
- 修复助手创建失败
|
|
||||||
- 修复翻译自动复制功能
|
|
||||||
- 修复小程序外部链接打开
|
|
||||||
- 修复消息布局和溢出问题
|
|
||||||
- 修复 API 密钥解析以保留空格
|
|
||||||
- 修复不同导航栏布局中的 Agent 显示
|
|
||||||
<!--LANG:END-->
|
<!--LANG:END-->
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||||
},
|
|
||||||
onwarn(warning, warn) {
|
|
||||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
|
||||||
warn(warning)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sourcemap: isDev
|
sourcemap: isDev
|
||||||
@@ -88,7 +84,6 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src'),
|
'@renderer': resolve('src/renderer/src'),
|
||||||
'@shared': resolve('packages/shared'),
|
'@shared': resolve('packages/shared'),
|
||||||
'@types': resolve('src/renderer/src/types'),
|
|
||||||
'@logger': resolve('src/renderer/src/services/LoggerService'),
|
'@logger': resolve('src/renderer/src/services/LoggerService'),
|
||||||
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
||||||
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
|
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
|
||||||
@@ -116,10 +111,6 @@ export default defineConfig({
|
|||||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
||||||
},
|
|
||||||
onwarn(warning, warn) {
|
|
||||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
|
||||||
warn(warning)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import tseslint from '@electron-toolkit/eslint-config-ts'
|
|||||||
import eslint from '@eslint/js'
|
import eslint from '@eslint/js'
|
||||||
import eslintReact from '@eslint-react/eslint-plugin'
|
import eslintReact from '@eslint-react/eslint-plugin'
|
||||||
import { defineConfig } from 'eslint/config'
|
import { defineConfig } from 'eslint/config'
|
||||||
import importZod from 'eslint-plugin-import-zod'
|
|
||||||
import oxlint from 'eslint-plugin-oxlint'
|
import oxlint from 'eslint-plugin-oxlint'
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||||
@@ -12,12 +11,11 @@ export default defineConfig([
|
|||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
eslintReact.configs['recommended-typescript'],
|
eslintReact.configs['recommended-typescript'],
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs['recommended-latest'],
|
||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
'simple-import-sort': simpleImportSort,
|
'simple-import-sort': simpleImportSort,
|
||||||
'unused-imports': unusedImports,
|
'unused-imports': unusedImports
|
||||||
'import-zod': importZod
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
@@ -27,7 +25,6 @@ export default defineConfig([
|
|||||||
'simple-import-sort/exports': 'error',
|
'simple-import-sort/exports': 'error',
|
||||||
'unused-imports/no-unused-imports': 'error',
|
'unused-imports/no-unused-imports': 'error',
|
||||||
'@eslint-react/no-prop-types': 'error',
|
'@eslint-react/no-prop-types': 'error',
|
||||||
'import-zod/prefer-zod-namespace': 'error'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
|
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
|
||||||
|
|||||||
49
package.json
49
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.7.0-beta.2",
|
"version": "1.6.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@@ -43,18 +43,15 @@
|
|||||||
"release": "node scripts/version.js",
|
"release": "node scripts/version.js",
|
||||||
"publish": "yarn build:check && yarn release patch push",
|
"publish": "yarn build:check && yarn release patch push",
|
||||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||||
"agents:generate": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts",
|
"generate:agents": "yarn workspace @cherry-studio/database agents",
|
||||||
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
|
|
||||||
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
|
|
||||||
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
|
|
||||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||||
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
||||||
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
||||||
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
|
"check:i18n": "tsx scripts/check-i18n.ts",
|
||||||
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
|
"sync:i18n": "tsx scripts/sync-i18n.ts",
|
||||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||||
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||||
"update:languages": "tsx scripts/update-languages.ts",
|
"update:languages": "tsx scripts/update-languages.ts",
|
||||||
@@ -68,7 +65,7 @@
|
|||||||
"test:e2e": "yarn playwright test",
|
"test:e2e": "yarn playwright test",
|
||||||
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
|
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
|
||||||
"test:scripts": "vitest scripts",
|
"test:scripts": "vitest scripts",
|
||||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n && yarn format:check",
|
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n",
|
||||||
"format": "biome format --write && biome lint --write",
|
"format": "biome format --write && biome lint --write",
|
||||||
"format:check": "biome format && biome lint",
|
"format:check": "biome format && biome lint",
|
||||||
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
|
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
|
||||||
@@ -78,7 +75,6 @@
|
|||||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
|
|
||||||
"@libsql/client": "0.14.0",
|
"@libsql/client": "0.14.0",
|
||||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||||
@@ -101,10 +97,10 @@
|
|||||||
"@agentic/exa": "^7.3.3",
|
"@agentic/exa": "^7.3.3",
|
||||||
"@agentic/searxng": "^7.3.3",
|
"@agentic/searxng": "^7.3.3",
|
||||||
"@agentic/tavily": "^7.3.3",
|
"@agentic/tavily": "^7.3.3",
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.35",
|
"@ai-sdk/amazon-bedrock": "^3.0.21",
|
||||||
"@ai-sdk/google-vertex": "^3.0.40",
|
"@ai-sdk/google-vertex": "^3.0.27",
|
||||||
"@ai-sdk/mistral": "^2.0.19",
|
"@ai-sdk/mistral": "^2.0.14",
|
||||||
"@ai-sdk/perplexity": "^2.0.13",
|
"@ai-sdk/perplexity": "^2.0.9",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@anthropic-ai/sdk": "^0.41.0",
|
"@anthropic-ai/sdk": "^0.41.0",
|
||||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||||
@@ -154,9 +150,7 @@
|
|||||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
||||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||||
"@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch",
|
|
||||||
"@playwright/test": "^1.52.0",
|
"@playwright/test": "^1.52.0",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
|
||||||
"@reduxjs/toolkit": "^2.2.5",
|
"@reduxjs/toolkit": "^2.2.5",
|
||||||
"@shikijs/markdown-it": "^3.12.0",
|
"@shikijs/markdown-it": "^3.12.0",
|
||||||
"@swc/plugin-styled-components": "^8.0.4",
|
"@swc/plugin-styled-components": "^8.0.4",
|
||||||
@@ -208,7 +202,6 @@
|
|||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/tinycolor2": "^1",
|
"@types/tinycolor2": "^1",
|
||||||
"@types/turndown": "^5.0.5",
|
"@types/turndown": "^5.0.5",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@types/word-extractor": "^1",
|
"@types/word-extractor": "^1",
|
||||||
"@typescript/native-preview": "latest",
|
"@typescript/native-preview": "latest",
|
||||||
"@uiw/codemirror-extensions-langs": "^4.25.1",
|
"@uiw/codemirror-extensions-langs": "^4.25.1",
|
||||||
@@ -222,7 +215,7 @@
|
|||||||
"@viz-js/lang-dot": "^1.0.5",
|
"@viz-js/lang-dot": "^1.0.5",
|
||||||
"@viz-js/viz": "^3.14.0",
|
"@viz-js/viz": "^3.14.0",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.4.4",
|
||||||
"ai": "^5.0.68",
|
"ai": "^5.0.44",
|
||||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
@@ -245,12 +238,9 @@
|
|||||||
"docx": "^9.0.2",
|
"docx": "^9.0.2",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"drizzle-kit": "^0.31.4",
|
"electron": "37.4.0",
|
||||||
"drizzle-orm": "^0.44.5",
|
|
||||||
"electron": "37.6.0",
|
|
||||||
"electron-builder": "26.0.15",
|
"electron-builder": "26.0.15",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-reload": "^2.0.0-alpha.1",
|
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"electron-updater": "6.6.4",
|
"electron-updater": "6.6.4",
|
||||||
"electron-vite": "4.0.0",
|
"electron-vite": "4.0.0",
|
||||||
@@ -259,12 +249,10 @@
|
|||||||
"emoji-picker-element": "^1.22.1",
|
"emoji-picker-element": "^1.22.1",
|
||||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||||
"eslint": "^9.22.0",
|
"eslint": "^9.22.0",
|
||||||
"eslint-plugin-import-zod": "^1.2.0",
|
|
||||||
"eslint-plugin-oxlint": "^1.15.0",
|
"eslint-plugin-oxlint": "^1.15.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"express-validator": "^7.2.1",
|
|
||||||
"fast-diff": "^1.3.0",
|
"fast-diff": "^1.3.0",
|
||||||
"fast-xml-parser": "^5.2.0",
|
"fast-xml-parser": "^5.2.0",
|
||||||
"fetch-socks": "1.3.2",
|
"fetch-socks": "1.3.2",
|
||||||
@@ -297,15 +285,15 @@
|
|||||||
"notion-helper": "^1.3.22",
|
"notion-helper": "^1.3.22",
|
||||||
"npx-scope-finder": "^1.2.0",
|
"npx-scope-finder": "^1.2.0",
|
||||||
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||||
"oxlint": "^1.22.0",
|
"oxlint": "^1.15.0",
|
||||||
"oxlint-tsgolint": "^0.2.0",
|
"oxlint-tsgolint": "^0.2.0",
|
||||||
"p-queue": "^8.1.0",
|
"p-queue": "^8.1.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"playwright": "^1.52.0",
|
"playwright": "^1.52.0",
|
||||||
"proxy-agent": "^6.5.0",
|
"proxy-agent": "^6.5.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hotkeys-hook": "^4.6.1",
|
"react-hotkeys-hook": "^4.6.1",
|
||||||
"react-i18next": "^14.1.2",
|
"react-i18next": "^14.1.2",
|
||||||
@@ -337,7 +325,6 @@
|
|||||||
"string-width": "^7.2.0",
|
"string-width": "^7.2.0",
|
||||||
"striptags": "^3.2.0",
|
"striptags": "^3.2.0",
|
||||||
"styled-components": "^6.1.11",
|
"styled-components": "^6.1.11",
|
||||||
"swr": "^2.3.6",
|
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"tiny-pinyin": "^1.3.2",
|
"tiny-pinyin": "^1.3.2",
|
||||||
@@ -348,7 +335,7 @@
|
|||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"undici": "6.21.2",
|
"undici": "6.21.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^10.0.0",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"vite": "npm:rolldown-vite@latest",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"webdav": "^5.8.0",
|
"webdav": "^5.8.0",
|
||||||
@@ -372,7 +359,6 @@
|
|||||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||||
"node-abi": "4.12.0",
|
"node-abi": "4.12.0",
|
||||||
@@ -380,11 +366,10 @@
|
|||||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||||
"tar-fs": "^2.1.4",
|
|
||||||
"undici": "6.21.2",
|
"undici": "6.21.2",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"vite": "npm:rolldown-vite@latest",
|
||||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||||
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch"
|
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.9.1",
|
"packageManager": "yarn@4.9.1",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cherrystudio/ai-core",
|
"name": "@cherrystudio/ai-core",
|
||||||
"version": "1.0.1",
|
"version": "1.0.0-alpha.18",
|
||||||
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
@@ -36,14 +36,15 @@
|
|||||||
"ai": "^5.0.26"
|
"ai": "^5.0.26"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.27",
|
"@ai-sdk/anthropic": "^2.0.17",
|
||||||
"@ai-sdk/azure": "^2.0.49",
|
"@ai-sdk/azure": "^2.0.30",
|
||||||
"@ai-sdk/deepseek": "^1.0.23",
|
"@ai-sdk/deepseek": "^1.0.17",
|
||||||
"@ai-sdk/openai": "^2.0.48",
|
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.22",
|
"@ai-sdk/openai": "^2.0.30",
|
||||||
|
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
"@ai-sdk/provider-utils": "^3.0.12",
|
"@ai-sdk/provider-utils": "^3.0.9",
|
||||||
"@ai-sdk/xai": "^2.0.26",
|
"@ai-sdk/xai": "^2.0.18",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -261,39 +261,22 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
|||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分离 provider-defined 和其他类型的工具
|
context.mcpTools = params.tools
|
||||||
const providerDefinedTools: ToolSet = {}
|
|
||||||
const promptTools: ToolSet = {}
|
|
||||||
|
|
||||||
for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) {
|
// 构建系统提示符
|
||||||
if (tool.type === 'provider-defined') {
|
|
||||||
// provider-defined 类型的工具保留在 tools 参数中
|
|
||||||
providerDefinedTools[toolName] = tool
|
|
||||||
} else {
|
|
||||||
// 其他工具转换为 prompt 模式
|
|
||||||
promptTools[toolName] = tool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有当有非 provider-defined 工具时才保存到 context
|
|
||||||
if (Object.keys(promptTools).length > 0) {
|
|
||||||
context.mcpTools = promptTools
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建系统提示符(只包含非 provider-defined 工具)
|
|
||||||
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
|
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
|
||||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
|
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
|
||||||
let systemMessage: string | null = systemPrompt
|
let systemMessage: string | null = systemPrompt
|
||||||
if (config.createSystemMessage) {
|
if (config.createSystemMessage) {
|
||||||
// 🎯 如果用户提供了自定义处理函数,使用它
|
// 🎯 如果用户提供了自定义处理函数,使用它
|
||||||
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留 provider-defined tools,移除其他 tools
|
// 移除 tools,改为 prompt 模式
|
||||||
const transformedParams = {
|
const transformedParams = {
|
||||||
...params,
|
...params,
|
||||||
...(systemMessage ? { system: systemMessage } : {}),
|
...(systemMessage ? { system: systemMessage } : {}),
|
||||||
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
|
tools: undefined
|
||||||
}
|
}
|
||||||
context.originalParams = transformedParams
|
context.originalParams = transformedParams
|
||||||
return transformedParams
|
return transformedParams
|
||||||
@@ -302,9 +285,8 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
|||||||
let textBuffer = ''
|
let textBuffer = ''
|
||||||
// let stepId = ''
|
// let stepId = ''
|
||||||
|
|
||||||
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
|
|
||||||
if (!context.mcpTools) {
|
if (!context.mcpTools) {
|
||||||
return new TransformStream()
|
throw new Error('No tools available')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 context 中获取或初始化 usage 累加器
|
// 从 context 中获取或初始化 usage 累加器
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { anthropic } from '@ai-sdk/anthropic'
|
import { anthropic } from '@ai-sdk/anthropic'
|
||||||
import { google } from '@ai-sdk/google'
|
import { google } from '@ai-sdk/google'
|
||||||
import { openai } from '@ai-sdk/openai'
|
import { openai } from '@ai-sdk/openai'
|
||||||
import { InferToolInput, InferToolOutput, type Tool } from 'ai'
|
|
||||||
|
|
||||||
import { ProviderOptionsMap } from '../../../options/types'
|
import { ProviderOptionsMap } from '../../../options/types'
|
||||||
import { OpenRouterSearchConfig } from './openrouter'
|
import { OpenRouterSearchConfig } from './openrouter'
|
||||||
@@ -15,13 +14,6 @@ export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tool
|
|||||||
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
|
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
|
||||||
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
|
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
|
||||||
|
|
||||||
type NormalizeTool<T> = T extends Tool<infer INPUT, infer OUTPUT> ? Tool<INPUT, OUTPUT> : Tool<any, any>
|
|
||||||
|
|
||||||
type AnthropicWebSearchTool = NormalizeTool<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
|
||||||
type OpenAIWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearch>>
|
|
||||||
type OpenAIChatWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearchPreview>>
|
|
||||||
type GoogleWebSearchTool = NormalizeTool<ReturnType<typeof google.tools.googleSearch>>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插件初始化时接收的完整配置对象
|
* 插件初始化时接收的完整配置对象
|
||||||
*
|
*
|
||||||
@@ -66,31 +58,24 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
|||||||
|
|
||||||
export type WebSearchToolOutputSchema = {
|
export type WebSearchToolOutputSchema = {
|
||||||
// Anthropic 工具 - 手动定义
|
// Anthropic 工具 - 手动定义
|
||||||
anthropic: InferToolOutput<AnthropicWebSearchTool>
|
anthropicWebSearch: Array<{
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
pageAge: string | null
|
||||||
|
encryptedContent: string
|
||||||
|
type: string
|
||||||
|
}>
|
||||||
|
|
||||||
// OpenAI 工具 - 基于实际输出
|
// OpenAI 工具 - 基于实际输出
|
||||||
// TODO: 上游定义不规范,是unknown
|
openaiWebSearch: {
|
||||||
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
|
|
||||||
openai: {
|
|
||||||
status: 'completed' | 'failed'
|
|
||||||
}
|
|
||||||
'openai-chat': {
|
|
||||||
status: 'completed' | 'failed'
|
status: 'completed' | 'failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google 工具
|
// Google 工具
|
||||||
// TODO: 上游定义不规范,是unknown
|
googleSearch: {
|
||||||
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
|
|
||||||
google: {
|
|
||||||
webSearchQueries?: string[]
|
webSearchQueries?: string[]
|
||||||
groundingChunks?: Array<{
|
groundingChunks?: Array<{
|
||||||
web?: { uri: string; title: string }
|
web?: { uri: string; title: string }
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebSearchToolInputSchema = {
|
|
||||||
anthropic: InferToolInput<AnthropicWebSearchTool>
|
|
||||||
openai: InferToolInput<OpenAIWebSearchTool>
|
|
||||||
google: InferToolInput<GoogleWebSearchTool>
|
|
||||||
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { LanguageModelV2 } from '@ai-sdk/provider'
|
|||||||
import { createXai } from '@ai-sdk/xai'
|
import { createXai } from '@ai-sdk/xai'
|
||||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||||
import { customProvider, Provider } from 'ai'
|
import { customProvider, Provider } from 'ai'
|
||||||
import * as z from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基础 Provider IDs
|
* 基础 Provider IDs
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export enum IpcChannel {
|
|||||||
App_SetLanguage = 'app:set-language',
|
App_SetLanguage = 'app:set-language',
|
||||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||||
|
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||||
App_CheckForUpdate = 'app:check-for-update',
|
App_CheckForUpdate = 'app:check-for-update',
|
||||||
App_QuitAndInstall = 'app:quit-and-install',
|
|
||||||
App_Reload = 'app:reload',
|
App_Reload = 'app:reload',
|
||||||
App_Quit = 'app:quit',
|
App_Quit = 'app:quit',
|
||||||
App_Info = 'app:info',
|
App_Info = 'app:info',
|
||||||
@@ -34,7 +34,6 @@ export enum IpcChannel {
|
|||||||
App_GetBinaryPath = 'app:get-binary-path',
|
App_GetBinaryPath = 'app:get-binary-path',
|
||||||
App_InstallUvBinary = 'app:install-uv-binary',
|
App_InstallUvBinary = 'app:install-uv-binary',
|
||||||
App_InstallBunBinary = 'app:install-bun-binary',
|
App_InstallBunBinary = 'app:install-bun-binary',
|
||||||
App_InstallOvmsBinary = 'app:install-ovms-binary',
|
|
||||||
App_LogToMain = 'app:log-to-main',
|
App_LogToMain = 'app:log-to-main',
|
||||||
App_SaveData = 'app:save-data',
|
App_SaveData = 'app:save-data',
|
||||||
App_GetDiskInfo = 'app:get-disk-info',
|
App_GetDiskInfo = 'app:get-disk-info',
|
||||||
@@ -53,7 +52,6 @@ export enum IpcChannel {
|
|||||||
|
|
||||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||||
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
||||||
Webview_SearchHotkey = 'webview:search-hotkey',
|
|
||||||
|
|
||||||
// Open
|
// Open
|
||||||
Open_Path = 'open:path',
|
Open_Path = 'open:path',
|
||||||
@@ -92,10 +90,6 @@ export enum IpcChannel {
|
|||||||
// Python
|
// Python
|
||||||
Python_Execute = 'python:execute',
|
Python_Execute = 'python:execute',
|
||||||
|
|
||||||
// agent messages
|
|
||||||
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
|
|
||||||
AgentMessage_GetHistory = 'agent-message:get-history',
|
|
||||||
|
|
||||||
//copilot
|
//copilot
|
||||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||||
@@ -189,7 +183,6 @@ export enum IpcChannel {
|
|||||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||||
File_StartWatcher = 'file:startWatcher',
|
File_StartWatcher = 'file:startWatcher',
|
||||||
File_StopWatcher = 'file:stopWatcher',
|
File_StopWatcher = 'file:stopWatcher',
|
||||||
File_ShowInFolder = 'file:showInFolder',
|
|
||||||
|
|
||||||
// file service
|
// file service
|
||||||
FileService_Upload = 'file-service:upload',
|
FileService_Upload = 'file-service:upload',
|
||||||
@@ -227,7 +220,6 @@ export enum IpcChannel {
|
|||||||
// system
|
// system
|
||||||
System_GetDeviceType = 'system:getDeviceType',
|
System_GetDeviceType = 'system:getDeviceType',
|
||||||
System_GetHostname = 'system:getHostname',
|
System_GetHostname = 'system:getHostname',
|
||||||
System_GetCpuName = 'system:getCpuName',
|
|
||||||
|
|
||||||
// DevTools
|
// DevTools
|
||||||
System_ToggleDevTools = 'system:toggleDevTools',
|
System_ToggleDevTools = 'system:toggleDevTools',
|
||||||
@@ -235,6 +227,7 @@ export enum IpcChannel {
|
|||||||
// events
|
// events
|
||||||
BackupProgress = 'backup-progress',
|
BackupProgress = 'backup-progress',
|
||||||
ThemeUpdated = 'theme:updated',
|
ThemeUpdated = 'theme:updated',
|
||||||
|
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||||
RestoreProgress = 'restore-progress',
|
RestoreProgress = 'restore-progress',
|
||||||
UpdateError = 'update-error',
|
UpdateError = 'update-error',
|
||||||
UpdateAvailable = 'update-available',
|
UpdateAvailable = 'update-available',
|
||||||
@@ -317,7 +310,6 @@ export enum IpcChannel {
|
|||||||
ApiServer_Stop = 'api-server:stop',
|
ApiServer_Stop = 'api-server:stop',
|
||||||
ApiServer_Restart = 'api-server:restart',
|
ApiServer_Restart = 'api-server:restart',
|
||||||
ApiServer_GetStatus = 'api-server:get-status',
|
ApiServer_GetStatus = 'api-server:get-status',
|
||||||
// NOTE: This api is not be used.
|
|
||||||
ApiServer_GetConfig = 'api-server:get-config',
|
ApiServer_GetConfig = 'api-server:get-config',
|
||||||
|
|
||||||
// Anthropic OAuth
|
// Anthropic OAuth
|
||||||
@@ -337,16 +329,6 @@ export enum IpcChannel {
|
|||||||
|
|
||||||
// OCR
|
// OCR
|
||||||
OCR_ocr = 'ocr:ocr',
|
OCR_ocr = 'ocr:ocr',
|
||||||
OCR_ListProviders = 'ocr:list-providers',
|
|
||||||
|
|
||||||
// OVMS
|
|
||||||
Ovms_AddModel = 'ovms:add-model',
|
|
||||||
Ovms_StopAddModel = 'ovms:stop-addmodel',
|
|
||||||
Ovms_GetModels = 'ovms:get-models',
|
|
||||||
Ovms_IsRunning = 'ovms:is-running',
|
|
||||||
Ovms_GetStatus = 'ovms:get-status',
|
|
||||||
Ovms_RunOVMS = 'ovms:run-ovms',
|
|
||||||
Ovms_StopOVMS = 'ovms:stop-ovms',
|
|
||||||
|
|
||||||
// CherryAI
|
// CherryAI
|
||||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
Cherryai_GetSignature = 'cherryai:get-signature'
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
|
||||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages'
|
|
||||||
|
|
||||||
export type ClaudeCodeRawValue =
|
|
||||||
| {
|
|
||||||
type: string
|
|
||||||
session_id: string
|
|
||||||
slash_commands: string[]
|
|
||||||
tools: string[]
|
|
||||||
raw: Extract<SDKMessage, { type: 'system' }>
|
|
||||||
}
|
|
||||||
| ContentBlockParam
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Shared Anthropic AI client utilities for Cherry Studio
|
|
||||||
*
|
|
||||||
* This module provides functions for creating Anthropic SDK clients with different
|
|
||||||
* authentication methods (OAuth, API key) and building Claude Code system messages.
|
|
||||||
* It supports both standard Anthropic API and Anthropic Vertex AI endpoints.
|
|
||||||
*
|
|
||||||
* This shared module can be used by both main and renderer processes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Anthropic from '@anthropic-ai/sdk'
|
|
||||||
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
|
|
||||||
import { loggerService } from '@logger'
|
|
||||||
import { Provider } from '@types'
|
|
||||||
import type { ModelMessage } from 'ai'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('anthropic-sdk')
|
|
||||||
|
|
||||||
const defaultClaudeCodeSystemPrompt = `You are Claude Code, Anthropic's official CLI for Claude.`
|
|
||||||
|
|
||||||
const defaultClaudeCodeSystem: Array<TextBlockParam> = [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: defaultClaudeCodeSystemPrompt
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and configures an Anthropic SDK client based on the provider configuration.
|
|
||||||
*
|
|
||||||
* This function supports two authentication methods:
|
|
||||||
* 1. OAuth: Uses OAuth tokens passed as parameter
|
|
||||||
* 2. API Key: Uses traditional API key authentication
|
|
||||||
*
|
|
||||||
* For OAuth authentication, it includes Claude Code specific headers and beta features.
|
|
||||||
* For API key authentication, it uses the provider's configuration with custom headers.
|
|
||||||
*
|
|
||||||
* @param provider - The provider configuration containing authentication details
|
|
||||||
* @param oauthToken - Optional OAuth token for OAuth authentication
|
|
||||||
* @returns An initialized Anthropic or AnthropicVertex client
|
|
||||||
* @throws Error when OAuth token is not available for OAuth authentication
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // OAuth authentication
|
|
||||||
* const oauthProvider = { authType: 'oauth' };
|
|
||||||
* const oauthClient = getSdkClient(oauthProvider, 'oauth-token-here');
|
|
||||||
*
|
|
||||||
* // API key authentication
|
|
||||||
* const apiKeyProvider = {
|
|
||||||
* authType: 'apikey',
|
|
||||||
* apiKey: 'your-api-key',
|
|
||||||
* apiHost: 'https://api.anthropic.com'
|
|
||||||
* };
|
|
||||||
* const apiKeyClient = getSdkClient(apiKeyProvider);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function getSdkClient(
|
|
||||||
provider: Provider,
|
|
||||||
oauthToken?: string | null,
|
|
||||||
extraHeaders?: Record<string, string | string[]>
|
|
||||||
): Anthropic {
|
|
||||||
if (provider.authType === 'oauth') {
|
|
||||||
if (!oauthToken) {
|
|
||||||
throw new Error('OAuth token is not available')
|
|
||||||
}
|
|
||||||
return new Anthropic({
|
|
||||||
authToken: oauthToken,
|
|
||||||
baseURL: 'https://api.anthropic.com',
|
|
||||||
dangerouslyAllowBrowser: true,
|
|
||||||
defaultHeaders: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'anthropic-version': '2023-06-01',
|
|
||||||
'anthropic-beta':
|
|
||||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
|
|
||||||
'anthropic-dangerous-direct-browser-access': 'true',
|
|
||||||
'user-agent': 'claude-cli/1.0.118 (external, sdk-ts)',
|
|
||||||
'x-app': 'cli',
|
|
||||||
'x-stainless-retry-count': '0',
|
|
||||||
'x-stainless-timeout': '600',
|
|
||||||
'x-stainless-lang': 'js',
|
|
||||||
'x-stainless-package-version': '0.60.0',
|
|
||||||
'x-stainless-os': 'MacOS',
|
|
||||||
'x-stainless-arch': 'arm64',
|
|
||||||
'x-stainless-runtime': 'node',
|
|
||||||
'x-stainless-runtime-version': 'v22.18.0',
|
|
||||||
...extraHeaders
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const baseURL =
|
|
||||||
provider.type === 'anthropic'
|
|
||||||
? provider.apiHost
|
|
||||||
: (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost
|
|
||||||
|
|
||||||
logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id })
|
|
||||||
|
|
||||||
if (provider.id === 'aihubmix') {
|
|
||||||
return new Anthropic({
|
|
||||||
apiKey: provider.apiKey,
|
|
||||||
baseURL,
|
|
||||||
dangerouslyAllowBrowser: true,
|
|
||||||
defaultHeaders: {
|
|
||||||
'anthropic-beta': 'output-128k-2025-02-19',
|
|
||||||
'APP-Code': 'MLTG2087',
|
|
||||||
...provider.extra_headers,
|
|
||||||
...extraHeaders
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Anthropic({
|
|
||||||
apiKey: provider.apiKey,
|
|
||||||
authToken: provider.apiKey,
|
|
||||||
baseURL,
|
|
||||||
dangerouslyAllowBrowser: true,
|
|
||||||
defaultHeaders: {
|
|
||||||
'anthropic-beta': 'output-128k-2025-02-19',
|
|
||||||
...provider.extra_headers
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds and prepends the Claude Code system message to user-provided system messages.
|
|
||||||
*
|
|
||||||
* This function ensures that all interactions with Claude include the official Claude Code
|
|
||||||
* system prompt, which identifies the assistant as "Claude Code, Anthropic's official CLI for Claude."
|
|
||||||
*
|
|
||||||
* The function handles three cases:
|
|
||||||
* 1. No system message provided: Returns only the default Claude Code system message
|
|
||||||
* 2. String system message: Converts to array format and prepends Claude Code message
|
|
||||||
* 3. Array system message: Checks if Claude Code message exists and prepends if missing
|
|
||||||
*
|
|
||||||
* @param system - Optional user-provided system message (string or TextBlockParam array)
|
|
||||||
* @returns Combined system message with Claude Code prompt prepended
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): Array<TextBlockParam> {
|
|
||||||
if (!system) {
|
|
||||||
return defaultClaudeCodeSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof system === 'string') {
|
|
||||||
if (system.trim() === defaultClaudeCodeSystemPrompt || system.trim() === '') {
|
|
||||||
return defaultClaudeCodeSystem
|
|
||||||
} else {
|
|
||||||
return [...defaultClaudeCodeSystem, { type: 'text', text: system }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(system)) {
|
|
||||||
const firstSystem = system[0]
|
|
||||||
if (firstSystem.type === 'text' && firstSystem.text.trim() === defaultClaudeCodeSystemPrompt) {
|
|
||||||
return system
|
|
||||||
} else {
|
|
||||||
return [...defaultClaudeCodeSystem, ...system]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultClaudeCodeSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildClaudeCodeSystemModelMessage(system?: string | Array<TextBlockParam>): Array<ModelMessage> {
|
|
||||||
const textBlocks = buildClaudeCodeSystemMessage(system)
|
|
||||||
return textBlocks.map((block) => ({
|
|
||||||
role: 'system',
|
|
||||||
content: block.text
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -217,8 +217,7 @@ export enum codeTools {
|
|||||||
claudeCode = 'claude-code',
|
claudeCode = 'claude-code',
|
||||||
geminiCli = 'gemini-cli',
|
geminiCli = 'gemini-cli',
|
||||||
openaiCodex = 'openai-codex',
|
openaiCodex = 'openai-codex',
|
||||||
iFlowCli = 'iflow-cli',
|
iFlowCli = 'iflow-cli'
|
||||||
githubCopilotCli = 'github-copilot-cli'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum terminalApps {
|
export enum terminalApps {
|
||||||
|
|||||||
@@ -22,12 +22,3 @@ export type MCPProgressEvent = {
|
|||||||
callId: string
|
callId: string
|
||||||
progress: number // 0-1 range
|
progress: number // 0-1 range
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebviewKeyEvent = {
|
|
||||||
webviewId: number
|
|
||||||
key: string
|
|
||||||
control: boolean
|
|
||||||
meta: boolean
|
|
||||||
shift: boolean
|
|
||||||
alt: boolean
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
--> 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 +0,0 @@
|
|||||||
ALTER TABLE `session_messages` ADD `agent_session_id` text DEFAULT '';
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
{
|
|
||||||
"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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
{
|
|
||||||
"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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
const https = require('https')
|
const https = require('https')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
|
||||||
const { execSync } = require('child_process')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a file from a URL with redirect handling
|
* Downloads a file from a URL with redirect handling
|
||||||
@@ -34,39 +32,4 @@ async function downloadWithRedirects(url, destinationPath) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
module.exports = { downloadWithRedirects }
|
||||||
* Downloads a file using PowerShell Invoke-WebRequest command
|
|
||||||
* @param {string} url The URL to download from
|
|
||||||
* @param {string} destinationPath The path to save the file to
|
|
||||||
* @returns {Promise<boolean>} Promise that resolves to true if download succeeds
|
|
||||||
*/
|
|
||||||
async function downloadWithPowerShell(url, destinationPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
// Only support windows platform for PowerShell download
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return reject(new Error('PowerShell download is only supported on Windows'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputDir = path.dirname(destinationPath)
|
|
||||||
fs.mkdirSync(outputDir, { recursive: true })
|
|
||||||
|
|
||||||
// PowerShell command to download the file with progress disabled for faster download
|
|
||||||
const psCommand = `powershell -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest '${url}' -OutFile '${destinationPath}'"`
|
|
||||||
|
|
||||||
console.log(`Downloading with PowerShell: ${url}`)
|
|
||||||
execSync(psCommand, { stdio: 'inherit' })
|
|
||||||
|
|
||||||
if (fs.existsSync(destinationPath)) {
|
|
||||||
console.log(`Download completed: ${destinationPath}`)
|
|
||||||
resolve(true)
|
|
||||||
} else {
|
|
||||||
reject(new Error('Download failed: File not found after download'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
reject(new Error(`PowerShell download failed: ${error.message}`))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { downloadWithRedirects, downloadWithPowerShell }
|
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
const os = require('os')
|
|
||||||
const { execSync } = require('child_process')
|
|
||||||
const { downloadWithPowerShell } = require('./download')
|
|
||||||
|
|
||||||
// Base URL for downloading OVMS binaries
|
|
||||||
const OVMS_RELEASE_BASE_URL =
|
|
||||||
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip'
|
|
||||||
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* error code:
|
|
||||||
* 101: Unsupported CPU (not Intel Ultra)
|
|
||||||
* 102: Unsupported platform (not Windows)
|
|
||||||
* 103: Download failed
|
|
||||||
* 104: Installation failed
|
|
||||||
* 105: Failed to create ovdnd.exe
|
|
||||||
* 106: Failed to create run.bat
|
|
||||||
* 110: Cleanup of old installation failed
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean old OVMS installation if it exists
|
|
||||||
*/
|
|
||||||
function cleanOldOvmsInstallation() {
|
|
||||||
console.log('Cleaning the existing OVMS installation...')
|
|
||||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
|
||||||
const csOvmsDir = path.join(csDir, 'ovms')
|
|
||||||
if (fs.existsSync(csOvmsDir)) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(csOvmsDir, { recursive: true })
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
|
|
||||||
return 110
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install OVMS Base package
|
|
||||||
*/
|
|
||||||
async function installOvmsBase() {
|
|
||||||
// Download the base package
|
|
||||||
const tempdir = os.tmpdir()
|
|
||||||
const tempFilename = path.join(tempdir, 'ovms.zip')
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
|
|
||||||
|
|
||||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
|
||||||
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename)
|
|
||||||
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Download OVMS Base failed: ${error.message}`)
|
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
return 103
|
|
||||||
}
|
|
||||||
|
|
||||||
// unzip the base package to the target directory
|
|
||||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
|
||||||
const csOvmsDir = path.join(csDir, 'ovms')
|
|
||||||
fs.mkdirSync(csOvmsDir, { recursive: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use tar.exe to extract the ZIP file
|
|
||||||
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
|
|
||||||
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
|
|
||||||
console.log(`OVMS extracted to ${csOvmsDir}`)
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
console.log(`Installation directory: ${csDir}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error installing OVMS: ${error.message}`)
|
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
return 104
|
|
||||||
}
|
|
||||||
|
|
||||||
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
|
|
||||||
// copy ovms.exe to ovdnd.exe
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
|
|
||||||
console.log('Copied ovms.exe to ovdnd.exe')
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
|
|
||||||
return 105
|
|
||||||
}
|
|
||||||
|
|
||||||
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
|
|
||||||
// del %USERPROFILE%\.cherrystudio\ovms_log.log
|
|
||||||
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
|
|
||||||
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
|
|
||||||
fs.appendFileSync(runBatPath, '\r\n')
|
|
||||||
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
|
|
||||||
fs.appendFileSync(
|
|
||||||
runBatPath,
|
|
||||||
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
|
|
||||||
)
|
|
||||||
console.log(`Created run.bat at: ${runBatPath}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error creating run.bat: ${error.message}`)
|
|
||||||
return 106
|
|
||||||
}
|
|
||||||
|
|
||||||
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
|
|
||||||
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
|
|
||||||
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
|
|
||||||
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
|
|
||||||
console.log(`Created config file: ${configJsonPath}`)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install OVMS Extra package
|
|
||||||
*/
|
|
||||||
async function installOvmsExtra() {
|
|
||||||
// Download the extra package
|
|
||||||
const tempdir = os.tmpdir()
|
|
||||||
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
|
|
||||||
|
|
||||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
|
||||||
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
|
|
||||||
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Download OVMS Extra failed: ${error.message}`)
|
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
return 103
|
|
||||||
}
|
|
||||||
|
|
||||||
// unzip the extra package to the target directory
|
|
||||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
|
||||||
const csOvmsDir = path.join(csDir, 'ovms')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use tar.exe to extract the ZIP file
|
|
||||||
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
|
|
||||||
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
|
|
||||||
console.log(`OVMS extracted to ${csOvmsDir}`)
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
console.log(`Installation directory: ${csDir}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error installing OVMS Extra: ${error.message}`)
|
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
return 104
|
|
||||||
}
|
|
||||||
|
|
||||||
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
|
|
||||||
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
|
|
||||||
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
|
|
||||||
try {
|
|
||||||
const files = fs.readdirSync(patchDir)
|
|
||||||
files.forEach((file) => {
|
|
||||||
const srcPath = path.join(patchDir, file)
|
|
||||||
const destPath = path.join(csOvmsBinDir, file)
|
|
||||||
fs.copyFileSync(srcPath, destPath)
|
|
||||||
console.log(`Applied patch file: ${file}`)
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error applying OVMS patch: ${error.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the CPU Name and ID
|
|
||||||
*/
|
|
||||||
function getCpuInfo() {
|
|
||||||
const cpuInfo = {
|
|
||||||
name: '',
|
|
||||||
id: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use PowerShell to get CPU information
|
|
||||||
try {
|
|
||||||
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
|
|
||||||
const psOutput = execSync(psCommand).toString()
|
|
||||||
const cpuData = JSON.parse(psOutput)
|
|
||||||
|
|
||||||
if (Array.isArray(cpuData)) {
|
|
||||||
cpuInfo.name = cpuData[0].Name || ''
|
|
||||||
cpuInfo.id = cpuData[0].DeviceID || ''
|
|
||||||
} else {
|
|
||||||
cpuInfo.name = cpuData.Name || ''
|
|
||||||
cpuInfo.id = cpuData.DeviceID || ''
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to get CPU info: ${error.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cpuInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main function to install OVMS
|
|
||||||
*/
|
|
||||||
async function installOvms() {
|
|
||||||
const platform = os.platform()
|
|
||||||
console.log(`Detected platform: ${platform}`)
|
|
||||||
|
|
||||||
const cpuName = getCpuInfo().name
|
|
||||||
console.log(`CPU Name: ${cpuName}`)
|
|
||||||
|
|
||||||
// Check if CPU name contains "Ultra"
|
|
||||||
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
|
|
||||||
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
|
|
||||||
return 101
|
|
||||||
}
|
|
||||||
|
|
||||||
// only support windows
|
|
||||||
if (platform !== 'win32') {
|
|
||||||
console.error('OVMS installation is only supported on Windows.')
|
|
||||||
return 102
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean old installation if it exists
|
|
||||||
const cleanupCode = cleanOldOvmsInstallation()
|
|
||||||
if (cleanupCode !== 0) {
|
|
||||||
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
|
|
||||||
return cleanupCode
|
|
||||||
}
|
|
||||||
|
|
||||||
const installBaseCode = await installOvmsBase()
|
|
||||||
if (installBaseCode !== 0) {
|
|
||||||
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
|
|
||||||
cleanOldOvmsInstallation()
|
|
||||||
return installBaseCode
|
|
||||||
}
|
|
||||||
|
|
||||||
const installExtraCode = await installOvmsExtra()
|
|
||||||
if (installExtraCode !== 0) {
|
|
||||||
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
|
|
||||||
return installExtraCode
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the installation
|
|
||||||
installOvms()
|
|
||||||
.then((retcode) => {
|
|
||||||
if (retcode === 0) {
|
|
||||||
console.log('OVMS installation successful')
|
|
||||||
} else {
|
|
||||||
console.error('OVMS installation failed')
|
|
||||||
}
|
|
||||||
process.exit(retcode)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('OVMS installation failed:', error)
|
|
||||||
process.exit(100)
|
|
||||||
})
|
|
||||||
@@ -9,9 +9,8 @@ import * as path from 'path'
|
|||||||
|
|
||||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||||
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
||||||
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
const baseLocale = 'zh-cn'
|
||||||
const baseFileName = `${baseLocale}.json`
|
const baseFileName = `${baseLocale}.json`
|
||||||
const baseLocalePath = path.join(__dirname, '../src/renderer/src/i18n/locales', baseFileName)
|
|
||||||
|
|
||||||
type I18NValue = string | { [key: string]: I18NValue }
|
type I18NValue = string | { [key: string]: I18NValue }
|
||||||
type I18N = { [key: string]: I18NValue }
|
type I18N = { [key: string]: I18NValue }
|
||||||
@@ -106,9 +105,6 @@ const translateRecursively = async (originObj: I18N, systemPrompt: string): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
if (!fs.existsSync(baseLocalePath)) {
|
|
||||||
throw new Error(`${baseLocalePath} not found.`)
|
|
||||||
}
|
|
||||||
const localeFiles = fs
|
const localeFiles = fs
|
||||||
.readdirSync(localesDir)
|
.readdirSync(localesDir)
|
||||||
.filter((file) => file.endsWith('.json') && file !== baseFileName)
|
.filter((file) => file.endsWith('.json') && file !== baseFileName)
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ const allX64 = {
|
|||||||
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor'
|
|
||||||
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
|
|
||||||
|
|
||||||
const platformToArch = {
|
const platformToArch = {
|
||||||
mac: 'darwin',
|
mac: 'darwin',
|
||||||
windows: 'win32',
|
windows: 'win32',
|
||||||
@@ -49,6 +46,9 @@ exports.default = async function (context) {
|
|||||||
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
|
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
|
||||||
const platform = context.packager.platform.name
|
const platform = context.packager.platform.name
|
||||||
|
|
||||||
|
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
|
||||||
|
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
|
||||||
|
|
||||||
const downloadPackages = async (packages) => {
|
const downloadPackages = async (packages) => {
|
||||||
console.log('downloading packages ......')
|
console.log('downloading packages ......')
|
||||||
const downloadPromises = []
|
const downloadPromises = []
|
||||||
@@ -67,39 +67,25 @@ exports.default = async function (context) {
|
|||||||
await Promise.all(downloadPromises)
|
await Promise.all(downloadPromises)
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeFilters = async (filtersToExclude, filtersToInclude) => {
|
const changeFilters = async (packages, filtersToExclude, filtersToInclude) => {
|
||||||
|
await downloadPackages(packages)
|
||||||
// remove filters for the target architecture (allow inclusion)
|
// remove filters for the target architecture (allow inclusion)
|
||||||
|
|
||||||
let filters = context.packager.config.files[0].filter
|
let filters = context.packager.config.files[0].filter
|
||||||
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
|
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
|
||||||
|
|
||||||
// add filters for other architectures (exclude them)
|
// add filters for other architectures (exclude them)
|
||||||
filters.push(...filtersToExclude)
|
filters.push(...filtersToExclude)
|
||||||
|
|
||||||
context.packager.config.files[0].filter = filters
|
context.packager.config.files[0].filter = filters
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64)
|
|
||||||
|
|
||||||
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 + '/ripgrep/' + `${archType}-${platformToArch[platform]}/**`
|
|
||||||
]
|
|
||||||
|
|
||||||
if (arch === Arch.arm64) {
|
if (arch === Arch.arm64) {
|
||||||
await changeFilters(
|
await changeFilters(allArm64, x64Filters, arm64Filters)
|
||||||
[...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
|
return
|
||||||
[...arm64Filters, ...includeClaudeCodeFilters]
|
}
|
||||||
)
|
|
||||||
} else {
|
if (arch === Arch.x64) {
|
||||||
await changeFilters(
|
await changeFilters(allX64, arm64Filters, x64Filters)
|
||||||
[...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
|
return
|
||||||
[...x64Filters, ...includeClaudeCodeFilters]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as path from 'path'
|
|||||||
import { sortedObjectByKeys } from './sort'
|
import { sortedObjectByKeys } from './sort'
|
||||||
|
|
||||||
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||||
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
const baseLocale = 'zh-cn'
|
||||||
const baseFileName = `${baseLocale}.json`
|
const baseFileName = `${baseLocale}.json`
|
||||||
const baseFilePath = path.join(translationsDir, baseFileName)
|
const baseFilePath = path.join(translationsDir, baseFileName)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { sortedObjectByKeys } from './sort'
|
|||||||
|
|
||||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||||
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
||||||
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
const baseLocale = 'zh-cn'
|
||||||
const baseFileName = `${baseLocale}.json`
|
const baseFileName = `${baseLocale}.json`
|
||||||
const baseFilePath = path.join(localesDir, baseFileName)
|
const baseFilePath = path.join(localesDir, baseFileName)
|
||||||
|
|
||||||
|
|||||||
@@ -3,42 +3,23 @@ import cors from 'cors'
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
import { LONG_POLL_TIMEOUT_MS } from './config/timeouts'
|
|
||||||
import { authMiddleware } from './middleware/auth'
|
import { authMiddleware } from './middleware/auth'
|
||||||
import { errorHandler } from './middleware/error'
|
import { errorHandler } from './middleware/error'
|
||||||
import { setupOpenAPIDocumentation } from './middleware/openapi'
|
import { setupOpenAPIDocumentation } from './middleware/openapi'
|
||||||
import { agentsRoutes } from './routes/agents'
|
|
||||||
import { chatRoutes } from './routes/chat'
|
import { chatRoutes } from './routes/chat'
|
||||||
import { mcpRoutes } from './routes/mcp'
|
import { mcpRoutes } from './routes/mcp'
|
||||||
import { messagesProviderRoutes, messagesRoutes } from './routes/messages'
|
|
||||||
import { modelsRoutes } from './routes/models'
|
import { modelsRoutes } from './routes/models'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServer')
|
const logger = loggerService.withContext('ApiServer')
|
||||||
|
|
||||||
const extendMessagesTimeout: express.RequestHandler = (req, res, next) => {
|
|
||||||
req.setTimeout(LONG_POLL_TIMEOUT_MS)
|
|
||||||
res.setTimeout(LONG_POLL_TIMEOUT_MS)
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(
|
|
||||||
express.json({
|
|
||||||
limit: '50mb'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Global middleware
|
// Global middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start
|
const duration = Date.now() - start
|
||||||
logger.info('API request completed', {
|
logger.info(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`)
|
||||||
method: req.method,
|
|
||||||
path: req.path,
|
|
||||||
statusCode: res.statusCode,
|
|
||||||
durationMs: duration
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
@@ -120,28 +101,27 @@ app.get('/', (_req, res) => {
|
|||||||
name: 'Cherry Studio API',
|
name: 'Cherry Studio API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
health: 'GET /health'
|
health: 'GET /health',
|
||||||
|
models: 'GET /v1/models',
|
||||||
|
chat: 'POST /v1/chat/completions',
|
||||||
|
mcp: 'GET /v1/mcps'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Setup OpenAPI documentation before protected routes so docs remain public
|
|
||||||
setupOpenAPIDocumentation(app)
|
|
||||||
|
|
||||||
// Provider-specific messages route requires authentication
|
|
||||||
app.use('/:provider/v1/messages', authMiddleware, extendMessagesTimeout, messagesProviderRoutes)
|
|
||||||
|
|
||||||
// API v1 routes with auth
|
// API v1 routes with auth
|
||||||
const apiRouter = express.Router()
|
const apiRouter = express.Router()
|
||||||
apiRouter.use(authMiddleware)
|
apiRouter.use(authMiddleware)
|
||||||
|
apiRouter.use(express.json())
|
||||||
// Mount routes
|
// Mount routes
|
||||||
apiRouter.use('/chat', chatRoutes)
|
apiRouter.use('/chat', chatRoutes)
|
||||||
apiRouter.use('/mcps', mcpRoutes)
|
apiRouter.use('/mcps', mcpRoutes)
|
||||||
apiRouter.use('/messages', extendMessagesTimeout, messagesRoutes)
|
|
||||||
apiRouter.use('/models', modelsRoutes)
|
apiRouter.use('/models', modelsRoutes)
|
||||||
apiRouter.use('/agents', agentsRoutes)
|
|
||||||
app.use('/v1', apiRouter)
|
app.use('/v1', apiRouter)
|
||||||
|
|
||||||
|
// Setup OpenAPI documentation
|
||||||
|
setupOpenAPIDocumentation(app)
|
||||||
|
|
||||||
// Error handling (must be last)
|
// Error handling (must be last)
|
||||||
app.use(errorHandler)
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
return this._config
|
return this._config
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.warn('Failed to load config from Redux, using defaults', { error })
|
logger.warn('Failed to load config from Redux, using defaults:', error)
|
||||||
this._config = {
|
this._config = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
port: defaultPort,
|
port: defaultPort,
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export const LONG_POLL_TIMEOUT_MS = 120 * 60_000 // 120 minutes
|
|
||||||
|
|
||||||
export const MESSAGE_STREAM_TIMEOUT_MS = LONG_POLL_TIMEOUT_MS
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import { config } from '../../config'
|
|
||||||
import { authMiddleware } from '../auth'
|
|
||||||
|
|
||||||
// Mock the config module
|
|
||||||
vi.mock('../../config', () => ({
|
|
||||||
config: {
|
|
||||||
get: vi.fn()
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock the logger
|
|
||||||
vi.mock('@logger', () => ({
|
|
||||||
loggerService: {
|
|
||||||
withContext: vi.fn(() => ({
|
|
||||||
debug: vi.fn()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockConfig = config as any
|
|
||||||
|
|
||||||
describe('authMiddleware', () => {
|
|
||||||
let req: Partial<Request>
|
|
||||||
let res: Partial<Response>
|
|
||||||
let next: NextFunction
|
|
||||||
let jsonMock: ReturnType<typeof vi.fn>
|
|
||||||
let statusMock: ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jsonMock = vi.fn()
|
|
||||||
statusMock = vi.fn(() => ({ json: jsonMock }))
|
|
||||||
|
|
||||||
req = {
|
|
||||||
header: vi.fn()
|
|
||||||
}
|
|
||||||
res = {
|
|
||||||
status: statusMock
|
|
||||||
}
|
|
||||||
next = vi.fn()
|
|
||||||
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Missing credentials', () => {
|
|
||||||
it('should return 401 when both auth headers are missing', async () => {
|
|
||||||
;(req.header as any).mockReturnValue('')
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(401)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: missing credentials' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 401 when both auth headers are empty strings', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return ''
|
|
||||||
if (header === 'x-api-key') return ''
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(401)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: missing credentials' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Server configuration', () => {
|
|
||||||
it('should return 403 when API key is not configured', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return 'some-key'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
mockConfig.get.mockResolvedValue({ apiKey: '' })
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 403 when API key is null', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return 'some-key'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
mockConfig.get.mockResolvedValue({ apiKey: null })
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('API Key authentication (priority)', () => {
|
|
||||||
const validApiKey = 'valid-api-key-123'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should authenticate successfully with valid API key', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return validApiKey
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled()
|
|
||||||
expect(statusMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 403 with invalid API key', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return 'invalid-key'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 401 with empty API key', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return ' '
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(401)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: empty x-api-key' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle API key with whitespace', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return ` ${validApiKey} `
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled()
|
|
||||||
expect(statusMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should prioritize API key over Bearer token when both are present', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return validApiKey
|
|
||||||
if (header === 'authorization') return 'Bearer invalid-token'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled()
|
|
||||||
expect(statusMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 403 when API key is invalid even if Bearer token is valid', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return 'invalid-key'
|
|
||||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Bearer token authentication (fallback)', () => {
|
|
||||||
const validApiKey = 'valid-api-key-123'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should authenticate successfully with valid Bearer token when no API key', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled()
|
|
||||||
expect(statusMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 403 with invalid Bearer token', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return 'Bearer invalid-token'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 401 with malformed authorization header', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return 'Basic sometoken'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(401)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 401 with Bearer without space', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return 'Bearer'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(401)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle Bearer token with only trailing spaces (edge case)', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return 'Bearer ' // This will be trimmed to "Bearer" and fail format check
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(401)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle Bearer token with case insensitive prefix', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return `bearer ${validApiKey}`
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled()
|
|
||||||
expect(statusMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle Bearer token with whitespace', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return ` Bearer ${validApiKey} `
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled()
|
|
||||||
expect(statusMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
|
||||||
const validApiKey = 'valid-api-key-123'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle config.get() rejection', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return validApiKey
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
mockConfig.get.mockRejectedValue(new Error('Config error'))
|
|
||||||
|
|
||||||
await expect(authMiddleware(req as Request, res as Response, next)).rejects.toThrow('Config error')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should use timing-safe comparison for different length tokens', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return 'short'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 401 when neither credential format is valid', async () => {
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return 'Invalid format'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(401)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Timing attack protection', () => {
|
|
||||||
const validApiKey = 'valid-api-key-123'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle similar length but different API keys securely', async () => {
|
|
||||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
|
||||||
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'x-api-key') return similarKey
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle similar length but different Bearer tokens securely', async () => {
|
|
||||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
|
||||||
|
|
||||||
;(req.header as any).mockImplementation((header: string) => {
|
|
||||||
if (header === 'authorization') return `Bearer ${similarKey}`
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await authMiddleware(req as Request, res as Response, next)
|
|
||||||
|
|
||||||
expect(statusMock).toHaveBeenCalledWith(403)
|
|
||||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
|
||||||
expect(next).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -3,17 +3,8 @@ import { NextFunction, Request, Response } from 'express'
|
|||||||
|
|
||||||
import { config } from '../config'
|
import { config } from '../config'
|
||||||
|
|
||||||
const isValidToken = (token: string, apiKey: string): boolean => {
|
|
||||||
if (token.length !== apiKey.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const tokenBuf = Buffer.from(token)
|
|
||||||
const keyBuf = Buffer.from(apiKey)
|
|
||||||
return crypto.timingSafeEqual(tokenBuf, keyBuf)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const auth = req.header('authorization') || ''
|
const auth = req.header('Authorization') || ''
|
||||||
const xApiKey = req.header('x-api-key') || ''
|
const xApiKey = req.header('x-api-key') || ''
|
||||||
|
|
||||||
// Fast rejection if neither credential header provided
|
// Fast rejection if neither credential header provided
|
||||||
@@ -21,46 +12,51 @@ export const authMiddleware = async (req: Request, res: Response, next: NextFunc
|
|||||||
return res.status(401).json({ error: 'Unauthorized: missing credentials' })
|
return res.status(401).json({ error: 'Unauthorized: missing credentials' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { apiKey } = await config.get()
|
let token: string | undefined
|
||||||
|
|
||||||
if (!apiKey) {
|
// Prefer Bearer if well‑formed
|
||||||
return res.status(403).json({ error: 'Forbidden' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check API key first (priority)
|
|
||||||
if (xApiKey) {
|
|
||||||
const trimmedApiKey = xApiKey.trim()
|
|
||||||
if (!trimmedApiKey) {
|
|
||||||
return res.status(401).json({ error: 'Unauthorized: empty x-api-key' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidToken(trimmedApiKey, apiKey)) {
|
|
||||||
return next()
|
|
||||||
} else {
|
|
||||||
return res.status(403).json({ error: 'Forbidden' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to Bearer token
|
|
||||||
if (auth) {
|
if (auth) {
|
||||||
const trimmed = auth.trim()
|
const trimmed = auth.trim()
|
||||||
const bearerPrefix = /^Bearer\s+/i
|
const bearerPrefix = /^Bearer\s+/i
|
||||||
|
if (bearerPrefix.test(trimmed)) {
|
||||||
if (!bearerPrefix.test(trimmed)) {
|
const candidate = trimmed.replace(bearerPrefix, '').trim()
|
||||||
return res.status(401).json({ error: 'Unauthorized: invalid authorization format' })
|
if (!candidate) {
|
||||||
}
|
return res.status(401).json({ error: 'Unauthorized: empty bearer token' })
|
||||||
|
}
|
||||||
const token = trimmed.replace(bearerPrefix, '').trim()
|
token = candidate
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({ error: 'Unauthorized: empty bearer token' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidToken(token, apiKey)) {
|
|
||||||
return next()
|
|
||||||
} else {
|
|
||||||
return res.status(403).json({ error: 'Forbidden' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'Unauthorized: invalid credentials format' })
|
// Fallback to x-api-key if token still not resolved
|
||||||
|
if (!token && xApiKey) {
|
||||||
|
if (!xApiKey.trim()) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: empty x-api-key' })
|
||||||
|
}
|
||||||
|
token = xApiKey.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// At this point we had at least one header, but none yielded a usable token
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: invalid credentials format' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey } = await config.get()
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
// If server not configured, treat as forbidden (or could be 500). Choose 403 to avoid leaking config state.
|
||||||
|
return res.status(403).json({ error: 'Forbidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing-safe compare when lengths match, else immediate forbidden
|
||||||
|
if (token.length !== apiKey.length) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenBuf = Buffer.from(token)
|
||||||
|
const keyBuf = Buffer.from(apiKey)
|
||||||
|
if (!crypto.timingSafeEqual(tokenBuf, keyBuf)) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const logger = loggerService.withContext('ApiServerErrorHandler')
|
|||||||
|
|
||||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
logger.error('API server error', { error: err })
|
logger.error('API Server Error:', err)
|
||||||
|
|
||||||
// Don't expose internal errors in production
|
// Don't expose internal errors in production
|
||||||
const isDev = process.env.NODE_ENV === 'development'
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
|
|||||||
@@ -197,11 +197,10 @@ export function setupOpenAPIDocumentation(app: Express) {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info('OpenAPI documentation ready', {
|
logger.info('OpenAPI documentation setup complete')
|
||||||
docsPath: '/api-docs',
|
logger.info('Documentation available at /api-docs')
|
||||||
specPath: '/api-docs.json'
|
logger.info('OpenAPI spec available at /api-docs.json')
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to setup OpenAPI documentation', { error })
|
logger.error('Failed to setup OpenAPI documentation:', error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,567 +0,0 @@
|
|||||||
import { loggerService } from '@logger'
|
|
||||||
import { AgentModelValidationError, agentService, sessionService } 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 and automatically
|
|
||||||
* provisions an initial session that mirrors the agent's settings.
|
|
||||||
* 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.debug('Creating agent')
|
|
||||||
logger.debug('Agent payload', { body: req.body })
|
|
||||||
|
|
||||||
const agent = await agentService.createAgent(req.body)
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info('Agent created', { agentId: agent.id })
|
|
||||||
logger.debug('Creating default session for agent', { agentId: agent.id })
|
|
||||||
|
|
||||||
await sessionService.createSession(agent.id, {})
|
|
||||||
|
|
||||||
logger.info('Default session created for agent', { agentId: agent.id })
|
|
||||||
return res.status(201).json(agent)
|
|
||||||
} catch (sessionError: any) {
|
|
||||||
logger.error('Failed to create default session for new agent, rolling back agent creation', {
|
|
||||||
agentId: agent.id,
|
|
||||||
error: sessionError
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await agentService.deleteAgent(agent.id)
|
|
||||||
} catch (rollbackError: any) {
|
|
||||||
logger.error('Failed to roll back agent after session creation failure', {
|
|
||||||
agentId: agent.id,
|
|
||||||
error: rollbackError
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: `Failed to create default session for agent: ${sessionError.message}`,
|
|
||||||
type: 'internal_error',
|
|
||||||
code: 'agent_session_creation_failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} 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.debug('Listing agents', { limit, offset })
|
|
||||||
|
|
||||||
const result = await agentService.listAgents({ limit, offset })
|
|
||||||
|
|
||||||
logger.info('Agents listed', {
|
|
||||||
returned: result.agents.length,
|
|
||||||
total: result.total,
|
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
})
|
|
||||||
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.debug('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', { agentId })
|
|
||||||
return res.json(agent)
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error getting agent', { error, agentId: req.params.agentId })
|
|
||||||
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.debug('Updating agent', { agentId })
|
|
||||||
logger.debug('Replace payload', { body: 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', { 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, agentId })
|
|
||||||
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
|
|
||||||
* tools:
|
|
||||||
* type: array
|
|
||||||
* items:
|
|
||||||
* type: string
|
|
||||||
* description: Tools
|
|
||||||
* 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.debug('Partially updating agent', { agentId })
|
|
||||||
logger.debug('Patch payload', { body: 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 patched', { 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, agentId })
|
|
||||||
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.debug('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', { agentId })
|
|
||||||
return res.status(204).send()
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error deleting agent', { error, agentId: req.params.agentId })
|
|
||||||
return res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: 'Failed to delete agent',
|
|
||||||
type: 'internal_error',
|
|
||||||
code: 'agent_delete_failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * as agentHandlers from './agents'
|
|
||||||
export * as messageHandlers from './messages'
|
|
||||||
export * as sessionHandlers from './sessions'
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { loggerService } from '@logger'
|
|
||||||
import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts'
|
|
||||||
import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController'
|
|
||||||
import { agentService, sessionMessageService, sessionService } from '@main/services/agents'
|
|
||||||
import { Request, Response } from 'express'
|
|
||||||
|
|
||||||
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> => {
|
|
||||||
let clearAbortTimeout: (() => void) | undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { agentId, sessionId } = req.params
|
|
||||||
|
|
||||||
const session = await verifyAgentAndSession(agentId, sessionId)
|
|
||||||
|
|
||||||
const messageData = req.body
|
|
||||||
|
|
||||||
logger.info('Creating streaming message', { agentId, sessionId })
|
|
||||||
logger.debug('Streaming message payload', { 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,
|
|
||||||
registerAbortHandler,
|
|
||||||
clearAbortTimeout: helperClearAbortTimeout
|
|
||||||
} = createStreamAbortController({
|
|
||||||
timeoutMs: MESSAGE_STREAM_TIMEOUT_MS
|
|
||||||
})
|
|
||||||
clearAbortTimeout = helperClearAbortTimeout
|
|
||||||
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 cleanupAbortTimeout = () => {
|
|
||||||
clearAbortTimeout?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalizeResponse = () => {
|
|
||||||
if (responseEnded) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!streamFinished) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
responseEnded = true
|
|
||||||
cleanupAbortTimeout()
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
registerAbortHandler((abortReason) => {
|
|
||||||
cleanupAbortTimeout()
|
|
||||||
|
|
||||||
if (responseEnded) return
|
|
||||||
|
|
||||||
responseEnded = true
|
|
||||||
|
|
||||||
if (abortReason === STREAM_TIMEOUT_REASON) {
|
|
||||||
logger.error('Streaming message timeout', { agentId, 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 })
|
|
||||||
}
|
|
||||||
} else if (abortReason === 'Client disconnected') {
|
|
||||||
logger.info('Streaming client disconnected', { agentId, sessionId })
|
|
||||||
} else {
|
|
||||||
logger.warn('Streaming aborted', { agentId, sessionId, reason: abortReason })
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.cancel(abortReason ?? 'stream aborted').catch(() => {})
|
|
||||||
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream')
|
|
||||||
res.setHeader('Cache-Control', 'no-cache')
|
|
||||||
res.setHeader('Connection', 'keep-alive')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.writableEnded) {
|
|
||||||
res.end()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
if (abortController.signal.aborted) return
|
|
||||||
abortController.abort('Client disconnected')
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
cleanupAbortTimeout()
|
|
||||||
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', { agentId, 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
|
|
||||||
cleanupAbortTimeout()
|
|
||||||
res.end()
|
|
||||||
})
|
|
||||||
// Clear timeout when response ends
|
|
||||||
res.on('close', cleanupAbortTimeout)
|
|
||||||
res.on('finish', cleanupAbortTimeout)
|
|
||||||
} catch (error: any) {
|
|
||||||
clearAbortTimeout?.()
|
|
||||||
logger.error('Error in streaming message handler', {
|
|
||||||
error,
|
|
||||||
agentId: req.params.agentId,
|
|
||||||
sessionId: req.params.sessionId
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteMessage = async (req: Request, res: Response): Promise<Response> => {
|
|
||||||
try {
|
|
||||||
const { agentId, sessionId, messageId: messageIdParam } = req.params
|
|
||||||
const messageId = Number(messageIdParam)
|
|
||||||
|
|
||||||
await verifyAgentAndSession(agentId, sessionId)
|
|
||||||
|
|
||||||
const deleted = await sessionMessageService.deleteSessionMessage(sessionId, messageId)
|
|
||||||
|
|
||||||
if (!deleted) {
|
|
||||||
logger.warn('Session message not found', { agentId, sessionId, messageId })
|
|
||||||
return res.status(404).json({
|
|
||||||
error: {
|
|
||||||
message: 'Message not found for this session',
|
|
||||||
type: 'not_found',
|
|
||||||
code: 'session_message_not_found'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Session message deleted', { agentId, sessionId, messageId })
|
|
||||||
return res.status(204).send()
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.status === 404) {
|
|
||||||
logger.warn('Delete message failed - missing resource', {
|
|
||||||
agentId: req.params.agentId,
|
|
||||||
sessionId: req.params.sessionId,
|
|
||||||
messageId: req.params.messageId,
|
|
||||||
error
|
|
||||||
})
|
|
||||||
return res.status(404).json({
|
|
||||||
error: {
|
|
||||||
message: error.message,
|
|
||||||
type: 'not_found',
|
|
||||||
code: error.code ?? 'session_message_not_found'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error('Error deleting session message', {
|
|
||||||
error,
|
|
||||||
agentId: req.params.agentId,
|
|
||||||
sessionId: req.params.sessionId,
|
|
||||||
messageId: Number(req.params.messageId)
|
|
||||||
})
|
|
||||||
return res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: 'Failed to delete session message',
|
|
||||||
type: 'internal_error',
|
|
||||||
code: 'session_message_delete_failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
import { loggerService } from '@logger'
|
|
||||||
import { AgentModelValidationError, sessionMessageService, sessionService } from '@main/services/agents'
|
|
||||||
import { 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.debug('Creating new session', { agentId })
|
|
||||||
logger.debug('Session payload', { sessionData })
|
|
||||||
|
|
||||||
const session = await sessionService.createSession(agentId, sessionData)
|
|
||||||
|
|
||||||
logger.info('Session created', { agentId, sessionId: 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, agentId })
|
|
||||||
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> => {
|
|
||||||
const { agentId } = req.params
|
|
||||||
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.debug('Listing agent sessions', { agentId, limit, offset, status })
|
|
||||||
|
|
||||||
const result = await sessionService.listSessions(agentId, { limit, offset })
|
|
||||||
|
|
||||||
logger.info('Agent sessions listed', {
|
|
||||||
agentId,
|
|
||||||
returned: result.sessions.length,
|
|
||||||
total: result.total,
|
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
})
|
|
||||||
return res.json({
|
|
||||||
data: result.sessions,
|
|
||||||
total: result.total,
|
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error listing sessions', { error, agentId })
|
|
||||||
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.debug('Getting session', { agentId, sessionId })
|
|
||||||
|
|
||||||
const session = await sessionService.getSession(agentId, sessionId)
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
logger.warn('Session not found', { agentId, 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.debug('Fetching session messages', { sessionId })
|
|
||||||
const { messages } = await sessionMessageService.listSessionMessages(sessionId)
|
|
||||||
|
|
||||||
// Add messages to session
|
|
||||||
const sessionWithMessages = {
|
|
||||||
...session,
|
|
||||||
messages: messages
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Session retrieved', { agentId, sessionId, messageCount: messages.length })
|
|
||||||
return res.json(sessionWithMessages)
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error getting session', { error, agentId: req.params.agentId, sessionId: req.params.sessionId })
|
|
||||||
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.debug('Updating session', { agentId, sessionId })
|
|
||||||
logger.debug('Replace payload', { body: 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 not found for update', { agentId, sessionId })
|
|
||||||
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 missing during update', { agentId, sessionId })
|
|
||||||
return res.status(404).json({
|
|
||||||
error: {
|
|
||||||
message: 'Session not found',
|
|
||||||
type: 'not_found',
|
|
||||||
code: 'session_not_found'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Session updated', { agentId, 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, agentId, sessionId })
|
|
||||||
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.debug('Patching session', { agentId, sessionId })
|
|
||||||
logger.debug('Patch payload', { body: 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 not found for patch', { agentId, sessionId })
|
|
||||||
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 missing while patching', { agentId, sessionId })
|
|
||||||
return res.status(404).json({
|
|
||||||
error: {
|
|
||||||
message: 'Session not found',
|
|
||||||
type: 'not_found',
|
|
||||||
code: 'session_not_found'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Session patched', { agentId, 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, agentId, sessionId })
|
|
||||||
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.debug('Deleting session', { agentId, sessionId })
|
|
||||||
|
|
||||||
// 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 not found for deletion', { agentId, sessionId })
|
|
||||||
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 missing during delete', { agentId, sessionId })
|
|
||||||
return res.status(404).json({
|
|
||||||
error: {
|
|
||||||
message: 'Session not found',
|
|
||||||
type: 'not_found',
|
|
||||||
code: 'session_not_found'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Session deleted', { agentId, sessionId })
|
|
||||||
|
|
||||||
const { total } = await sessionService.listSessions(agentId, { limit: 1 })
|
|
||||||
|
|
||||||
if (total === 0) {
|
|
||||||
logger.info('No remaining sessions, creating default', { agentId })
|
|
||||||
try {
|
|
||||||
const fallbackSession = await sessionService.createSession(agentId, {})
|
|
||||||
logger.info('Default session created after delete', {
|
|
||||||
agentId,
|
|
||||||
sessionId: fallbackSession?.id
|
|
||||||
})
|
|
||||||
} catch (recoveryError: any) {
|
|
||||||
logger.error('Failed to recreate session after deleting last session', {
|
|
||||||
agentId,
|
|
||||||
error: recoveryError
|
|
||||||
})
|
|
||||||
return res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: `Failed to recreate session after deletion: ${recoveryError.message}`,
|
|
||||||
type: 'internal_error',
|
|
||||||
code: 'session_recovery_failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(204).send()
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error deleting session', { error, agentId: req.params.agentId, sessionId: req.params.sessionId })
|
|
||||||
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.debug('Listing all sessions', { limit, offset, status })
|
|
||||||
|
|
||||||
const result = await sessionService.listSessions(undefined, { limit, offset })
|
|
||||||
|
|
||||||
logger.info('Sessions listed', {
|
|
||||||
returned: result.sessions.length,
|
|
||||||
total: result.total,
|
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
})
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,965 +0,0 @@
|
|||||||
import express from 'express'
|
|
||||||
|
|
||||||
import { agentHandlers, messageHandlers, sessionHandlers } from './handlers'
|
|
||||||
import { checkAgentExists, handleValidationErrors } from './middleware'
|
|
||||||
import {
|
|
||||||
validateAgent,
|
|
||||||
validateAgentId,
|
|
||||||
validateAgentReplace,
|
|
||||||
validateAgentUpdate,
|
|
||||||
validatePagination,
|
|
||||||
validateSession,
|
|
||||||
validateSessionId,
|
|
||||||
validateSessionMessage,
|
|
||||||
validateSessionMessageId,
|
|
||||||
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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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
|
|
||||||
* /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)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /agents/{agentId}/sessions/{sessionId}/messages/{messageId}:
|
|
||||||
* delete:
|
|
||||||
* summary: Delete a message from 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
|
|
||||||
* - in: path
|
|
||||||
* name: messageId
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* description: Message ID
|
|
||||||
* responses:
|
|
||||||
* 204:
|
|
||||||
* description: Message deleted successfully
|
|
||||||
* 404:
|
|
||||||
* description: Agent, session, or message not found
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* $ref: '#/components/schemas/ErrorResponse'
|
|
||||||
*/
|
|
||||||
messagesRouter.delete('/:messageId', validateSessionMessageId, handleValidationErrors, messageHandlers.deleteMessage)
|
|
||||||
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
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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: error as Error,
|
|
||||||
agentId: req.params.agentId
|
|
||||||
})
|
|
||||||
res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: 'Failed to validate agent',
|
|
||||||
type: 'internal_error',
|
|
||||||
code: 'agent_validation_failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './common'
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { PaginationQuerySchema } from '@types'
|
|
||||||
|
|
||||||
import { createZodValidator } from './zodValidator'
|
|
||||||
|
|
||||||
export const validatePagination = createZodValidator({
|
|
||||||
query: PaginationQuerySchema
|
|
||||||
})
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './agents'
|
|
||||||
export * from './common'
|
|
||||||
export * from './messages'
|
|
||||||
export * from './sessions'
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { CreateSessionMessageRequestSchema, SessionMessageIdParamSchema } from '@types'
|
|
||||||
|
|
||||||
import { createZodValidator } from './zodValidator'
|
|
||||||
|
|
||||||
export const validateSessionMessage = createZodValidator({
|
|
||||||
body: CreateSessionMessageRequestSchema
|
|
||||||
})
|
|
||||||
|
|
||||||
export const validateSessionMessageId = createZodValidator({
|
|
||||||
params: SessionMessageIdParamSchema
|
|
||||||
})
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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,105 +1,15 @@
|
|||||||
import express, { Request, Response } from 'express'
|
import express, { Request, Response } from 'express'
|
||||||
|
import OpenAI from 'openai'
|
||||||
import { ChatCompletionCreateParams } from 'openai/resources'
|
import { ChatCompletionCreateParams } from 'openai/resources'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import {
|
import { chatCompletionService } from '../services/chat-completion'
|
||||||
ChatCompletionModelError,
|
import { validateModelId } from '../utils'
|
||||||
chatCompletionService,
|
|
||||||
ChatCompletionValidationError
|
|
||||||
} from '../services/chat-completion'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerChatRoutes')
|
const logger = loggerService.withContext('ApiServerChatRoutes')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
interface ErrorResponseBody {
|
|
||||||
error: {
|
|
||||||
message: string
|
|
||||||
type: string
|
|
||||||
code: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapChatCompletionError = (error: unknown): { status: number; body: ErrorResponseBody } => {
|
|
||||||
if (error instanceof ChatCompletionValidationError) {
|
|
||||||
logger.warn('Chat completion validation error', {
|
|
||||||
errors: error.errors
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: {
|
|
||||||
error: {
|
|
||||||
message: error.errors.join('; '),
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
code: 'validation_failed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ChatCompletionModelError) {
|
|
||||||
logger.warn('Chat completion model error', error.error)
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: {
|
|
||||||
error: {
|
|
||||||
message: error.error.message,
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
code: error.error.code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
let statusCode = 500
|
|
||||||
let errorType = 'server_error'
|
|
||||||
let errorCode = 'internal_error'
|
|
||||||
|
|
||||||
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
|
||||||
statusCode = 401
|
|
||||||
errorType = 'authentication_error'
|
|
||||||
errorCode = 'invalid_api_key'
|
|
||||||
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
|
||||||
statusCode = 429
|
|
||||||
errorType = 'rate_limit_error'
|
|
||||||
errorCode = 'rate_limit_exceeded'
|
|
||||||
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
|
||||||
statusCode = 502
|
|
||||||
errorType = 'server_error'
|
|
||||||
errorCode = 'upstream_error'
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error('Chat completion error', { error })
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: statusCode,
|
|
||||||
body: {
|
|
||||||
error: {
|
|
||||||
message: error.message || 'Internal server error',
|
|
||||||
type: errorType,
|
|
||||||
code: errorCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error('Chat completion unknown error', { error })
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 500,
|
|
||||||
body: {
|
|
||||||
error: {
|
|
||||||
message: 'Internal server error',
|
|
||||||
type: 'server_error',
|
|
||||||
code: 'internal_error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /v1/chat/completions:
|
* /v1/chat/completions:
|
||||||
@@ -150,7 +60,7 @@ const mapChatCompletionError = (error: unknown): { status: number; body: ErrorRe
|
|||||||
* type: integer
|
* type: integer
|
||||||
* total_tokens:
|
* total_tokens:
|
||||||
* type: integer
|
* type: integer
|
||||||
* text/event-stream:
|
* text/plain:
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Server-sent events stream (when stream=true)
|
* description: Server-sent events stream (when stream=true)
|
||||||
@@ -193,31 +103,72 @@ router.post('/completions', async (req: Request, res: Response) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Chat completion request', {
|
logger.info('Chat completion request:', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
messageCount: request.messages?.length || 0,
|
messageCount: request.messages?.length || 0,
|
||||||
stream: request.stream,
|
stream: request.stream,
|
||||||
temperature: request.temperature
|
temperature: request.temperature
|
||||||
})
|
})
|
||||||
|
|
||||||
const isStreaming = !!request.stream
|
// 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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isStreaming) {
|
// Validate model ID and get provider
|
||||||
const { stream } = await chatCompletionService.processStreamingCompletion(request)
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
const provider = modelValidation.provider!
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
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('Connection', 'keep-alive')
|
res.setHeader('Connection', 'keep-alive')
|
||||||
res.setHeader('X-Accel-Buffering', 'no')
|
|
||||||
res.flushHeaders()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of streamResponse as any) {
|
||||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
}
|
}
|
||||||
res.write('data: [DONE]\n\n')
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
} catch (streamError: any) {
|
} catch (streamError: any) {
|
||||||
logger.error('Stream error', { error: streamError })
|
logger.error('Stream error:', streamError)
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
@@ -227,17 +178,47 @@ router.post('/completions', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
})}\n\n`
|
})}\n\n`
|
||||||
)
|
)
|
||||||
} finally {
|
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { response } = await chatCompletionService.processCompletion(request)
|
// Handle non-streaming
|
||||||
|
const response = await client.chat.completions.create(request)
|
||||||
return res.json(response)
|
return res.json(response)
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
const { status, body } = mapChatCompletionError(error)
|
logger.error('Chat completion error:', error)
|
||||||
return res.status(status).json(body)
|
|
||||||
|
let statusCode = 500
|
||||||
|
let errorType = 'server_error'
|
||||||
|
let errorCode = 'internal_error'
|
||||||
|
let errorMessage = 'Internal server error'
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorMessage = error.message
|
||||||
|
|
||||||
|
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
||||||
|
statusCode = 401
|
||||||
|
errorType = 'authentication_error'
|
||||||
|
errorCode = 'invalid_api_key'
|
||||||
|
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
||||||
|
statusCode = 429
|
||||||
|
errorType = 'rate_limit_error'
|
||||||
|
errorCode = 'rate_limit_exceeded'
|
||||||
|
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
||||||
|
statusCode = 502
|
||||||
|
errorType = 'server_error'
|
||||||
|
errorCode = 'upstream_error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
error: {
|
||||||
|
message: errorMessage,
|
||||||
|
type: errorType,
|
||||||
|
code: errorCode
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ const router = express.Router()
|
|||||||
*/
|
*/
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Listing MCP servers')
|
logger.info('Get all MCP servers request received')
|
||||||
const servers = await mcpApiService.getAllServers(req)
|
const servers = await mcpApiService.getAllServers(req)
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: servers
|
data: servers
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error fetching MCP servers', { error })
|
logger.error('Error fetching MCP servers:', error)
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@@ -103,12 +103,10 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/:server_id', async (req: Request, res: Response) => {
|
router.get('/:server_id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Get MCP server info request received', {
|
logger.info('Get MCP server info request received')
|
||||||
serverId: req.params.server_id
|
|
||||||
})
|
|
||||||
const server = await mcpApiService.getServerInfo(req.params.server_id)
|
const server = await mcpApiService.getServerInfo(req.params.server_id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn('MCP server not found', { serverId: req.params.server_id })
|
logger.warn('MCP server not found')
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@@ -123,7 +121,7 @@ router.get('/:server_id', async (req: Request, res: Response) => {
|
|||||||
data: server
|
data: server
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error fetching MCP server info', { error, serverId: req.params.server_id })
|
logger.error('Error fetching MCP server info:', error)
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
@@ -139,7 +137,7 @@ router.get('/:server_id', async (req: Request, res: Response) => {
|
|||||||
router.all('/:server_id/mcp', async (req: Request, res: Response) => {
|
router.all('/:server_id/mcp', async (req: Request, res: Response) => {
|
||||||
const server = await mcpApiService.getServerById(req.params.server_id)
|
const server = await mcpApiService.getServerById(req.params.server_id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn('MCP server not found', { serverId: req.params.server_id })
|
logger.warn('MCP server not found')
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
|
|
||||||
import { loggerService } from '@logger'
|
|
||||||
import { Provider } from '@types'
|
|
||||||
import express, { Request, Response } from 'express'
|
|
||||||
|
|
||||||
import { messagesService } from '../services/messages'
|
|
||||||
import { getProviderById, validateModelId } from '../utils'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
|
||||||
|
|
||||||
const router = express.Router()
|
|
||||||
const providerRouter = express.Router({ mergeParams: true })
|
|
||||||
|
|
||||||
// Helper function for basic request validation
|
|
||||||
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
|
|
||||||
const request: MessageCreateParams = req.body
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: {
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
message: 'Request body is required'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandleMessageProcessingOptions {
|
|
||||||
req: Request
|
|
||||||
res: Response
|
|
||||||
provider: Provider
|
|
||||||
request: MessageCreateParams
|
|
||||||
modelId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMessageProcessing({
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
provider,
|
|
||||||
request,
|
|
||||||
modelId
|
|
||||||
}: HandleMessageProcessingOptions): Promise<void> {
|
|
||||||
try {
|
|
||||||
const validation = messagesService.validateRequest(request)
|
|
||||||
if (!validation.isValid) {
|
|
||||||
res.status(400).json({
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
message: validation.errors.join('; ')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraHeaders = messagesService.prepareHeaders(req.headers)
|
|
||||||
const { client, anthropicRequest } = await messagesService.processMessage({
|
|
||||||
provider,
|
|
||||||
request,
|
|
||||||
extraHeaders,
|
|
||||||
modelId
|
|
||||||
})
|
|
||||||
|
|
||||||
if (request.stream) {
|
|
||||||
await messagesService.handleStreaming(client, anthropicRequest, { response: res }, provider)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await client.messages.create(anthropicRequest)
|
|
||||||
res.json(response)
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Message processing error', { error })
|
|
||||||
const { statusCode, errorResponse } = messagesService.transformError(error)
|
|
||||||
res.status(statusCode).json(errorResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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) => {
|
|
||||||
// Validate request body
|
|
||||||
const bodyValidation = await validateRequestBody(req)
|
|
||||||
if (!bodyValidation.valid) {
|
|
||||||
return res.status(400).json(bodyValidation.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const request: MessageCreateParams = req.body
|
|
||||||
|
|
||||||
// Validate model ID and get provider
|
|
||||||
const modelValidation = await validateModelId(request.model)
|
|
||||||
if (!modelValidation.valid) {
|
|
||||||
const error = modelValidation.error!
|
|
||||||
logger.warn('Model validation failed', {
|
|
||||||
model: request.model,
|
|
||||||
error
|
|
||||||
})
|
|
||||||
return res.status(400).json({
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
message: error.message
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = modelValidation.provider!
|
|
||||||
const modelId = modelValidation.modelId!
|
|
||||||
|
|
||||||
return handleMessageProcessing({ req, res, provider, request, modelId })
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Message processing error', { error })
|
|
||||||
const { statusCode, errorResponse } = messagesService.transformError(error)
|
|
||||||
return res.status(statusCode).json(errorResponse)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /{provider_id}/v1/messages:
|
|
||||||
* post:
|
|
||||||
* summary: Create message with provider in path
|
|
||||||
* description: Create a message response using provider ID from URL path
|
|
||||||
* tags: [Messages]
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: provider_id
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Provider ID (e.g., "my-anthropic")
|
|
||||||
* example: "my-anthropic"
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - model
|
|
||||||
* - max_tokens
|
|
||||||
* - messages
|
|
||||||
* properties:
|
|
||||||
* model:
|
|
||||||
* type: string
|
|
||||||
* description: Model ID without provider prefix
|
|
||||||
* example: "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
|
|
||||||
* 401:
|
|
||||||
* description: Unauthorized
|
|
||||||
* 429:
|
|
||||||
* description: Rate limit exceeded
|
|
||||||
* 500:
|
|
||||||
* description: Internal server error
|
|
||||||
*/
|
|
||||||
providerRouter.post('/', async (req: Request, res: Response) => {
|
|
||||||
// Validate request body
|
|
||||||
const bodyValidation = await validateRequestBody(req)
|
|
||||||
if (!bodyValidation.valid) {
|
|
||||||
return res.status(400).json(bodyValidation.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const providerId = req.params.provider
|
|
||||||
|
|
||||||
if (!providerId) {
|
|
||||||
return res.status(400).json({
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
message: 'Provider ID is required in URL path'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get provider directly by ID from URL path
|
|
||||||
const provider = await getProviderById(providerId)
|
|
||||||
if (!provider) {
|
|
||||||
return res.status(400).json({
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
message: `Provider '${providerId}' not found or not enabled`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const request: MessageCreateParams = req.body
|
|
||||||
|
|
||||||
return handleMessageProcessing({ req, res, provider, request })
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Message processing error', { error })
|
|
||||||
const { statusCode, errorResponse } = messagesService.transformError(error)
|
|
||||||
return res.status(statusCode).json(errorResponse)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export { providerRouter as messagesProviderRoutes, router as messagesRoutes }
|
|
||||||
@@ -1,124 +1,73 @@
|
|||||||
import { ApiModelsFilterSchema, ApiModelsResponse } from '@types'
|
|
||||||
import express, { Request, Response } from 'express'
|
import express, { Request, Response } from 'express'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import { modelsService } from '../services/models'
|
import { chatCompletionService } from '../services/chat-completion'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerModelsRoutes')
|
const logger = loggerService.withContext('ApiServerModelsRoutes')
|
||||||
|
|
||||||
const router = express
|
const router = express.Router()
|
||||||
.Router()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /v1/models:
|
* /v1/models:
|
||||||
* get:
|
* get:
|
||||||
* summary: List available models
|
* summary: List available models
|
||||||
* description: Returns a list of available AI models from all configured providers with optional filtering
|
* description: Returns a list of available AI models from all configured providers
|
||||||
* tags: [Models]
|
* tags: [Models]
|
||||||
* parameters:
|
* responses:
|
||||||
* - in: query
|
* 200:
|
||||||
* name: providerType
|
* description: List of available models
|
||||||
* schema:
|
* content:
|
||||||
* type: string
|
* application/json:
|
||||||
* enum: [openai, openai-response, anthropic, gemini]
|
* schema:
|
||||||
* description: Filter models by provider type
|
* type: object
|
||||||
* - in: query
|
* properties:
|
||||||
* name: offset
|
* object:
|
||||||
* schema:
|
* type: string
|
||||||
* type: integer
|
* example: list
|
||||||
* minimum: 0
|
* data:
|
||||||
* default: 0
|
* type: array
|
||||||
* description: Pagination offset
|
* items:
|
||||||
* - in: query
|
* $ref: '#/components/schemas/Model'
|
||||||
* name: limit
|
* 503:
|
||||||
* schema:
|
* description: Service unavailable
|
||||||
* type: integer
|
* content:
|
||||||
* minimum: 1
|
* application/json:
|
||||||
* description: Maximum number of models to return
|
* schema:
|
||||||
* responses:
|
* $ref: '#/components/schemas/Error'
|
||||||
* 200:
|
*/
|
||||||
* description: List of available models
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
* content:
|
try {
|
||||||
* application/json:
|
logger.info('Models list request received')
|
||||||
* 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.debug('Models list request received', { query: req.query })
|
|
||||||
|
|
||||||
// Validate query parameters using Zod schema
|
const models = await chatCompletionService.getModels()
|
||||||
const filterResult = ApiModelsFilterSchema.safeParse(req.query)
|
|
||||||
|
|
||||||
if (!filterResult.success) {
|
if (models.length === 0) {
|
||||||
logger.warn('Invalid model query parameters', { issues: filterResult.error.issues })
|
logger.warn(
|
||||||
return res.status(400).json({
|
'No models available from providers. This may be because no OpenAI providers are configured or enabled.'
|
||||||
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', { filter })
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Models response ready', {
|
|
||||||
filter,
|
|
||||||
total: response.total,
|
|
||||||
modelIds: 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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export { router as modelsRoutes }
|
export { router as modelsRoutes }
|
||||||
|
|||||||
@@ -1,72 +1,44 @@
|
|||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '../services/LoggerService'
|
||||||
|
|
||||||
import { agentService } from '../services/agents'
|
|
||||||
import { app } from './app'
|
import { app } from './app'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServer')
|
const logger = loggerService.withContext('ApiServer')
|
||||||
|
|
||||||
const GLOBAL_REQUEST_TIMEOUT_MS = 5 * 60_000
|
|
||||||
const GLOBAL_HEADERS_TIMEOUT_MS = GLOBAL_REQUEST_TIMEOUT_MS + 5_000
|
|
||||||
const GLOBAL_KEEPALIVE_TIMEOUT_MS = 60_000
|
|
||||||
|
|
||||||
export class ApiServer {
|
export class ApiServer {
|
||||||
private server: ReturnType<typeof createServer> | null = null
|
private server: ReturnType<typeof createServer> | null = null
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (this.server && this.server.listening) {
|
if (this.server) {
|
||||||
logger.warn('Server already running')
|
logger.warn('Server already running')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up any failed server instance
|
|
||||||
if (this.server && !this.server.listening) {
|
|
||||||
logger.warn('Cleaning up failed server instance')
|
|
||||||
this.server = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
const { port, host } = await config.load()
|
const { port, host, apiKey } = await config.load()
|
||||||
|
|
||||||
// Initialize AgentService
|
|
||||||
logger.info('Initializing AgentService')
|
|
||||||
await agentService.initialize()
|
|
||||||
logger.info('AgentService initialized')
|
|
||||||
|
|
||||||
// Create server with Express app
|
// Create server with Express app
|
||||||
this.server = createServer(app)
|
this.server = createServer(app)
|
||||||
this.applyServerTimeouts(this.server)
|
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.server!.listen(port, host, () => {
|
this.server!.listen(port, host, () => {
|
||||||
logger.info('API server started', { host, port })
|
logger.info(`API Server started at http://${host}:${port}`)
|
||||||
|
logger.info(`API Key: ${apiKey}`)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.server!.on('error', (error) => {
|
this.server!.on('error', reject)
|
||||||
// Clean up the server instance if listen fails
|
|
||||||
this.server = null
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyServerTimeouts(server: ReturnType<typeof createServer>): void {
|
|
||||||
server.requestTimeout = GLOBAL_REQUEST_TIMEOUT_MS
|
|
||||||
server.headersTimeout = Math.max(GLOBAL_HEADERS_TIMEOUT_MS, server.requestTimeout + 1_000)
|
|
||||||
server.keepAliveTimeout = GLOBAL_KEEPALIVE_TIMEOUT_MS
|
|
||||||
server.setTimeout(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.server) return
|
if (!this.server) return
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.server!.close(() => {
|
this.server!.close(() => {
|
||||||
logger.info('API server stopped')
|
logger.info('API Server stopped')
|
||||||
this.server = null
|
this.server = null
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
@@ -84,7 +56,7 @@ export class ApiServer {
|
|||||||
const isListening = this.server?.listening || false
|
const isListening = this.server?.listening || false
|
||||||
const result = hasServer && isListening
|
const result = hasServer && isListening
|
||||||
|
|
||||||
logger.debug('isRunning check', { hasServer, isListening, result })
|
logger.debug('isRunning check:', { hasServer, isListening, result })
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,83 @@
|
|||||||
import { Provider } from '@types'
|
|
||||||
import OpenAI from 'openai'
|
import OpenAI from 'openai'
|
||||||
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
|
import { ChatCompletionCreateParams } from 'openai/resources'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import { ModelValidationError, validateModelId } from '../utils'
|
import {
|
||||||
|
getProviderByModel,
|
||||||
|
getRealProviderModel,
|
||||||
|
listAllAvailableModels,
|
||||||
|
OpenAICompatibleModel,
|
||||||
|
transformModelToOpenAI,
|
||||||
|
validateProvider
|
||||||
|
} from '../utils'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ChatCompletionService')
|
const logger = loggerService.withContext('ChatCompletionService')
|
||||||
|
|
||||||
|
export interface ModelData extends OpenAICompatibleModel {
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatCompletionValidationError extends Error {
|
|
||||||
constructor(public readonly errors: string[]) {
|
|
||||||
super(`Request validation failed: ${errors.join('; ')}`)
|
|
||||||
this.name = 'ChatCompletionValidationError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatCompletionModelError extends Error {
|
|
||||||
constructor(public readonly error: ModelValidationError) {
|
|
||||||
super(`Model validation failed: ${error.message}`)
|
|
||||||
this.name = 'ChatCompletionModelError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PrepareRequestResult =
|
|
||||||
| { status: 'validation_error'; errors: string[] }
|
|
||||||
| { status: 'model_error'; error: ModelValidationError }
|
|
||||||
| {
|
|
||||||
status: 'ok'
|
|
||||||
provider: Provider
|
|
||||||
modelId: string
|
|
||||||
client: OpenAI
|
|
||||||
providerRequest: ChatCompletionCreateParams
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatCompletionService {
|
export class ChatCompletionService {
|
||||||
async resolveProviderContext(
|
async getModels(): Promise<ModelData[]> {
|
||||||
model: string
|
try {
|
||||||
): Promise<
|
logger.info('Getting available models from providers')
|
||||||
{ 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 provider = modelValidation.provider!
|
const models = await listAllAvailableModels()
|
||||||
|
|
||||||
if (provider.type !== 'openai') {
|
// Use Map to deduplicate models by their full ID (provider:model_id)
|
||||||
return {
|
const uniqueModels = new Map<string, ModelData>()
|
||||||
ok: false,
|
|
||||||
error: {
|
for (const model of models) {
|
||||||
type: 'unsupported_provider_type',
|
const openAIModel = transformModelToOpenAI(model)
|
||||||
message: `Provider '${provider.id}' of type '${provider.type}' is not supported for OpenAI chat completions`,
|
const fullModelId = openAIModel.id // This is already in format "provider:model_id"
|
||||||
code: 'unsupported_provider_type'
|
|
||||||
|
// Only add if not already present (first occurrence wins)
|
||||||
|
if (!uniqueModels.has(fullModelId)) {
|
||||||
|
uniqueModels.set(fullModelId, {
|
||||||
|
...openAIModel,
|
||||||
|
provider_id: model.provider,
|
||||||
|
model_id: model.id,
|
||||||
|
name: model.name
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.debug(`Skipping duplicate model: ${fullModelId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const modelId = modelValidation.modelId!
|
const modelData = Array.from(uniqueModels.values())
|
||||||
|
|
||||||
const client = new OpenAI({
|
logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`)
|
||||||
baseURL: provider.apiHost,
|
|
||||||
apiKey: provider.apiKey
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
if (models.length > modelData.length) {
|
||||||
ok: true,
|
logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const providerContext = await this.resolveProviderContext(request.model!)
|
return modelData
|
||||||
if (!providerContext.ok) {
|
} catch (error: any) {
|
||||||
return {
|
logger.error('Error getting models:', error)
|
||||||
status: 'model_error',
|
return []
|
||||||
error: providerContext.error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { provider, modelId, client } = providerContext
|
|
||||||
|
|
||||||
logger.debug('Model validation successful', {
|
|
||||||
provider: provider.id,
|
|
||||||
providerType: provider.type,
|
|
||||||
modelId,
|
|
||||||
fullModelId: request.model
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'ok',
|
|
||||||
provider,
|
|
||||||
modelId,
|
|
||||||
client,
|
|
||||||
providerRequest: stream
|
|
||||||
? {
|
|
||||||
...request,
|
|
||||||
model: modelId,
|
|
||||||
stream: true as const
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...request,
|
|
||||||
model: modelId,
|
|
||||||
stream: false as const
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validateRequest(request: ChatCompletionCreateParams): ValidationResult {
|
validateRequest(request: ChatCompletionCreateParams): ValidationResult {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// Validate model
|
||||||
|
if (!request.model) {
|
||||||
|
errors.push('Model is required')
|
||||||
|
} else if (typeof request.model !== 'string') {
|
||||||
|
errors.push('Model must be a string')
|
||||||
|
} else if (!request.model.includes(':')) {
|
||||||
|
errors.push('Model must be in format "provider:model_id"')
|
||||||
|
}
|
||||||
|
|
||||||
// Validate messages
|
// Validate messages
|
||||||
if (!request.messages) {
|
if (!request.messages) {
|
||||||
errors.push('Messages array is required')
|
errors.push('Messages array is required')
|
||||||
@@ -147,6 +98,17 @@ export class ChatCompletionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate optional parameters
|
// Validate optional parameters
|
||||||
|
if (request.temperature !== undefined) {
|
||||||
|
if (typeof request.temperature !== 'number' || request.temperature < 0 || request.temperature > 2) {
|
||||||
|
errors.push('Temperature must be a number between 0 and 2')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.max_tokens !== undefined) {
|
||||||
|
if (typeof request.max_tokens !== 'number' || request.max_tokens < 1) {
|
||||||
|
errors.push('max_tokens must be a positive number')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
@@ -154,30 +116,48 @@ export class ChatCompletionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async processCompletion(request: ChatCompletionCreateParams): Promise<{
|
async processCompletion(request: ChatCompletionCreateParams): Promise<OpenAI.Chat.Completions.ChatCompletion> {
|
||||||
provider: Provider
|
|
||||||
modelId: string
|
|
||||||
response: OpenAI.Chat.Completions.ChatCompletion
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
logger.debug('Processing chat completion request', {
|
logger.info('Processing chat completion request:', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
messageCount: request.messages.length,
|
messageCount: request.messages.length,
|
||||||
stream: request.stream
|
stream: request.stream
|
||||||
})
|
})
|
||||||
|
|
||||||
const preparation = await this.prepareRequest(request, false)
|
// Validate request
|
||||||
if (preparation.status === 'validation_error') {
|
const validation = this.validateRequest(request)
|
||||||
throw new ChatCompletionValidationError(preparation.errors)
|
if (!validation.isValid) {
|
||||||
|
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preparation.status === 'model_error') {
|
// Get provider for the model
|
||||||
throw new ChatCompletionModelError(preparation.error)
|
const provider = await getProviderByModel(request.model!)
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Provider not found for model: ${request.model}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { provider, modelId, client, providerRequest } = preparation
|
// Validate provider
|
||||||
|
if (!validateProvider(provider)) {
|
||||||
|
throw new Error(`Provider validation failed for: ${provider.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug('Sending request to provider', {
|
// Extract model ID from the full model string
|
||||||
|
const modelId = getRealProviderModel(request.model)
|
||||||
|
|
||||||
|
// Create OpenAI client for the provider
|
||||||
|
const client = new OpenAI({
|
||||||
|
baseURL: provider.apiHost,
|
||||||
|
apiKey: provider.apiKey
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prepare request with the actual model ID
|
||||||
|
const providerRequest = {
|
||||||
|
...request,
|
||||||
|
model: modelId,
|
||||||
|
stream: false
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending request to provider:', {
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
model: modelId,
|
model: modelId,
|
||||||
apiHost: provider.apiHost
|
apiHost: provider.apiHost
|
||||||
@@ -185,71 +165,71 @@ export class ChatCompletionService {
|
|||||||
|
|
||||||
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
||||||
|
|
||||||
logger.info('Chat completion processed', {
|
logger.info('Successfully processed chat completion')
|
||||||
modelId,
|
return response
|
||||||
provider: provider.id
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
modelId,
|
|
||||||
response
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error processing chat completion', {
|
logger.error('Error processing chat completion:', error)
|
||||||
error,
|
|
||||||
model: request.model
|
|
||||||
})
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async processStreamingCompletion(request: ChatCompletionCreateParams): Promise<{
|
async *processStreamingCompletion(
|
||||||
provider: Provider
|
request: ChatCompletionCreateParams
|
||||||
modelId: string
|
): AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk> {
|
||||||
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
logger.debug('Processing streaming chat completion request', {
|
logger.info('Processing streaming chat completion request:', {
|
||||||
model: request.model,
|
model: request.model,
|
||||||
messageCount: request.messages.length
|
messageCount: request.messages.length
|
||||||
})
|
})
|
||||||
|
|
||||||
const preparation = await this.prepareRequest(request, true)
|
// Validate request
|
||||||
if (preparation.status === 'validation_error') {
|
const validation = this.validateRequest(request)
|
||||||
throw new ChatCompletionValidationError(preparation.errors)
|
if (!validation.isValid) {
|
||||||
|
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preparation.status === 'model_error') {
|
// Get provider for the model
|
||||||
throw new ChatCompletionModelError(preparation.error)
|
const provider = await getProviderByModel(request.model!)
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Provider not found for model: ${request.model}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { provider, modelId, client, providerRequest } = preparation
|
// Validate provider
|
||||||
|
if (!validateProvider(provider)) {
|
||||||
|
throw new Error(`Provider validation failed for: ${provider.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug('Sending streaming request to provider', {
|
// Extract model ID from the full model string
|
||||||
|
const modelId = getRealProviderModel(request.model)
|
||||||
|
|
||||||
|
// Create OpenAI client for the provider
|
||||||
|
const client = new OpenAI({
|
||||||
|
baseURL: provider.apiHost,
|
||||||
|
apiKey: provider.apiKey
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prepare streaming request
|
||||||
|
const streamingRequest = {
|
||||||
|
...request,
|
||||||
|
model: modelId,
|
||||||
|
stream: true as const
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending streaming request to provider:', {
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
model: modelId,
|
model: modelId,
|
||||||
apiHost: provider.apiHost
|
apiHost: provider.apiHost
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamRequest = providerRequest as ChatCompletionCreateParamsStreaming
|
const stream = await client.chat.completions.create(streamingRequest)
|
||||||
const stream = (await client.chat.completions.create(
|
|
||||||
streamRequest
|
|
||||||
)) as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
|
||||||
|
|
||||||
logger.info('Streaming chat completion started', {
|
for await (const chunk of stream) {
|
||||||
modelId,
|
yield chunk
|
||||||
provider: provider.id
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
modelId,
|
|
||||||
stream
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Successfully completed streaming chat completion')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error processing streaming chat completion', {
|
logger.error('Error processing streaming chat completion:', error)
|
||||||
error,
|
|
||||||
model: request.model
|
|
||||||
})
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { Request, Response } from 'express'
|
|||||||
import { IncomingMessage, ServerResponse } from 'http'
|
import { IncomingMessage, ServerResponse } from 'http'
|
||||||
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import { getMcpServerById, getMCPServersFromRedux } from '../utils/mcp'
|
import { reduxService } from '../../services/ReduxService'
|
||||||
|
import { getMcpServerById } from '../utils/mcp'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MCPApiService')
|
const logger = loggerService.withContext('MCPApiService')
|
||||||
const transports: Record<string, StreamableHTTPServerTransport> = {}
|
const transports: Record<string, StreamableHTTPServerTransport> = {}
|
||||||
@@ -49,18 +50,42 @@ class MCPApiService extends EventEmitter {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.initMcpServer()
|
this.initMcpServer()
|
||||||
logger.debug('MCPApiService initialized')
|
logger.silly('MCPApiService initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMcpServer() {
|
private initMcpServer() {
|
||||||
this.transport.onmessage = this.onMessage
|
this.transport.onmessage = this.onMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get servers directly from Redux store
|
||||||
|
*/
|
||||||
|
private async getServersFromRedux(): Promise<MCPServer[]> {
|
||||||
|
try {
|
||||||
|
logger.silly('Getting servers from Redux store')
|
||||||
|
|
||||||
|
// Try to get from cache first (faster)
|
||||||
|
const cachedServers = reduxService.selectSync<MCPServer[]>('state.mcp.servers')
|
||||||
|
if (cachedServers && Array.isArray(cachedServers)) {
|
||||||
|
logger.silly(`Found ${cachedServers.length} servers in Redux cache`)
|
||||||
|
return cachedServers
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cache is not available, get fresh data
|
||||||
|
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
||||||
|
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
|
||||||
|
return servers || []
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Failed to get servers from Redux:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// get all activated servers
|
// get all activated servers
|
||||||
async getAllServers(req: Request): Promise<McpServersResp> {
|
async getAllServers(req: Request): Promise<McpServersResp> {
|
||||||
try {
|
try {
|
||||||
const servers = await getMCPServersFromRedux()
|
const servers = await this.getServersFromRedux()
|
||||||
logger.debug('Returning servers from Redux', { count: servers.length })
|
logger.silly(`Returning ${servers.length} servers`)
|
||||||
const resp: McpServersResp = {
|
const resp: McpServersResp = {
|
||||||
servers: {}
|
servers: {}
|
||||||
}
|
}
|
||||||
@@ -77,7 +102,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get all servers', { error })
|
logger.error('Failed to get all servers:', error)
|
||||||
throw new Error('Failed to retrieve servers')
|
throw new Error('Failed to retrieve servers')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,47 +110,87 @@ class MCPApiService extends EventEmitter {
|
|||||||
// get server by id
|
// get server by id
|
||||||
async getServerById(id: string): Promise<MCPServer | null> {
|
async getServerById(id: string): Promise<MCPServer | null> {
|
||||||
try {
|
try {
|
||||||
logger.debug('getServerById called', { id })
|
logger.silly(`getServerById called with id: ${id}`)
|
||||||
const servers = await getMCPServersFromRedux()
|
const servers = await this.getServersFromRedux()
|
||||||
const server = servers.find((s) => s.id === id)
|
const server = servers.find((s) => s.id === id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn('Server not found', { id })
|
logger.warn(`Server with id ${id} not found`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
logger.debug('Returning server', { id })
|
logger.silly(`Returning server with id ${id}`)
|
||||||
return server
|
return server
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get server', { id, error })
|
logger.error(`Failed to get server with id ${id}:`, error)
|
||||||
throw new Error('Failed to retrieve server')
|
throw new Error('Failed to retrieve server')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getServerInfo(id: string): Promise<any> {
|
async getServerInfo(id: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
|
logger.silly(`getServerInfo called with id: ${id}`)
|
||||||
const server = await this.getServerById(id)
|
const server = await this.getServerById(id)
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.warn('Server not found while fetching info', { id })
|
logger.warn(`Server with id ${id} not found`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
logger.silly(`Returning server info for id ${id}`)
|
||||||
|
|
||||||
const client = await mcpService.initClient(server)
|
const client = await mcpService.initClient(server)
|
||||||
const tools = await client.listTools()
|
const tools = await client.listTools()
|
||||||
|
|
||||||
|
logger.info(`Server with id ${id} info:`, { tools: JSON.stringify(tools) })
|
||||||
|
|
||||||
|
// const [version, tools, prompts, resources] = await Promise.all([
|
||||||
|
// () => {
|
||||||
|
// try {
|
||||||
|
// return client.getServerVersion()
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error(`Failed to get server version for id ${id}:`, { error: error })
|
||||||
|
// return '1.0.0'
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// (() => {
|
||||||
|
// try {
|
||||||
|
// return client.listTools()
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error(`Failed to list tools for id ${id}:`, { error: error })
|
||||||
|
// return []
|
||||||
|
// }
|
||||||
|
// })(),
|
||||||
|
// (() => {
|
||||||
|
// try {
|
||||||
|
// return client.listPrompts()
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error(`Failed to list prompts for id ${id}:`, { error: error })
|
||||||
|
// return []
|
||||||
|
// }
|
||||||
|
// })(),
|
||||||
|
// (() => {
|
||||||
|
// try {
|
||||||
|
// return client.listResources()
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error(`Failed to list resources for id ${id}:`, { error: error })
|
||||||
|
// return []
|
||||||
|
// }
|
||||||
|
// })()
|
||||||
|
// ])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: server.name,
|
name: server.name,
|
||||||
type: server.type,
|
type: server.type,
|
||||||
description: server.description,
|
description: server.description,
|
||||||
tools: tools.tools
|
tools
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get server info', { id, error })
|
logger.error(`Failed to get server info with id ${id}:`, error)
|
||||||
throw new Error('Failed to retrieve server info')
|
throw new Error('Failed to retrieve server info')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRequest(req: Request, res: Response, server: MCPServer) {
|
async handleRequest(req: Request, res: Response, server: MCPServer) {
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
||||||
logger.debug('Handling MCP request', { sessionId, serverId: server.id })
|
logger.silly(`Handling request for server with sessionId ${sessionId}`)
|
||||||
let transport: StreamableHTTPServerTransport
|
let transport: StreamableHTTPServerTransport
|
||||||
if (sessionId && transports[sessionId]) {
|
if (sessionId && transports[sessionId]) {
|
||||||
transport = transports[sessionId]
|
transport = transports[sessionId]
|
||||||
@@ -138,7 +203,7 @@ class MCPApiService extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
logger.info('Transport closed', { sessionId })
|
logger.info(`Transport for sessionId ${sessionId} closed`)
|
||||||
if (transport.sessionId) {
|
if (transport.sessionId) {
|
||||||
delete transports[transport.sessionId]
|
delete transports[transport.sessionId]
|
||||||
}
|
}
|
||||||
@@ -173,15 +238,12 @@ class MCPApiService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Dispatching MCP request', {
|
logger.info(`Request body`, { rawBody: req.body, messages: JSON.stringify(messages) })
|
||||||
sessionId: transport.sessionId ?? sessionId,
|
|
||||||
messageCount: messages.length
|
|
||||||
})
|
|
||||||
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, messages)
|
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
private onMessage(message: JSONRPCMessage, extra?: MessageExtraInfo) {
|
private onMessage(message: JSONRPCMessage, extra?: MessageExtraInfo) {
|
||||||
logger.debug('Received MCP message', { message, extra })
|
logger.info(`Received message: ${JSON.stringify(message)}`, extra)
|
||||||
// Handle message here
|
// Handle message here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
import Anthropic from '@anthropic-ai/sdk'
|
|
||||||
import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources'
|
|
||||||
import { loggerService } from '@logger'
|
|
||||||
import anthropicService from '@main/services/AnthropicService'
|
|
||||||
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
|
|
||||||
import { Provider } from '@types'
|
|
||||||
import { Response } from 'express'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('MessagesService')
|
|
||||||
const EXCLUDED_FORWARD_HEADERS: ReadonlySet<string> = new Set([
|
|
||||||
'host',
|
|
||||||
'x-api-key',
|
|
||||||
'authorization',
|
|
||||||
'sentry-trace',
|
|
||||||
'baggage',
|
|
||||||
'content-length',
|
|
||||||
'connection'
|
|
||||||
])
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
|
||||||
isValid: boolean
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorResponse {
|
|
||||||
type: 'error'
|
|
||||||
error: {
|
|
||||||
type: string
|
|
||||||
message: string
|
|
||||||
requestId?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StreamConfig {
|
|
||||||
response: Response
|
|
||||||
onChunk?: (chunk: MessageStreamEvent) => void
|
|
||||||
onError?: (error: any) => void
|
|
||||||
onComplete?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessMessageOptions {
|
|
||||||
provider: Provider
|
|
||||||
request: MessageCreateParams
|
|
||||||
extraHeaders?: Record<string, string | string[]>
|
|
||||||
modelId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessMessageResult {
|
|
||||||
client: Anthropic
|
|
||||||
anthropicRequest: MessageCreateParams
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MessagesService {
|
|
||||||
validateRequest(request: MessageCreateParams): ValidationResult {
|
|
||||||
// TODO: Implement comprehensive request validation
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
if (!request.model || typeof request.model !== 'string') {
|
|
||||||
errors.push('Model is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof request.max_tokens !== 'number' || !Number.isFinite(request.max_tokens) || request.max_tokens < 1) {
|
|
||||||
errors.push('max_tokens is required and must be a positive number')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
|
|
||||||
errors.push('messages is required and must be a non-empty array')
|
|
||||||
} else {
|
|
||||||
request.messages.forEach((message, index) => {
|
|
||||||
if (!message || typeof message !== 'object') {
|
|
||||||
errors.push(`messages[${index}] must be an object`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('role' in message) || typeof message.role !== 'string' || message.role.trim().length === 0) {
|
|
||||||
errors.push(`messages[${index}].role is required`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const content: unknown = message.content
|
|
||||||
if (content === undefined || content === null) {
|
|
||||||
errors.push(`messages[${index}].content is required`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof content === 'string' && content.trim().length === 0) {
|
|
||||||
errors.push(`messages[${index}].content cannot be empty`)
|
|
||||||
} else if (Array.isArray(content) && content.length === 0) {
|
|
||||||
errors.push(`messages[${index}].content must include at least one item when using an array`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: errors.length === 0,
|
|
||||||
errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClient(provider: Provider, extraHeaders?: Record<string, string | string[]>): Promise<Anthropic> {
|
|
||||||
// Create Anthropic client for the provider
|
|
||||||
if (provider.authType === 'oauth') {
|
|
||||||
const oauthToken = await anthropicService.getValidAccessToken()
|
|
||||||
return getSdkClient(provider, oauthToken, extraHeaders)
|
|
||||||
}
|
|
||||||
return getSdkClient(provider, null, extraHeaders)
|
|
||||||
}
|
|
||||||
|
|
||||||
prepareHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[]> {
|
|
||||||
const extraHeaders: Record<string, string | string[]> = {}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedKey = key.toLowerCase()
|
|
||||||
if (EXCLUDED_FORWARD_HEADERS.has(normalizedKey)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
extraHeaders[normalizedKey] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return extraHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
createAnthropicRequest(request: MessageCreateParams, provider: Provider, modelId?: string): MessageCreateParams {
|
|
||||||
const anthropicRequest: MessageCreateParams = {
|
|
||||||
...request,
|
|
||||||
stream: !!request.stream
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override model if provided
|
|
||||||
if (modelId) {
|
|
||||||
anthropicRequest.model = modelId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Claude Code system message for OAuth providers
|
|
||||||
if (provider.type === 'anthropic' && provider.authType === 'oauth') {
|
|
||||||
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system)
|
|
||||||
}
|
|
||||||
|
|
||||||
return anthropicRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleStreaming(
|
|
||||||
client: Anthropic,
|
|
||||||
request: MessageCreateParams,
|
|
||||||
config: StreamConfig,
|
|
||||||
provider: Provider
|
|
||||||
): Promise<void> {
|
|
||||||
const { response, onChunk, onError, onComplete } = config
|
|
||||||
|
|
||||||
// Set streaming headers
|
|
||||||
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
||||||
response.setHeader('Cache-Control', 'no-cache, no-transform')
|
|
||||||
response.setHeader('Connection', 'keep-alive')
|
|
||||||
response.setHeader('X-Accel-Buffering', 'no')
|
|
||||||
response.flushHeaders()
|
|
||||||
|
|
||||||
const flushableResponse = response as Response & { flush?: () => void }
|
|
||||||
const flushStream = () => {
|
|
||||||
if (typeof flushableResponse.flush !== 'function') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
flushableResponse.flush()
|
|
||||||
} catch (flushError: unknown) {
|
|
||||||
logger.warn('Failed to flush streaming response', { error: flushError })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeSse = (eventType: string | undefined, payload: unknown) => {
|
|
||||||
if (response.writableEnded || response.destroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType) {
|
|
||||||
response.write(`event: ${eventType}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
|
||||||
response.write(`data: ${data}\n\n`)
|
|
||||||
flushStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = client.messages.stream(request)
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
if (response.writableEnded || response.destroyed) {
|
|
||||||
logger.warn('Streaming response ended before stream completion', {
|
|
||||||
provider: provider.id,
|
|
||||||
model: request.model
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
writeSse(chunk.type, chunk)
|
|
||||||
|
|
||||||
if (onChunk) {
|
|
||||||
onChunk(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeSse(undefined, '[DONE]')
|
|
||||||
|
|
||||||
if (onComplete) {
|
|
||||||
onComplete()
|
|
||||||
}
|
|
||||||
} catch (streamError: any) {
|
|
||||||
logger.error('Stream error', {
|
|
||||||
error: streamError,
|
|
||||||
provider: provider.id,
|
|
||||||
model: request.model,
|
|
||||||
apiHost: provider.apiHost,
|
|
||||||
anthropicApiHost: provider.anthropicApiHost
|
|
||||||
})
|
|
||||||
writeSse(undefined, {
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: 'api_error',
|
|
||||||
message: 'Stream processing error'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (onError) {
|
|
||||||
onError(streamError)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!response.writableEnded) {
|
|
||||||
response.end()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transformError(error: any): { statusCode: number; errorResponse: ErrorResponse } {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infer error type from message if not from Anthropic API
|
|
||||||
if (!anthropicStatus && error instanceof Error) {
|
|
||||||
const errorMessageText = error.message ?? ''
|
|
||||||
|
|
||||||
if (errorMessageText.includes('API key') || errorMessageText.includes('authentication')) {
|
|
||||||
statusCode = 401
|
|
||||||
errorType = 'authentication_error'
|
|
||||||
} else if (errorMessageText.includes('rate limit') || errorMessageText.includes('quota')) {
|
|
||||||
statusCode = 429
|
|
||||||
errorType = 'rate_limit_error'
|
|
||||||
} else if (errorMessageText.includes('timeout') || errorMessageText.includes('connection')) {
|
|
||||||
statusCode = 502
|
|
||||||
errorType = 'api_error'
|
|
||||||
} else if (errorMessageText.includes('validation') || errorMessageText.includes('invalid')) {
|
|
||||||
statusCode = 400
|
|
||||||
errorType = 'invalid_request_error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeErrorMessage =
|
|
||||||
typeof errorMessage === 'string' && errorMessage.length > 0 ? errorMessage : 'Internal server error'
|
|
||||||
|
|
||||||
return {
|
|
||||||
statusCode,
|
|
||||||
errorResponse: {
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: errorType,
|
|
||||||
message: safeErrorMessage,
|
|
||||||
requestId: error?.request_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async processMessage(options: ProcessMessageOptions): Promise<ProcessMessageResult> {
|
|
||||||
const { provider, request, extraHeaders, modelId } = options
|
|
||||||
|
|
||||||
const client = await this.getClient(provider, extraHeaders)
|
|
||||||
const anthropicRequest = this.createAnthropicRequest(request, provider, modelId)
|
|
||||||
|
|
||||||
const messageCount = Array.isArray(request.messages) ? request.messages.length : 0
|
|
||||||
|
|
||||||
logger.info('Processing anthropic messages request', {
|
|
||||||
provider: provider.id,
|
|
||||||
apiHost: provider.apiHost,
|
|
||||||
anthropicApiHost: provider.anthropicApiHost,
|
|
||||||
model: anthropicRequest.model,
|
|
||||||
stream: !!anthropicRequest.stream,
|
|
||||||
// systemPrompt: JSON.stringify(!!request.system),
|
|
||||||
// messages: JSON.stringify(request.messages),
|
|
||||||
messageCount,
|
|
||||||
toolCount: Array.isArray(request.tools) ? request.tools.length : 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return client and request for route layer to handle streaming/non-streaming
|
|
||||||
return {
|
|
||||||
client,
|
|
||||||
anthropicRequest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const messagesService = new MessagesService()
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { isEmpty } from 'lodash'
|
|
||||||
|
|
||||||
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
|
|
||||||
import { loggerService } from '../../services/LoggerService'
|
|
||||||
import {
|
|
||||||
getAvailableProviders,
|
|
||||||
getProviderAnthropicModelChecker,
|
|
||||||
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 })
|
|
||||||
|
|
||||||
let providers = await getAvailableProviders()
|
|
||||||
|
|
||||||
if (filter.providerType === 'anthropic') {
|
|
||||||
providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const models = await listAllAvailableModels(providers)
|
|
||||||
// Use Map to deduplicate models by their full ID (provider:model_id)
|
|
||||||
const uniqueModels = new Map<string, ApiModel>()
|
|
||||||
|
|
||||||
for (const model of models) {
|
|
||||||
const provider = providers.find((p) => p.id === model.provider)
|
|
||||||
logger.debug(`Processing model ${model.id}`)
|
|
||||||
if (!provider) {
|
|
||||||
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.providerType === 'anthropic') {
|
|
||||||
const checker = getProviderAnthropicModelChecker(provider.id)
|
|
||||||
if (!checker(model)) {
|
|
||||||
logger.debug(`Skipping model ${model.id} from ${model.provider}. Reason: Not an Anthropic model.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAIModel = transformModelToOpenAI(model, provider)
|
|
||||||
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())
|
|
||||||
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('Models retrieved', {
|
|
||||||
returned: modelData.length,
|
|
||||||
discovered: models.length,
|
|
||||||
filter
|
|
||||||
})
|
|
||||||
|
|
||||||
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, filter })
|
|
||||||
return {
|
|
||||||
object: 'list',
|
|
||||||
data: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const modelsService = new ModelsService()
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
export type StreamAbortHandler = (reason: unknown) => void
|
|
||||||
|
|
||||||
export interface StreamAbortController {
|
|
||||||
abortController: AbortController
|
|
||||||
registerAbortHandler: (handler: StreamAbortHandler) => void
|
|
||||||
clearAbortTimeout: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STREAM_TIMEOUT_REASON = 'stream timeout'
|
|
||||||
|
|
||||||
interface CreateStreamAbortControllerOptions {
|
|
||||||
timeoutMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createStreamAbortController = (options: CreateStreamAbortControllerOptions): StreamAbortController => {
|
|
||||||
const { timeoutMs } = options
|
|
||||||
const abortController = new AbortController()
|
|
||||||
const signal = abortController.signal
|
|
||||||
|
|
||||||
let timeoutId: NodeJS.Timeout | undefined
|
|
||||||
let abortHandler: StreamAbortHandler | undefined
|
|
||||||
|
|
||||||
const clearAbortTimeout = () => {
|
|
||||||
if (!timeoutId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
timeoutId = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAbort = () => {
|
|
||||||
clearAbortTimeout()
|
|
||||||
|
|
||||||
if (!abortHandler) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
abortHandler(signal.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
signal.addEventListener('abort', handleAbort, { once: true })
|
|
||||||
|
|
||||||
const registerAbortHandler = (handler: StreamAbortHandler) => {
|
|
||||||
abortHandler = handler
|
|
||||||
|
|
||||||
if (signal.aborted) {
|
|
||||||
abortHandler(signal.reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeoutMs > 0) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
if (!signal.aborted) {
|
|
||||||
abortController.abort(STREAM_TIMEOUT_REASON)
|
|
||||||
}
|
|
||||||
}, timeoutMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
abortController,
|
|
||||||
registerAbortHandler,
|
|
||||||
clearAbortTimeout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +1,46 @@
|
|||||||
import { CacheService } from '@main/services/CacheService'
|
|
||||||
import { loggerService } from '@main/services/LoggerService'
|
import { loggerService } from '@main/services/LoggerService'
|
||||||
import { reduxService } from '@main/services/ReduxService'
|
import { reduxService } from '@main/services/ReduxService'
|
||||||
import { ApiModel, Model, Provider } from '@types'
|
import { Model, Provider } from '@types'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerUtils')
|
const logger = loggerService.withContext('ApiServerUtils')
|
||||||
|
|
||||||
// Cache configuration
|
// OpenAI compatible model format
|
||||||
const PROVIDERS_CACHE_KEY = 'api-server:providers'
|
export interface OpenAICompatibleModel {
|
||||||
const PROVIDERS_CACHE_TTL = 10 * 1000 // 10 seconds
|
id: string
|
||||||
|
object: 'model'
|
||||||
|
created: number
|
||||||
|
owned_by: string
|
||||||
|
provider?: string
|
||||||
|
provider_model_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAvailableProviders(): Promise<Provider[]> {
|
export async function getAvailableProviders(): Promise<Provider[]> {
|
||||||
try {
|
try {
|
||||||
// Try to get from cache first (faster)
|
// Wait for store to be ready before accessing providers
|
||||||
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
|
||||||
if (cachedSupportedProviders && cachedSupportedProviders.length > 0) {
|
|
||||||
logger.debug('Providers resolved from cache', {
|
|
||||||
count: cachedSupportedProviders.length
|
|
||||||
})
|
|
||||||
return cachedSupportedProviders
|
|
||||||
}
|
|
||||||
|
|
||||||
// If cache is not available, get fresh data from Redux
|
|
||||||
const providers = await reduxService.select('state.llm.providers')
|
const providers = await reduxService.select('state.llm.providers')
|
||||||
if (!providers || !Array.isArray(providers)) {
|
if (!providers || !Array.isArray(providers)) {
|
||||||
logger.warn('No providers found in Redux store')
|
logger.warn('No providers found in Redux store, returning empty array')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support OpenAI and Anthropic type providers for API server
|
// Only support OpenAI type providers for API server
|
||||||
const supportedProviders = providers.filter(
|
const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai')
|
||||||
(p: Provider) => p.enabled && (p.type === 'openai' || p.type === 'anthropic')
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cache the filtered results
|
logger.info(`Filtered to ${openAIProviders.length} OpenAI providers from ${providers.length} total providers`)
|
||||||
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
|
|
||||||
|
|
||||||
logger.info('Providers filtered', {
|
return openAIProviders
|
||||||
supported: supportedProviders.length,
|
|
||||||
total: providers.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return supportedProviders
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get providers from Redux store', { error })
|
logger.error('Failed to get providers from Redux store:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllAvailableModels(providers?: Provider[]): Promise<Model[]> {
|
export async function listAllAvailableModels(): Promise<Model[]> {
|
||||||
try {
|
try {
|
||||||
if (!providers) {
|
const providers = await getAvailableProviders()
|
||||||
providers = await getAvailableProviders()
|
|
||||||
}
|
|
||||||
return providers.map((p: Provider) => p.models || []).flat()
|
return providers.map((p: Provider) => p.models || []).flat()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to list available models', { error })
|
logger.error('Failed to list available models:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,13 +48,15 @@ export async function listAllAvailableModels(providers?: Provider[]): Promise<Mo
|
|||||||
export async function getProviderByModel(model: string): Promise<Provider | undefined> {
|
export async function getProviderByModel(model: string): Promise<Provider | undefined> {
|
||||||
try {
|
try {
|
||||||
if (!model || typeof model !== 'string') {
|
if (!model || typeof model !== 'string') {
|
||||||
logger.warn('Invalid model parameter', { model })
|
logger.warn(`Invalid model parameter: ${model}`)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate model format first
|
// Validate model format first
|
||||||
if (!model.includes(':')) {
|
if (!model.includes(':')) {
|
||||||
logger.warn('Invalid model format missing separator', { model })
|
logger.warn(
|
||||||
|
`Invalid model format, must contain ':' separator. Expected format "provider:model_id", got: ${model}`
|
||||||
|
)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +64,7 @@ export async function getProviderByModel(model: string): Promise<Provider | unde
|
|||||||
const modelInfo = model.split(':')
|
const modelInfo = model.split(':')
|
||||||
|
|
||||||
if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) {
|
if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) {
|
||||||
logger.warn('Invalid model format with empty parts', { model })
|
logger.warn(`Invalid model format, expected "provider:model_id" with non-empty parts, got: ${model}`)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +72,16 @@ export async function getProviderByModel(model: string): Promise<Provider | unde
|
|||||||
const provider = providers.find((p: Provider) => p.id === providerId)
|
const provider = providers.find((p: Provider) => p.id === providerId)
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
logger.warn('Provider not found for model', {
|
logger.warn(
|
||||||
providerId,
|
`Provider '${providerId}' not found or not enabled. Available providers: ${providers.map((p) => p.id).join(', ')}`
|
||||||
available: providers.map((p) => p.id)
|
)
|
||||||
})
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Provider resolved for model', { providerId, model })
|
logger.debug(`Found provider '${providerId}' for model: ${model}`)
|
||||||
return provider
|
return provider
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get provider by model', { error, model })
|
logger.error('Failed to get provider by model:', error)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,12 +96,9 @@ export interface ModelValidationError {
|
|||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateModelId(model: string): Promise<{
|
export async function validateModelId(
|
||||||
valid: boolean
|
model: string
|
||||||
error?: ModelValidationError
|
): Promise<{ valid: boolean; error?: ModelValidationError; provider?: Provider; modelId?: string }> {
|
||||||
provider?: Provider
|
|
||||||
modelId?: string
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
if (!model || typeof model !== 'string') {
|
if (!model || typeof model !== 'string') {
|
||||||
return {
|
return {
|
||||||
@@ -185,7 +169,7 @@ export async function validateModelId(model: string): Promise<{
|
|||||||
modelId
|
modelId
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error validating model ID', { error, model })
|
logger.error('Error validating model ID:', error)
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: {
|
error: {
|
||||||
@@ -197,47 +181,17 @@ export async function validateModelId(model: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformModelToOpenAI(model: Model, provider?: Provider): ApiModel {
|
export function transformModelToOpenAI(model: Model): OpenAICompatibleModel {
|
||||||
const providerDisplayName = provider?.name
|
|
||||||
return {
|
return {
|
||||||
id: `${model.provider}:${model.id}`,
|
id: `${model.provider}:${model.id}`,
|
||||||
object: 'model',
|
object: 'model',
|
||||||
name: model.name,
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
created: Math.floor(Date.now() / 1000),
|
||||||
owned_by: model.owned_by || providerDisplayName || model.provider,
|
owned_by: model.owned_by || model.provider,
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
provider_name: providerDisplayName,
|
|
||||||
provider_type: provider?.type,
|
|
||||||
provider_model_id: model.id
|
provider_model_id: model.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProviderById(providerId: string): Promise<Provider | undefined> {
|
|
||||||
try {
|
|
||||||
if (!providerId || typeof providerId !== 'string') {
|
|
||||||
logger.warn('Invalid provider ID parameter', { providerId })
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const providers = await getAvailableProviders()
|
|
||||||
const provider = providers.find((p: Provider) => p.id === providerId)
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
logger.warn('Provider not found by ID', {
|
|
||||||
providerId,
|
|
||||||
available: providers.map((p) => p.id)
|
|
||||||
})
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Provider found by ID', { providerId })
|
|
||||||
return provider
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Failed to get provider by ID', { error, providerId })
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateProvider(provider: Provider): boolean {
|
export function validateProvider(provider: Provider): boolean {
|
||||||
try {
|
try {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -246,7 +200,7 @@ export function validateProvider(provider: Provider): boolean {
|
|||||||
|
|
||||||
// Check required fields
|
// Check required fields
|
||||||
if (!provider.id || !provider.type || !provider.apiKey || !provider.apiHost) {
|
if (!provider.id || !provider.type || !provider.apiKey || !provider.apiHost) {
|
||||||
logger.warn('Provider missing required fields', {
|
logger.warn('Provider missing required fields:', {
|
||||||
id: !!provider.id,
|
id: !!provider.id,
|
||||||
type: !!provider.type,
|
type: !!provider.type,
|
||||||
apiKey: !!provider.apiKey,
|
apiKey: !!provider.apiKey,
|
||||||
@@ -257,38 +211,21 @@ export function validateProvider(provider: Provider): boolean {
|
|||||||
|
|
||||||
// Check if provider is enabled
|
// Check if provider is enabled
|
||||||
if (!provider.enabled) {
|
if (!provider.enabled) {
|
||||||
logger.debug('Provider is disabled', { providerId: provider.id })
|
logger.debug(`Provider is disabled: ${provider.id}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support OpenAI and Anthropic type providers
|
// Only support OpenAI type providers
|
||||||
if (provider.type !== 'openai' && provider.type !== 'anthropic') {
|
if (provider.type !== 'openai') {
|
||||||
logger.debug('Provider type not supported', {
|
logger.debug(
|
||||||
providerId: provider.id,
|
`Provider type '${provider.type}' not supported, only 'openai' type is currently supported: ${provider.id}`
|
||||||
providerType: provider.type
|
)
|
||||||
})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error validating provider', {
|
logger.error('Error validating provider:', error)
|
||||||
error,
|
|
||||||
providerId: provider?.id
|
|
||||||
})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model) => boolean) => {
|
|
||||||
switch (providerId) {
|
|
||||||
case 'cherryin':
|
|
||||||
case 'new-api':
|
|
||||||
return (m: Model) => m.endpoint_type === 'anthropic'
|
|
||||||
case 'aihubmix':
|
|
||||||
return (m: Model) => m.id.includes('claude')
|
|
||||||
default:
|
|
||||||
// allow all models when checker not configured
|
|
||||||
return () => true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { CacheService } from '@main/services/CacheService'
|
|
||||||
import mcpService from '@main/services/MCPService'
|
import mcpService from '@main/services/MCPService'
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
|
||||||
@@ -9,10 +8,6 @@ import { reduxService } from '../../services/ReduxService'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('MCPApiService')
|
const logger = loggerService.withContext('MCPApiService')
|
||||||
|
|
||||||
// Cache configuration
|
|
||||||
const MCP_SERVERS_CACHE_KEY = 'api-server:mcp-servers'
|
|
||||||
const MCP_SERVERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
|
||||||
|
|
||||||
const cachedServers: Record<string, Server> = {}
|
const cachedServers: Record<string, Server> = {}
|
||||||
|
|
||||||
async function handleListToolsRequest(request: any, extra: any): Promise<ListToolsResult> {
|
async function handleListToolsRequest(request: any, extra: any): Promise<ListToolsResult> {
|
||||||
@@ -38,35 +33,20 @@ async function handleCallToolRequest(request: any, extra: any): Promise<any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getMcpServerConfigById(id: string): Promise<MCPServer | undefined> {
|
async function getMcpServerConfigById(id: string): Promise<MCPServer | undefined> {
|
||||||
const servers = await getMCPServersFromRedux()
|
const servers = await getServersFromRedux()
|
||||||
return servers.find((s) => s.id === id || s.name === id)
|
return servers.find((s) => s.id === id || s.name === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get servers directly from Redux store
|
* Get servers directly from Redux store
|
||||||
*/
|
*/
|
||||||
export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
async function getServersFromRedux(): Promise<MCPServer[]> {
|
||||||
try {
|
try {
|
||||||
logger.debug('Getting servers from Redux store')
|
|
||||||
|
|
||||||
// Try to get from cache first (faster)
|
|
||||||
const cachedServers = CacheService.get<MCPServer[]>(MCP_SERVERS_CACHE_KEY)
|
|
||||||
if (cachedServers) {
|
|
||||||
logger.debug('MCP servers resolved from cache', { count: cachedServers.length })
|
|
||||||
return cachedServers
|
|
||||||
}
|
|
||||||
|
|
||||||
// If cache is not available, get fresh data from Redux
|
|
||||||
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
||||||
const serverList = servers || []
|
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
|
||||||
|
return servers || []
|
||||||
// Cache the results
|
|
||||||
CacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL)
|
|
||||||
|
|
||||||
logger.debug('Fetched servers from Redux store', { count: serverList.length })
|
|
||||||
return serverList
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get servers from Redux', { error })
|
logger.error('Failed to get servers from Redux:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +54,7 @@ export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
|||||||
export async function getMcpServerById(id: string): Promise<Server> {
|
export async function getMcpServerById(id: string): Promise<Server> {
|
||||||
const server = cachedServers[id]
|
const server = cachedServers[id]
|
||||||
if (!server) {
|
if (!server) {
|
||||||
const servers = await getMCPServersFromRedux()
|
const servers = await getServersFromRedux()
|
||||||
const mcpServer = servers.find((s) => s.id === id || s.name === id)
|
const mcpServer = servers.find((s) => s.id === id || s.name === id)
|
||||||
if (!mcpServer) {
|
if (!mcpServer) {
|
||||||
throw new Error(`Server not found: ${id}`)
|
throw new Error(`Server not found: ${id}`)
|
||||||
@@ -91,6 +71,6 @@ export async function getMcpServerById(id: string): Promise<Server> {
|
|||||||
cachedServers[id] = newServer
|
cachedServers[id] = newServer
|
||||||
return newServer
|
return newServer
|
||||||
}
|
}
|
||||||
logger.debug('Returning cached MCP server', { id, hasHandlers: Boolean(server) })
|
logger.silly('getMcpServer ', { server: server })
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'
|
|||||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||||
|
|
||||||
import { isDev, isLinux, isWin } from './constant'
|
import { isDev, isLinux, isWin } from './constant'
|
||||||
|
|
||||||
import process from 'node:process'
|
|
||||||
|
|
||||||
import { registerIpc } from './ipc'
|
import { registerIpc } from './ipc'
|
||||||
import { agentService } from './services/agents'
|
|
||||||
import { apiServerService } from './services/ApiServerService'
|
|
||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
import mcpService from './services/MCPService'
|
import mcpService from './services/MCPService'
|
||||||
import { nodeTraceService } from './services/NodeTraceService'
|
import { nodeTraceService } from './services/NodeTraceService'
|
||||||
@@ -30,7 +26,8 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
|||||||
import { registerShortcuts } from './services/ShortcutService'
|
import { registerShortcuts } from './services/ShortcutService'
|
||||||
import { TrayService } from './services/TrayService'
|
import { TrayService } from './services/TrayService'
|
||||||
import { windowService } from './services/WindowService'
|
import { windowService } from './services/WindowService'
|
||||||
import { initWebviewHotkeys } from './services/WebviewService'
|
import process from 'node:process'
|
||||||
|
import { apiServerService } from './services/ApiServerService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MainEntry')
|
const logger = loggerService.withContext('MainEntry')
|
||||||
|
|
||||||
@@ -109,7 +106,6 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
// Some APIs can only be used after this event occurs.
|
// Some APIs can only be used after this event occurs.
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
initWebviewHotkeys()
|
|
||||||
// Set app user model id for windows
|
// Set app user model id for windows
|
||||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||||
|
|
||||||
@@ -151,34 +147,11 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
//start selection assistant service
|
//start selection assistant service
|
||||||
initSelectionService()
|
initSelectionService()
|
||||||
|
|
||||||
// Initialize Agent Service
|
// Start API server if enabled
|
||||||
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 or if agents exist
|
|
||||||
try {
|
try {
|
||||||
const config = await apiServerService.getCurrentConfig()
|
const config = await apiServerService.getCurrentConfig()
|
||||||
logger.info('API server config:', config)
|
logger.info('API server config:', config)
|
||||||
|
if (config.enabled) {
|
||||||
// Check if there are any agents
|
|
||||||
let shouldStart = config.enabled
|
|
||||||
if (!shouldStart) {
|
|
||||||
try {
|
|
||||||
const { total } = await agentService.listAgents({ limit: 1 })
|
|
||||||
if (total > 0) {
|
|
||||||
shouldStart = true
|
|
||||||
logger.info(`Detected ${total} agent(s), auto-starting API server`)
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.warn('Failed to check agent count:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldStart) {
|
|
||||||
await apiServerService.start()
|
await apiServerService.start()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -11,21 +11,11 @@ import { handleZoomFactor } from '@main/utils/zoom'
|
|||||||
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import {
|
import { FileMetadata, Notification, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types'
|
||||||
AgentPersistedMessage,
|
|
||||||
FileMetadata,
|
|
||||||
Notification,
|
|
||||||
OcrProvider,
|
|
||||||
Provider,
|
|
||||||
Shortcut,
|
|
||||||
SupportedOcrFile,
|
|
||||||
ThemeMode
|
|
||||||
} from '@types'
|
|
||||||
import checkDiskSpace from 'check-disk-space'
|
import checkDiskSpace from 'check-disk-space'
|
||||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||||
import fontList from 'font-list'
|
import fontList from 'font-list'
|
||||||
|
|
||||||
import { agentMessageRepository } from './services/agents/database'
|
|
||||||
import { apiServerService } from './services/ApiServerService'
|
import { apiServerService } from './services/ApiServerService'
|
||||||
import appService from './services/AppService'
|
import appService from './services/AppService'
|
||||||
import AppUpdater from './services/AppUpdater'
|
import AppUpdater from './services/AppUpdater'
|
||||||
@@ -45,7 +35,6 @@ import NotificationService from './services/NotificationService'
|
|||||||
import * as NutstoreService from './services/NutstoreService'
|
import * as NutstoreService from './services/NutstoreService'
|
||||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||||
import { ocrService } from './services/ocr/OcrService'
|
import { ocrService } from './services/ocr/OcrService'
|
||||||
import OvmsManager from './services/OvmsManager'
|
|
||||||
import { proxyManager } from './services/ProxyManager'
|
import { proxyManager } from './services/ProxyManager'
|
||||||
import { pythonService } from './services/PythonService'
|
import { pythonService } from './services/PythonService'
|
||||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||||
@@ -92,7 +81,6 @@ const obsidianVaultService = new ObsidianVaultService()
|
|||||||
const vertexAIService = VertexAIService.getInstance()
|
const vertexAIService = VertexAIService.getInstance()
|
||||||
const memoryService = MemoryService.getInstance()
|
const memoryService = MemoryService.getInstance()
|
||||||
const dxtService = new DxtService()
|
const dxtService = new DxtService()
|
||||||
const ovmsManager = new OvmsManager()
|
|
||||||
|
|
||||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||||
const appUpdater = new AppUpdater()
|
const appUpdater = new AppUpdater()
|
||||||
@@ -142,7 +130,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
|
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
||||||
|
|
||||||
// language
|
// language
|
||||||
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||||
@@ -212,27 +200,6 @@ 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
IpcChannel.AgentMessage_GetHistory,
|
|
||||||
async (_event, { sessionId }: { sessionId: string }): Promise<AgentPersistedMessage[]> => {
|
|
||||||
try {
|
|
||||||
return await agentMessageRepository.getSessionHistory(sessionId)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get agent session history', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
//only for mac
|
//only for mac
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
||||||
@@ -465,7 +432,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
// system
|
// system
|
||||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
||||||
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
||||||
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
|
|
||||||
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
||||||
const win = BrowserWindow.fromWebContents(e.sender)
|
const win = BrowserWindow.fromWebContents(e.sender)
|
||||||
win && win.webContents.toggleDevTools()
|
win && win.webContents.toggleDevTools()
|
||||||
@@ -529,7 +495,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
|
||||||
|
|
||||||
// file service
|
// file service
|
||||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||||
@@ -745,7 +710,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||||
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||||
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
||||||
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
|
|
||||||
|
|
||||||
//copilot
|
//copilot
|
||||||
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
|
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
|
||||||
@@ -786,6 +750,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
|
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
|
||||||
setOpenLinkExternal(webviewId, isExternal)
|
setOpenLinkExternal(webviewId, isExternal)
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
||||||
const webview = webContents.fromId(webviewId)
|
const webview = webContents.fromId(webviewId)
|
||||||
if (!webview) return
|
if (!webview) return
|
||||||
@@ -875,18 +840,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
||||||
ocrService.ocr(file, provider)
|
ocrService.ocr(file, provider)
|
||||||
)
|
)
|
||||||
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
|
|
||||||
|
|
||||||
// OVMS
|
|
||||||
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
|
||||||
ovmsManager.addModel(modelName, modelId, modelSource, task)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
|
|
||||||
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
|
|
||||||
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
|
|
||||||
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
|
|
||||||
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
|
|
||||||
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
|
|
||||||
|
|
||||||
// CherryAI
|
// CherryAI
|
||||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||||
|
|||||||
@@ -1,473 +0,0 @@
|
|||||||
/**
|
|
||||||
* DiDi MCP Server Implementation
|
|
||||||
*
|
|
||||||
* Based on official DiDi MCP API capabilities.
|
|
||||||
* API Documentation: https://mcp.didichuxing.com/api?tap=api
|
|
||||||
*
|
|
||||||
* Provides ride-hailing services including map search, price estimation,
|
|
||||||
* order management, and driver tracking.
|
|
||||||
*
|
|
||||||
* Note: Only available in Mainland China.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { loggerService } from '@logger'
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('DiDiMCPServer')
|
|
||||||
|
|
||||||
export class DiDiMcpServer {
|
|
||||||
private _server: Server
|
|
||||||
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
|
|
||||||
private apiKey: string
|
|
||||||
|
|
||||||
constructor(apiKey?: string) {
|
|
||||||
this._server = new Server(
|
|
||||||
{
|
|
||||||
name: 'didi-mcp-server',
|
|
||||||
version: '0.1.0'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: {
|
|
||||||
tools: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get API key from parameter or environment variables
|
|
||||||
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
|
|
||||||
if (!this.apiKey) {
|
|
||||||
logger.warn('DIDI_API_KEY environment variable is not set')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setupRequestHandlers()
|
|
||||||
}
|
|
||||||
|
|
||||||
get server(): Server {
|
|
||||||
return this._server
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupRequestHandlers() {
|
|
||||||
// List available tools
|
|
||||||
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
||||||
return {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
name: 'maps_textsearch',
|
|
||||||
description: 'Search for POI locations based on keywords and city',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
city: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Query city'
|
|
||||||
},
|
|
||||||
keywords: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Search keywords'
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Location coordinates, format: longitude,latitude'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['keywords', 'city']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'taxi_cancel_order',
|
|
||||||
description: 'Cancel a taxi order',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
order_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Order ID from order creation or query results'
|
|
||||||
},
|
|
||||||
reason: {
|
|
||||||
type: 'string',
|
|
||||||
description:
|
|
||||||
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['order_id']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'taxi_create_order',
|
|
||||||
description: 'Create taxi order directly via API without opening any app interface',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
caller_car_phone: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Caller phone number (optional)'
|
|
||||||
},
|
|
||||||
estimate_trace_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Estimation trace ID from estimation results'
|
|
||||||
},
|
|
||||||
product_category: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['product_category', 'estimate_trace_id']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'taxi_estimate',
|
|
||||||
description: 'Get available ride-hailing vehicle types and fare estimates',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
from_lat: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Departure latitude, must be from map tools'
|
|
||||||
},
|
|
||||||
from_lng: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Departure longitude, must be from map tools'
|
|
||||||
},
|
|
||||||
from_name: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Departure location name'
|
|
||||||
},
|
|
||||||
to_lat: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Destination latitude, must be from map tools'
|
|
||||||
},
|
|
||||||
to_lng: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Destination longitude, must be from map tools'
|
|
||||||
},
|
|
||||||
to_name: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Destination name'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'taxi_generate_ride_app_link',
|
|
||||||
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
from_lat: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Departure latitude, must be from map tools'
|
|
||||||
},
|
|
||||||
from_lng: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Departure longitude, must be from map tools'
|
|
||||||
},
|
|
||||||
product_category: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
|
|
||||||
},
|
|
||||||
to_lat: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Destination latitude, must be from map tools'
|
|
||||||
},
|
|
||||||
to_lng: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Destination longitude, must be from map tools'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'taxi_get_driver_location',
|
|
||||||
description: 'Get real-time driver location for a taxi order',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
order_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Taxi order ID'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['order_id']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'taxi_query_order',
|
|
||||||
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
order_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle tool calls
|
|
||||||
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
||||||
const { name, arguments: args } = request.params
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (name) {
|
|
||||||
case 'maps_textsearch':
|
|
||||||
return await this.handleMapsTextSearch(args)
|
|
||||||
case 'taxi_cancel_order':
|
|
||||||
return await this.handleTaxiCancelOrder(args)
|
|
||||||
case 'taxi_create_order':
|
|
||||||
return await this.handleTaxiCreateOrder(args)
|
|
||||||
case 'taxi_estimate':
|
|
||||||
return await this.handleTaxiEstimate(args)
|
|
||||||
case 'taxi_generate_ride_app_link':
|
|
||||||
return await this.handleTaxiGenerateRideAppLink(args)
|
|
||||||
case 'taxi_get_driver_location':
|
|
||||||
return await this.handleTaxiGetDriverLocation(args)
|
|
||||||
case 'taxi_query_order':
|
|
||||||
return await this.handleTaxiQueryOrder(args)
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown tool: ${name}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error calling tool ${name}:`, error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMapsTextSearch(args: any) {
|
|
||||||
const { city, keywords, location } = args
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
name: 'maps_textsearch',
|
|
||||||
arguments: {
|
|
||||||
keywords,
|
|
||||||
city,
|
|
||||||
...(location && { location })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('tools/call', params)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(response, null, 2)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Maps text search error:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTaxiCancelOrder(args: any) {
|
|
||||||
const { order_id, reason } = args
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
name: 'taxi_cancel_order',
|
|
||||||
arguments: {
|
|
||||||
order_id,
|
|
||||||
...(reason && { reason })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('tools/call', params)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(response, null, 2)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Taxi cancel order error:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTaxiCreateOrder(args: any) {
|
|
||||||
const { caller_car_phone, estimate_trace_id, product_category } = args
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
name: 'taxi_create_order',
|
|
||||||
arguments: {
|
|
||||||
product_category,
|
|
||||||
estimate_trace_id,
|
|
||||||
...(caller_car_phone && { caller_car_phone })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('tools/call', params)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(response, null, 2)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Taxi create order error:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTaxiEstimate(args: any) {
|
|
||||||
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
name: 'taxi_estimate',
|
|
||||||
arguments: {
|
|
||||||
from_lng,
|
|
||||||
from_lat,
|
|
||||||
from_name,
|
|
||||||
to_lng,
|
|
||||||
to_lat,
|
|
||||||
to_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('tools/call', params)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(response, null, 2)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Taxi estimate error:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTaxiGenerateRideAppLink(args: any) {
|
|
||||||
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
name: 'taxi_generate_ride_app_link',
|
|
||||||
arguments: {
|
|
||||||
from_lng,
|
|
||||||
from_lat,
|
|
||||||
to_lng,
|
|
||||||
to_lat,
|
|
||||||
...(product_category && { product_category })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('tools/call', params)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(response, null, 2)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Taxi generate ride app link error:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTaxiGetDriverLocation(args: any) {
|
|
||||||
const { order_id } = args
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
name: 'taxi_get_driver_location',
|
|
||||||
arguments: {
|
|
||||||
order_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('tools/call', params)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(response, null, 2)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Taxi get driver location error:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTaxiQueryOrder(args: any) {
|
|
||||||
const { order_id } = args
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
name: 'taxi_query_order',
|
|
||||||
arguments: {
|
|
||||||
...(order_id && { order_id })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('tools/call', params)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: JSON.stringify(response, null, 2)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Taxi query order error:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async makeRequest(method: string, params: any): Promise<any> {
|
|
||||||
const requestData = {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: method,
|
|
||||||
id: Date.now(),
|
|
||||||
...(Object.keys(params).length > 0 && { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
// API key is passed as URL parameter
|
|
||||||
const url = `${this.baseUrl}?key=${this.apiKey}`
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestData)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DiDiMcpServer
|
|
||||||
@@ -3,7 +3,7 @@ import { loggerService } from '@logger'
|
|||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||||
import { net } from 'electron'
|
import { net } from 'electron'
|
||||||
import * as z from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const logger = loggerService.withContext('DifyKnowledgeServer')
|
const logger = loggerService.withContext('DifyKnowledgeServer')
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|||||||
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
||||||
|
|
||||||
import BraveSearchServer from './brave-search'
|
import BraveSearchServer from './brave-search'
|
||||||
import DiDiMcpServer from './didi-mcp'
|
|
||||||
import DifyKnowledgeServer from './dify-knowledge'
|
import DifyKnowledgeServer from './dify-knowledge'
|
||||||
import FetchServer from './fetch'
|
import FetchServer from './fetch'
|
||||||
import FileSystemServer from './filesystem'
|
import FileSystemServer from './filesystem'
|
||||||
@@ -43,10 +42,6 @@ export function createInMemoryMCPServer(
|
|||||||
case BuiltinMCPServerNames.python: {
|
case BuiltinMCPServerNames.python: {
|
||||||
return new PythonServer().server
|
return new PythonServer().server
|
||||||
}
|
}
|
||||||
case BuiltinMCPServerNames.didiMCP: {
|
|
||||||
const apiKey = envs.DIDI_API_KEY
|
|
||||||
return new DiDiMcpServer(apiKey).server
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|||||||
import { net } from 'electron'
|
import { net } from 'electron'
|
||||||
import { JSDOM } from 'jsdom'
|
import { JSDOM } from 'jsdom'
|
||||||
import TurndownService from 'turndown'
|
import TurndownService from 'turndown'
|
||||||
import * as z from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const RequestPayloadSchema = z.object({
|
export const RequestPayloadSchema = z.object({
|
||||||
url: z.url(),
|
url: z.url(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import fs from 'fs/promises'
|
|||||||
import { minimatch } from 'minimatch'
|
import { minimatch } from 'minimatch'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import * as z from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MCP:FileSystemServer')
|
const logger = loggerService.withContext('MCP:FileSystemServer')
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import {
|
import { ApiServerConfig } from '@types'
|
||||||
ApiServerConfig,
|
|
||||||
GetApiServerStatusResult,
|
|
||||||
RestartApiServerStatusResult,
|
|
||||||
StartApiServerStatusResult,
|
|
||||||
StopApiServerStatusResult
|
|
||||||
} from '@types'
|
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
|
|
||||||
import { apiServer } from '../apiServer'
|
import { apiServer } from '../apiServer'
|
||||||
@@ -58,7 +52,7 @@ export class ApiServerService {
|
|||||||
|
|
||||||
registerIpcHandlers(): void {
|
registerIpcHandlers(): void {
|
||||||
// API Server
|
// API Server
|
||||||
ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise<StartApiServerStatusResult> => {
|
ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
|
||||||
try {
|
try {
|
||||||
await this.start()
|
await this.start()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -67,7 +61,7 @@ export class ApiServerService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise<StopApiServerStatusResult> => {
|
ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
|
||||||
try {
|
try {
|
||||||
await this.stop()
|
await this.stop()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -76,7 +70,7 @@ export class ApiServerService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise<RestartApiServerStatusResult> => {
|
ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
|
||||||
try {
|
try {
|
||||||
await this.restart()
|
await this.restart()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -85,7 +79,7 @@ export class ApiServerService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => {
|
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
|
||||||
try {
|
try {
|
||||||
const config = await this.getCurrentConfig()
|
const config = await this.getCurrentConfig()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { isWin } from '@main/constant'
|
import { isWin } from '@main/constant'
|
||||||
import { getIpCountry } from '@main/utils/ipService'
|
import { getIpCountry } from '@main/utils/ipService'
|
||||||
|
import { locales } from '@main/utils/locales'
|
||||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||||
import { app, net } from 'electron'
|
import { app, BrowserWindow, dialog, net } from 'electron'
|
||||||
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import semver from 'semver'
|
import semver from 'semver'
|
||||||
|
|
||||||
|
import icon from '../../../build/icon.png?asset'
|
||||||
import { configManager } from './ConfigManager'
|
import { configManager } from './ConfigManager'
|
||||||
import { windowService } from './WindowService'
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ const LANG_MARKERS = {
|
|||||||
|
|
||||||
export default class AppUpdater {
|
export default class AppUpdater {
|
||||||
autoUpdater: _AppUpdater = autoUpdater
|
autoUpdater: _AppUpdater = autoUpdater
|
||||||
|
private releaseInfo: UpdateInfo | undefined
|
||||||
private cancellationToken: CancellationToken = new CancellationToken()
|
private cancellationToken: CancellationToken = new CancellationToken()
|
||||||
private updateCheckResult: UpdateCheckResult | null = null
|
private updateCheckResult: UpdateCheckResult | null = null
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ export default class AppUpdater {
|
|||||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||||
|
this.releaseInfo = processedReleaseInfo
|
||||||
logger.info('update downloaded', processedReleaseInfo)
|
logger.info('update downloaded', processedReleaseInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -243,9 +247,37 @@ export default class AppUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public quitAndInstall() {
|
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
||||||
app.isQuitting = true
|
if (!this.releaseInfo) {
|
||||||
setImmediate(() => autoUpdater.quitAndInstall())
|
return
|
||||||
|
}
|
||||||
|
const locale = locales[configManager.getLanguage()]
|
||||||
|
const { update: updateLocale } = locale.translation
|
||||||
|
|
||||||
|
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
||||||
|
if (detail === '') {
|
||||||
|
detail = updateLocale.noReleaseNotes
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog
|
||||||
|
.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: updateLocale.title,
|
||||||
|
icon,
|
||||||
|
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
||||||
|
detail,
|
||||||
|
buttons: [updateLocale.later, updateLocale.install],
|
||||||
|
defaultId: 1,
|
||||||
|
cancelId: 0
|
||||||
|
})
|
||||||
|
.then(({ response }) => {
|
||||||
|
if (response === 1) {
|
||||||
|
app.isQuitting = true
|
||||||
|
setImmediate(() => autoUpdater.quitAndInstall())
|
||||||
|
} else {
|
||||||
|
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -317,9 +349,38 @@ export default class AppUpdater {
|
|||||||
|
|
||||||
return processedInfo
|
return processedInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format release notes for display
|
||||||
|
* @param releaseNotes - Release notes in various formats
|
||||||
|
* @returns Formatted string for display
|
||||||
|
*/
|
||||||
|
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||||
|
if (!releaseNotes) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof releaseNotes === 'string') {
|
||||||
|
// Check if it contains multi-language markers
|
||||||
|
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||||
|
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||||
|
}
|
||||||
|
return releaseNotes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(releaseNotes)) {
|
||||||
|
return releaseNotes.map((note) => note.note).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
interface GithubReleaseInfo {
|
interface GithubReleaseInfo {
|
||||||
draft: boolean
|
draft: boolean
|
||||||
prerelease: boolean
|
prerelease: boolean
|
||||||
tag_name: string
|
tag_name: string
|
||||||
}
|
}
|
||||||
|
interface ReleaseNoteInfo {
|
||||||
|
readonly version: string
|
||||||
|
readonly note: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ interface VersionInfo {
|
|||||||
|
|
||||||
class CodeToolsService {
|
class CodeToolsService {
|
||||||
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
||||||
private terminalsCache: {
|
private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null
|
||||||
terminals: TerminalConfig[]
|
|
||||||
timestamp: number
|
|
||||||
} | null = null
|
|
||||||
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
|
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
|
||||||
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
||||||
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
|
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
|
||||||
@@ -85,8 +82,6 @@ class CodeToolsService {
|
|||||||
return '@qwen-code/qwen-code'
|
return '@qwen-code/qwen-code'
|
||||||
case codeTools.iFlowCli:
|
case codeTools.iFlowCli:
|
||||||
return '@iflow-ai/iflow-cli'
|
return '@iflow-ai/iflow-cli'
|
||||||
case codeTools.githubCopilotCli:
|
|
||||||
return '@github/copilot'
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||||
}
|
}
|
||||||
@@ -104,8 +99,6 @@ class CodeToolsService {
|
|||||||
return 'qwen'
|
return 'qwen'
|
||||||
case codeTools.iFlowCli:
|
case codeTools.iFlowCli:
|
||||||
return 'iflow'
|
return 'iflow'
|
||||||
case codeTools.githubCopilotCli:
|
|
||||||
return 'copilot'
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||||
}
|
}
|
||||||
@@ -151,9 +144,7 @@ class CodeToolsService {
|
|||||||
case terminalApps.powershell:
|
case terminalApps.powershell:
|
||||||
// Check for PowerShell in PATH
|
// Check for PowerShell in PATH
|
||||||
try {
|
try {
|
||||||
await execAsync('powershell -Command "Get-Host"', {
|
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
|
||||||
timeout: 3000
|
|
||||||
})
|
|
||||||
return terminal
|
return terminal
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
@@ -393,9 +384,7 @@ class CodeToolsService {
|
|||||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||||
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
||||||
|
|
||||||
const { stdout } = await execAsync(`"${executablePath}" --version`, {
|
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
|
||||||
timeout: 10000
|
|
||||||
})
|
|
||||||
// Extract version number from output (format may vary by tool)
|
// Extract version number from output (format may vary by tool)
|
||||||
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
||||||
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
||||||
@@ -436,10 +425,7 @@ class CodeToolsService {
|
|||||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
this.versionCache.set(cacheKey, {
|
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
|
||||||
version: latestVersion!,
|
|
||||||
timestamp: now
|
|
||||||
})
|
|
||||||
logger.debug(`Cached latest version for ${packageName}`)
|
logger.debug(`Cached latest version for ${packageName}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
||||||
|
|||||||
@@ -725,10 +725,7 @@ class FileStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
||||||
const resolved = await shell.openPath(path)
|
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||||
if (resolved !== '') {
|
|
||||||
throw new Error(resolved)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1232,19 +1229,6 @@ class FileStorage {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public showInFolder = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
|
||||||
if (!fs.existsSync(path)) {
|
|
||||||
const msg = `File or folder does not exist: ${path}`
|
|
||||||
logger.error(msg)
|
|
||||||
throw new Error(msg)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
shell.showItemInFolder(path)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to show item in folder:', error as Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fileStorage = new FileStorage()
|
export const fileStorage = new FileStorage()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
|
|||||||
import { fileStorage } from '@main/services/FileStorage'
|
import { fileStorage } from '@main/services/FileStorage'
|
||||||
import { windowService } from '@main/services/WindowService'
|
import { windowService } from '@main/services/WindowService'
|
||||||
import { getDataPath } from '@main/utils'
|
import { getDataPath } from '@main/utils'
|
||||||
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
|
import { getAllFiles } from '@main/utils/file'
|
||||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||||
import { MB } from '@shared/config/constant'
|
import { MB } from '@shared/config/constant'
|
||||||
import type { LoaderReturn } from '@shared/config/types'
|
import type { LoaderReturn } from '@shared/config/types'
|
||||||
@@ -147,16 +147,11 @@ class KnowledgeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDbPath = (id: string): string => {
|
|
||||||
// 消除网络搜索requestI d中的特殊字符
|
|
||||||
return path.join(this.storageDir, sanitizeFilename(id, '_'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete knowledge base file
|
* Delete knowledge base file
|
||||||
*/
|
*/
|
||||||
private deleteKnowledgeFile = (id: string): boolean => {
|
private deleteKnowledgeFile = (id: string): boolean => {
|
||||||
const dbPath = this.getDbPath(id)
|
const dbPath = path.join(this.storageDir, id)
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(dbPath, { recursive: true })
|
fs.rmSync(dbPath, { recursive: true })
|
||||||
@@ -249,8 +244,7 @@ class KnowledgeService {
|
|||||||
dimensions
|
dimensions
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const dbPath = this.getDbPath(id)
|
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
||||||
const libSqlDb = new LibSqlDb({ path: dbPath })
|
|
||||||
// Save database instance for later closing
|
// Save database instance for later closing
|
||||||
this.dbInstances.set(id, libSqlDb)
|
this.dbInstances.set(id, libSqlDb)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
|||||||
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
|
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
|
||||||
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
||||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||||
import getLoginShellEnvironment from '@main/utils/shell-env'
|
|
||||||
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
|
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||||
@@ -44,12 +43,14 @@ import {
|
|||||||
} from '@types'
|
} from '@types'
|
||||||
import { app, net } from 'electron'
|
import { app, net } from 'electron'
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
|
import { memoize } from 'lodash'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
import { CacheService } from './CacheService'
|
import { CacheService } from './CacheService'
|
||||||
import DxtService from './DxtService'
|
import DxtService from './DxtService'
|
||||||
import { CallBackServer } from './mcp/oauth/callback'
|
import { CallBackServer } from './mcp/oauth/callback'
|
||||||
import { McpOAuthClientProvider } from './mcp/oauth/provider'
|
import { McpOAuthClientProvider } from './mcp/oauth/provider'
|
||||||
|
import getLoginShellEnvironment from './mcp/shell-env'
|
||||||
import { windowService } from './WindowService'
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
// Generic type for caching wrapped functions
|
// Generic type for caching wrapped functions
|
||||||
@@ -334,7 +335,7 @@ class McpService {
|
|||||||
|
|
||||||
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
|
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
|
||||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||||
const loginShellEnv = await getLoginShellEnvironment()
|
const loginShellEnv = await this.getLoginShellEnv()
|
||||||
|
|
||||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||||
if (cmd.includes('bun')) {
|
if (cmd.includes('bun')) {
|
||||||
@@ -877,6 +878,20 @@ class McpService {
|
|||||||
return await cachedGetResource(server, uri)
|
return await cachedGetResource(server, uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLoginShellEnv = memoize(async (): Promise<Record<string, string>> => {
|
||||||
|
try {
|
||||||
|
const loginEnv = await getLoginShellEnvironment()
|
||||||
|
const pathSeparator = process.platform === 'win32' ? ';' : ':'
|
||||||
|
const cherryBinPath = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||||
|
loginEnv.PATH = `${loginEnv.PATH}${pathSeparator}${cherryBinPath}`
|
||||||
|
logger.debug('Successfully fetched login shell environment variables:')
|
||||||
|
return loginEnv
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch login shell environment variables:', error as Error)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 实现 abortTool 方法
|
// 实现 abortTool 方法
|
||||||
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
||||||
const activeToolCall = this.activeToolCalls.get(callId)
|
const activeToolCall = this.activeToolCalls.get(callId)
|
||||||
|
|||||||
@@ -1,586 +0,0 @@
|
|||||||
import { exec } from 'node:child_process'
|
|
||||||
import { homedir } from 'node:os'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
|
|
||||||
import { loggerService } from '@logger'
|
|
||||||
import * as fs from 'fs-extra'
|
|
||||||
import * as path from 'path'
|
|
||||||
|
|
||||||
const logger = loggerService.withContext('OvmsManager')
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
interface OvmsProcess {
|
|
||||||
pid: number
|
|
||||||
path: string
|
|
||||||
workingDirectory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelConfig {
|
|
||||||
name: string
|
|
||||||
base_path: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OvmsConfig {
|
|
||||||
mediapipe_config_list: ModelConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
class OvmsManager {
|
|
||||||
private ovms: OvmsProcess | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively terminate a process and all its child processes
|
|
||||||
* @param pid Process ID to terminate
|
|
||||||
* @returns Promise<{ success: boolean; message?: string }>
|
|
||||||
*/
|
|
||||||
private async terminalProcess(pid: number): Promise<{ success: boolean; message?: string }> {
|
|
||||||
try {
|
|
||||||
// Check if the process is running
|
|
||||||
const processCheckCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
|
||||||
const { stdout: processStdout } = await execAsync(`powershell -Command "${processCheckCommand}"`)
|
|
||||||
|
|
||||||
if (!processStdout.trim()) {
|
|
||||||
logger.info(`Process with PID ${pid} is not running`)
|
|
||||||
return { success: true, message: `Process with PID ${pid} is not running` }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find child processes
|
|
||||||
const childProcessCommand = `Get-WmiObject -Class Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | Select-Object ProcessId | ConvertTo-Json`
|
|
||||||
const { stdout: childStdout } = await execAsync(`powershell -Command "${childProcessCommand}"`)
|
|
||||||
|
|
||||||
// If there are child processes, terminate them first
|
|
||||||
if (childStdout.trim()) {
|
|
||||||
const childProcesses = JSON.parse(childStdout)
|
|
||||||
const childList = Array.isArray(childProcesses) ? childProcesses : [childProcesses]
|
|
||||||
|
|
||||||
logger.info(`Found ${childList.length} child processes for PID ${pid}`)
|
|
||||||
|
|
||||||
// Recursively terminate each child process
|
|
||||||
for (const childProcess of childList) {
|
|
||||||
const childPid = childProcess.ProcessId
|
|
||||||
logger.info(`Terminating child process PID: ${childPid}`)
|
|
||||||
await this.terminalProcess(childPid)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info(`No child processes found for PID ${pid}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally, terminate the parent process
|
|
||||||
const killCommand = `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`
|
|
||||||
await execAsync(`powershell -Command "${killCommand}"`)
|
|
||||||
logger.info(`Terminated process with PID: ${pid}`)
|
|
||||||
|
|
||||||
// Wait for the process to disappear with 5-second timeout
|
|
||||||
const timeout = 5000 // 5 seconds
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
while (Date.now() - startTime < timeout) {
|
|
||||||
const checkCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
|
||||||
const { stdout: checkStdout } = await execAsync(`powershell -Command "${checkCommand}"`)
|
|
||||||
|
|
||||||
if (!checkStdout.trim()) {
|
|
||||||
logger.info(`Process with PID ${pid} has disappeared`)
|
|
||||||
return { success: true, message: `Process ${pid} and all child processes terminated successfully` }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 300ms before checking again
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn(`Process with PID ${pid} did not disappear within timeout`)
|
|
||||||
return { success: false, message: `Process ${pid} did not disappear within 5 seconds` }
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to terminate process ${pid}:`, error as Error)
|
|
||||||
return { success: false, message: `Failed to terminate process ${pid}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop OVMS process if it's running
|
|
||||||
* @returns Promise<{ success: boolean; message?: string }>
|
|
||||||
*/
|
|
||||||
public async stopOvms(): Promise<{ success: boolean; message?: string }> {
|
|
||||||
try {
|
|
||||||
// Check if OVMS process is running
|
|
||||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
|
||||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
|
||||||
|
|
||||||
if (!stdout.trim()) {
|
|
||||||
logger.info('OVMS process is not running')
|
|
||||||
return { success: true, message: 'OVMS process is not running' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const processes = JSON.parse(stdout)
|
|
||||||
const processList = Array.isArray(processes) ? processes : [processes]
|
|
||||||
|
|
||||||
if (processList.length === 0) {
|
|
||||||
logger.info('OVMS process is not running')
|
|
||||||
return { success: true, message: 'OVMS process is not running' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminate all OVMS processes using terminalProcess
|
|
||||||
for (const process of processList) {
|
|
||||||
const result = await this.terminalProcess(process.Id)
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`)
|
|
||||||
return { success: false, message: `Failed to terminate OVMS process: ${result.message}` }
|
|
||||||
}
|
|
||||||
logger.info(`Terminated OVMS process with PID: ${process.Id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the ovms instance
|
|
||||||
this.ovms = null
|
|
||||||
|
|
||||||
logger.info('OVMS process stopped successfully')
|
|
||||||
return { success: true, message: 'OVMS process stopped successfully' }
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to stop OVMS process: ${error}`)
|
|
||||||
return { success: false, message: 'Failed to stop OVMS process' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run OVMS by ensuring config.json exists and executing run.bat
|
|
||||||
* @returns Promise<{ success: boolean; message?: string }>
|
|
||||||
*/
|
|
||||||
public async runOvms(): Promise<{ success: boolean; message?: string }> {
|
|
||||||
const homeDir = homedir()
|
|
||||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
|
||||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
|
||||||
const runBatPath = path.join(ovmsDir, 'run.bat')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if config.json exists, if not create it with default content
|
|
||||||
if (!(await fs.pathExists(configPath))) {
|
|
||||||
logger.info(`Config file does not exist, creating: ${configPath}`)
|
|
||||||
|
|
||||||
// Ensure the models directory exists
|
|
||||||
await fs.ensureDir(path.dirname(configPath))
|
|
||||||
|
|
||||||
// Create config.json with default content
|
|
||||||
const defaultConfig = {
|
|
||||||
mediapipe_config_list: [],
|
|
||||||
model_config_list: []
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeJson(configPath, defaultConfig, { spaces: 2 })
|
|
||||||
logger.info(`Config file created: ${configPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if run.bat exists
|
|
||||||
if (!(await fs.pathExists(runBatPath))) {
|
|
||||||
logger.error(`run.bat not found at: ${runBatPath}`)
|
|
||||||
return { success: false, message: 'run.bat not found' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run run.bat without waiting for it to complete
|
|
||||||
logger.info(`Starting OVMS with run.bat: ${runBatPath}`)
|
|
||||||
exec(`"${runBatPath}"`, { cwd: ovmsDir }, (error) => {
|
|
||||||
if (error) {
|
|
||||||
logger.error(`Error running run.bat: ${error}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info('OVMS started successfully')
|
|
||||||
return { success: true }
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to run OVMS: ${error}`)
|
|
||||||
return { success: false, message: 'Failed to run OVMS' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get OVMS status - checks installation and running status
|
|
||||||
* @returns 'not-installed' | 'not-running' | 'running'
|
|
||||||
*/
|
|
||||||
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
|
|
||||||
const homeDir = homedir()
|
|
||||||
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if OVMS executable exists
|
|
||||||
if (!(await fs.pathExists(ovmsPath))) {
|
|
||||||
logger.info(`OVMS executable not found at: ${ovmsPath}`)
|
|
||||||
return 'not-installed'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if OVMS process is running
|
|
||||||
//const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq "${ovmsPath.replace(/\\/g, '\\\\')}" } | Select-Object Id | ConvertTo-Json`;
|
|
||||||
//const { stdout } = await execAsync(`powershell -Command "${psCommand}"`);
|
|
||||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
|
||||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
|
||||||
|
|
||||||
if (!stdout.trim()) {
|
|
||||||
logger.info('OVMS process not running')
|
|
||||||
return 'not-running'
|
|
||||||
}
|
|
||||||
|
|
||||||
const processes = JSON.parse(stdout)
|
|
||||||
const processList = Array.isArray(processes) ? processes : [processes]
|
|
||||||
|
|
||||||
if (processList.length > 0) {
|
|
||||||
logger.info('OVMS process is running')
|
|
||||||
return 'running'
|
|
||||||
} else {
|
|
||||||
logger.info('OVMS process not running')
|
|
||||||
return 'not-running'
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.info(`Failed to check OVMS status: ${error}`)
|
|
||||||
return 'not-running'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize OVMS by finding the executable path and working directory
|
|
||||||
*/
|
|
||||||
public async initializeOvms(): Promise<boolean> {
|
|
||||||
// Use PowerShell to find ovms.exe processes with their paths
|
|
||||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
|
||||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
|
||||||
|
|
||||||
if (!stdout.trim()) {
|
|
||||||
logger.error('Command to find OVMS process returned no output')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
logger.debug(`OVMS process output: ${stdout}`)
|
|
||||||
|
|
||||||
const processes = JSON.parse(stdout)
|
|
||||||
const processList = Array.isArray(processes) ? processes : [processes]
|
|
||||||
|
|
||||||
// Find the first process with a valid path
|
|
||||||
for (const process of processList) {
|
|
||||||
this.ovms = {
|
|
||||||
pid: process.Id,
|
|
||||||
path: process.Path,
|
|
||||||
workingDirectory: path.dirname(process.Path)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.ovms !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the Model Name and ID are valid, they are valid only if they are not used in the config.json
|
|
||||||
* @param modelName Name of the model to check
|
|
||||||
* @param modelId ID of the model to check
|
|
||||||
*/
|
|
||||||
public async isNameAndIDAvalid(modelName: string, modelId: string): Promise<boolean> {
|
|
||||||
if (!modelName || !modelId) {
|
|
||||||
logger.error('Model name and ID cannot be empty')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeDir = homedir()
|
|
||||||
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
|
|
||||||
try {
|
|
||||||
if (!(await fs.pathExists(configPath))) {
|
|
||||||
logger.warn(`Config file does not exist: ${configPath}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: OvmsConfig = await fs.readJson(configPath)
|
|
||||||
if (!config.mediapipe_config_list) {
|
|
||||||
logger.warn(`No mediapipe_config_list found in config: ${configPath}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the model name or ID already exists in the config
|
|
||||||
const exists = config.mediapipe_config_list.some(
|
|
||||||
(model) => model.name === modelName || model.base_path === modelId
|
|
||||||
)
|
|
||||||
if (exists) {
|
|
||||||
logger.warn(`Model with name "${modelName}" or ID "${modelId}" already exists in the config`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to check model existence: ${error}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyModelPath(modelDirPath: string): Promise<boolean> {
|
|
||||||
const homeDir = homedir()
|
|
||||||
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
|
|
||||||
if (!(await fs.pathExists(patchDir))) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelId = path.basename(modelDirPath)
|
|
||||||
|
|
||||||
// get all sub directories in patchDir
|
|
||||||
const patchs = await fs.readdir(patchDir)
|
|
||||||
for (const patch of patchs) {
|
|
||||||
const fullPatchPath = path.join(patchDir, patch)
|
|
||||||
|
|
||||||
if (fs.lstatSync(fullPatchPath).isDirectory()) {
|
|
||||||
if (modelId.toLowerCase().includes(patch.toLowerCase())) {
|
|
||||||
// copy all files from fullPath to modelDirPath
|
|
||||||
try {
|
|
||||||
const files = await fs.readdir(fullPatchPath)
|
|
||||||
for (const file of files) {
|
|
||||||
const srcFile = path.join(fullPatchPath, file)
|
|
||||||
const destFile = path.join(modelDirPath, file)
|
|
||||||
await fs.copyFile(srcFile, destFile)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to copy files from ${fullPatchPath} to ${modelDirPath}: ${error}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
logger.info(`Applied patchs for model ${modelId}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a model to OVMS by downloading it
|
|
||||||
* @param modelName Name of the model to add
|
|
||||||
* @param modelId ID of the model to download
|
|
||||||
* @param modelSource Model Source: huggingface, hf-mirror and modelscope, default is huggingface
|
|
||||||
* @param task Task type: text_generation, embedding, rerank, image_generation
|
|
||||||
*/
|
|
||||||
public async addModel(
|
|
||||||
modelName: string,
|
|
||||||
modelId: string,
|
|
||||||
modelSource: string,
|
|
||||||
task: string = 'text_generation'
|
|
||||||
): Promise<{ success: boolean; message?: string }> {
|
|
||||||
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
|
|
||||||
|
|
||||||
const homeDir = homedir()
|
|
||||||
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
|
||||||
const pathModel = path.join(ovdndDir, 'models', modelId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// check the ovdnDir+'models'+modelId exist or not
|
|
||||||
if (await fs.pathExists(pathModel)) {
|
|
||||||
logger.error(`Model with ID ${modelId} already exists`)
|
|
||||||
return { success: false, message: 'Model ID already exists!' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove the model directory if it exists
|
|
||||||
if (await fs.pathExists(pathModel)) {
|
|
||||||
logger.info(`Removing existing model directory: ${pathModel}`)
|
|
||||||
await fs.remove(pathModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use ovdnd.exe for downloading instead of ovms.exe
|
|
||||||
const ovdndPath = path.join(ovdndDir, 'ovdnd.exe')
|
|
||||||
const command =
|
|
||||||
`"${ovdndPath}" --pull ` +
|
|
||||||
`--model_repository_path "${ovdndDir}/models" ` +
|
|
||||||
`--source_model "${modelId}" ` +
|
|
||||||
`--model_name "${modelName}" ` +
|
|
||||||
`--target_device GPU ` +
|
|
||||||
`--task ${task} ` +
|
|
||||||
`--overwrite_models`
|
|
||||||
|
|
||||||
const env: Record<string, string | undefined> = {
|
|
||||||
...process.env,
|
|
||||||
OVMS_DIR: ovdndDir,
|
|
||||||
PYTHONHOME: path.join(ovdndDir, 'python'),
|
|
||||||
PATH: `${process.env.PATH};${ovdndDir};${path.join(ovdndDir, 'python')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modelSource) {
|
|
||||||
env.HF_ENDPOINT = modelSource
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Running command: ${command} from ${modelSource}`)
|
|
||||||
const { stdout } = await execAsync(command, { env: env, cwd: ovdndDir })
|
|
||||||
|
|
||||||
logger.info('Model download completed')
|
|
||||||
logger.debug(`Command output: ${stdout}`)
|
|
||||||
} catch (error) {
|
|
||||||
// remove ovdnDir+'models'+modelId if it exists
|
|
||||||
if (await fs.pathExists(pathModel)) {
|
|
||||||
logger.info(`Removing failed model directory: ${pathModel}`)
|
|
||||||
await fs.remove(pathModel)
|
|
||||||
}
|
|
||||||
logger.error(`Failed to add model: ${error}`)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `Download model ${modelId} failed, please check following items and try it again:<p>- the model id</p><p>- network connection and proxy</p>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update config file
|
|
||||||
if (!(await this.updateModelConfig(modelName, modelId))) {
|
|
||||||
logger.error('Failed to update model config')
|
|
||||||
return { success: false, message: 'Failed to update model config' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await this.applyModelPath(pathModel))) {
|
|
||||||
logger.error('Failed to apply model patchs')
|
|
||||||
return { success: false, message: 'Failed to apply model patchs' }
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Model ${modelName} added successfully with ID ${modelId}`)
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the model download process if it's running
|
|
||||||
* @returns Promise<{ success: boolean; message?: string }>
|
|
||||||
*/
|
|
||||||
public async stopAddModel(): Promise<{ success: boolean; message?: string }> {
|
|
||||||
try {
|
|
||||||
// Check if ovdnd.exe process is running
|
|
||||||
const psCommand = `Get-Process -Name "ovdnd" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
|
||||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
|
||||||
|
|
||||||
if (!stdout.trim()) {
|
|
||||||
logger.info('ovdnd process is not running')
|
|
||||||
return { success: true, message: 'Model download process is not running' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const processes = JSON.parse(stdout)
|
|
||||||
const processList = Array.isArray(processes) ? processes : [processes]
|
|
||||||
|
|
||||||
if (processList.length === 0) {
|
|
||||||
logger.info('ovdnd process is not running')
|
|
||||||
return { success: true, message: 'Model download process is not running' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminate all ovdnd processes
|
|
||||||
for (const process of processList) {
|
|
||||||
this.terminalProcess(process.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Model download process stopped successfully')
|
|
||||||
return { success: true, message: 'Model download process stopped successfully' }
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to stop model download process: ${error}`)
|
|
||||||
return { success: false, message: 'Failed to stop model download process' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* check if the model id exists in the OVMS configuration
|
|
||||||
* @param modelId ID of the model to check
|
|
||||||
*/
|
|
||||||
public async checkModelExists(modelId: string): Promise<boolean> {
|
|
||||||
const homeDir = homedir()
|
|
||||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
|
||||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!(await fs.pathExists(configPath))) {
|
|
||||||
logger.warn(`Config file does not exist: ${configPath}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: OvmsConfig = await fs.readJson(configPath)
|
|
||||||
if (!config.mediapipe_config_list) {
|
|
||||||
logger.warn('No mediapipe_config_list found in config')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.mediapipe_config_list.some((model) => model.base_path === modelId)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to check model existence: ${error}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the model configuration file
|
|
||||||
*/
|
|
||||||
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
|
|
||||||
const homeDir = homedir()
|
|
||||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
|
||||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure the models directory exists
|
|
||||||
await fs.ensureDir(path.dirname(configPath))
|
|
||||||
let config: OvmsConfig
|
|
||||||
|
|
||||||
// Read existing config or create new one
|
|
||||||
if (await fs.pathExists(configPath)) {
|
|
||||||
config = await fs.readJson(configPath)
|
|
||||||
} else {
|
|
||||||
config = { mediapipe_config_list: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure mediapipe_config_list exists
|
|
||||||
if (!config.mediapipe_config_list) {
|
|
||||||
config.mediapipe_config_list = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new model config
|
|
||||||
const newModelConfig: ModelConfig = {
|
|
||||||
name: modelName,
|
|
||||||
base_path: modelId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if model already exists, if so, update it
|
|
||||||
const existingIndex = config.mediapipe_config_list.findIndex((model) => model.base_path === modelId)
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
config.mediapipe_config_list[existingIndex] = newModelConfig
|
|
||||||
logger.info(`Updated existing model config: ${modelName}`)
|
|
||||||
} else {
|
|
||||||
config.mediapipe_config_list.push(newModelConfig)
|
|
||||||
logger.info(`Added new model config: ${modelName}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write config back to file
|
|
||||||
await fs.writeJson(configPath, config, { spaces: 2 })
|
|
||||||
logger.info(`Config file updated: ${configPath}`)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to update model config: ${error}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all models from OVMS config, filtered for image generation models
|
|
||||||
* @returns Array of model configurations
|
|
||||||
*/
|
|
||||||
public async getModels(): Promise<ModelConfig[]> {
|
|
||||||
const homeDir = homedir()
|
|
||||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
|
||||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!(await fs.pathExists(configPath))) {
|
|
||||||
logger.warn(`Config file does not exist: ${configPath}`)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: OvmsConfig = await fs.readJson(configPath)
|
|
||||||
if (!config.mediapipe_config_list) {
|
|
||||||
logger.warn('No mediapipe_config_list found in config')
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
|
|
||||||
const imageGenerationModels = config.mediapipe_config_list.filter((model) => {
|
|
||||||
const modelName = model.name.toLowerCase()
|
|
||||||
return (
|
|
||||||
modelName.startsWith('sd') ||
|
|
||||||
modelName.startsWith('stable-diffusion') ||
|
|
||||||
modelName.startsWith('stable diffusion') ||
|
|
||||||
modelName.startsWith('flux')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(`Found ${imageGenerationModels.length} image generation models`)
|
|
||||||
return imageGenerationModels
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to get models: ${error}`)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OvmsManager
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { session, shell, webContents } from 'electron'
|
||||||
import { app, session, shell, webContents } from 'electron'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* init the useragent of the webview session
|
* init the useragent of the webview session
|
||||||
@@ -37,66 +36,3 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachKeyboardHandler = (contents: Electron.WebContents) => {
|
|
||||||
if (contents.getType?.() !== 'webview') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
|
|
||||||
if (!input) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = input.key?.toLowerCase()
|
|
||||||
if (!key) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFindShortcut = (input.control || input.meta) && key === 'f'
|
|
||||||
const isEscape = key === 'escape'
|
|
||||||
const isEnter = key === 'enter'
|
|
||||||
|
|
||||||
if (!isFindShortcut && !isEscape && !isEnter) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = contents.hostWebContents
|
|
||||||
if (!host || host.isDestroyed()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
|
|
||||||
if (isFindShortcut) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the hotkey event to the renderer
|
|
||||||
// The renderer will decide whether to preventDefault for Escape and Enter
|
|
||||||
// based on whether the search bar is visible
|
|
||||||
host.send(IpcChannel.Webview_SearchHotkey, {
|
|
||||||
webviewId: contents.id,
|
|
||||||
key,
|
|
||||||
control: Boolean(input.control),
|
|
||||||
meta: Boolean(input.meta),
|
|
||||||
shift: Boolean(input.shift),
|
|
||||||
alt: Boolean(input.alt)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
contents.on('before-input-event', handleBeforeInput)
|
|
||||||
contents.once('destroyed', () => {
|
|
||||||
contents.removeListener('before-input-event', handleBeforeInput)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initWebviewHotkeys() {
|
|
||||||
webContents.getAllWebContents().forEach((contents) => {
|
|
||||||
if (contents.isDestroyed()) return
|
|
||||||
attachKeyboardHandler(contents)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.on('web-contents-created', (_, contents) => {
|
|
||||||
attachKeyboardHandler(contents)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -274,4 +274,46 @@ describe('AppUpdater', () => {
|
|||||||
expect(result.releaseNotes).toBeNull()
|
expect(result.releaseNotes).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('formatReleaseNotes', () => {
|
||||||
|
it('should format string release notes with markers', () => {
|
||||||
|
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||||
|
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||||
|
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||||
|
|
||||||
|
expect(result).toBe('English')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format string release notes without markers', () => {
|
||||||
|
const notes = 'Simple notes'
|
||||||
|
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||||
|
|
||||||
|
expect(result).toBe('Simple notes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format array release notes', () => {
|
||||||
|
const notes = [
|
||||||
|
{ version: '1.0.0', note: 'Note 1' },
|
||||||
|
{ version: '1.0.1', note: 'Note 2' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||||
|
|
||||||
|
expect(result).toBe('Note 1\nNote 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null release notes', () => {
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||||
|
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined release notes', () => {
|
||||||
|
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||||
|
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
import { type Client, createClient } from '@libsql/client'
|
|
||||||
import { loggerService } from '@logger'
|
|
||||||
import { mcpApiService } from '@main/apiServer/services/mcp'
|
|
||||||
import { ModelValidationError, validateModelId } from '@main/apiServer/utils'
|
|
||||||
import { AgentType, MCPTool, objectKeys, SlashCommand, Tool } 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'
|
|
||||||
import { builtinSlashCommands } from './services/claudecode/commands'
|
|
||||||
import { builtinTools } from './services/claudecode/tools'
|
|
||||||
|
|
||||||
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[] = ['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
|
|
||||||
}
|
|
||||||
|
|
||||||
public async listMcpTools(agentType: AgentType, ids?: string[]): Promise<Tool[]> {
|
|
||||||
const tools: Tool[] = []
|
|
||||||
if (agentType === 'claude-code') {
|
|
||||||
tools.push(...builtinTools)
|
|
||||||
}
|
|
||||||
if (ids && ids.length > 0) {
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
const server = await mcpApiService.getServerInfo(id)
|
|
||||||
if (server) {
|
|
||||||
server.tools.forEach((tool: MCPTool) => {
|
|
||||||
tools.push({
|
|
||||||
id: `mcp_${id}_${tool.name}`,
|
|
||||||
name: tool.name,
|
|
||||||
type: 'mcp',
|
|
||||||
description: tool.description || '',
|
|
||||||
requirePermissions: true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Failed to list MCP tools', {
|
|
||||||
id,
|
|
||||||
error: error as Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tools
|
|
||||||
}
|
|
||||||
|
|
||||||
public async listSlashCommands(agentType: AgentType): Promise<SlashCommand[]> {
|
|
||||||
if (agentType === 'claude-code') {
|
|
||||||
return builtinSlashCommands
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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'
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Drizzle ORM schema exports
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './agents.schema'
|
|
||||||
export * from './messages.schema'
|
|
||||||
export * from './migrations.schema'
|
|
||||||
export * from './sessions.schema'
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import { loggerService } from '@logger'
|
|
||||||
import type {
|
|
||||||
AgentMessageAssistantPersistPayload,
|
|
||||||
AgentMessagePersistExchangePayload,
|
|
||||||
AgentMessagePersistExchangeResult,
|
|
||||||
AgentMessageUserPersistPayload,
|
|
||||||
AgentPersistedMessage,
|
|
||||||
AgentSessionMessageEntity
|
|
||||||
} from '@types'
|
|
||||||
import { and, asc, eq } from 'drizzle-orm'
|
|
||||||
|
|
||||||
import { BaseService } from '../BaseService'
|
|
||||||
import type { InsertSessionMessageRow, SessionMessageRow } 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findExistingMessageRow(
|
|
||||||
writer: TxClient,
|
|
||||||
sessionId: string,
|
|
||||||
role: string,
|
|
||||||
messageId: string
|
|
||||||
): Promise<SessionMessageRow | null> {
|
|
||||||
const candidateRows: SessionMessageRow[] = await writer
|
|
||||||
.select()
|
|
||||||
.from(sessionMessagesTable)
|
|
||||||
.where(and(eq(sessionMessagesTable.session_id, sessionId), eq(sessionMessagesTable.role, role)))
|
|
||||||
.orderBy(asc(sessionMessagesTable.created_at))
|
|
||||||
|
|
||||||
for (const row of candidateRows) {
|
|
||||||
if (!row?.content) continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(row.content) as AgentPersistedMessage | undefined
|
|
||||||
if (parsed?.message?.id === messageId) {
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Failed to parse session message content JSON during lookup', error as Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertMessage(
|
|
||||||
params: PersistUserMessageParams | PersistAssistantMessageParams
|
|
||||||
): Promise<AgentSessionMessageEntity> {
|
|
||||||
await AgentMessageRepository.initialize()
|
|
||||||
this.ensureInitialized()
|
|
||||||
|
|
||||||
const { sessionId, agentSessionId = '', payload, metadata, createdAt, tx } = params
|
|
||||||
|
|
||||||
if (!payload?.message?.role) {
|
|
||||||
throw new Error('Message payload missing role')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.message.id) {
|
|
||||||
throw new Error('Message payload missing id')
|
|
||||||
}
|
|
||||||
|
|
||||||
const writer = this.getWriter(tx)
|
|
||||||
const now = createdAt ?? payload.message.createdAt ?? new Date().toISOString()
|
|
||||||
const serializedPayload = this.serializeMessage(payload)
|
|
||||||
const serializedMetadata = this.serializeMetadata(metadata)
|
|
||||||
|
|
||||||
const existingRow = await this.findExistingMessageRow(writer, sessionId, payload.message.role, payload.message.id)
|
|
||||||
|
|
||||||
if (existingRow) {
|
|
||||||
const metadataToPersist = serializedMetadata ?? existingRow.metadata ?? undefined
|
|
||||||
const agentSessionToPersist = agentSessionId || existingRow.agent_session_id || ''
|
|
||||||
|
|
||||||
await writer
|
|
||||||
.update(sessionMessagesTable)
|
|
||||||
.set({
|
|
||||||
content: serializedPayload,
|
|
||||||
metadata: metadataToPersist,
|
|
||||||
agent_session_id: agentSessionToPersist,
|
|
||||||
updated_at: now
|
|
||||||
})
|
|
||||||
.where(eq(sessionMessagesTable.id, existingRow.id))
|
|
||||||
|
|
||||||
return this.deserialize({
|
|
||||||
...existingRow,
|
|
||||||
content: serializedPayload,
|
|
||||||
metadata: metadataToPersist,
|
|
||||||
agent_session_id: agentSessionToPersist,
|
|
||||||
updated_at: now
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertData: InsertSessionMessageRow = {
|
|
||||||
session_id: sessionId,
|
|
||||||
role: payload.message.role,
|
|
||||||
content: serializedPayload,
|
|
||||||
agent_session_id: agentSessionId,
|
|
||||||
metadata: serializedMetadata,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now
|
|
||||||
}
|
|
||||||
|
|
||||||
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
|
|
||||||
|
|
||||||
return this.deserialize(saved)
|
|
||||||
}
|
|
||||||
|
|
||||||
async persistUserMessage(params: PersistUserMessageParams): Promise<AgentSessionMessageEntity> {
|
|
||||||
return this.upsertMessage({ ...params, agentSessionId: params.agentSessionId ?? '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async persistAssistantMessage(params: PersistAssistantMessageParams): Promise<AgentSessionMessageEntity> {
|
|
||||||
return this.upsertMessage(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
exchangeResult.userMessage = await this.persistUserMessage({
|
|
||||||
sessionId,
|
|
||||||
agentSessionId,
|
|
||||||
payload: user.payload,
|
|
||||||
metadata: user.metadata,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
tx
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assistant?.payload) {
|
|
||||||
exchangeResult.assistantMessage = await this.persistAssistantMessage({
|
|
||||||
sessionId,
|
|
||||||
agentSessionId,
|
|
||||||
payload: assistant.payload,
|
|
||||||
metadata: assistant.metadata,
|
|
||||||
createdAt: assistant.createdAt,
|
|
||||||
tx
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return exchangeResult
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSessionHistory(sessionId: string): Promise<AgentPersistedMessage[]> {
|
|
||||||
await AgentMessageRepository.initialize()
|
|
||||||
this.ensureInitialized()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await this.database
|
|
||||||
.select()
|
|
||||||
.from(sessionMessagesTable)
|
|
||||||
.where(eq(sessionMessagesTable.session_id, sessionId))
|
|
||||||
.orderBy(asc(sessionMessagesTable.created_at))
|
|
||||||
|
|
||||||
const messages: AgentPersistedMessage[] = []
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const deserialized = this.deserialize(row)
|
|
||||||
if (deserialized?.content) {
|
|
||||||
messages.push(deserialized.content as AgentPersistedMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Loaded ${messages.length} messages for session ${sessionId}`)
|
|
||||||
return messages
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load session history', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const agentMessageRepository = AgentMessageRepository.getInstance()
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
})
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user