Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca44133e90 | |||
| b4810bb487 | |||
| dc0f9c5f08 | |||
| 595fd878a6 | |||
| 9d45991181 | |||
| cf2f2fd707 | |||
| d4b1db0407 | |||
| 8470e252d6 | |||
| 131444ac52 | |||
| ab3083f943 | |||
| 1e1d5c4a14 | |||
| c8ab0b9428 | |||
| 33ce41704d | |||
| 4eb3aa31ee | |||
| d1a9dfa3e6 | |||
| 0e5ebcfd00 | |||
| c4e0a6acfe | |||
| 2243bb2862 | |||
| 1f7d2fa93f | |||
| fb680ce764 | |||
| dc5bc64040 | |||
| 1c2ce7e0aa | |||
| a290ee7f39 | |||
| 79c697c34d | |||
| 76271cbf77 | |||
| 51dcdf94fb | |||
| 254051cf62 | |||
| 24d2e6e6ce | |||
| cf9bfce43c | |||
| 9e0ee24fd7 | |||
| 5eb2772d53 | |||
| f943f05cb1 | |||
| 96ce645064 | |||
| 1a972ac0e0 | |||
| 2e173631a0 | |||
| c457d4a868 | |||
| b74655651d | |||
| f27a481c3c | |||
| 4028b26c1d | |||
| 011b6f2df1 | |||
| 7b3b73d390 | |||
| 004d6d8201 | |||
| 7cf57adceb | |||
| 76bf78b810 | |||
| f4441e2a55 | |||
| 84f590ec7b | |||
| a5865cfd01 | |||
| 4e7c714ea2 | |||
| d2c4231458 | |||
| b5004e2a51 | |||
| e0c334b5ed | |||
| d482e661fb | |||
| 3ac1caca69 | |||
| 94c112c066 | |||
| 2e694a87f8 | |||
| 4ae30db53a | |||
| f4a6dd91cf | |||
| c08a570c27 | |||
| 9c318c9526 | |||
| 4cee09870a | |||
| 866e8e8734 | |||
| 80e1784777 | |||
| 0d760ffa2e | |||
| 88f7e6a854 | |||
| de37e2355d | |||
| f27b04c5b0 | |||
| a02b8f4609 | |||
| 7b90dfb46c | |||
| 26a9dba01a | |||
| a176814ad1 | |||
| ea51439aac | |||
| 162e33f478 | |||
| ee4c310725 | |||
| a000ff2a1a | |||
| 2f9576b2ae | |||
| 92554dd398 | |||
| 9473ddc762 | |||
| 5f469a71f3 | |||
| 87bac60afc | |||
| 704339e835 | |||
| c8ab7180ba | |||
| 11757546c3 | |||
| 420b9ec2f2 | |||
| 1c73271e33 | |||
| acdbe6b9ed | |||
| 6c201228d9 | |||
| 73b2a375ad | |||
| 89bb830b60 | |||
| 2399db4944 | |||
| 62774b34d3 | |||
| 654f19eaa9 | |||
| ce642f17d9 | |||
| d7bcd5a20e | |||
| 27903e7d9d | |||
| a8c0d0a684 | |||
| 5e33c89fe7 | |||
| 42849e4586 | |||
| 6a8544fb0e | |||
| 37f7042f0f | |||
| 65d066cbef | |||
| 504531d4d5 | |||
| d4b3428160 | |||
| cd881ceb34 | |||
| 68b37e66e9 | |||
| d6e7ed81ee | |||
| a9843b4128 | |||
| d4c6131fa3 | |||
| d2d5064eed | |||
| 8bec7640fa | |||
| fcf53f06ef | |||
| 2048f210e7 | |||
| 78eacccf6e | |||
| a436ab1d78 | |||
| 2aedbf5702 | |||
| b7e7174f3d | |||
| e7e5c0456f | |||
| 53e38ed1aa | |||
| f91e7da0a1 | |||
| 74db4c4646 | |||
| 1e4902b267 | |||
| 932b1d529a | |||
| 53046460ec | |||
| 38ac42af8c | |||
| 538291c03f | |||
| 142ad9e41e | |||
| 7250ce3514 | |||
| 02cf012671 | |||
| d11a2cd95c | |||
| 65ac3181a8 | |||
| 998e54246f | |||
| fcd8f7a26e | |||
| b991afd69a | |||
| d9d8bae2d6 | |||
| 422ba52093 | |||
| 51630f95fd | |||
| ac1cab60a3 | |||
| 23f61b0d62 | |||
| 759f8518b2 | |||
| 7bd6c92f43 | |||
| ff705d99b3 | |||
| 7ec17dc771 | |||
| 35883e8601 | |||
| 48b7bdb9ba | |||
| d2d5b4370c | |||
| 27c31d6e0c | |||
| 961ee22327 |
@@ -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@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ 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_comment' && github.event.sender.type != 'Bot')
|
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
|
||||||
|
&& 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
|
||||||
@@ -29,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -42,7 +45,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: "*"
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
|
||||||
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 中的以下四种事件:
|
||||||
@@ -105,3 +108,5 @@ 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 }}
|
||||||
|
|||||||
@@ -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@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
@@ -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@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
github.rest.git.deleteRef({
|
github.rest.git.deleteRef({
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
@@ -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@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
@@ -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@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
|
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
|
||||||
--- a/dist/index.mjs
|
--- a/dist/index.mjs
|
||||||
+++ b/dist/index.mjs
|
+++ b/dist/index.mjs
|
||||||
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||||
|
|
||||||
// src/get-model-path.ts
|
// src/get-model-path.ts
|
||||||
function getModelPath(modelId) {
|
function getModelPath(modelId) {
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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,31 +0,0 @@
|
|||||||
diff --git a/sdk.mjs b/sdk.mjs
|
|
||||||
index e2dbafb4e2faa1bf2b6b02f0009a2b9bbf57c757..ea333ae8c69fcd27a9f2d89b3dbfc0a3e4e4dec4 100755
|
|
||||||
--- a/sdk.mjs
|
|
||||||
+++ b/sdk.mjs
|
|
||||||
@@ -6213,7 +6213,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
|
|
||||||
@@ -6452,13 +6452,12 @@ class ProcessTransport {
|
|
||||||
throw new ReferenceError(errorMessage);
|
|
||||||
}
|
|
||||||
const isNative = isNativeBinary(pathToClaudeCodeExecutable);
|
|
||||||
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
|
|
||||||
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
|
|
||||||
- this.logDebug(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
|
|
||||||
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
|
|
||||||
- this.child = spawn(spawnCommand, spawnArgs, {
|
|
||||||
+
|
|
||||||
+ this.logDebug(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
|
|
||||||
+ 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
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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];
|
||||||
@@ -125,7 +125,61 @@ afterSign: scripts/notarize.js
|
|||||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
Optimized note-taking feature, now able to quickly rename by modifying the title
|
<!--LANG:en-->
|
||||||
Fixed issue where CherryAI free model could not be used
|
What's New in v1.7.0-beta.2
|
||||||
Fixed issue where VertexAI proxy address could not be called normally
|
|
||||||
Fixed issue where built-in tools from service providers could not be called normally
|
New Features:
|
||||||
|
- Session Settings: Manage session-specific settings and model configurations independently
|
||||||
|
- Notes Full-Text Search: Search across all notes with match highlighting
|
||||||
|
- Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only)
|
||||||
|
- Intel OV OCR: Hardware-accelerated OCR using Intel NPU
|
||||||
|
- Auto-start API Server: Automatically starts when agents exist
|
||||||
|
|
||||||
|
Improvements:
|
||||||
|
- Agent model selection now requires explicit user choice
|
||||||
|
- Added Mistral AI provider support
|
||||||
|
- Added NewAPI generic provider support
|
||||||
|
- Improved navbar layout consistency across different modes
|
||||||
|
- Enhanced chat component responsiveness
|
||||||
|
- Better code block display on small screens
|
||||||
|
- 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
|
||||||
|
|
||||||
|
<!--LANG:zh-CN-->
|
||||||
|
v1.7.0-beta.2 新特性
|
||||||
|
|
||||||
|
新功能:
|
||||||
|
- 会话设置:独立管理会话特定的设置和模型配置
|
||||||
|
- 笔记全文搜索:跨所有笔记搜索并高亮匹配内容
|
||||||
|
- 内置滴滴 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-->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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'
|
||||||
@@ -11,11 +12,12 @@ 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['recommended-latest'],
|
reactHooks.configs.flat.recommended,
|
||||||
{
|
{
|
||||||
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',
|
||||||
@@ -25,6 +27,7 @@ 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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.7.0-alpha.3",
|
"version": "1.7.0-beta.2",
|
||||||
"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",
|
||||||
@@ -78,11 +78,12 @@
|
|||||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-code": "patch:@anthropic-ai/claude-code@npm%3A1.0.118#~/.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.patch",
|
"@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",
|
||||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||||
|
"express": "^5.1.0",
|
||||||
"font-list": "^2.0.0",
|
"font-list": "^2.0.0",
|
||||||
"graceful-fs": "^4.2.11",
|
"graceful-fs": "^4.2.11",
|
||||||
"jsdom": "26.1.0",
|
"jsdom": "26.1.0",
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
"selection-hook": "^1.0.12",
|
"selection-hook": "^1.0.12",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||||
"turndown": "7.2.0"
|
"turndown": "7.2.0"
|
||||||
},
|
},
|
||||||
@@ -99,10 +101,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.21",
|
"@ai-sdk/amazon-bedrock": "^3.0.35",
|
||||||
"@ai-sdk/google-vertex": "^3.0.27",
|
"@ai-sdk/google-vertex": "^3.0.40",
|
||||||
"@ai-sdk/mistral": "^2.0.14",
|
"@ai-sdk/mistral": "^2.0.19",
|
||||||
"@ai-sdk/perplexity": "^2.0.9",
|
"@ai-sdk/perplexity": "^2.0.13",
|
||||||
"@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",
|
||||||
@@ -152,6 +154,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",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@reduxjs/toolkit": "^2.2.5",
|
"@reduxjs/toolkit": "^2.2.5",
|
||||||
@@ -219,7 +222,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.44",
|
"ai": "^5.0.68",
|
||||||
"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",
|
||||||
@@ -244,7 +247,7 @@
|
|||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"drizzle-orm": "^0.44.5",
|
"drizzle-orm": "^0.44.5",
|
||||||
"electron": "37.4.0",
|
"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-reload": "^2.0.0-alpha.1",
|
||||||
@@ -256,11 +259,11 @@
|
|||||||
"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": "^5.2.0",
|
"eslint-plugin-react-hooks": "^7.0.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": "^5.1.0",
|
|
||||||
"express-validator": "^7.2.1",
|
"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",
|
||||||
@@ -294,15 +297,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.15.0",
|
"oxlint": "^1.22.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.0.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.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",
|
||||||
@@ -334,7 +337,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",
|
||||||
"swagger-ui-express": "^5.0.1",
|
|
||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
@@ -370,6 +372,7 @@
|
|||||||
"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",
|
||||||
@@ -377,10 +380,11 @@
|
|||||||
"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.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.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"
|
||||||
},
|
},
|
||||||
"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.0-alpha.18",
|
"version": "1.0.1",
|
||||||
"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,14 @@
|
|||||||
"ai": "^5.0.26"
|
"ai": "^5.0.26"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.17",
|
"@ai-sdk/anthropic": "^2.0.27",
|
||||||
"@ai-sdk/azure": "^2.0.30",
|
"@ai-sdk/azure": "^2.0.49",
|
||||||
"@ai-sdk/deepseek": "^1.0.17",
|
"@ai-sdk/deepseek": "^1.0.23",
|
||||||
"@ai-sdk/openai": "^2.0.30",
|
"@ai-sdk/openai": "^2.0.48",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
"@ai-sdk/openai-compatible": "^1.0.22",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
"@ai-sdk/provider-utils": "^3.0.9",
|
"@ai-sdk/provider-utils": "^3.0.12",
|
||||||
"@ai-sdk/xai": "^2.0.18",
|
"@ai-sdk/xai": "^2.0.26",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from 'ai'
|
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,6 +15,13 @@ 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>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插件初始化时接收的完整配置对象
|
* 插件初始化时接收的完整配置对象
|
||||||
*
|
*
|
||||||
@@ -59,7 +66,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
|||||||
|
|
||||||
export type WebSearchToolOutputSchema = {
|
export type WebSearchToolOutputSchema = {
|
||||||
// Anthropic 工具 - 手动定义
|
// Anthropic 工具 - 手动定义
|
||||||
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
anthropic: InferToolOutput<AnthropicWebSearchTool>
|
||||||
|
|
||||||
// OpenAI 工具 - 基于实际输出
|
// OpenAI 工具 - 基于实际输出
|
||||||
// TODO: 上游定义不规范,是unknown
|
// TODO: 上游定义不规范,是unknown
|
||||||
@@ -82,8 +89,8 @@ export type WebSearchToolOutputSchema = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WebSearchToolInputSchema = {
|
export type WebSearchToolInputSchema = {
|
||||||
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
anthropic: InferToolInput<AnthropicWebSearchTool>
|
||||||
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
|
openai: InferToolInput<OpenAIWebSearchTool>
|
||||||
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
|
google: InferToolInput<GoogleWebSearchTool>
|
||||||
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
|
'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 { z } from 'zod'
|
import * as 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,6 +34,7 @@ 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',
|
||||||
@@ -52,6 +53,7 @@ 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',
|
||||||
@@ -187,6 +189,7 @@ 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',
|
||||||
@@ -224,6 +227,7 @@ 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',
|
||||||
@@ -231,7 +235,6 @@ 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',
|
||||||
@@ -314,6 +317,7 @@ 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
|
||||||
@@ -333,6 +337,16 @@ 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,4 +1,4 @@
|
|||||||
import type { SDKMessage } from '@anthropic-ai/claude-code'
|
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages'
|
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages'
|
||||||
|
|
||||||
export type ClaudeCodeRawValue =
|
export type ClaudeCodeRawValue =
|
||||||
|
|||||||
@@ -12,8 +12,19 @@ import Anthropic from '@anthropic-ai/sdk'
|
|||||||
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
|
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { Provider } from '@types'
|
import { Provider } from '@types'
|
||||||
|
import type { ModelMessage } from 'ai'
|
||||||
|
|
||||||
const logger = loggerService.withContext('anthropic-sdk')
|
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.
|
* Creates and configures an Anthropic SDK client based on the provider configuration.
|
||||||
*
|
*
|
||||||
@@ -44,7 +55,11 @@ const logger = loggerService.withContext('anthropic-sdk')
|
|||||||
* const apiKeyClient = getSdkClient(apiKeyProvider);
|
* const apiKeyClient = getSdkClient(apiKeyProvider);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function getSdkClient(provider: Provider, oauthToken?: string | null): Anthropic {
|
export function getSdkClient(
|
||||||
|
provider: Provider,
|
||||||
|
oauthToken?: string | null,
|
||||||
|
extraHeaders?: Record<string, string | string[]>
|
||||||
|
): Anthropic {
|
||||||
if (provider.authType === 'oauth') {
|
if (provider.authType === 'oauth') {
|
||||||
if (!oauthToken) {
|
if (!oauthToken) {
|
||||||
throw new Error('OAuth token is not available')
|
throw new Error('OAuth token is not available')
|
||||||
@@ -68,7 +83,8 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
|
|||||||
'x-stainless-os': 'MacOS',
|
'x-stainless-os': 'MacOS',
|
||||||
'x-stainless-arch': 'arm64',
|
'x-stainless-arch': 'arm64',
|
||||||
'x-stainless-runtime': 'node',
|
'x-stainless-runtime': 'node',
|
||||||
'x-stainless-runtime-version': 'v22.18.0'
|
'x-stainless-runtime-version': 'v22.18.0',
|
||||||
|
...extraHeaders
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -87,7 +103,8 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
|
|||||||
defaultHeaders: {
|
defaultHeaders: {
|
||||||
'anthropic-beta': 'output-128k-2025-02-19',
|
'anthropic-beta': 'output-128k-2025-02-19',
|
||||||
'APP-Code': 'MLTG2087',
|
'APP-Code': 'MLTG2087',
|
||||||
...provider.extra_headers
|
...provider.extra_headers,
|
||||||
|
...extraHeaders
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -118,53 +135,36 @@ export function getSdkClient(provider: Provider, oauthToken?: string | null): An
|
|||||||
* @param system - Optional user-provided system message (string or TextBlockParam array)
|
* @param system - Optional user-provided system message (string or TextBlockParam array)
|
||||||
* @returns Combined system message with Claude Code prompt prepended
|
* @returns Combined system message with Claude Code prompt prepended
|
||||||
*
|
*
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // No system message
|
|
||||||
* const result1 = buildClaudeCodeSystemMessage();
|
|
||||||
* // Returns: "You are Claude Code, Anthropic's official CLI for Claude."
|
|
||||||
*
|
|
||||||
* // String system message
|
|
||||||
* const result2 = buildClaudeCodeSystemMessage("You are a helpful assistant.");
|
|
||||||
* // Returns: [
|
|
||||||
* // { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
|
|
||||||
* // { type: 'text', text: "You are a helpful assistant." }
|
|
||||||
* // ]
|
|
||||||
*
|
|
||||||
* // Array system message
|
|
||||||
* const systemArray = [{ type: 'text', text: 'Custom instructions' }];
|
|
||||||
* const result3 = buildClaudeCodeSystemMessage(systemArray);
|
|
||||||
* // Returns: Array with Claude Code message prepended
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): string | Array<TextBlockParam> {
|
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): Array<TextBlockParam> {
|
||||||
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
|
|
||||||
if (!system) {
|
if (!system) {
|
||||||
return defaultClaudeCodeSystem
|
return defaultClaudeCodeSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof system === 'string') {
|
if (typeof system === 'string') {
|
||||||
if (system.trim() === defaultClaudeCodeSystem) {
|
if (system.trim() === defaultClaudeCodeSystemPrompt || system.trim() === '') {
|
||||||
return system
|
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 [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: defaultClaudeCodeSystem
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: system
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (system[0].text.trim() != defaultClaudeCodeSystem) {
|
return defaultClaudeCodeSystem
|
||||||
system.unshift({
|
}
|
||||||
type: 'text',
|
|
||||||
text: defaultClaudeCodeSystem
|
export function buildClaudeCodeSystemModelMessage(system?: string | Array<TextBlockParam>): Array<ModelMessage> {
|
||||||
})
|
const textBlocks = buildClaudeCodeSystemMessage(system)
|
||||||
}
|
return textBlocks.map((block) => ({
|
||||||
|
role: 'system',
|
||||||
return system
|
content: block.text
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ 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,3 +22,12 @@ 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,5 +1,7 @@
|
|||||||
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
|
||||||
@@ -32,4 +34,39 @@ 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 }
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -35,7 +35,7 @@ const allX64 = {
|
|||||||
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeCodeVenderPath = '@anthropic-ai/claude-code/vendor'
|
const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor'
|
||||||
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
|
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
|
||||||
|
|
||||||
const platformToArch = {
|
const platformToArch = {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
|
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { Provider } from '@types'
|
||||||
import express, { Request, Response } from 'express'
|
import express, { Request, Response } from 'express'
|
||||||
|
|
||||||
import { Provider } from '../../../renderer/src/types/provider'
|
import { messagesService } from '../services/messages'
|
||||||
import { MessagesService, messagesService } from '../services/messages'
|
|
||||||
import { getProviderById, validateModelId } from '../utils'
|
import { getProviderById, validateModelId } from '../utils'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
||||||
@@ -11,7 +11,7 @@ const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const providerRouter = express.Router({ mergeParams: true })
|
const providerRouter = express.Router({ mergeParams: true })
|
||||||
|
|
||||||
// Helper functions for shared logic
|
// Helper function for basic request validation
|
||||||
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
|
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
|
||||||
const request: MessageCreateParams = req.body
|
const request: MessageCreateParams = req.body
|
||||||
|
|
||||||
@@ -31,157 +31,53 @@ async function validateRequestBody(req: Request): Promise<{ valid: boolean; erro
|
|||||||
return { valid: true }
|
return { valid: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStreamingResponse(
|
interface HandleMessageProcessingOptions {
|
||||||
res: Response,
|
req: Request
|
||||||
request: MessageCreateParams,
|
res: Response
|
||||||
provider: Provider,
|
provider: Provider
|
||||||
messagesService: MessagesService
|
request: MessageCreateParams
|
||||||
): Promise<void> {
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
|
||||||
res.setHeader('Connection', 'keep-alive')
|
|
||||||
res.setHeader('X-Accel-Buffering', 'no')
|
|
||||||
res.flushHeaders()
|
|
||||||
const flushableResponse = res 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const chunk of messagesService.processStreamingMessage(request, provider)) {
|
|
||||||
res.write(`event: ${chunk.type}\n`)
|
|
||||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
|
||||||
flushStream()
|
|
||||||
}
|
|
||||||
res.write('data: [DONE]\n\n')
|
|
||||||
flushStream()
|
|
||||||
} catch (streamError: any) {
|
|
||||||
logger.error('Stream error', {
|
|
||||||
error: streamError,
|
|
||||||
provider: provider.id,
|
|
||||||
model: request.model,
|
|
||||||
apiHost: provider.apiHost,
|
|
||||||
anthropicApiHost: provider.anthropicApiHost
|
|
||||||
})
|
|
||||||
res.write(
|
|
||||||
`data: ${JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: 'api_error',
|
|
||||||
message: 'Stream processing error'
|
|
||||||
}
|
|
||||||
})}\n\n`
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
res.end()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleErrorResponse(res: Response, error: any): Response {
|
|
||||||
logger.error('Message processing error', { error })
|
|
||||||
|
|
||||||
let statusCode = 500
|
|
||||||
let errorType = 'api_error'
|
|
||||||
let errorMessage = 'Internal server error'
|
|
||||||
|
|
||||||
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
|
|
||||||
const anthropicError = error?.error
|
|
||||||
|
|
||||||
if (anthropicStatus) {
|
|
||||||
statusCode = anthropicStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anthropicError?.type) {
|
|
||||||
errorType = anthropicError.type
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anthropicError?.message) {
|
|
||||||
errorMessage = anthropicError.message
|
|
||||||
} else if (error instanceof Error && error.message) {
|
|
||||||
errorMessage = error.message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anthropicStatus && error instanceof Error) {
|
|
||||||
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
|
||||||
statusCode = 401
|
|
||||||
errorType = 'authentication_error'
|
|
||||||
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
|
||||||
statusCode = 429
|
|
||||||
errorType = 'rate_limit_error'
|
|
||||||
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
|
||||||
statusCode = 502
|
|
||||||
errorType = 'api_error'
|
|
||||||
} else if (error.message.includes('validation') || error.message.includes('invalid')) {
|
|
||||||
statusCode = 400
|
|
||||||
errorType = 'invalid_request_error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(statusCode).json({
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
type: errorType,
|
|
||||||
message: errorMessage,
|
|
||||||
requestId: error?.request_id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processMessageRequest(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
provider: Provider,
|
|
||||||
modelId?: string
|
modelId?: string
|
||||||
): Promise<Response | void> {
|
}
|
||||||
|
|
||||||
|
async function handleMessageProcessing({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
provider,
|
||||||
|
request,
|
||||||
|
modelId
|
||||||
|
}: HandleMessageProcessingOptions): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: MessageCreateParams = req.body
|
|
||||||
|
|
||||||
// Use provided modelId or keep original model
|
|
||||||
if (modelId) {
|
|
||||||
request.model = modelId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate request
|
|
||||||
const validation = messagesService.validateRequest(request)
|
const validation = messagesService.validateRequest(request)
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
return res.status(400).json({
|
res.status(400).json({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: {
|
error: {
|
||||||
type: 'invalid_request_error',
|
type: 'invalid_request_error',
|
||||||
message: validation.errors.join('; ')
|
message: validation.errors.join('; ')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Processing anthropic messages request', {
|
|
||||||
provider: provider.id,
|
|
||||||
apiHost: provider.apiHost,
|
|
||||||
anthropicApiHost: provider.anthropicApiHost,
|
|
||||||
model: request.model,
|
|
||||||
stream: request.stream,
|
|
||||||
thinking: request.thinking
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle streaming
|
|
||||||
if (request.stream) {
|
|
||||||
await handleStreamingResponse(res, request, provider, messagesService)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-streaming
|
const extraHeaders = messagesService.prepareHeaders(req.headers)
|
||||||
const response = await messagesService.processMessage(request, provider)
|
const { client, anthropicRequest } = await messagesService.processMessage({
|
||||||
return res.json(response)
|
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) {
|
} catch (error: any) {
|
||||||
return handleErrorResponse(res, error)
|
logger.error('Message processing error', { error })
|
||||||
|
const { statusCode, errorResponse } = messagesService.transformError(error)
|
||||||
|
res.status(statusCode).json(errorResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,10 +234,11 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
const provider = modelValidation.provider!
|
const provider = modelValidation.provider!
|
||||||
const modelId = modelValidation.modelId!
|
const modelId = modelValidation.modelId!
|
||||||
|
|
||||||
// Use shared processing function
|
return handleMessageProcessing({ req, res, provider, request, modelId })
|
||||||
return await processMessageRequest(req, res, provider, modelId)
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return handleErrorResponse(res, error)
|
logger.error('Message processing error', { error })
|
||||||
|
const { statusCode, errorResponse } = messagesService.transformError(error)
|
||||||
|
return res.status(statusCode).json(errorResponse)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -493,10 +390,13 @@ providerRouter.post('/', async (req: Request, res: Response) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use shared processing function (no modelId override needed)
|
const request: MessageCreateParams = req.body
|
||||||
return await processMessageRequest(req, res, provider)
|
|
||||||
|
return handleMessageProcessing({ req, res, provider, request })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return handleErrorResponse(res, error)
|
logger.error('Message processing error', { error })
|
||||||
|
const { statusCode, errorResponse } = messagesService.transformError(error)
|
||||||
|
return res.status(statusCode).json(errorResponse)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -104,9 +104,7 @@ const router = express
|
|||||||
|
|
||||||
logger.info('Models response ready', {
|
logger.info('Models response ready', {
|
||||||
filter,
|
filter,
|
||||||
total: response.total
|
total: response.total,
|
||||||
})
|
|
||||||
logger.debug('Model IDs returned', {
|
|
||||||
modelIds: response.data.map((m) => m.id)
|
modelIds: response.data.map((m) => m.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
|
||||||
import { agentService } from '../services/agents'
|
import { agentService } from '../services/agents'
|
||||||
import { loggerService } from '../services/LoggerService'
|
|
||||||
import { app } from './app'
|
import { app } from './app'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
|
|
||||||
@@ -15,11 +16,17 @@ 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) {
|
if (this.server && this.server.listening) {
|
||||||
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 } = await config.load()
|
||||||
|
|
||||||
@@ -39,7 +46,11 @@ export class ApiServer {
|
|||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.server!.on('error', reject)
|
this.server!.on('error', (error) => {
|
||||||
|
// Clean up the server instance if listen fails
|
||||||
|
this.server = null
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,93 @@
|
|||||||
import Anthropic from '@anthropic-ai/sdk'
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import { Message, MessageCreateParams, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources'
|
import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import anthropicService from '@main/services/AnthropicService'
|
import anthropicService from '@main/services/AnthropicService'
|
||||||
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
|
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
|
||||||
import { Provider } from '@types'
|
import { Provider } from '@types'
|
||||||
|
import { Response } from 'express'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MessagesService')
|
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 {
|
export interface ValidationResult {
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
errors: string[]
|
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 {
|
export class MessagesService {
|
||||||
// oxlint-disable-next-line no-unused-vars
|
|
||||||
validateRequest(request: MessageCreateParams): ValidationResult {
|
validateRequest(request: MessageCreateParams): ValidationResult {
|
||||||
// TODO: Implement comprehensive request validation
|
// TODO: Implement comprehensive request validation
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
if (!request.model) {
|
if (!request.model || typeof request.model !== 'string') {
|
||||||
errors.push('Model is required')
|
errors.push('Model is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.max_tokens || request.max_tokens < 1) {
|
if (typeof request.max_tokens !== 'number' || !Number.isFinite(request.max_tokens) || request.max_tokens < 1) {
|
||||||
errors.push('max_tokens is required and must be at least 1')
|
errors.push('max_tokens is required and must be a positive number')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
|
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
|
||||||
errors.push('messages is required and must be a non-empty array')
|
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 {
|
return {
|
||||||
@@ -36,79 +96,224 @@ export class MessagesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClient(provider: Provider): Promise<Anthropic> {
|
async getClient(provider: Provider, extraHeaders?: Record<string, string | string[]>): Promise<Anthropic> {
|
||||||
// Create Anthropic client for the provider
|
// Create Anthropic client for the provider
|
||||||
if (provider.authType === 'oauth') {
|
if (provider.authType === 'oauth') {
|
||||||
const oauthToken = await anthropicService.getValidAccessToken()
|
const oauthToken = await anthropicService.getValidAccessToken()
|
||||||
return getSdkClient(provider, oauthToken)
|
return getSdkClient(provider, oauthToken, extraHeaders)
|
||||||
}
|
}
|
||||||
return getSdkClient(provider)
|
return getSdkClient(provider, null, extraHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
async processMessage(request: MessageCreateParams, provider: Provider): Promise<Message> {
|
prepareHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[]> {
|
||||||
logger.debug('Preparing Anthropic message request', {
|
const extraHeaders: Record<string, string | string[]> = {}
|
||||||
model: request.model,
|
|
||||||
messageCount: request.messages.length,
|
|
||||||
stream: request.stream,
|
|
||||||
maxTokens: request.max_tokens,
|
|
||||||
provider: provider.id
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create Anthropic client for the provider
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
const client = await this.getClient(provider)
|
if (value === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare request with the actual model ID
|
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 = {
|
const anthropicRequest: MessageCreateParams = {
|
||||||
...request,
|
...request,
|
||||||
stream: false
|
stream: !!request.stream
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.authType === 'oauth') {
|
// Override model if provided
|
||||||
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system || '')
|
if (modelId) {
|
||||||
|
anthropicRequest.model = modelId
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.messages.create(anthropicRequest)
|
// Add Claude Code system message for OAuth providers
|
||||||
|
if (provider.type === 'anthropic' && provider.authType === 'oauth') {
|
||||||
|
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system)
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Anthropic message completed', {
|
return anthropicRequest
|
||||||
model: request.model,
|
|
||||||
provider: provider.id
|
|
||||||
})
|
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async *processStreamingMessage(
|
async handleStreaming(
|
||||||
|
client: Anthropic,
|
||||||
request: MessageCreateParams,
|
request: MessageCreateParams,
|
||||||
|
config: StreamConfig,
|
||||||
provider: Provider
|
provider: Provider
|
||||||
): AsyncIterable<RawMessageStreamEvent> {
|
): Promise<void> {
|
||||||
logger.debug('Preparing streaming Anthropic message request', {
|
const { response, onChunk, onError, onComplete } = config
|
||||||
model: request.model,
|
|
||||||
messageCount: request.messages.length,
|
// Set streaming headers
|
||||||
provider: provider.id
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create Anthropic client for the provider
|
// Return client and request for route layer to handle streaming/non-streaming
|
||||||
const client = await this.getClient(provider)
|
return {
|
||||||
|
client,
|
||||||
// Prepare streaming request
|
anthropicRequest
|
||||||
const streamingRequest: MessageCreateParams = {
|
|
||||||
...request,
|
|
||||||
stream: true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.authType === 'oauth') {
|
|
||||||
streamingRequest.system = buildClaudeCodeSystemMessage(request.system || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = client.messages.stream(streamingRequest)
|
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
yield chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Completed streaming Anthropic message', {
|
|
||||||
model: request.model,
|
|
||||||
provider: provider.id
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import { isEmpty } from 'lodash'
|
||||||
|
|
||||||
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
|
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
|
||||||
import { loggerService } from '../../services/LoggerService'
|
import { loggerService } from '../../services/LoggerService'
|
||||||
import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils'
|
import {
|
||||||
|
getAvailableProviders,
|
||||||
|
getProviderAnthropicModelChecker,
|
||||||
|
listAllAvailableModels,
|
||||||
|
transformModelToOpenAI
|
||||||
|
} from '../utils'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ModelsService')
|
const logger = loggerService.withContext('ModelsService')
|
||||||
|
|
||||||
@@ -16,9 +23,7 @@ export class ModelsService {
|
|||||||
let providers = await getAvailableProviders()
|
let providers = await getAvailableProviders()
|
||||||
|
|
||||||
if (filter.providerType === 'anthropic') {
|
if (filter.providerType === 'anthropic') {
|
||||||
providers = providers.filter(
|
providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim()))
|
||||||
(p) => p.type === 'anthropic' || (p.anthropicApiHost !== undefined && p.anthropicApiHost.trim() !== '')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await listAllAvailableModels(providers)
|
const models = await listAllAvailableModels(providers)
|
||||||
@@ -27,18 +32,18 @@ export class ModelsService {
|
|||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
const provider = providers.find((p) => p.id === model.provider)
|
const provider = providers.find((p) => p.id === model.provider)
|
||||||
logger.debug(`Processing model ${model.id} from provider ${model.provider}`, {
|
logger.debug(`Processing model ${model.id}`)
|
||||||
isAnthropicModel: provider?.isAnthropicModel
|
if (!provider) {
|
||||||
})
|
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
|
||||||
if (
|
|
||||||
!provider ||
|
|
||||||
(filter.providerType === 'anthropic' && provider.isAnthropicModel && !provider.isAnthropicModel(model))
|
|
||||||
) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Special case: For "aihubmix", it should be covered by above condition, but just in case
|
|
||||||
if (provider.id === 'aihubmix' && filter.providerType === 'anthropic' && !model.id.includes('claude')) {
|
if (filter.providerType === 'anthropic') {
|
||||||
continue
|
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 openAIModel = transformModelToOpenAI(model, provider)
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ const logger = loggerService.withContext('ApiServerUtils')
|
|||||||
|
|
||||||
// Cache configuration
|
// Cache configuration
|
||||||
const PROVIDERS_CACHE_KEY = 'api-server:providers'
|
const PROVIDERS_CACHE_KEY = 'api-server:providers'
|
||||||
const PROVIDERS_CACHE_TTL = 1 * 60 * 1000 // 1 minutes
|
const PROVIDERS_CACHE_TTL = 10 * 1000 // 10 seconds
|
||||||
|
|
||||||
export async function getAvailableProviders(): Promise<Provider[]> {
|
export async function getAvailableProviders(): Promise<Provider[]> {
|
||||||
try {
|
try {
|
||||||
// Try to get from cache first (faster)
|
// Try to get from cache first (faster)
|
||||||
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
||||||
if (cachedSupportedProviders) {
|
if (cachedSupportedProviders && cachedSupportedProviders.length > 0) {
|
||||||
logger.debug('Providers resolved from cache', {
|
logger.debug('Providers resolved from cache', {
|
||||||
count: cachedSupportedProviders.length
|
count: cachedSupportedProviders.length
|
||||||
})
|
})
|
||||||
@@ -279,3 +279,16 @@ export function validateProvider(provider: Provider): boolean {
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MainEntry')
|
const logger = loggerService.withContext('MainEntry')
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ 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')
|
||||||
|
|
||||||
@@ -157,11 +159,26 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
logger.error('Failed to initialize Agent service:', error)
|
logger.error('Failed to initialize Agent service:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start API server if enabled
|
// 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) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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'
|
||||||
@@ -91,6 +92,7 @@ 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()
|
||||||
@@ -140,7 +142,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_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
|
||||||
|
|
||||||
// language
|
// language
|
||||||
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||||
@@ -463,6 +465,7 @@ 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()
|
||||||
@@ -526,6 +529,7 @@ 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) => {
|
||||||
@@ -741,6 +745,7 @@ 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))
|
||||||
@@ -781,7 +786,6 @@ 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
|
||||||
@@ -871,6 +875,18 @@ 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))
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
/**
|
||||||
|
* 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 { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
const logger = loggerService.withContext('DifyKnowledgeServer')
|
const logger = loggerService.withContext('DifyKnowledgeServer')
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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'
|
||||||
@@ -42,6 +43,10 @@ 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 { z } from 'zod'
|
import * as 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 { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MCP:FileSystemServer')
|
const logger = loggerService.withContext('MCP:FileSystemServer')
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { ApiServerConfig } from '@types'
|
import {
|
||||||
|
ApiServerConfig,
|
||||||
|
GetApiServerStatusResult,
|
||||||
|
RestartApiServerStatusResult,
|
||||||
|
StartApiServerStatusResult,
|
||||||
|
StopApiServerStatusResult
|
||||||
|
} from '@types'
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
|
|
||||||
import { apiServer } from '../apiServer'
|
import { apiServer } from '../apiServer'
|
||||||
@@ -52,7 +58,7 @@ export class ApiServerService {
|
|||||||
|
|
||||||
registerIpcHandlers(): void {
|
registerIpcHandlers(): void {
|
||||||
// API Server
|
// API Server
|
||||||
ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
|
ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise<StartApiServerStatusResult> => {
|
||||||
try {
|
try {
|
||||||
await this.start()
|
await this.start()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -61,7 +67,7 @@ export class ApiServerService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
|
ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise<StopApiServerStatusResult> => {
|
||||||
try {
|
try {
|
||||||
await this.stop()
|
await this.stop()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -70,7 +76,7 @@ export class ApiServerService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
|
ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise<RestartApiServerStatusResult> => {
|
||||||
try {
|
try {
|
||||||
await this.restart()
|
await this.restart()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -79,7 +85,7 @@ export class ApiServerService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
|
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => {
|
||||||
try {
|
try {
|
||||||
const config = await this.getCurrentConfig()
|
const config = await this.getCurrentConfig()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
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, BrowserWindow, dialog, net } from 'electron'
|
import { app, 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'
|
||||||
|
|
||||||
@@ -26,7 +24,6 @@ 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
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,37 +243,9 @@ export default class AppUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
public quitAndInstall() {
|
||||||
if (!this.releaseInfo) {
|
app.isQuitting = true
|
||||||
return
|
setImmediate(() => autoUpdater.quitAndInstall())
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -349,38 +317,9 @@ 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,7 +31,10 @@ 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: { terminals: TerminalConfig[]; timestamp: number } | null = null
|
private terminalsCache: {
|
||||||
|
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
|
||||||
@@ -82,6 +85,8 @@ 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}`)
|
||||||
}
|
}
|
||||||
@@ -99,6 +104,8 @@ 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}`)
|
||||||
}
|
}
|
||||||
@@ -144,7 +151,9 @@ 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"', { timeout: 3000 })
|
await execAsync('powershell -Command "Get-Host"', {
|
||||||
|
timeout: 3000
|
||||||
|
})
|
||||||
return terminal
|
return terminal
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
@@ -384,7 +393,9 @@ 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`, { timeout: 10000 })
|
const { stdout } = await execAsync(`"${executablePath}" --version`, {
|
||||||
|
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]
|
||||||
@@ -425,7 +436,10 @@ class CodeToolsService {
|
|||||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
|
this.versionCache.set(cacheKey, {
|
||||||
|
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,7 +725,10 @@ class FileStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
||||||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
const resolved = await shell.openPath(path)
|
||||||
|
if (resolved !== '') {
|
||||||
|
throw new Error(resolved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1229,6 +1232,19 @@ 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 } from '@main/utils/file'
|
import { getAllFiles, sanitizeFilename } 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,11 +147,16 @@ 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 = path.join(this.storageDir, id)
|
const dbPath = this.getDbPath(id)
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(dbPath, { recursive: true })
|
fs.rmSync(dbPath, { recursive: true })
|
||||||
@@ -244,7 +249,8 @@ class KnowledgeService {
|
|||||||
dimensions
|
dimensions
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
const dbPath = this.getDbPath(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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,586 @@
|
|||||||
|
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,4 +1,5 @@
|
|||||||
import { session, shell, webContents } from 'electron'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
|
import { app, session, shell, webContents } from 'electron'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* init the useragent of the webview session
|
* init the useragent of the webview session
|
||||||
@@ -36,3 +37,66 @@ 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,46 +274,4 @@ 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,341 +0,0 @@
|
|||||||
# Agent Message Architecture Design Document
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes the architecture for handling agent messages in Cherry Studio, including how agent-specific messages are generated, transformed to AI SDK format, stored, and sent to the UI. The system is designed to be agent-agnostic, allowing multiple agent types (Claude Code, OpenAI, etc.) to integrate seamlessly.
|
|
||||||
|
|
||||||
## Core Design Principles
|
|
||||||
|
|
||||||
1. **Agent Agnosticism**: The core message handling system should work with any agent type without modification
|
|
||||||
2. **Data Preservation**: All raw agent data must be preserved alongside transformed UI-friendly formats
|
|
||||||
3. **Streaming First**: Support real-time streaming of agent responses to the UI
|
|
||||||
4. **Type Safety**: Strong TypeScript interfaces ensure consistency across the pipeline
|
|
||||||
|
|
||||||
## Architecture Components
|
|
||||||
|
|
||||||
### 1. Agent Service Layer
|
|
||||||
|
|
||||||
Each agent (e.g., ClaudeCodeService) implements the `AgentServiceInterface`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface AgentServiceInterface {
|
|
||||||
invoke(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Responsibilities:
|
|
||||||
- Spawn and manage agent-specific processes (e.g., Claude Code CLI)
|
|
||||||
- Parse agent-specific output formats (e.g., SDKMessage for Claude Code)
|
|
||||||
- Transform agent messages to AI SDK format
|
|
||||||
- Emit standardized `AgentStreamEvent` objects
|
|
||||||
|
|
||||||
### 2. Agent Stream Events
|
|
||||||
|
|
||||||
The standardized event interface that all agents emit:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface AgentStreamEvent {
|
|
||||||
type: 'chunk' | 'error' | 'complete'
|
|
||||||
chunk?: UIMessageChunk // AI SDK format for UI
|
|
||||||
rawAgentMessage?: any // Agent-specific raw message
|
|
||||||
error?: Error
|
|
||||||
agentResult?: any // Complete agent-specific result
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Session Message Service
|
|
||||||
|
|
||||||
The `SessionMessageService` acts as the orchestration layer:
|
|
||||||
|
|
||||||
#### Responsibilities:
|
|
||||||
- Manages session lifecycle and persistence
|
|
||||||
- Collects streaming chunks and raw agent messages
|
|
||||||
- Stores structured data in the database
|
|
||||||
- Forwards events to the API layer
|
|
||||||
|
|
||||||
### 4. Database Storage
|
|
||||||
|
|
||||||
Session messages are stored with complete structured data:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface SessionMessageContent {
|
|
||||||
aiSDKChunks: UIMessageChunk[] // UI-friendly format
|
|
||||||
rawAgentMessages: any[] // Original agent messages
|
|
||||||
agentResult?: any // Complete agent result
|
|
||||||
agentType: string // Agent identifier
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[User Input] --> B[API Handler]
|
|
||||||
B --> C[SessionMessageService]
|
|
||||||
C --> D[Agent Service]
|
|
||||||
D --> E[Agent Process]
|
|
||||||
E --> F[Raw Agent Output]
|
|
||||||
F --> G[Transform to AI SDK]
|
|
||||||
G --> H[Emit AgentStreamEvent]
|
|
||||||
H --> I[SessionMessageService]
|
|
||||||
I --> J[Store in Database]
|
|
||||||
I --> K[Forward to Client]
|
|
||||||
K --> L[UI Rendering]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Message Transformation Process
|
|
||||||
|
|
||||||
### Step 1: Raw Agent Message Generation
|
|
||||||
|
|
||||||
Each agent generates messages in its native format:
|
|
||||||
|
|
||||||
**Claude Code Example:**
|
|
||||||
```typescript
|
|
||||||
// SDKMessage from Claude Code CLI
|
|
||||||
{
|
|
||||||
type: 'assistant',
|
|
||||||
uuid: 'msg_123',
|
|
||||||
session_id: 'session_456',
|
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
content: [
|
|
||||||
{ type: 'text', text: 'Hello, I can help...' },
|
|
||||||
{ type: 'tool_use', id: 'tool_1', name: 'read_file', input: {...} }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Transformation to AI SDK Format
|
|
||||||
|
|
||||||
The agent service transforms native messages to AI SDK `UIMessageChunk`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In ClaudeCodeService
|
|
||||||
const emitChunks = (sdkMessage: SDKMessage) => {
|
|
||||||
// Transform to AI SDK format
|
|
||||||
const chunks = transformSDKMessageToUIChunk(sdkMessage)
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
stream.emit('data', {
|
|
||||||
type: 'chunk',
|
|
||||||
chunk, // AI SDK format
|
|
||||||
rawAgentMessage: sdkMessage // Preserve original
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Transformed AI SDK Chunk:**
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
type: 'text-delta',
|
|
||||||
id: 'msg_123',
|
|
||||||
delta: 'Hello, I can help...',
|
|
||||||
providerMetadata: {
|
|
||||||
claudeCode: {
|
|
||||||
originalSDKMessage: {...},
|
|
||||||
uuid: 'msg_123',
|
|
||||||
session_id: 'session_456'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Session Message Processing
|
|
||||||
|
|
||||||
The SessionMessageService collects and processes events:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Collect streaming data
|
|
||||||
const streamedChunks: UIMessageChunk[] = []
|
|
||||||
const rawAgentMessages: any[] = []
|
|
||||||
|
|
||||||
claudeStream.on('data', async (event: AgentStreamEvent) => {
|
|
||||||
switch (event.type) {
|
|
||||||
case 'chunk':
|
|
||||||
streamedChunks.push(event.chunk)
|
|
||||||
if (event.rawAgentMessage) {
|
|
||||||
rawAgentMessages.push(event.rawAgentMessage)
|
|
||||||
}
|
|
||||||
// Forward to client
|
|
||||||
sessionStream.emit('data', { type: 'chunk', chunk: event.chunk })
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'complete':
|
|
||||||
// Store complete structured data
|
|
||||||
const content = {
|
|
||||||
aiSDKChunks: streamedChunks,
|
|
||||||
rawAgentMessages: rawAgentMessages,
|
|
||||||
agentResult: event.agentResult,
|
|
||||||
agentType: event.agentResult?.agentType || 'unknown'
|
|
||||||
}
|
|
||||||
// Save to database...
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Client Streaming
|
|
||||||
|
|
||||||
The API handler converts events to Server-Sent Events (SSE):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In API handler
|
|
||||||
messageStream.on('data', (event: any) => {
|
|
||||||
switch (event.type) {
|
|
||||||
case 'chunk':
|
|
||||||
// Send AI SDK chunk as SSE
|
|
||||||
res.write(`data: ${JSON.stringify(event.chunk)}\n\n`)
|
|
||||||
break
|
|
||||||
case 'complete':
|
|
||||||
res.write('data: [DONE]\n\n')
|
|
||||||
res.end()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding New Agent Types
|
|
||||||
|
|
||||||
To add support for a new agent (e.g., OpenAI):
|
|
||||||
|
|
||||||
### 1. Create Agent Service
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
class OpenAIService implements AgentServiceInterface {
|
|
||||||
invokeStream(prompt: string, cwd: string, sessionId?: string, options?: any): AgentStream {
|
|
||||||
const stream = new OpenAIStream()
|
|
||||||
|
|
||||||
// Call OpenAI API
|
|
||||||
const openaiResponse = await openai.chat.completions.create({
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
stream: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Transform OpenAI format to AI SDK
|
|
||||||
for await (const chunk of openaiResponse) {
|
|
||||||
const aiSDKChunk = transformOpenAIToAISDK(chunk)
|
|
||||||
stream.emit('data', {
|
|
||||||
type: 'chunk',
|
|
||||||
chunk: aiSDKChunk,
|
|
||||||
rawAgentMessage: chunk // Preserve OpenAI format
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create Transform Function
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function transformOpenAIToAISDK(openaiChunk: OpenAIChunk): UIMessageChunk {
|
|
||||||
return {
|
|
||||||
type: 'text-delta',
|
|
||||||
id: openaiChunk.id,
|
|
||||||
delta: openaiChunk.choices[0].delta.content,
|
|
||||||
providerMetadata: {
|
|
||||||
openai: {
|
|
||||||
original: openaiChunk,
|
|
||||||
model: openaiChunk.model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Register Agent Type
|
|
||||||
|
|
||||||
Update the agent type enum and factory:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export type AgentType = 'claude-code' | 'openai' | 'anthropic-api'
|
|
||||||
|
|
||||||
function createAgentService(type: AgentType): AgentServiceInterface {
|
|
||||||
switch (type) {
|
|
||||||
case 'claude-code':
|
|
||||||
return new ClaudeCodeService()
|
|
||||||
case 'openai':
|
|
||||||
return new OpenAIService()
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits of This Architecture
|
|
||||||
|
|
||||||
1. **Extensibility**: Easy to add new agent types without modifying core logic
|
|
||||||
2. **Data Integrity**: Raw agent data is never lost during transformation
|
|
||||||
3. **Debugging**: Complete message history available for troubleshooting
|
|
||||||
4. **Performance**: Streaming support for real-time responses
|
|
||||||
5. **Type Safety**: Strong interfaces prevent runtime errors
|
|
||||||
6. **UI Consistency**: All agents provide data in standard AI SDK format
|
|
||||||
|
|
||||||
## Key Interfaces Reference
|
|
||||||
|
|
||||||
### AgentStreamEvent
|
|
||||||
```typescript
|
|
||||||
interface AgentStreamEvent {
|
|
||||||
type: 'chunk' | 'error' | 'complete'
|
|
||||||
chunk?: UIMessageChunk
|
|
||||||
rawAgentMessage?: any
|
|
||||||
error?: Error
|
|
||||||
agentResult?: any
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SessionMessageEntity
|
|
||||||
```typescript
|
|
||||||
interface SessionMessageEntity {
|
|
||||||
id: number
|
|
||||||
session_id: string
|
|
||||||
parent_id?: number
|
|
||||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
|
||||||
type: string
|
|
||||||
content: string | SessionMessageContent
|
|
||||||
metadata?: Record<string, any>
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SessionMessageContent
|
|
||||||
```typescript
|
|
||||||
interface SessionMessageContent {
|
|
||||||
aiSDKChunks: UIMessageChunk[]
|
|
||||||
rawAgentMessages: any[]
|
|
||||||
agentResult?: any
|
|
||||||
agentType: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
- Test each transform function independently
|
|
||||||
- Verify event emission sequences
|
|
||||||
- Validate data structure preservation
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
- Test complete flow from input to database
|
|
||||||
- Verify streaming behavior
|
|
||||||
- Test error handling and recovery
|
|
||||||
|
|
||||||
### Agent-Specific Tests
|
|
||||||
- Validate agent-specific transformations
|
|
||||||
- Test edge cases for each agent type
|
|
||||||
- Verify metadata preservation
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Message Replay**: Ability to replay sessions from stored raw messages
|
|
||||||
2. **Format Migration**: Tools to migrate between agent formats
|
|
||||||
3. **Analytics**: Aggregate metrics from raw agent data
|
|
||||||
4. **Caching**: Cache transformed chunks for performance
|
|
||||||
5. **Compression**: Compress raw messages for storage efficiency
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
This architecture provides a robust, extensible foundation for handling messages from multiple AI agents while maintaining data integrity and providing a consistent interface for the UI. The separation of concerns between agent-specific logic and core message handling ensures the system can evolve to support new agents and features without breaking existing functionality.
|
|
||||||
@@ -59,16 +59,23 @@ export abstract class BaseService {
|
|||||||
}
|
}
|
||||||
if (ids && ids.length > 0) {
|
if (ids && ids.length > 0) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const server = await mcpApiService.getServerInfo(id)
|
try {
|
||||||
if (server) {
|
const server = await mcpApiService.getServerInfo(id)
|
||||||
server.tools.forEach((tool: MCPTool) => {
|
if (server) {
|
||||||
tools.push({
|
server.tools.forEach((tool: MCPTool) => {
|
||||||
id: `mcp_${id}_${tool.name}`,
|
tools.push({
|
||||||
name: tool.name,
|
id: `mcp_${id}_${tool.name}`,
|
||||||
type: 'mcp',
|
name: tool.name,
|
||||||
description: tool.description || '',
|
type: 'mcp',
|
||||||
requirePermissions: true
|
description: tool.description || '',
|
||||||
|
requirePermissions: true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to list MCP tools', {
|
||||||
|
id,
|
||||||
|
error: error as Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
ListOptions
|
ListOptions
|
||||||
} from '@types'
|
} from '@types'
|
||||||
import { TextStreamPart } from 'ai'
|
import { TextStreamPart } from 'ai'
|
||||||
import { and, desc, eq } from 'drizzle-orm'
|
import { and, desc, eq, not } from 'drizzle-orm'
|
||||||
|
|
||||||
import { BaseService } from '../BaseService'
|
import { BaseService } from '../BaseService'
|
||||||
import { sessionMessagesTable } from '../database/schema'
|
import { sessionMessagesTable } from '../database/schema'
|
||||||
@@ -276,7 +276,7 @@ export class SessionMessageService extends BaseService {
|
|||||||
const result = await this.database
|
const result = await this.database
|
||||||
.select({ agent_session_id: sessionMessagesTable.agent_session_id })
|
.select({ agent_session_id: sessionMessagesTable.agent_session_id })
|
||||||
.from(sessionMessagesTable)
|
.from(sessionMessagesTable)
|
||||||
.where(eq(sessionMessagesTable.session_id, sessionId))
|
.where(and(eq(sessionMessagesTable.session_id, sessionId), not(eq(sessionMessagesTable.agent_session_id, ''))))
|
||||||
.orderBy(desc(sessionMessagesTable.created_at))
|
.orderBy(desc(sessionMessagesTable.created_at))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SDKMessage } from '@anthropic-ai/claude-code'
|
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-code'
|
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { config as apiConfigService } from '@main/apiServer/config'
|
import { config as apiConfigService } from '@main/apiServer/config'
|
||||||
import { validateModelId } from '@main/apiServer/utils'
|
import { validateModelId } from '@main/apiServer/utils'
|
||||||
@@ -27,7 +27,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Resolve Claude Code CLI robustly (works in dev and in asar)
|
// Resolve Claude Code CLI robustly (works in dev and in asar)
|
||||||
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-code/cli.js')
|
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-agent-sdk/cli.js')
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
this.claudeExecutablePath = this.claudeExecutablePath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
|
this.claudeExecutablePath = this.claudeExecutablePath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
|
||||||
}
|
}
|
||||||
@@ -84,9 +84,13 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...loginShellEnvWithoutProxies,
|
...loginShellEnvWithoutProxies,
|
||||||
ANTHROPIC_API_KEY: apiConfig.apiKey,
|
// TODO: fix the proxy api server
|
||||||
ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
|
// ANTHROPIC_API_KEY: apiConfig.apiKey,
|
||||||
ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
|
// ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
|
||||||
|
// ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
|
||||||
|
ANTHROPIC_API_KEY: modelInfo.provider.apiKey,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
|
||||||
|
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
|
||||||
ANTHROPIC_MODEL: modelInfo.modelId,
|
ANTHROPIC_MODEL: modelInfo.modelId,
|
||||||
ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId,
|
ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId,
|
||||||
ELECTRON_RUN_AS_NODE: '1',
|
ELECTRON_RUN_AS_NODE: '1',
|
||||||
@@ -106,7 +110,14 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
logger.warn('claude stderr', { chunk })
|
logger.warn('claude stderr', { chunk })
|
||||||
errorChunks.push(chunk)
|
errorChunks.push(chunk)
|
||||||
},
|
},
|
||||||
appendSystemPrompt: session.instructions,
|
systemPrompt: session.instructions
|
||||||
|
? {
|
||||||
|
type: 'preset',
|
||||||
|
preset: 'claude_code',
|
||||||
|
append: session.instructions
|
||||||
|
}
|
||||||
|
: { type: 'preset', preset: 'claude_code' },
|
||||||
|
settingSources: ['project'],
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
permissionMode: session.configuration?.permission_mode,
|
permissionMode: session.configuration?.permission_mode,
|
||||||
maxTurns: session.configuration?.max_turns,
|
maxTurns: session.configuration?.max_turns,
|
||||||
@@ -135,6 +146,8 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
|
|
||||||
if (lastAgentSessionId) {
|
if (lastAgentSessionId) {
|
||||||
options.resume = lastAgentSessionId
|
options.resume = lastAgentSessionId
|
||||||
|
// TODO: use fork session when we support branching sessions
|
||||||
|
// options.forkSession = true
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Starting Claude Code SDK query', {
|
logger.info('Starting Claude Code SDK query', {
|
||||||
@@ -211,6 +224,11 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
message,
|
message,
|
||||||
event: JSON.stringify(message.event)
|
event: JSON.stringify(message.event)
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
logger.silly('Claude response', {
|
||||||
|
message,
|
||||||
|
event: JSON.stringify(message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform SDKMessage to UIMessageChunks
|
// Transform SDKMessage to UIMessageChunks
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
* emitting `text-*` parts and a synthetic `finish-step`.
|
* emitting `text-*` parts and a synthetic `finish-step`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SDKMessage } from '@anthropic-ai/claude-code'
|
import { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||||
import type { BetaStopReason } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
import type { BetaStopReason } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai'
|
import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai'
|
||||||
@@ -591,6 +591,15 @@ function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>):
|
|||||||
raw: message
|
raw: message
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else if (message.subtype === 'compact_boundary') {
|
||||||
|
chunks.push({
|
||||||
|
type: 'raw',
|
||||||
|
rawValue: {
|
||||||
|
type: 'compact',
|
||||||
|
session_id: message.session_id,
|
||||||
|
raw: message
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
OAuthTokens
|
OAuthTokens
|
||||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||||
import EventEmitter from 'events'
|
import EventEmitter from 'events'
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
export interface OAuthStorageData {
|
export interface OAuthStorageData {
|
||||||
clientInfo?: OAuthClientInformation
|
clientInfo?: OAuthClientInformation
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { loggerService } from '@logger'
|
|||||||
import { isLinux } from '@main/constant'
|
import { isLinux } from '@main/constant'
|
||||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||||
|
|
||||||
|
import { ovOcrService } from './builtin/OvOcrService'
|
||||||
import { ppocrService } from './builtin/PpocrService'
|
import { ppocrService } from './builtin/PpocrService'
|
||||||
import { systemOcrService } from './builtin/SystemOcrService'
|
import { systemOcrService } from './builtin/SystemOcrService'
|
||||||
import { tesseractService } from './builtin/TesseractService'
|
import { tesseractService } from './builtin/TesseractService'
|
||||||
@@ -22,6 +23,10 @@ export class OcrService {
|
|||||||
this.registry.delete(providerId)
|
this.registry.delete(providerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public listProviderIds(): string[] {
|
||||||
|
return Array.from(this.registry.keys())
|
||||||
|
}
|
||||||
|
|
||||||
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
|
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
|
||||||
const handler = this.registry.get(provider.id)
|
const handler = this.registry.get(provider.id)
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
@@ -39,3 +44,5 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
|
|||||||
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||||
|
|
||||||
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
|
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
|
||||||
|
|
||||||
|
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { isWin } from '@main/constant'
|
||||||
|
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as os from 'os'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
import { OcrBaseService } from './OcrBaseService'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('OvOcrService')
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
|
||||||
|
|
||||||
|
export class OvOcrService extends OcrBaseService {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
public isAvailable(): boolean {
|
||||||
|
return (
|
||||||
|
isWin &&
|
||||||
|
os.cpus()[0].model.toLowerCase().includes('intel') &&
|
||||||
|
os.cpus()[0].model.toLowerCase().includes('ultra') &&
|
||||||
|
fs.existsSync(PATH_BAT_FILE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOvOcrPath(): string {
|
||||||
|
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImgDir(): string {
|
||||||
|
return path.join(this.getOvOcrPath(), 'img')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOutputDir(): string {
|
||||||
|
return path.join(this.getOvOcrPath(), 'output')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearDirectory(dirPath: string): Promise<void> {
|
||||||
|
if (fs.existsSync(dirPath)) {
|
||||||
|
const files = await fs.promises.readdir(dirPath)
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(dirPath, file)
|
||||||
|
const stats = await fs.promises.stat(filePath)
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await this.clearDirectory(filePath)
|
||||||
|
await fs.promises.rmdir(filePath)
|
||||||
|
} else {
|
||||||
|
await fs.promises.unlink(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the directory does not exist, create it
|
||||||
|
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
|
||||||
|
const imgDir = this.getImgDir()
|
||||||
|
const targetFilePath = path.join(imgDir, targetFileName)
|
||||||
|
await fs.promises.copyFile(sourceFilePath, targetFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runOcrBatch(): Promise<void> {
|
||||||
|
const ovOcrPath = this.getOvOcrPath()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute run.bat in the ov-ocr directory
|
||||||
|
await execAsync(`"${PATH_BAT_FILE}"`, {
|
||||||
|
cwd: ovOcrPath,
|
||||||
|
timeout: 60000 // 60 second timeout
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error running ovocr batch: ${error}`)
|
||||||
|
throw new Error(`Failed to run OCR batch: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
|
||||||
|
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Clear img directory and output directory
|
||||||
|
await this.clearDirectory(this.getImgDir())
|
||||||
|
await this.clearDirectory(this.getOutputDir())
|
||||||
|
|
||||||
|
// 2. Copy file to img directory
|
||||||
|
const fileName = path.basename(filePath)
|
||||||
|
await this.copyFileToImgDir(filePath, fileName)
|
||||||
|
logger.info(`File copied to img directory: ${fileName}`)
|
||||||
|
|
||||||
|
// 3. Run run.bat
|
||||||
|
logger.info('Running OV OCR batch process...')
|
||||||
|
await this.runOcrBatch()
|
||||||
|
|
||||||
|
// 4. Check that output/[basename].txt file exists
|
||||||
|
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
|
||||||
|
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
|
||||||
|
if (!fs.existsSync(outputFilePath)) {
|
||||||
|
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Read output/[basename].txt file content
|
||||||
|
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
|
||||||
|
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
|
||||||
|
|
||||||
|
// 6. Return result
|
||||||
|
return { text: ocrText }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error during OV OCR process: ${error}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
|
||||||
|
if (isImageFileMetadata(file)) {
|
||||||
|
return this.ocrImage(file.path, options)
|
||||||
|
} else {
|
||||||
|
throw new Error('Unsupported file type, currently only image files are supported')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ovOcrService = new OvOcrService()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { loadOcrImage } from '@main/utils/ocr'
|
import { loadOcrImage } from '@main/utils/ocr'
|
||||||
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
|
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
|
||||||
import { net } from 'electron'
|
import { net } from 'electron'
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
import { OcrBaseService } from './OcrBaseService'
|
import { OcrBaseService } from './OcrBaseService'
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
|||||||
import { SpanContext } from '@opentelemetry/api'
|
import { SpanContext } from '@opentelemetry/api'
|
||||||
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
|
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
|
||||||
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
||||||
import type { FileChangeEvent } from '@shared/config/types'
|
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import type { Notification } from '@types'
|
import type { Notification } from '@types'
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FileListResponse,
|
FileListResponse,
|
||||||
FileMetadata,
|
FileMetadata,
|
||||||
FileUploadResponse,
|
FileUploadResponse,
|
||||||
|
GetApiServerStatusResult,
|
||||||
KnowledgeBaseParams,
|
KnowledgeBaseParams,
|
||||||
KnowledgeItem,
|
KnowledgeItem,
|
||||||
KnowledgeSearchResult,
|
KnowledgeSearchResult,
|
||||||
@@ -22,8 +23,11 @@ import {
|
|||||||
OcrProvider,
|
OcrProvider,
|
||||||
OcrResult,
|
OcrResult,
|
||||||
Provider,
|
Provider,
|
||||||
|
RestartApiServerStatusResult,
|
||||||
S3Config,
|
S3Config,
|
||||||
Shortcut,
|
Shortcut,
|
||||||
|
StartApiServerStatusResult,
|
||||||
|
StopApiServerStatusResult,
|
||||||
SupportedOcrFile,
|
SupportedOcrFile,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
WebDavConfig
|
WebDavConfig
|
||||||
@@ -51,7 +55,7 @@ const api = {
|
|||||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
|
||||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||||
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
||||||
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
||||||
@@ -95,7 +99,8 @@ const api = {
|
|||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
||||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
|
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
|
||||||
|
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
|
||||||
},
|
},
|
||||||
devTools: {
|
devTools: {
|
||||||
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
|
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
|
||||||
@@ -199,7 +204,8 @@ const api = {
|
|||||||
}
|
}
|
||||||
ipcRenderer.on('file-change', listener)
|
ipcRenderer.on('file-change', listener)
|
||||||
return () => ipcRenderer.off('file-change', listener)
|
return () => ipcRenderer.off('file-change', listener)
|
||||||
}
|
},
|
||||||
|
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
|
||||||
},
|
},
|
||||||
fs: {
|
fs: {
|
||||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
|
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
|
||||||
@@ -221,7 +227,7 @@ const api = {
|
|||||||
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
||||||
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
||||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
||||||
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
|
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
|
||||||
add: ({
|
add: ({
|
||||||
base,
|
base,
|
||||||
item,
|
item,
|
||||||
@@ -286,6 +292,16 @@ const api = {
|
|||||||
clearAuthCache: (projectId: string, clientEmail?: string) =>
|
clearAuthCache: (projectId: string, clientEmail?: string) =>
|
||||||
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
|
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
|
||||||
},
|
},
|
||||||
|
ovms: {
|
||||||
|
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
|
||||||
|
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),
|
||||||
|
getModels: () => ipcRenderer.invoke(IpcChannel.Ovms_GetModels),
|
||||||
|
isRunning: () => ipcRenderer.invoke(IpcChannel.Ovms_IsRunning),
|
||||||
|
getStatus: () => ipcRenderer.invoke(IpcChannel.Ovms_GetStatus),
|
||||||
|
runOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_RunOVMS),
|
||||||
|
stopOvms: () => ipcRenderer.invoke(IpcChannel.Ovms_StopOVMS)
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||||
@@ -351,6 +367,7 @@ const api = {
|
|||||||
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
|
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
|
||||||
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
|
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
|
||||||
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
|
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
|
||||||
|
installOvmsBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallOvmsBinary),
|
||||||
protocol: {
|
protocol: {
|
||||||
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
|
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
|
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
|
||||||
@@ -377,7 +394,16 @@ const api = {
|
|||||||
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
||||||
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
|
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
|
||||||
|
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
storeSync: {
|
storeSync: {
|
||||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||||
@@ -454,7 +480,8 @@ const api = {
|
|||||||
},
|
},
|
||||||
ocr: {
|
ocr: {
|
||||||
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
|
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
|
||||||
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
|
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
|
||||||
|
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
|
||||||
},
|
},
|
||||||
cherryai: {
|
cherryai: {
|
||||||
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
||||||
@@ -474,6 +501,12 @@ const api = {
|
|||||||
ipcRenderer.removeListener(channel, listener)
|
ipcRenderer.removeListener(channel, listener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
apiServer: {
|
||||||
|
getStatus: (): Promise<GetApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus),
|
||||||
|
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
|
||||||
|
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
|
||||||
|
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
|
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
|
||||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||||
|
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
|
||||||
|
import { formatErrorMessage } from '@renderer/utils/error'
|
||||||
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
|
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
|
||||||
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
|
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
|
||||||
import type { TextStreamPart, ToolSet } from 'ai'
|
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
|
||||||
|
|
||||||
import { ToolCallChunkHandler } from './handleToolCallChunk'
|
import { ToolCallChunkHandler } from './handleToolCallChunk'
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ export class AiSdkToChunkAdapter {
|
|||||||
private isFirstChunk = true
|
private isFirstChunk = true
|
||||||
private enableWebSearch: boolean = false
|
private enableWebSearch: boolean = false
|
||||||
private onSessionUpdate?: (sessionId: string) => void
|
private onSessionUpdate?: (sessionId: string) => void
|
||||||
|
private responseStartTimestamp: number | null = null
|
||||||
|
private firstTokenTimestamp: number | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private onChunk: (chunk: Chunk) => void,
|
private onChunk: (chunk: Chunk) => void,
|
||||||
@@ -38,6 +42,17 @@ export class AiSdkToChunkAdapter {
|
|||||||
this.onSessionUpdate = onSessionUpdate
|
this.onSessionUpdate = onSessionUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private markFirstTokenIfNeeded() {
|
||||||
|
if (this.firstTokenTimestamp === null && this.responseStartTimestamp !== null) {
|
||||||
|
this.firstTokenTimestamp = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTimingState() {
|
||||||
|
this.responseStartTimestamp = null
|
||||||
|
this.firstTokenTimestamp = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 AI SDK 流结果
|
* 处理 AI SDK 流结果
|
||||||
* @param aiSdkResult AI SDK 的流结果对象
|
* @param aiSdkResult AI SDK 的流结果对象
|
||||||
@@ -65,6 +80,8 @@ export class AiSdkToChunkAdapter {
|
|||||||
webSearchResults: [],
|
webSearchResults: [],
|
||||||
reasoningId: ''
|
reasoningId: ''
|
||||||
}
|
}
|
||||||
|
this.resetTimingState()
|
||||||
|
this.responseStartTimestamp = Date.now()
|
||||||
// Reset link converter state at the start of stream
|
// Reset link converter state at the start of stream
|
||||||
this.isFirstChunk = true
|
this.isFirstChunk = true
|
||||||
|
|
||||||
@@ -77,6 +94,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
if (this.enableWebSearch) {
|
if (this.enableWebSearch) {
|
||||||
const remainingText = flushLinkConverterBuffer()
|
const remainingText = flushLinkConverterBuffer()
|
||||||
if (remainingText) {
|
if (remainingText) {
|
||||||
|
this.markFirstTokenIfNeeded()
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.TEXT_DELTA,
|
type: ChunkType.TEXT_DELTA,
|
||||||
text: remainingText
|
text: remainingText
|
||||||
@@ -91,6 +109,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock()
|
reader.releaseLock()
|
||||||
|
this.resetTimingState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +171,7 @@ export class AiSdkToChunkAdapter {
|
|||||||
|
|
||||||
// Only emit chunk if there's text to send
|
// Only emit chunk if there's text to send
|
||||||
if (finalText) {
|
if (finalText) {
|
||||||
|
this.markFirstTokenIfNeeded()
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.TEXT_DELTA,
|
type: ChunkType.TEXT_DELTA,
|
||||||
text: this.accumulate ? final.text : finalText
|
text: this.accumulate ? final.text : finalText
|
||||||
@@ -176,6 +196,9 @@ export class AiSdkToChunkAdapter {
|
|||||||
break
|
break
|
||||||
case 'reasoning-delta':
|
case 'reasoning-delta':
|
||||||
final.reasoningContent += chunk.text || ''
|
final.reasoningContent += chunk.text || ''
|
||||||
|
if (chunk.text) {
|
||||||
|
this.markFirstTokenIfNeeded()
|
||||||
|
}
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.THINKING_DELTA,
|
type: ChunkType.THINKING_DELTA,
|
||||||
text: final.reasoningContent || ''
|
text: final.reasoningContent || ''
|
||||||
@@ -275,44 +298,37 @@ export class AiSdkToChunkAdapter {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'finish':
|
case 'finish': {
|
||||||
|
const usage = {
|
||||||
|
completion_tokens: chunk.totalUsage?.outputTokens || 0,
|
||||||
|
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
|
||||||
|
total_tokens: chunk.totalUsage?.totalTokens || 0
|
||||||
|
}
|
||||||
|
const metrics = this.buildMetrics(chunk.totalUsage)
|
||||||
|
const baseResponse = {
|
||||||
|
text: final.text || '',
|
||||||
|
reasoning_content: final.reasoningContent || ''
|
||||||
|
}
|
||||||
|
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.BLOCK_COMPLETE,
|
type: ChunkType.BLOCK_COMPLETE,
|
||||||
response: {
|
response: {
|
||||||
text: final.text || '',
|
...baseResponse,
|
||||||
reasoning_content: final.reasoningContent || '',
|
usage: { ...usage },
|
||||||
usage: {
|
metrics: metrics ? { ...metrics } : undefined
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
|
||||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
|
||||||
},
|
|
||||||
metrics: chunk.totalUsage
|
|
||||||
? {
|
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
time_completion_millsec: 0
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||||
response: {
|
response: {
|
||||||
text: final.text || '',
|
...baseResponse,
|
||||||
reasoning_content: final.reasoningContent || '',
|
usage: { ...usage },
|
||||||
usage: {
|
metrics: metrics ? { ...metrics } : undefined
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
|
||||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
|
||||||
},
|
|
||||||
metrics: chunk.totalUsage
|
|
||||||
? {
|
|
||||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
|
||||||
time_completion_millsec: 0
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this.resetTimingState()
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// === 源和文件相关事件 ===
|
// === 源和文件相关事件 ===
|
||||||
case 'source':
|
case 'source':
|
||||||
@@ -341,13 +357,48 @@ export class AiSdkToChunkAdapter {
|
|||||||
case 'error':
|
case 'error':
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.ERROR,
|
type: ChunkType.ERROR,
|
||||||
error: chunk.error as Record<string, any>
|
error:
|
||||||
|
chunk.error instanceof AISDKError
|
||||||
|
? chunk.error
|
||||||
|
: new ProviderSpecificError({
|
||||||
|
message: formatErrorMessage(chunk.error),
|
||||||
|
provider: 'unknown',
|
||||||
|
cause: chunk.error
|
||||||
|
})
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildMetrics(totalUsage?: {
|
||||||
|
inputTokens?: number | null
|
||||||
|
outputTokens?: number | null
|
||||||
|
totalTokens?: number | null
|
||||||
|
}) {
|
||||||
|
if (!totalUsage) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const completionTokens = totalUsage.outputTokens ?? 0
|
||||||
|
const now = Date.now()
|
||||||
|
const start = this.responseStartTimestamp ?? now
|
||||||
|
const firstToken = this.firstTokenTimestamp
|
||||||
|
const timeFirstToken = Math.max(firstToken != null ? firstToken - start : 0, 0)
|
||||||
|
const baseForCompletion = firstToken ?? start
|
||||||
|
let timeCompletion = Math.max(now - baseForCompletion, 0)
|
||||||
|
|
||||||
|
if (timeCompletion === 0 && completionTokens > 0) {
|
||||||
|
timeCompletion = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
time_first_token_millsec: timeFirstToken,
|
||||||
|
time_completion_millsec: timeCompletion
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AiSdkToChunkAdapter
|
export default AiSdkToChunkAdapter
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
|||||||
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||||
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||||
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
|
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||||
|
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
|
||||||
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||||
|
|
||||||
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
||||||
@@ -21,7 +22,6 @@ import LegacyAiProvider from './legacy/index'
|
|||||||
import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas'
|
import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas'
|
||||||
import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
|
import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
|
||||||
import { buildPlugins } from './plugins/PluginBuilder'
|
import { buildPlugins } from './plugins/PluginBuilder'
|
||||||
import { buildClaudeCodeSystemMessage } from './provider/config/anthropic'
|
|
||||||
import { createAiSdkProvider } from './provider/factory'
|
import { createAiSdkProvider } from './provider/factory'
|
||||||
import {
|
import {
|
||||||
getActualProvider,
|
getActualProvider,
|
||||||
@@ -83,10 +83,8 @@ export default class ModernAiProvider {
|
|||||||
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保配置存在
|
// 每次请求时重新生成配置以确保API key轮换生效
|
||||||
if (!this.config) {
|
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
||||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 准备特殊配置
|
// 准备特殊配置
|
||||||
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
||||||
@@ -122,13 +120,9 @@ export default class ModernAiProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') {
|
if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') {
|
||||||
const claudeCodeSystemMessage = buildClaudeCodeSystemMessage(params.system)
|
const claudeCodeSystemMessage = buildClaudeCodeSystemModelMessage(params.system)
|
||||||
params.system = undefined // 清除原有system,避免重复
|
params.system = undefined // 清除原有system,避免重复
|
||||||
if (Array.isArray(params.messages)) {
|
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
|
||||||
params.messages = [...claudeCodeSystemMessage, ...params.messages]
|
|
||||||
} else {
|
|
||||||
params.messages = claudeCodeSystemMessage
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.topicId && getEnableDeveloperMode()) {
|
if (config.topicId && getEnableDeveloperMode()) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { VertexAPIClient } from './gemini/VertexAPIClient'
|
|||||||
import { NewAPIClient } from './newapi/NewAPIClient'
|
import { NewAPIClient } from './newapi/NewAPIClient'
|
||||||
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||||
|
import { OVMSClient } from './ovms/OVMSClient'
|
||||||
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
||||||
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
|
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@ export class ApiClientFactory {
|
|||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider.id === 'ovms') {
|
||||||
|
logger.debug(`Creating OVMSClient for provider: ${provider.id}`)
|
||||||
|
instance = new OVMSClient(provider) as BaseApiClient
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
// 然后检查标准的 Provider Type
|
// 然后检查标准的 Provider Type
|
||||||
switch (provider.type) {
|
switch (provider.type) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
|
|||||||
@@ -70,13 +70,19 @@ export abstract class BaseApiClient<
|
|||||||
{
|
{
|
||||||
public provider: Provider
|
public provider: Provider
|
||||||
protected host: string
|
protected host: string
|
||||||
protected apiKey: string
|
|
||||||
protected sdkInstance?: TSdkInstance
|
protected sdkInstance?: TSdkInstance
|
||||||
|
|
||||||
constructor(provider: Provider) {
|
constructor(provider: Provider) {
|
||||||
this.provider = provider
|
this.provider = provider
|
||||||
this.host = this.getBaseURL()
|
this.host = this.getBaseURL()
|
||||||
this.apiKey = this.getApiKey()
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current API key with rotation support
|
||||||
|
* This getter ensures API keys rotate on each access when multiple keys are configured
|
||||||
|
*/
|
||||||
|
protected get apiKey(): string {
|
||||||
|
return this.getApiKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants'
|
||||||
import {
|
import {
|
||||||
isClaudeReasoningModel,
|
isClaudeReasoningModel,
|
||||||
isOpenAIReasoningModel,
|
isOpenAIReasoningModel,
|
||||||
@@ -167,8 +168,7 @@ export abstract class OpenAIBaseClient<
|
|||||||
defaultHeaders: {
|
defaultHeaders: {
|
||||||
...this.defaultHeaders(),
|
...this.defaultHeaders(),
|
||||||
...this.provider.extra_headers,
|
...this.provider.extra_headers,
|
||||||
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
|
...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {})
|
||||||
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
|
|
||||||
}
|
}
|
||||||
}) as TSdkInstance
|
}) as TSdkInstance
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { isSupportedModel } from '@renderer/config/models'
|
||||||
|
import { objectKeys, Provider } from '@renderer/types'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
|
||||||
|
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('OVMSClient')
|
||||||
|
|
||||||
|
export class OVMSClient extends OpenAIAPIClient {
|
||||||
|
constructor(provider: Provider) {
|
||||||
|
super(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||||
|
try {
|
||||||
|
const sdk = await this.getSdkInstance()
|
||||||
|
|
||||||
|
const chatModelsResponse = await sdk.request({
|
||||||
|
method: 'get',
|
||||||
|
path: '../v1/config'
|
||||||
|
})
|
||||||
|
logger.debug(`Chat models response: ${JSON.stringify(chatModelsResponse)}`)
|
||||||
|
|
||||||
|
// Parse the config response to extract model information
|
||||||
|
const config = chatModelsResponse as Record<string, any>
|
||||||
|
const models = objectKeys(config)
|
||||||
|
.map((modelName) => {
|
||||||
|
const modelInfo = config[modelName]
|
||||||
|
|
||||||
|
// Check if model has at least one version with "AVAILABLE" state
|
||||||
|
const hasAvailableVersion = modelInfo?.model_version_status?.some(
|
||||||
|
(versionStatus: any) => versionStatus?.state === 'AVAILABLE'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasAvailableVersion) {
|
||||||
|
return {
|
||||||
|
id: modelName,
|
||||||
|
object: 'model' as const,
|
||||||
|
owned_by: 'ovms',
|
||||||
|
created: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null // Skip models without available versions
|
||||||
|
})
|
||||||
|
.filter(Boolean) // Remove null entries
|
||||||
|
logger.debug(`Processed models: ${JSON.stringify(models)}`)
|
||||||
|
|
||||||
|
// Filter out unsupported models
|
||||||
|
return models.filter((model): model is OpenAI.Models.Model => model !== null && isSupportedModel(model))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error listing OVMS models: ${error}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import type { MCPTool, Message, Model, Provider } from '@renderer/types'
|
|||||||
import type { Chunk } from '@renderer/types/chunk'
|
import type { Chunk } from '@renderer/types/chunk'
|
||||||
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
|
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
|
||||||
|
|
||||||
|
import { noThinkMiddleware } from './noThinkMiddleware'
|
||||||
|
|
||||||
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
|
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,6 +188,14 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
|
|||||||
// 其他provider的通用处理
|
// 其他provider的通用处理
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OVMS+MCP's specific middleware
|
||||||
|
if (config.provider.id === 'ovms' && config.mcpTools && config.mcpTools.length > 0) {
|
||||||
|
builder.add({
|
||||||
|
name: 'no-think',
|
||||||
|
middleware: noThinkMiddleware()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { LanguageModelMiddleware } from 'ai'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('noThinkMiddleware')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No Think Middleware
|
||||||
|
* Automatically appends ' /no_think' string to the end of user messages for the provider
|
||||||
|
* This prevents the model from generating unnecessary thinking process and returns results directly
|
||||||
|
* @returns LanguageModelMiddleware
|
||||||
|
*/
|
||||||
|
export function noThinkMiddleware(): LanguageModelMiddleware {
|
||||||
|
return {
|
||||||
|
middlewareVersion: 'v2',
|
||||||
|
|
||||||
|
transformParams: async ({ params }) => {
|
||||||
|
const transformedParams = { ...params }
|
||||||
|
// Process messages in prompt
|
||||||
|
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
|
||||||
|
transformedParams.prompt = transformedParams.prompt.map((message) => {
|
||||||
|
// Only process user messages
|
||||||
|
if (message.role === 'user') {
|
||||||
|
// Process content array
|
||||||
|
if (Array.isArray(message.content)) {
|
||||||
|
const lastContent = message.content[message.content.length - 1]
|
||||||
|
// If the last content is text type, append ' /no_think'
|
||||||
|
if (lastContent && lastContent.type === 'text' && typeof lastContent.text === 'string') {
|
||||||
|
// Avoid duplicate additions
|
||||||
|
if (!lastContent.text.endsWith('/no_think')) {
|
||||||
|
logger.debug('Adding /no_think to user message')
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: [
|
||||||
|
...message.content.slice(0, -1),
|
||||||
|
{
|
||||||
|
...lastContent,
|
||||||
|
text: lastContent.text + ' /no_think'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformedParams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
|||||||
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
||||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||||
|
import { replacePromptVariables } from '@renderer/utils/prompt'
|
||||||
import type { ModelMessage, Tool } from 'ai'
|
import type { ModelMessage, Tool } from 'ai'
|
||||||
import { stepCountIs } from 'ai'
|
import { stepCountIs } from 'ai'
|
||||||
|
|
||||||
@@ -159,14 +160,14 @@ export async function buildStreamTextParams(
|
|||||||
abortSignal: options.requestOptions?.signal,
|
abortSignal: options.requestOptions?.signal,
|
||||||
headers: options.requestOptions?.headers,
|
headers: options.requestOptions?.headers,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
stopWhen: stepCountIs(10),
|
stopWhen: stepCountIs(20),
|
||||||
maxRetries: 0
|
maxRetries: 0
|
||||||
}
|
}
|
||||||
if (tools) {
|
if (tools) {
|
||||||
params.tools = tools
|
params.tools = tools
|
||||||
}
|
}
|
||||||
if (assistant.prompt) {
|
if (assistant.prompt) {
|
||||||
params.system = assistant.prompt
|
params.system = await replacePromptVariables(assistant.prompt, model.name)
|
||||||
}
|
}
|
||||||
logger.debug('params', params)
|
logger.debug('params', params)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@renderer/services/LoggerService', () => ({
|
||||||
|
loggerService: {
|
||||||
|
withContext: () => ({
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/services/AssistantService', () => ({
|
||||||
|
getProviderByModel: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/store', () => ({
|
||||||
|
default: {
|
||||||
|
getState: () => ({ copilot: { defaultHeaders: {} } })
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import type { Model, Provider } from '@renderer/types'
|
||||||
|
|
||||||
|
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
|
||||||
|
import { providerToAiSdkConfig } from '../providerConfig'
|
||||||
|
|
||||||
|
const createWindowKeyv = () => {
|
||||||
|
const store = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
get: (key: string) => store.get(key),
|
||||||
|
set: (key: string, value: string) => {
|
||||||
|
store.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCopilotProvider = (): Provider => ({
|
||||||
|
id: 'copilot',
|
||||||
|
type: 'openai',
|
||||||
|
name: 'GitHub Copilot',
|
||||||
|
apiKey: 'test-key',
|
||||||
|
apiHost: 'https://api.githubcopilot.com',
|
||||||
|
models: [],
|
||||||
|
isSystem: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const createModel = (id: string, name = id): Model => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
provider: 'copilot',
|
||||||
|
group: 'copilot'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Copilot responses routing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
;(globalThis as any).window = {
|
||||||
|
...(globalThis as any).window,
|
||||||
|
keyv: createWindowKeyv()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects official GPT-5 Codex identifiers case-insensitively', () => {
|
||||||
|
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'gpt-5-codex'))).toBe(true)
|
||||||
|
expect(isCopilotResponsesModel(createModel('GPT-5-CODEX', 'GPT-5-CODEX'))).toBe(true)
|
||||||
|
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'custom-name'))).toBe(true)
|
||||||
|
expect(isCopilotResponsesModel(createModel('custom-id', 'custom-name'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('configures gpt-5-codex with the Copilot provider', () => {
|
||||||
|
const provider = createCopilotProvider()
|
||||||
|
const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX'))
|
||||||
|
|
||||||
|
expect(config.providerId).toBe('github-copilot-openai-compatible')
|
||||||
|
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION)
|
||||||
|
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
|
||||||
|
expect(config.options.headers?.['copilot-vision-request']).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the Copilot provider for other models and keeps headers', () => {
|
||||||
|
const provider = createCopilotProvider()
|
||||||
|
const config = providerToAiSdkConfig(provider, createModel('gpt-4'))
|
||||||
|
|
||||||
|
expect(config.providerId).toBe('github-copilot-openai-compatible')
|
||||||
|
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
|
||||||
|
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { SystemModelMessage } from 'ai'
|
|
||||||
|
|
||||||
export function buildClaudeCodeSystemMessage(system?: string): Array<SystemModelMessage> {
|
|
||||||
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
|
|
||||||
if (!system || system.trim() === defaultClaudeCodeSystem) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: defaultClaudeCodeSystem
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: defaultClaudeCodeSystem
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: system
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Model } from '@renderer/types'
|
||||||
|
|
||||||
|
export const COPILOT_EDITOR_VERSION = 'vscode/1.104.1'
|
||||||
|
export const COPILOT_PLUGIN_VERSION = 'copilot-chat/0.26.7'
|
||||||
|
export const COPILOT_INTEGRATION_ID = 'vscode-chat'
|
||||||
|
export const COPILOT_USER_AGENT = 'GitHubCopilotChat/0.26.7'
|
||||||
|
|
||||||
|
export const COPILOT_DEFAULT_HEADERS = {
|
||||||
|
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
|
||||||
|
'User-Agent': COPILOT_USER_AGENT,
|
||||||
|
'Editor-Version': COPILOT_EDITOR_VERSION,
|
||||||
|
'Editor-Plugin-Version': COPILOT_PLUGIN_VERSION,
|
||||||
|
'editor-version': COPILOT_EDITOR_VERSION,
|
||||||
|
'editor-plugin-version': COPILOT_PLUGIN_VERSION,
|
||||||
|
'copilot-vision-request': 'true'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Models that require the OpenAI Responses endpoint when routed through GitHub Copilot (#10560)
|
||||||
|
const COPILOT_RESPONSES_MODEL_IDS = ['gpt-5-codex']
|
||||||
|
|
||||||
|
export function isCopilotResponsesModel(model: Model): boolean {
|
||||||
|
const normalizedId = model.id?.trim().toLowerCase()
|
||||||
|
const normalizedName = model.name?.trim().toLowerCase()
|
||||||
|
return COPILOT_RESPONSES_MODEL_IDS.some((target) => normalizedId === target || normalizedName === target)
|
||||||
|
}
|
||||||
@@ -28,7 +28,8 @@ const STATIC_PROVIDER_MAPPING: Record<string, ProviderId> = {
|
|||||||
gemini: 'google', // Google Gemini -> google
|
gemini: 'google', // Google Gemini -> google
|
||||||
'azure-openai': 'azure', // Azure OpenAI -> azure
|
'azure-openai': 'azure', // Azure OpenAI -> azure
|
||||||
'openai-response': 'openai', // OpenAI Responses -> openai
|
'openai-response': 'openai', // OpenAI Responses -> openai
|
||||||
grok: 'xai' // Grok -> xai
|
grok: 'xai', // Grok -> xai
|
||||||
|
copilot: 'github-copilot-openai-compatible'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { formatApiHost } from '@renderer/utils/api'
|
|||||||
import { cloneDeep, trim } from 'lodash'
|
import { cloneDeep, trim } from 'lodash'
|
||||||
|
|
||||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||||
|
import { COPILOT_DEFAULT_HEADERS } from './constants'
|
||||||
import { getAiSdkProviderId } from './factory'
|
import { getAiSdkProviderId } from './factory'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ProviderConfigProcessor')
|
const logger = loggerService.withContext('ProviderConfigProcessor')
|
||||||
@@ -62,13 +63,14 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
|||||||
// return createVertexProvider(provider)
|
// return createVertexProvider(provider)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
if (isNewApiProvider(provider)) {
|
||||||
|
return newApiResolverCreator(model, provider)
|
||||||
|
}
|
||||||
|
|
||||||
if (isSystemProvider(provider)) {
|
if (isSystemProvider(provider)) {
|
||||||
if (provider.id === 'aihubmix') {
|
if (provider.id === 'aihubmix') {
|
||||||
return aihubmixProviderCreator(model, provider)
|
return aihubmixProviderCreator(model, provider)
|
||||||
}
|
}
|
||||||
if (isNewApiProvider(provider)) {
|
|
||||||
return newApiResolverCreator(model, provider)
|
|
||||||
}
|
|
||||||
if (provider.id === 'vertexai') {
|
if (provider.id === 'vertexai') {
|
||||||
return vertexAnthropicProviderCreator(model, provider)
|
return vertexAnthropicProviderCreator(model, provider)
|
||||||
}
|
}
|
||||||
@@ -109,6 +111,9 @@ function formatProviderApiHost(provider: Provider): Provider {
|
|||||||
if (!formatted.anthropicApiHost) {
|
if (!formatted.anthropicApiHost) {
|
||||||
formatted.anthropicApiHost = formatted.apiHost
|
formatted.anthropicApiHost = formatted.apiHost
|
||||||
}
|
}
|
||||||
|
} else if (formatted.id === 'copilot') {
|
||||||
|
const trimmed = trim(formatted.apiHost)
|
||||||
|
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
||||||
} else if (formatted.type === 'gemini') {
|
} else if (formatted.type === 'gemini') {
|
||||||
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
|
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
|
||||||
} else {
|
} else {
|
||||||
@@ -151,6 +156,26 @@ export function providerToAiSdkConfig(
|
|||||||
baseURL: trim(actualProvider.apiHost),
|
baseURL: trim(actualProvider.apiHost),
|
||||||
apiKey: getRotatedApiKey(actualProvider)
|
apiKey: getRotatedApiKey(actualProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCopilotProvider = actualProvider.id === 'copilot'
|
||||||
|
if (isCopilotProvider) {
|
||||||
|
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
|
||||||
|
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
|
||||||
|
headers: {
|
||||||
|
...COPILOT_DEFAULT_HEADERS,
|
||||||
|
...storedHeaders,
|
||||||
|
...actualProvider.extra_headers
|
||||||
|
},
|
||||||
|
name: actualProvider.id,
|
||||||
|
includeUsage: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerId: 'github-copilot-openai-compatible',
|
||||||
|
options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理OpenAI模式
|
// 处理OpenAI模式
|
||||||
const extraOptions: any = {}
|
const extraOptions: any = {}
|
||||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||||
@@ -172,15 +197,6 @@ export function providerToAiSdkConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// copilot
|
|
||||||
if (actualProvider.id === 'copilot') {
|
|
||||||
extraOptions.headers = {
|
|
||||||
...extraOptions.headers,
|
|
||||||
'editor-version': 'vscode/1.97.2',
|
|
||||||
'copilot-vision-request': 'true'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// azure
|
// azure
|
||||||
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
|
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
|
||||||
extraOptions.apiVersion = actualProvider.apiVersion
|
extraOptions.apiVersion = actualProvider.apiVersion
|
||||||
@@ -229,7 +245,6 @@ export function providerToAiSdkConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果AI SDK支持该provider,使用原生配置
|
|
||||||
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
|
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
|
||||||
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
|
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
|
||||||
return {
|
return {
|
||||||
@@ -277,9 +292,17 @@ export async function prepareSpecialProviderConfig(
|
|||||||
) {
|
) {
|
||||||
switch (provider.id) {
|
switch (provider.id) {
|
||||||
case 'copilot': {
|
case 'copilot': {
|
||||||
const defaultHeaders = store.getState().copilot.defaultHeaders
|
const defaultHeaders = store.getState().copilot.defaultHeaders ?? {}
|
||||||
const { token } = await window.api.copilot.getToken(defaultHeaders)
|
const headers = {
|
||||||
|
...COPILOT_DEFAULT_HEADERS,
|
||||||
|
...defaultHeaders
|
||||||
|
}
|
||||||
|
const { token } = await window.api.copilot.getToken(headers)
|
||||||
config.options.apiKey = token
|
config.options.apiKey = token
|
||||||
|
config.options.headers = {
|
||||||
|
...headers,
|
||||||
|
...config.options.headers
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'cherryai': {
|
case 'cherryai': {
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
|
|||||||
supportsImageGeneration: true,
|
supportsImageGeneration: true,
|
||||||
aliases: ['vertexai-anthropic']
|
aliases: ['vertexai-anthropic']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'github-copilot-openai-compatible',
|
||||||
|
name: 'GitHub Copilot OpenAI Compatible',
|
||||||
|
import: () => import('@opeoginni/github-copilot-openai-compatible'),
|
||||||
|
creatorFunctionName: 'createGitHubCopilotOpenAICompatible',
|
||||||
|
supportsImageGeneration: false,
|
||||||
|
aliases: ['copilot', 'github-copilot']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'bedrock',
|
id: 'bedrock',
|
||||||
name: 'Amazon Bedrock',
|
name: 'Amazon Bedrock',
|
||||||
@@ -47,6 +55,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
|
|||||||
creatorFunctionName: 'createPerplexity',
|
creatorFunctionName: 'createPerplexity',
|
||||||
supportsImageGeneration: false,
|
supportsImageGeneration: false,
|
||||||
aliases: ['perplexity']
|
aliases: ['perplexity']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mistral',
|
||||||
|
name: 'Mistral',
|
||||||
|
import: () => import('@ai-sdk/mistral'),
|
||||||
|
creatorFunctionName: 'createMistral',
|
||||||
|
supportsImageGeneration: false,
|
||||||
|
aliases: ['mistral']
|
||||||
}
|
}
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Assistant, KnowledgeReference } from '@renderer/types'
|
|||||||
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
|
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
|
||||||
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 知识库搜索工具
|
* 知识库搜索工具
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
|
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
|
||||||
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
import { MemoryProcessor } from '../../services/MemoryProcessor'
|
import { MemoryProcessor } from '../../services/MemoryProcessor'
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
|
|||||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||||
import { ExtractResults } from '@renderer/utils/extract'
|
import { ExtractResults } from '@renderer/utils/extract'
|
||||||
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用预提取关键词的网络搜索工具
|
* 使用预提取关键词的网络搜索工具
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getThinkModelType,
|
getThinkModelType,
|
||||||
isDeepSeekHybridInferenceModel,
|
isDeepSeekHybridInferenceModel,
|
||||||
isDoubaoThinkingAutoModel,
|
isDoubaoThinkingAutoModel,
|
||||||
|
isGrok4FastReasoningModel,
|
||||||
isGrokReasoningModel,
|
isGrokReasoningModel,
|
||||||
isOpenAIReasoningModel,
|
isOpenAIReasoningModel,
|
||||||
isQwenAlwaysThinkModel,
|
isQwenAlwaysThinkModel,
|
||||||
@@ -52,7 +53,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
// Don't disable reasoning for models that require it
|
// Don't disable reasoning for models that require it
|
||||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
|
if (
|
||||||
|
isGrokReasoningModel(model) ||
|
||||||
|
isOpenAIReasoningModel(model) ||
|
||||||
|
isQwenAlwaysThinkModel(model) ||
|
||||||
|
model.id.includes('seed-oss')
|
||||||
|
) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return { reasoning: { enabled: false, exclude: true } }
|
return { reasoning: { enabled: false, exclude: true } }
|
||||||
@@ -100,6 +106,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
// reasoningEffort有效的情况
|
// reasoningEffort有效的情况
|
||||||
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
|
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
|
||||||
// 不同的 provider 有不同的思考控制方式,在这里统一解决
|
// 不同的 provider 有不同的思考控制方式,在这里统一解决
|
||||||
|
|
||||||
if (isDeepSeekHybridInferenceModel(model)) {
|
if (isDeepSeekHybridInferenceModel(model)) {
|
||||||
if (isSystemProvider(provider)) {
|
if (isSystemProvider(provider)) {
|
||||||
switch (provider.id) {
|
switch (provider.id) {
|
||||||
@@ -142,6 +149,16 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
|
|
||||||
// OpenRouter models
|
// OpenRouter models
|
||||||
if (model.provider === SystemProviderIds.openrouter) {
|
if (model.provider === SystemProviderIds.openrouter) {
|
||||||
|
// Grok 4 Fast doesn't support effort levels, always use enabled: true
|
||||||
|
if (isGrok4FastReasoningModel(model)) {
|
||||||
|
return {
|
||||||
|
reasoning: {
|
||||||
|
enabled: true // Ignore effort level, just enable reasoning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other OpenRouter models that support effort levels
|
||||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||||
return {
|
return {
|
||||||
reasoning: {
|
reasoning: {
|
||||||
@@ -412,6 +429,13 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get XAI-specific reasoning parameters
|
||||||
|
* This function should only be called for XAI provider models
|
||||||
|
* @param assistant - The assistant configuration
|
||||||
|
* @param model - The model being used
|
||||||
|
* @returns XAI-specific reasoning parameters
|
||||||
|
*/
|
||||||
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||||
if (!isSupportedReasoningEffortGrokModel(model)) {
|
if (!isSupportedReasoningEffortGrokModel(model)) {
|
||||||
return {}
|
return {}
|
||||||
@@ -419,6 +443,11 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
|
|||||||
|
|
||||||
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
||||||
|
|
||||||
|
if (!reasoningEffort) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For XAI provider Grok models, use reasoningEffort parameter directly
|
||||||
return {
|
return {
|
||||||
reasoningEffort
|
reasoningEffort
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
CreateAgentRequest,
|
CreateAgentRequest,
|
||||||
CreateAgentResponse,
|
CreateAgentResponse,
|
||||||
CreateAgentResponseSchema,
|
CreateAgentResponseSchema,
|
||||||
|
CreateAgentSessionResponse,
|
||||||
|
CreateAgentSessionResponseSchema,
|
||||||
CreateSessionForm,
|
CreateSessionForm,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
GetAgentResponse,
|
GetAgentResponse,
|
||||||
@@ -171,12 +173,12 @@ export class AgentApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSession(agentId: string, session: CreateSessionForm): Promise<GetAgentSessionResponse> {
|
public async createSession(agentId: string, session: CreateSessionForm): Promise<CreateAgentSessionResponse> {
|
||||||
const url = this.getSessionPaths(agentId).base
|
const url = this.getSessionPaths(agentId).base
|
||||||
try {
|
try {
|
||||||
const payload = session satisfies CreateSessionRequest
|
const payload = session satisfies CreateSessionRequest
|
||||||
const response = await this.axios.post(url, payload)
|
const response = await this.axios.post(url, payload)
|
||||||
const data = GetAgentSessionResponseSchema.parse(response.data)
|
const data = CreateAgentSessionResponseSchema.parse(response.data)
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw processError(error, 'Failed to add session.')
|
throw processError(error, 'Failed to add session.')
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
|||||||
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
|
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
|
||||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||||
*/
|
*/
|
||||||
min-width: 45ch;
|
min-width: 35ch;
|
||||||
|
|
||||||
.code-toolbar {
|
.code-toolbar {
|
||||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
|
|||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { Extension, keymap } from '@uiw/react-codemirror'
|
import { Extension, keymap } from '@uiw/react-codemirror'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { getNormalizedExtension } from './utils'
|
import { getNormalizedExtension } from './utils'
|
||||||
|
|
||||||
@@ -203,3 +203,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
|
|||||||
})
|
})
|
||||||
}, [onHeightChange])
|
}, [onHeightChange])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UseScrollToLineOptions {
|
||||||
|
highlight?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
|
||||||
|
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
|
||||||
|
const domAtPos = view.domAtPos(position)
|
||||||
|
let node: Node | null = domAtPos.node
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
node = node.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
node = node.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
|
||||||
|
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
|
||||||
|
if (previousHighlight) {
|
||||||
|
previousHighlight.classList.remove('animation-locate-highlight')
|
||||||
|
}
|
||||||
|
|
||||||
|
element.classList.add('animation-locate-highlight')
|
||||||
|
|
||||||
|
const handleAnimationEnd = () => {
|
||||||
|
element.classList.remove('animation-locate-highlight')
|
||||||
|
element.removeEventListener('animationend', handleAnimationEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
element.addEventListener('animationend', handleAnimationEnd)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(lineNumber: number, options?: UseScrollToLineOptions) => {
|
||||||
|
const view = editorViewRef.current
|
||||||
|
if (!view) return
|
||||||
|
|
||||||
|
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
|
||||||
|
|
||||||
|
const lineElement = findLineElement(view, targetLine.from)
|
||||||
|
if (lineElement) {
|
||||||
|
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||||
|
|
||||||
|
if (options?.highlight) {
|
||||||
|
requestAnimationFrame(() => highlightLine(view, lineElement))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
effects: EditorView.scrollIntoView(targetLine.from, {
|
||||||
|
y: 'start'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!options?.highlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const fallbackElement = findLineElement(view, targetLine.from)
|
||||||
|
if (fallbackElement) {
|
||||||
|
highlightLine(view, fallbackElement)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
[editorViewRef, findLineElement, highlightLine]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import diff from 'fast-diff'
|
|||||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
|
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
|
||||||
|
|
||||||
// 标记非用户编辑的变更
|
// 标记非用户编辑的变更
|
||||||
const External = Annotation.define<boolean>()
|
const External = Annotation.define<boolean>()
|
||||||
|
|
||||||
export interface CodeEditorHandles {
|
export interface CodeEditorHandles {
|
||||||
save?: () => void
|
save?: () => void
|
||||||
|
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodeEditorProps {
|
export interface CodeEditorProps {
|
||||||
@@ -75,10 +76,15 @@ export interface CodeEditorProps {
|
|||||||
/** CSS class name appended to the default `code-editor` class. */
|
/** CSS class name appended to the default `code-editor` class. */
|
||||||
className?: string
|
className?: string
|
||||||
/**
|
/**
|
||||||
* Whether the editor is editable.
|
* Whether the editor view is editable.
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
|
/**
|
||||||
|
* Set the editor state to read only but keep some user interactions, e.g., keymaps.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readOnly?: boolean
|
||||||
/**
|
/**
|
||||||
* Whether the editor is expanded.
|
* Whether the editor is expanded.
|
||||||
* If true, the height and maxHeight props are ignored.
|
* If true, the height and maxHeight props are ignored.
|
||||||
@@ -114,6 +120,7 @@ const CodeEditor = ({
|
|||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
editable = true,
|
editable = true,
|
||||||
|
readOnly = false,
|
||||||
expanded = true,
|
expanded = true,
|
||||||
wrapped = true
|
wrapped = true
|
||||||
}: CodeEditorProps) => {
|
}: CodeEditorProps) => {
|
||||||
@@ -175,8 +182,11 @@ const CodeEditor = ({
|
|||||||
].flat()
|
].flat()
|
||||||
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
||||||
|
|
||||||
|
const scrollToLine = useScrollToLine(editorViewRef)
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
save: handleSave
|
save: handleSave,
|
||||||
|
scrollToLine
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -189,6 +199,7 @@ const CodeEditor = ({
|
|||||||
maxHeight={expanded ? undefined : maxHeight}
|
maxHeight={expanded ? undefined : maxHeight}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
|
readOnly={readOnly}
|
||||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||||
theme={activeCmTheme}
|
theme={activeCmTheme}
|
||||||
extensions={customExtensions}
|
extensions={customExtensions}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { FC, memo, useMemo } from 'react'
|
||||||
|
|
||||||
|
interface HighlightTextProps {
|
||||||
|
text: string
|
||||||
|
keyword: string
|
||||||
|
caseSensitive?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text highlighting component that marks keyword matches
|
||||||
|
*/
|
||||||
|
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
|
||||||
|
const highlightedText = useMemo(() => {
|
||||||
|
if (!keyword || !text) {
|
||||||
|
return <span>{text}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape regex special characters
|
||||||
|
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const flags = caseSensitive ? 'g' : 'gi'
|
||||||
|
const regex = new RegExp(`(${escapedKeyword})`, flags)
|
||||||
|
|
||||||
|
// Split text by keyword matches
|
||||||
|
const parts = text.split(regex)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
// Check if part matches keyword
|
||||||
|
const isMatch = regex.test(part)
|
||||||
|
regex.lastIndex = 0 // Reset regex state
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
return <mark key={index}>{part}</mark>
|
||||||
|
}
|
||||||
|
return <span key={index}>{part}</span>
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [text, keyword, caseSensitive])
|
||||||
|
|
||||||
|
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
|
||||||
|
|
||||||
|
return <span className={combinedClassName}>{highlightedText}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(HighlightText)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cn } from '@heroui/react'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@@ -17,6 +18,10 @@ export interface HorizontalScrollContainerProps {
|
|||||||
dependencies?: readonly unknown[]
|
dependencies?: readonly unknown[]
|
||||||
scrollDistance?: number
|
scrollDistance?: number
|
||||||
className?: string
|
className?: string
|
||||||
|
classNames?: {
|
||||||
|
container?: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
gap?: string
|
gap?: string
|
||||||
expandable?: boolean
|
expandable?: boolean
|
||||||
}
|
}
|
||||||
@@ -26,6 +31,7 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
|||||||
dependencies = [],
|
dependencies = [],
|
||||||
scrollDistance = 200,
|
scrollDistance = 200,
|
||||||
className,
|
className,
|
||||||
|
classNames,
|
||||||
gap = '8px',
|
gap = '8px',
|
||||||
expandable = false
|
expandable = false
|
||||||
}) => {
|
}) => {
|
||||||
@@ -95,11 +101,16 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
className={className}
|
className={cn(className, classNames?.container)}
|
||||||
$expandable={expandable}
|
$expandable={expandable}
|
||||||
$disableHoverButton={isScrolledToEnd}
|
$disableHoverButton={isScrolledToEnd}
|
||||||
onClick={expandable ? handleContainerClick : undefined}>
|
onClick={expandable ? handleContainerClick : undefined}>
|
||||||
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
|
<ScrollContent
|
||||||
|
ref={scrollRef}
|
||||||
|
$gap={gap}
|
||||||
|
$isExpanded={isExpanded}
|
||||||
|
$expandable={expandable}
|
||||||
|
className={cn(classNames?.content)}>
|
||||||
{children}
|
{children}
|
||||||
</ScrollContent>
|
</ScrollContent>
|
||||||
{canScroll && !isExpanded && !isScrolledToEnd && (
|
{canScroll && !isExpanded && !isScrolledToEnd && (
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||||
{!isReady && (
|
{!isReady && (
|
||||||
<EmptyView>
|
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentAppInfo?.logo}
|
src={currentAppInfo?.logo}
|
||||||
size={80}
|
size={80}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const WebviewContainer = memo(
|
|||||||
onNavigateCallback: (appid: string, url: string) => void
|
onNavigateCallback: (appid: string, url: string) => void
|
||||||
}) => {
|
}) => {
|
||||||
const webviewRef = useRef<WebviewTag | null>(null)
|
const webviewRef = useRef<WebviewTag | null>(null)
|
||||||
const { enableSpellCheck } = useSettings()
|
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
|
||||||
|
|
||||||
const setRef = (appid: string) => {
|
const setRef = (appid: string) => {
|
||||||
onSetRefCallback(appid, null)
|
onSetRefCallback(appid, null)
|
||||||
@@ -76,6 +76,8 @@ const WebviewContainer = memo(
|
|||||||
const webviewId = webviewRef.current?.getWebContentsId()
|
const webviewId = webviewRef.current?.getWebContentsId()
|
||||||
if (webviewId) {
|
if (webviewId) {
|
||||||
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||||
|
// Set link opening behavior for this webview
|
||||||
|
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +106,22 @@ const WebviewContainer = memo(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [appid, url])
|
}, [appid, url])
|
||||||
|
|
||||||
|
// Update webview settings when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webviewRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webviewId = webviewRef.current.getWebContentsId()
|
||||||
|
if (webviewId) {
|
||||||
|
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||||
|
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// WebView may not be ready yet, settings will be applied in dom-ready event
|
||||||
|
logger.debug(`WebView ${appid} not ready for settings update`)
|
||||||
|
}
|
||||||
|
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
|
||||||
|
|
||||||
const WebviewStyle: React.CSSProperties = {
|
const WebviewStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface PopupContainerProps {
|
|||||||
message?: Message
|
message?: Message
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
topic?: Topic
|
topic?: Topic
|
||||||
|
rawContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换文件信息数组为树形结构
|
// 转换文件信息数组为树形结构
|
||||||
@@ -140,7 +141,8 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
resolve,
|
resolve,
|
||||||
message,
|
message,
|
||||||
messages,
|
messages,
|
||||||
topic
|
topic,
|
||||||
|
rawContent
|
||||||
}) => {
|
}) => {
|
||||||
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
|
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
@@ -229,7 +231,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let markdown = ''
|
let markdown = ''
|
||||||
if (topic) {
|
if (rawContent) {
|
||||||
|
markdown = rawContent
|
||||||
|
} else if (topic) {
|
||||||
markdown = await topicToMarkdown(topic, exportReasoning)
|
markdown = await topicToMarkdown(topic, exportReasoning)
|
||||||
} else if (messages && messages.length > 0) {
|
} else if (messages && messages.length > 0) {
|
||||||
markdown = messagesToMarkdown(messages, exportReasoning)
|
markdown = messagesToMarkdown(messages, exportReasoning)
|
||||||
@@ -299,7 +303,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={i18n.t('chat.topics.export.obsidian_atributes')}
|
title={i18n.t('chat.topics.export.obsidian_atributes')}
|
||||||
@@ -410,9 +413,11 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
|||||||
</Option>
|
</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
|
{!rawContent && (
|
||||||
<Switch checked={exportReasoning} onChange={setExportReasoning} />
|
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
|
||||||
</Form.Item>
|
<Switch checked={exportReasoning} onChange={setExportReasoning} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ShowParams {
|
|||||||
providerId: string
|
providerId: string
|
||||||
title?: string
|
title?: string
|
||||||
showHealthCheck?: boolean
|
showHealthCheck?: boolean
|
||||||
|
providerType?: 'llm' | 'webSearch' | 'preprocess'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props extends ShowParams {
|
interface Props extends ShowParams {
|
||||||
@@ -19,7 +20,7 @@ interface Props extends ShowParams {
|
|||||||
/**
|
/**
|
||||||
* API Key 列表弹窗容器组件
|
* API Key 列表弹窗容器组件
|
||||||
*/
|
*/
|
||||||
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
|
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -32,14 +33,20 @@ const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ListComponent = useMemo(() => {
|
const ListComponent = useMemo(() => {
|
||||||
if (isWebSearchProviderId(providerId)) {
|
const type =
|
||||||
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
providerType ||
|
||||||
|
(isWebSearchProviderId(providerId) ? 'webSearch' : isPreprocessProviderId(providerId) ? 'preprocess' : 'llm')
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'webSearch':
|
||||||
|
return <WebSearchApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
|
||||||
|
case 'preprocess':
|
||||||
|
return <DocPreprocessApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
|
||||||
|
case 'llm':
|
||||||
|
default:
|
||||||
|
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||||
}
|
}
|
||||||
if (isPreprocessProviderId(providerId)) {
|
}, [providerId, showHealthCheck, providerType])
|
||||||
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
|
||||||
}
|
|
||||||
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
|
||||||
}, [providerId, showHealthCheck])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface ObsidianExportOptions {
|
|||||||
topic?: Topic
|
topic?: Topic
|
||||||
message?: Message
|
message?: Message
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
|
rawContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ObsidianExportPopup {
|
export default class ObsidianExportPopup {
|
||||||
@@ -24,6 +25,7 @@ export default class ObsidianExportPopup {
|
|||||||
topic={options.topic}
|
topic={options.topic}
|
||||||
message={options.message}
|
message={options.message}
|
||||||
messages={options.messages}
|
messages={options.messages}
|
||||||
|
rawContent={options.rawContent}
|
||||||
obsidianTags={''}
|
obsidianTags={''}
|
||||||
open={true}
|
open={true}
|
||||||
resolve={(v) => {
|
resolve={(v) => {
|
||||||
|
|||||||
@@ -55,12 +55,15 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
|
|||||||
footer={null}>
|
footer={null}>
|
||||||
{extension !== undefined ? (
|
{extension !== undefined ? (
|
||||||
<Editor
|
<Editor
|
||||||
editable={false}
|
readOnly={true}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
height="100%"
|
height="100%"
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
value={text}
|
value={text}
|
||||||
language={extension}
|
language={extension}
|
||||||
|
options={{
|
||||||
|
keymap: true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text>{text}</Text>
|
<Text>{text}</Text>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
cn,
|
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -17,7 +16,7 @@ import {
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import type { Selection } from '@react-types/shared'
|
import type { Selection } from '@react-types/shared'
|
||||||
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
|
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
|
||||||
import { getModelLogo } from '@renderer/config/models'
|
import { agentModelFilter, getModelLogo } from '@renderer/config/models'
|
||||||
import { permissionModeCards } from '@renderer/constants/permissionModes'
|
import { permissionModeCards } from '@renderer/constants/permissionModes'
|
||||||
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
||||||
import { useApiModels } from '@renderer/hooks/agents/useModels'
|
import { useApiModels } from '@renderer/hooks/agents/useModels'
|
||||||
@@ -34,7 +33,7 @@ import {
|
|||||||
UpdateAgentForm
|
UpdateAgentForm
|
||||||
} from '@renderer/types'
|
} from '@renderer/types'
|
||||||
import { AlertTriangleIcon } from 'lucide-react'
|
import { AlertTriangleIcon } from 'lucide-react'
|
||||||
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { ErrorBoundary } from '../../ErrorBoundary'
|
import { ErrorBoundary } from '../../ErrorBoundary'
|
||||||
@@ -57,43 +56,30 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
|
|||||||
name: existing?.name ?? 'Claude Code',
|
name: existing?.name ?? 'Claude Code',
|
||||||
description: existing?.description,
|
description: existing?.description,
|
||||||
instructions: existing?.instructions,
|
instructions: existing?.instructions,
|
||||||
model: existing?.model ?? 'claude-4-sonnet',
|
model: existing?.model ?? '',
|
||||||
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [],
|
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [],
|
||||||
allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : [],
|
allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : [],
|
||||||
mcps: existing?.mcps ? [...existing.mcps] : [],
|
mcps: existing?.mcps ? [...existing.mcps] : [],
|
||||||
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
|
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
|
||||||
})
|
})
|
||||||
|
|
||||||
interface BaseProps {
|
type Props = {
|
||||||
agent?: AgentWithTools
|
agent?: AgentWithTools
|
||||||
}
|
|
||||||
|
|
||||||
interface TriggerProps extends BaseProps {
|
|
||||||
trigger: { content: ReactNode; className?: string }
|
|
||||||
isOpen?: never
|
|
||||||
onClose?: never
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StateProps extends BaseProps {
|
|
||||||
trigger?: never
|
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = TriggerProps | StateProps
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal component for creating or editing an agent.
|
* Modal component for creating or editing an agent.
|
||||||
*
|
*
|
||||||
* Either trigger or isOpen and onClose is given.
|
* Either trigger or isOpen and onClose is given.
|
||||||
* @param agent - Optional agent entity for editing mode.
|
* @param agent - Optional agent entity for editing mode.
|
||||||
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
|
|
||||||
* @param isOpen - Optional controlled modal open state. From useDisclosure.
|
* @param isOpen - Optional controlled modal open state. From useDisclosure.
|
||||||
* @param onClose - Optional callback when modal closes. From useDisclosure.
|
* @param onClose - Optional callback when modal closes. From useDisclosure.
|
||||||
* @returns Modal component for agent creation/editing
|
* @returns Modal component for agent creation/editing
|
||||||
*/
|
*/
|
||||||
export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => {
|
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose }) => {
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
|
const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
// const { setTimeoutTimer } = useTimer()
|
// const { setTimeoutTimer } = useTimer()
|
||||||
@@ -245,14 +231,23 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
|
|||||||
|
|
||||||
const modelOptions = useMemo(() => {
|
const modelOptions = useMemo(() => {
|
||||||
// mocked data. not final version
|
// mocked data. not final version
|
||||||
return (models ?? []).map((model) => ({
|
return (models ?? [])
|
||||||
type: 'model',
|
.filter((m) =>
|
||||||
key: model.id,
|
agentModelFilter({
|
||||||
label: model.name,
|
id: m.id,
|
||||||
avatar: getModelLogo(model.id),
|
provider: m.provider || '',
|
||||||
providerId: model.provider,
|
name: m.name,
|
||||||
providerName: model.provider_name
|
group: ''
|
||||||
})) satisfies ModelOption[]
|
})
|
||||||
|
)
|
||||||
|
.map((model) => ({
|
||||||
|
type: 'model',
|
||||||
|
key: model.id,
|
||||||
|
label: model.name,
|
||||||
|
avatar: getModelLogo(model.id),
|
||||||
|
providerId: model.provider,
|
||||||
|
providerName: model.provider_name
|
||||||
|
})) satisfies ModelOption[]
|
||||||
}, [models])
|
}, [models])
|
||||||
|
|
||||||
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
@@ -350,23 +345,6 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
|
|
||||||
encapsulated component. This is because the Modal component needs to bind the onOpen
|
|
||||||
event handler to the Button for proper focus management.
|
|
||||||
|
|
||||||
Or just use external isOpen/onOpen/onClose to control modal state.
|
|
||||||
*/}
|
|
||||||
|
|
||||||
{trigger && (
|
|
||||||
<div
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onOpen()
|
|
||||||
}}
|
|
||||||
className={cn('w-full', trigger.className)}>
|
|
||||||
{trigger.content}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const SessionModal: React.FC<Props> = ({
|
|||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
// const { setTimeoutTimer } = useTimer()
|
// const { setTimeoutTimer } = useTimer()
|
||||||
const { createSession } = useSessions(agentId)
|
const { createSession } = useSessions(agentId)
|
||||||
const updateSession = useUpdateSession(agentId)
|
const { updateSession } = useUpdateSession(agentId)
|
||||||
const { agent } = useAgent(agentId)
|
const { agent } = useAgent(agentId)
|
||||||
const isEditing = (session?: AgentSessionEntity) => session !== undefined
|
const isEditing = (session?: AgentSessionEntity) => session !== undefined
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Attribute used to store the original source line number in markdown editors
|
||||||
|
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
|
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||||
|
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
|
||||||
import DragHandle from '@tiptap/extension-drag-handle-react'
|
import DragHandle from '@tiptap/extension-drag-handle-react'
|
||||||
import { EditorContent } from '@tiptap/react'
|
import { EditorContent } from '@tiptap/react'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
@@ -29,6 +30,156 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
|
|||||||
import { useRichEditor } from './useRichEditor'
|
import { useRichEditor } from './useRichEditor'
|
||||||
const logger = loggerService.withContext('RichEditor')
|
const logger = loggerService.withContext('RichEditor')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find element by line number with fallback strategies:
|
||||||
|
* 1. Exact line + content match
|
||||||
|
* 2. Exact line match
|
||||||
|
* 3. Closest line <= target
|
||||||
|
*/
|
||||||
|
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
|
||||||
|
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
|
||||||
|
if (allElements.length === 0) {
|
||||||
|
logger.warn('No elements with data-source-line attribute found')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const exactMatches = editorDom.querySelectorAll(
|
||||||
|
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
|
||||||
|
) as NodeListOf<HTMLElement>
|
||||||
|
|
||||||
|
// Strategy 1: Exact line + content match
|
||||||
|
if (exactMatches.length > 1 && lineContent) {
|
||||||
|
for (const match of Array.from(exactMatches)) {
|
||||||
|
if (match.textContent?.includes(lineContent)) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Exact line match
|
||||||
|
if (exactMatches.length > 0) {
|
||||||
|
return exactMatches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Closest line <= target
|
||||||
|
let closestElement: HTMLElement | null = null
|
||||||
|
let closestLine = 0
|
||||||
|
|
||||||
|
for (const el of allElements) {
|
||||||
|
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
|
||||||
|
if (sourceLine <= lineNumber && sourceLine > closestLine) {
|
||||||
|
closestLine = sourceLine
|
||||||
|
closestElement = el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestElement
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create fixed-position highlight overlay at element location
|
||||||
|
* with boundary detection to prevent overflow and toolbar overlap
|
||||||
|
*/
|
||||||
|
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
|
||||||
|
try {
|
||||||
|
// Remove previous overlay
|
||||||
|
const previousOverlay = document.body.querySelector('.highlight-overlay')
|
||||||
|
if (previousOverlay) {
|
||||||
|
previousOverlay.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorWrapper = container.closest('.rich-editor-wrapper')
|
||||||
|
|
||||||
|
// Create overlay at element position
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const overlay = document.createElement('div')
|
||||||
|
overlay.className = 'highlight-overlay animation-locate-highlight'
|
||||||
|
overlay.style.position = 'fixed'
|
||||||
|
overlay.style.left = `${rect.left}px`
|
||||||
|
overlay.style.top = `${rect.top}px`
|
||||||
|
overlay.style.width = `${rect.width}px`
|
||||||
|
overlay.style.height = `${rect.height}px`
|
||||||
|
overlay.style.pointerEvents = 'none'
|
||||||
|
overlay.style.zIndex = '9999'
|
||||||
|
overlay.style.borderRadius = '4px'
|
||||||
|
|
||||||
|
document.body.appendChild(overlay)
|
||||||
|
|
||||||
|
// Update overlay position and visibility on scroll
|
||||||
|
const updatePosition = () => {
|
||||||
|
const newRect = element.getBoundingClientRect()
|
||||||
|
const newContainerRect = container.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
overlay.style.left = `${newRect.left}px`
|
||||||
|
overlay.style.top = `${newRect.top}px`
|
||||||
|
overlay.style.width = `${newRect.width}px`
|
||||||
|
overlay.style.height = `${newRect.height}px`
|
||||||
|
|
||||||
|
// Get current toolbar bottom (it might change)
|
||||||
|
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
|
||||||
|
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
|
||||||
|
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
|
||||||
|
|
||||||
|
// Check if overlay is within visible bounds
|
||||||
|
const overlayTop = newRect.top
|
||||||
|
const overlayBottom = newRect.bottom
|
||||||
|
const visibleTop = currentToolbarBottom // Don't overlap toolbar
|
||||||
|
const visibleBottom = newContainerRect.bottom
|
||||||
|
|
||||||
|
// Hide overlay if any part is outside the visible container area
|
||||||
|
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
|
||||||
|
overlay.style.opacity = '0'
|
||||||
|
overlay.style.visibility = 'hidden'
|
||||||
|
} else {
|
||||||
|
overlay.style.opacity = '1'
|
||||||
|
overlay.style.visibility = 'visible'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('scroll', updatePosition)
|
||||||
|
|
||||||
|
// Auto-remove after animation
|
||||||
|
const handleAnimationEnd = () => {
|
||||||
|
overlay.remove()
|
||||||
|
container.removeEventListener('scroll', updatePosition)
|
||||||
|
overlay.removeEventListener('animationend', handleAnimationEnd)
|
||||||
|
}
|
||||||
|
overlay.addEventListener('animationend', handleAnimationEnd)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create highlight overlay:', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to element and show highlight after scroll completes
|
||||||
|
*/
|
||||||
|
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
|
||||||
|
|
||||||
|
let scrollTimeout: NodeJS.Timeout
|
||||||
|
const handleScroll = () => {
|
||||||
|
clearTimeout(scrollTimeout)
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
container.removeEventListener('scroll', handleScroll)
|
||||||
|
requestAnimationFrame(() => createHighlightOverlay(element, container))
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll)
|
||||||
|
|
||||||
|
// Fallback: if element already in view (no scroll happens)
|
||||||
|
setTimeout(() => {
|
||||||
|
const initialScrollTop = container.scrollTop
|
||||||
|
setTimeout(() => {
|
||||||
|
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
|
||||||
|
container.removeEventListener('scroll', handleScroll)
|
||||||
|
clearTimeout(scrollTimeout)
|
||||||
|
requestAnimationFrame(() => createHighlightOverlay(element, container))
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
const RichEditor = ({
|
const RichEditor = ({
|
||||||
ref,
|
ref,
|
||||||
initialContent = '',
|
initialContent = '',
|
||||||
@@ -48,7 +199,8 @@ const RichEditor = ({
|
|||||||
enableContentSearch = false,
|
enableContentSearch = false,
|
||||||
isFullWidth = false,
|
isFullWidth = false,
|
||||||
fontFamily = 'default',
|
fontFamily = 'default',
|
||||||
fontSize = 16
|
fontSize = 16,
|
||||||
|
enableSpellCheck = false
|
||||||
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
|
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
|
||||||
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
|
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
|
||||||
// Use the rich editor hook for complete editor management
|
// Use the rich editor hook for complete editor management
|
||||||
@@ -71,6 +223,7 @@ const RichEditor = ({
|
|||||||
onBlur,
|
onBlur,
|
||||||
placeholder,
|
placeholder,
|
||||||
editable,
|
editable,
|
||||||
|
enableSpellCheck,
|
||||||
scrollParent: () => scrollContainerRef.current,
|
scrollParent: () => scrollContainerRef.current,
|
||||||
onShowTableActionMenu: ({ position, actions }) => {
|
onShowTableActionMenu: ({ position, actions }) => {
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
@@ -370,6 +523,22 @@ const RichEditor = ({
|
|||||||
scrollContainerRef.current.scrollTop = value
|
scrollContainerRef.current.scrollTop = value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
|
||||||
|
if (!editor || !scrollContainerRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
if (options?.highlight) {
|
||||||
|
scrollAndHighlight(element, scrollContainerRef.current)
|
||||||
|
} else {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed in scrollToLine:', error as Error)
|
||||||
|
}
|
||||||
|
},
|
||||||
// Dynamic command management
|
// Dynamic command management
|
||||||
registerCommand,
|
registerCommand,
|
||||||
registerToolbarCommand,
|
registerToolbarCommand,
|
||||||
|
|||||||