Compare commits
58 Commits
v1.7.0-alp
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d6fbe8f4 | ||
|
|
ff378ca567 | ||
|
|
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 |
2
.github/workflows/auto-i18n.yml
vendored
2
.github/workflows/auto-i18n.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: 📦 Setting Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude-translator.yml
vendored
2
.github/workflows/claude-translator.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/delete-branch.yml
vendored
2
.github/workflows/delete-branch.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Delete merged branch
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.git.deleteRef({
|
||||
|
||||
2
.github/workflows/nightly-build.yml
vendored
2
.github/workflows/nightly-build.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
31
.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch
vendored
Normal file
31
.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch
vendored
Normal file
@@ -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
|
||||
});
|
||||
@@ -125,7 +125,21 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
Optimized note-taking feature, now able to quickly rename by modifying the title
|
||||
Fixed issue where CherryAI free model could not be used
|
||||
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
|
||||
What's New in v1.6.3
|
||||
|
||||
Features:
|
||||
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
|
||||
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
|
||||
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
|
||||
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
|
||||
- Code Tools: Add GitHub Copilot CLI integration
|
||||
|
||||
Bug Fixes:
|
||||
- Fix migration for missing providers
|
||||
- Fix forked topic retaining old name after rename
|
||||
- Restore first token latency reporting in metrics
|
||||
- Fix UI scrollbar and overflow issues
|
||||
|
||||
Technical Updates:
|
||||
- Upgrade to Electron 37.6.0
|
||||
- Update dependencies across packages
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.7.0-alpha.3",
|
||||
"version": "1.7.0-alpha.5",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -78,7 +78,7 @@
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "patch:@anthropic-ai/claude-code@npm%3A1.0.118#~/.yarn/patches/@anthropic-ai-claude-code-npm-1.0.118-bbf4e9e59f.patch",
|
||||
"@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/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",
|
||||
@@ -99,10 +99,10 @@
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.21",
|
||||
"@ai-sdk/google-vertex": "^3.0.27",
|
||||
"@ai-sdk/mistral": "^2.0.14",
|
||||
"@ai-sdk/perplexity": "^2.0.9",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.29",
|
||||
"@ai-sdk/google-vertex": "^3.0.33",
|
||||
"@ai-sdk/mistral": "^2.0.17",
|
||||
"@ai-sdk/perplexity": "^2.0.11",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@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",
|
||||
@@ -219,7 +219,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"ai": "^5.0.44",
|
||||
"ai": "^5.0.59",
|
||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
@@ -244,7 +244,7 @@
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"electron": "37.4.0",
|
||||
"electron": "37.6.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
@@ -36,14 +36,14 @@
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.17",
|
||||
"@ai-sdk/azure": "^2.0.30",
|
||||
"@ai-sdk/deepseek": "^1.0.17",
|
||||
"@ai-sdk/openai": "^2.0.30",
|
||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||
"@ai-sdk/anthropic": "^2.0.22",
|
||||
"@ai-sdk/azure": "^2.0.42",
|
||||
"@ai-sdk/deepseek": "^1.0.20",
|
||||
"@ai-sdk/openai": "^2.0.42",
|
||||
"@ai-sdk/openai-compatible": "^1.0.19",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.9",
|
||||
"@ai-sdk/xai": "^2.0.18",
|
||||
"@ai-sdk/provider-utils": "^3.0.10",
|
||||
"@ai-sdk/xai": "^2.0.23",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -34,6 +34,7 @@ export enum IpcChannel {
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
App_InstallOvmsBinary = 'app:install-ovms-binary',
|
||||
App_LogToMain = 'app:log-to-main',
|
||||
App_SaveData = 'app:save-data',
|
||||
App_GetDiskInfo = 'app:get-disk-info',
|
||||
@@ -187,6 +188,7 @@ export enum IpcChannel {
|
||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||
File_StartWatcher = 'file:startWatcher',
|
||||
File_StopWatcher = 'file:stopWatcher',
|
||||
File_ShowInFolder = 'file:showInFolder',
|
||||
|
||||
// file service
|
||||
FileService_Upload = 'file-service:upload',
|
||||
@@ -224,6 +226,7 @@ export enum IpcChannel {
|
||||
// system
|
||||
System_GetDeviceType = 'system:getDeviceType',
|
||||
System_GetHostname = 'system:getHostname',
|
||||
System_GetCpuName = 'system:getCpuName',
|
||||
|
||||
// DevTools
|
||||
System_ToggleDevTools = 'system:toggleDevTools',
|
||||
@@ -334,6 +337,15 @@ export enum IpcChannel {
|
||||
// OCR
|
||||
OCR_ocr = 'ocr:ocr',
|
||||
|
||||
// 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_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'
|
||||
|
||||
export type ClaudeCodeRawValue =
|
||||
|
||||
@@ -12,8 +12,19 @@ import Anthropic from '@anthropic-ai/sdk'
|
||||
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
|
||||
import { loggerService } from '@logger'
|
||||
import { Provider } from '@types'
|
||||
import type { ModelMessage } from 'ai'
|
||||
|
||||
const logger = loggerService.withContext('anthropic-sdk')
|
||||
|
||||
const defaultClaudeCodeSystemPrompt = `You are Claude Code, Anthropic's official CLI for Claude.`
|
||||
|
||||
const defaultClaudeCodeSystem: Array<TextBlockParam> = [
|
||||
{
|
||||
type: 'text',
|
||||
text: defaultClaudeCodeSystemPrompt
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Creates and configures an Anthropic SDK client based on the provider configuration.
|
||||
*
|
||||
@@ -44,7 +55,11 @@ const logger = loggerService.withContext('anthropic-sdk')
|
||||
* 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 (!oauthToken) {
|
||||
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-arch': 'arm64',
|
||||
'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: {
|
||||
'anthropic-beta': 'output-128k-2025-02-19',
|
||||
'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)
|
||||
* @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> {
|
||||
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
|
||||
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): Array<TextBlockParam> {
|
||||
if (!system) {
|
||||
return defaultClaudeCodeSystem
|
||||
}
|
||||
|
||||
if (typeof system === 'string') {
|
||||
if (system.trim() === defaultClaudeCodeSystem) {
|
||||
return system
|
||||
if (system.trim() === defaultClaudeCodeSystemPrompt || system.trim() === '') {
|
||||
return defaultClaudeCodeSystem
|
||||
} else {
|
||||
return [...defaultClaudeCodeSystem, { type: 'text', text: system }]
|
||||
}
|
||||
}
|
||||
if (Array.isArray(system)) {
|
||||
const firstSystem = system[0]
|
||||
if (firstSystem.type === 'text' && firstSystem.text.trim() === defaultClaudeCodeSystemPrompt) {
|
||||
return system
|
||||
} else {
|
||||
return [...defaultClaudeCodeSystem, ...system]
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: defaultClaudeCodeSystem
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: system
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (system[0].text.trim() != defaultClaudeCodeSystem) {
|
||||
system.unshift({
|
||||
type: 'text',
|
||||
text: defaultClaudeCodeSystem
|
||||
})
|
||||
}
|
||||
|
||||
return system
|
||||
return defaultClaudeCodeSystem
|
||||
}
|
||||
|
||||
export function buildClaudeCodeSystemModelMessage(system?: string | Array<TextBlockParam>): Array<ModelMessage> {
|
||||
const textBlocks = buildClaudeCodeSystemMessage(system)
|
||||
return textBlocks.map((block) => ({
|
||||
role: 'system',
|
||||
content: block.text
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -217,7 +217,8 @@ export enum codeTools {
|
||||
claudeCode = 'claude-code',
|
||||
geminiCli = 'gemini-cli',
|
||||
openaiCodex = 'openai-codex',
|
||||
iFlowCli = 'iflow-cli'
|
||||
iFlowCli = 'iflow-cli',
|
||||
githubCopilotCli = 'github-copilot-cli'
|
||||
}
|
||||
|
||||
export enum terminalApps {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
|
||||
177
resources/scripts/install-ovms.js
Normal file
177
resources/scripts/install-ovms.js
Normal file
@@ -0,0 +1,177 @@
|
||||
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_PKG_NAME = 'ovms250911.zip'
|
||||
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
|
||||
|
||||
/**
|
||||
* Downloads and extracts the OVMS binary for the specified platform
|
||||
*/
|
||||
async function downloadOvmsBinary() {
|
||||
// Create output directory structure - OVMS goes into its own subdirectory
|
||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||
|
||||
// Ensure directories exist
|
||||
fs.mkdirSync(csDir, { recursive: true })
|
||||
|
||||
const csOvmsDir = path.join(csDir, 'ovms')
|
||||
// Delete existing OVMS directory if it exists
|
||||
if (fs.existsSync(csOvmsDir)) {
|
||||
fs.rmSync(csOvmsDir, { recursive: true })
|
||||
}
|
||||
|
||||
const tempdir = os.tmpdir()
|
||||
const tempFilename = path.join(tempdir, 'ovms.zip')
|
||||
|
||||
// Try each URL until one succeeds
|
||||
let downloadSuccess = false
|
||||
let lastError = null
|
||||
|
||||
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
|
||||
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
|
||||
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
|
||||
|
||||
try {
|
||||
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
|
||||
|
||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
||||
await downloadWithPowerShell(downloadUrl, tempFilename)
|
||||
|
||||
// If we get here, download was successful
|
||||
downloadSuccess = true
|
||||
console.log(`Successfully downloaded from: ${downloadUrl}`)
|
||||
break
|
||||
} catch (error) {
|
||||
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
|
||||
lastError = error
|
||||
|
||||
// Clean up failed download file if it exists
|
||||
if (fs.existsSync(tempFilename)) {
|
||||
try {
|
||||
fs.unlinkSync(tempFilename)
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue to next URL if this one failed
|
||||
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
|
||||
console.log(`Trying next URL...`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any download succeeded
|
||||
if (!downloadSuccess) {
|
||||
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
|
||||
return 103
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Extracting to ${csDir}...`)
|
||||
|
||||
// Use tar.exe to extract the ZIP file
|
||||
console.log(`Extracting OVMS to ${csDir}...`)
|
||||
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
|
||||
console.log(`OVMS extracted to ${csDir}`)
|
||||
|
||||
// Clean up temporary file
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Installation directory: ${csDir}`)
|
||||
} catch (error) {
|
||||
console.error(`Error installing OVMS: ${error.message}`)
|
||||
if (fs.existsSync(tempFilename)) {
|
||||
fs.unlinkSync(tempFilename)
|
||||
}
|
||||
|
||||
// Check if ovmsDir is empty and remove it if so
|
||||
try {
|
||||
const ovmsDir = path.join(csDir, 'ovms')
|
||||
const files = fs.readdirSync(ovmsDir)
|
||||
if (files.length === 0) {
|
||||
fs.rmSync(ovmsDir, { recursive: true })
|
||||
console.log(`Removed empty directory: ${ovmsDir}`)
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
|
||||
return 105
|
||||
}
|
||||
|
||||
return 104
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return await downloadOvmsBinary()
|
||||
}
|
||||
|
||||
// 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'
|
||||
}
|
||||
|
||||
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 platformToArch = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
|
||||
import { loggerService } from '@logger'
|
||||
import { Provider } from '@types'
|
||||
import express, { Request, Response } from 'express'
|
||||
|
||||
import { Provider } from '../../../renderer/src/types/provider'
|
||||
import { MessagesService, messagesService } from '../services/messages'
|
||||
import { messagesService } from '../services/messages'
|
||||
import { getProviderById, validateModelId } from '../utils'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
||||
@@ -11,7 +11,7 @@ const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
||||
const router = express.Router()
|
||||
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 }> {
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
@@ -31,157 +31,53 @@ async function validateRequestBody(req: Request): Promise<{ valid: boolean; erro
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
async function handleStreamingResponse(
|
||||
res: Response,
|
||||
request: MessageCreateParams,
|
||||
provider: Provider,
|
||||
messagesService: MessagesService
|
||||
): 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,
|
||||
interface HandleMessageProcessingOptions {
|
||||
req: Request
|
||||
res: Response
|
||||
provider: Provider
|
||||
request: MessageCreateParams
|
||||
modelId?: string
|
||||
): Promise<Response | void> {
|
||||
}
|
||||
|
||||
async function handleMessageProcessing({
|
||||
req,
|
||||
res,
|
||||
provider,
|
||||
request,
|
||||
modelId
|
||||
}: HandleMessageProcessingOptions): Promise<void> {
|
||||
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)
|
||||
if (!validation.isValid) {
|
||||
return res.status(400).json({
|
||||
res.status(400).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
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
|
||||
}
|
||||
|
||||
// Handle non-streaming
|
||||
const response = await messagesService.processMessage(request, provider)
|
||||
return res.json(response)
|
||||
const extraHeaders = messagesService.prepareHeaders(req.headers)
|
||||
const { client, anthropicRequest } = await messagesService.processMessage({
|
||||
provider,
|
||||
request,
|
||||
extraHeaders,
|
||||
modelId
|
||||
})
|
||||
|
||||
if (request.stream) {
|
||||
await messagesService.handleStreaming(client, anthropicRequest, { response: res }, provider)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await client.messages.create(anthropicRequest)
|
||||
res.json(response)
|
||||
} catch (error: any) {
|
||||
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 modelId = modelValidation.modelId!
|
||||
|
||||
// Use shared processing function
|
||||
return await processMessageRequest(req, res, provider, modelId)
|
||||
return handleMessageProcessing({ req, res, provider, request, modelId })
|
||||
} 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)
|
||||
return await processMessageRequest(req, res, provider)
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
return handleMessageProcessing({ req, res, provider, request })
|
||||
} 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', {
|
||||
filter,
|
||||
total: response.total
|
||||
})
|
||||
logger.debug('Model IDs returned', {
|
||||
total: response.total,
|
||||
modelIds: response.data.map((m) => m.id)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,33 +1,93 @@
|
||||
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 anthropicService from '@main/services/AnthropicService'
|
||||
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
|
||||
import { Provider } from '@types'
|
||||
import { Response } from 'express'
|
||||
|
||||
const logger = loggerService.withContext('MessagesService')
|
||||
const EXCLUDED_FORWARD_HEADERS: ReadonlySet<string> = new Set([
|
||||
'host',
|
||||
'x-api-key',
|
||||
'authorization',
|
||||
'sentry-trace',
|
||||
'baggage',
|
||||
'content-length',
|
||||
'connection'
|
||||
])
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
type: 'error'
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
requestId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamConfig {
|
||||
response: Response
|
||||
onChunk?: (chunk: MessageStreamEvent) => void
|
||||
onError?: (error: any) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export interface ProcessMessageOptions {
|
||||
provider: Provider
|
||||
request: MessageCreateParams
|
||||
extraHeaders?: Record<string, string | string[]>
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface ProcessMessageResult {
|
||||
client: Anthropic
|
||||
anthropicRequest: MessageCreateParams
|
||||
}
|
||||
|
||||
export class MessagesService {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
validateRequest(request: MessageCreateParams): ValidationResult {
|
||||
// TODO: Implement comprehensive request validation
|
||||
const errors: string[] = []
|
||||
|
||||
if (!request.model) {
|
||||
if (!request.model || typeof request.model !== 'string') {
|
||||
errors.push('Model is required')
|
||||
}
|
||||
|
||||
if (!request.max_tokens || request.max_tokens < 1) {
|
||||
errors.push('max_tokens is required and must be at least 1')
|
||||
if (typeof request.max_tokens !== 'number' || !Number.isFinite(request.max_tokens) || request.max_tokens < 1) {
|
||||
errors.push('max_tokens is required and must be a positive number')
|
||||
}
|
||||
|
||||
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
|
||||
errors.push('messages is required and must be a non-empty array')
|
||||
} else {
|
||||
request.messages.forEach((message, index) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
errors.push(`messages[${index}] must be an object`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!('role' in message) || typeof message.role !== 'string' || message.role.trim().length === 0) {
|
||||
errors.push(`messages[${index}].role is required`)
|
||||
}
|
||||
|
||||
const content: unknown = message.content
|
||||
if (content === undefined || content === null) {
|
||||
errors.push(`messages[${index}].content is required`)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof content === 'string' && content.trim().length === 0) {
|
||||
errors.push(`messages[${index}].content cannot be empty`)
|
||||
} else if (Array.isArray(content) && content.length === 0) {
|
||||
errors.push(`messages[${index}].content must include at least one item when using an array`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -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
|
||||
if (provider.authType === 'oauth') {
|
||||
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> {
|
||||
logger.debug('Preparing Anthropic message request', {
|
||||
model: request.model,
|
||||
messageCount: request.messages.length,
|
||||
stream: request.stream,
|
||||
maxTokens: request.max_tokens,
|
||||
provider: provider.id
|
||||
})
|
||||
prepareHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[]> {
|
||||
const extraHeaders: Record<string, string | string[]> = {}
|
||||
|
||||
// Create Anthropic client for the provider
|
||||
const client = await this.getClient(provider)
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
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 = {
|
||||
...request,
|
||||
stream: false
|
||||
stream: !!request.stream
|
||||
}
|
||||
|
||||
if (provider.authType === 'oauth') {
|
||||
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system || '')
|
||||
// Override model if provided
|
||||
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', {
|
||||
model: request.model,
|
||||
provider: provider.id
|
||||
})
|
||||
return response
|
||||
return anthropicRequest
|
||||
}
|
||||
|
||||
async *processStreamingMessage(
|
||||
async handleStreaming(
|
||||
client: Anthropic,
|
||||
request: MessageCreateParams,
|
||||
config: StreamConfig,
|
||||
provider: Provider
|
||||
): AsyncIterable<RawMessageStreamEvent> {
|
||||
logger.debug('Preparing streaming Anthropic message request', {
|
||||
model: request.model,
|
||||
messageCount: request.messages.length,
|
||||
provider: provider.id
|
||||
): Promise<void> {
|
||||
const { response, onChunk, onError, onComplete } = config
|
||||
|
||||
// Set streaming headers
|
||||
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
response.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||
response.setHeader('Connection', 'keep-alive')
|
||||
response.setHeader('X-Accel-Buffering', 'no')
|
||||
response.flushHeaders()
|
||||
|
||||
const flushableResponse = response as Response & { flush?: () => void }
|
||||
const flushStream = () => {
|
||||
if (typeof flushableResponse.flush !== 'function') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
flushableResponse.flush()
|
||||
} catch (flushError: unknown) {
|
||||
logger.warn('Failed to flush streaming response', { error: flushError })
|
||||
}
|
||||
}
|
||||
|
||||
const writeSse = (eventType: string | undefined, payload: unknown) => {
|
||||
if (response.writableEnded || response.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (eventType) {
|
||||
response.write(`event: ${eventType}\n`)
|
||||
}
|
||||
|
||||
const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||
response.write(`data: ${data}\n\n`)
|
||||
flushStream()
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = client.messages.stream(request)
|
||||
for await (const chunk of stream) {
|
||||
if (response.writableEnded || response.destroyed) {
|
||||
logger.warn('Streaming response ended before stream completion', {
|
||||
provider: provider.id,
|
||||
model: request.model
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
writeSse(chunk.type, chunk)
|
||||
|
||||
if (onChunk) {
|
||||
onChunk(chunk)
|
||||
}
|
||||
}
|
||||
writeSse(undefined, '[DONE]')
|
||||
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
} catch (streamError: any) {
|
||||
logger.error('Stream error', {
|
||||
error: streamError,
|
||||
provider: provider.id,
|
||||
model: request.model,
|
||||
apiHost: provider.apiHost,
|
||||
anthropicApiHost: provider.anthropicApiHost
|
||||
})
|
||||
writeSse(undefined, {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'Stream processing error'
|
||||
}
|
||||
})
|
||||
|
||||
if (onError) {
|
||||
onError(streamError)
|
||||
}
|
||||
} finally {
|
||||
if (!response.writableEnded) {
|
||||
response.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformError(error: any): { statusCode: number; errorResponse: ErrorResponse } {
|
||||
let statusCode = 500
|
||||
let errorType = 'api_error'
|
||||
let errorMessage = 'Internal server error'
|
||||
|
||||
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
|
||||
const anthropicError = error?.error
|
||||
|
||||
if (anthropicStatus) {
|
||||
statusCode = anthropicStatus
|
||||
}
|
||||
|
||||
if (anthropicError?.type) {
|
||||
errorType = anthropicError.type
|
||||
}
|
||||
|
||||
if (anthropicError?.message) {
|
||||
errorMessage = anthropicError.message
|
||||
} else if (error instanceof Error && error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Infer error type from message if not from Anthropic API
|
||||
if (!anthropicStatus && error instanceof Error) {
|
||||
const errorMessageText = error.message ?? ''
|
||||
|
||||
if (errorMessageText.includes('API key') || errorMessageText.includes('authentication')) {
|
||||
statusCode = 401
|
||||
errorType = 'authentication_error'
|
||||
} else if (errorMessageText.includes('rate limit') || errorMessageText.includes('quota')) {
|
||||
statusCode = 429
|
||||
errorType = 'rate_limit_error'
|
||||
} else if (errorMessageText.includes('timeout') || errorMessageText.includes('connection')) {
|
||||
statusCode = 502
|
||||
errorType = 'api_error'
|
||||
} else if (errorMessageText.includes('validation') || errorMessageText.includes('invalid')) {
|
||||
statusCode = 400
|
||||
errorType = 'invalid_request_error'
|
||||
}
|
||||
}
|
||||
|
||||
const safeErrorMessage =
|
||||
typeof errorMessage === 'string' && errorMessage.length > 0 ? errorMessage : 'Internal server error'
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
errorResponse: {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
message: safeErrorMessage,
|
||||
requestId: error?.request_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async processMessage(options: ProcessMessageOptions): Promise<ProcessMessageResult> {
|
||||
const { provider, request, extraHeaders, modelId } = options
|
||||
|
||||
const client = await this.getClient(provider, extraHeaders)
|
||||
const anthropicRequest = this.createAnthropicRequest(request, provider, modelId)
|
||||
|
||||
const messageCount = Array.isArray(request.messages) ? request.messages.length : 0
|
||||
|
||||
logger.info('Processing anthropic messages request', {
|
||||
provider: provider.id,
|
||||
apiHost: provider.apiHost,
|
||||
anthropicApiHost: provider.anthropicApiHost,
|
||||
model: anthropicRequest.model,
|
||||
stream: !!anthropicRequest.stream,
|
||||
// systemPrompt: JSON.stringify(!!request.system),
|
||||
// messages: JSON.stringify(request.messages),
|
||||
messageCount,
|
||||
toolCount: Array.isArray(request.tools) ? request.tools.length : 0
|
||||
})
|
||||
|
||||
// Create Anthropic client for the provider
|
||||
const client = await this.getClient(provider)
|
||||
|
||||
// Prepare streaming request
|
||||
const streamingRequest: MessageCreateParams = {
|
||||
...request,
|
||||
stream: true
|
||||
// Return client and request for route layer to handle streaming/non-streaming
|
||||
return {
|
||||
client,
|
||||
anthropicRequest
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ const logger = loggerService.withContext('ApiServerUtils')
|
||||
|
||||
// Cache configuration
|
||||
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[]> {
|
||||
try {
|
||||
// Try to get from cache first (faster)
|
||||
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
||||
if (cachedSupportedProviders) {
|
||||
if (cachedSupportedProviders && cachedSupportedProviders.length > 0) {
|
||||
logger.debug('Providers resolved from cache', {
|
||||
count: cachedSupportedProviders.length
|
||||
})
|
||||
|
||||
@@ -45,6 +45,7 @@ import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import OvmsManager from './services/OvmsManager'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
@@ -91,6 +92,7 @@ const obsidianVaultService = new ObsidianVaultService()
|
||||
const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
const ovmsManager = new OvmsManager()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
@@ -463,6 +465,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
// system
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
||||
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
|
||||
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
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_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
||||
|
||||
// file service
|
||||
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_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
||||
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
|
||||
|
||||
//copilot
|
||||
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
|
||||
@@ -872,6 +877,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ocrService.ocr(file, provider)
|
||||
)
|
||||
|
||||
// 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
|
||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ interface VersionInfo {
|
||||
|
||||
class CodeToolsService {
|
||||
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 readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
||||
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
|
||||
@@ -82,6 +85,8 @@ class CodeToolsService {
|
||||
return '@qwen-code/qwen-code'
|
||||
case codeTools.iFlowCli:
|
||||
return '@iflow-ai/iflow-cli'
|
||||
case codeTools.githubCopilotCli:
|
||||
return '@github/copilot'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
@@ -99,6 +104,8 @@ class CodeToolsService {
|
||||
return 'qwen'
|
||||
case codeTools.iFlowCli:
|
||||
return 'iflow'
|
||||
case codeTools.githubCopilotCli:
|
||||
return 'copilot'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
@@ -144,7 +151,9 @@ class CodeToolsService {
|
||||
case terminalApps.powershell:
|
||||
// Check for PowerShell in PATH
|
||||
try {
|
||||
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
|
||||
await execAsync('powershell -Command "Get-Host"', {
|
||||
timeout: 3000
|
||||
})
|
||||
return terminal
|
||||
} catch {
|
||||
try {
|
||||
@@ -384,7 +393,9 @@ class CodeToolsService {
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
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)
|
||||
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
||||
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
||||
@@ -425,7 +436,10 @@ class CodeToolsService {
|
||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||
|
||||
// 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}`)
|
||||
} catch (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> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
586
src/main/services/OvmsManager.ts
Normal file
586
src/main/services/OvmsManager.ts
Normal file
@@ -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,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) {
|
||||
for (const id of ids) {
|
||||
const server = await mcpApiService.getServerInfo(id)
|
||||
if (server) {
|
||||
server.tools.forEach((tool: MCPTool) => {
|
||||
tools.push({
|
||||
id: `mcp_${id}_${tool.name}`,
|
||||
name: tool.name,
|
||||
type: 'mcp',
|
||||
description: tool.description || '',
|
||||
requirePermissions: true
|
||||
try {
|
||||
const server = await mcpApiService.getServerInfo(id)
|
||||
if (server) {
|
||||
server.tools.forEach((tool: MCPTool) => {
|
||||
tools.push({
|
||||
id: `mcp_${id}_${tool.name}`,
|
||||
name: tool.name,
|
||||
type: 'mcp',
|
||||
description: tool.description || '',
|
||||
requirePermissions: true
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to list MCP tools', {
|
||||
id,
|
||||
error: error as Error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
ListOptions
|
||||
} from '@types'
|
||||
import { TextStreamPart } from 'ai'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq, not } from 'drizzle-orm'
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import { sessionMessagesTable } from '../database/schema'
|
||||
@@ -276,7 +276,7 @@ export class SessionMessageService extends BaseService {
|
||||
const result = await this.database
|
||||
.select({ agent_session_id: sessionMessagesTable.agent_session_id })
|
||||
.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))
|
||||
.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 { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
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 { config as apiConfigService } from '@main/apiServer/config'
|
||||
import { validateModelId } from '@main/apiServer/utils'
|
||||
@@ -27,7 +27,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
constructor() {
|
||||
// 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) {
|
||||
this.claudeExecutablePath = this.claudeExecutablePath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
|
||||
}
|
||||
@@ -84,9 +84,13 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
const env = {
|
||||
...loginShellEnvWithoutProxies,
|
||||
ANTHROPIC_API_KEY: apiConfig.apiKey,
|
||||
ANTHROPIC_AUTH_TOKEN: apiConfig.apiKey,
|
||||
ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
|
||||
// TODO: fix the proxy api server
|
||||
// ANTHROPIC_API_KEY: apiConfig.apiKey,
|
||||
// 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_SMALL_FAST_MODEL: modelInfo.modelId,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
@@ -106,7 +110,14 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
logger.warn('claude stderr', { 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,
|
||||
permissionMode: session.configuration?.permission_mode,
|
||||
maxTurns: session.configuration?.max_turns,
|
||||
@@ -135,6 +146,8 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
if (lastAgentSessionId) {
|
||||
options.resume = lastAgentSessionId
|
||||
// TODO: use fork session when we support branching sessions
|
||||
// options.forkSession = true
|
||||
}
|
||||
|
||||
logger.info('Starting Claude Code SDK query', {
|
||||
@@ -211,6 +224,11 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
message,
|
||||
event: JSON.stringify(message.event)
|
||||
})
|
||||
} else {
|
||||
logger.silly('Claude response', {
|
||||
message,
|
||||
event: JSON.stringify(message)
|
||||
})
|
||||
}
|
||||
|
||||
// Transform SDKMessage to UIMessageChunks
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* 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 { loggerService } from '@logger'
|
||||
import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai'
|
||||
@@ -591,6 +591,15 @@ function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>):
|
||||
raw: message
|
||||
}
|
||||
})
|
||||
} else if (message.subtype === 'compact_boundary') {
|
||||
chunks.push({
|
||||
type: 'raw',
|
||||
rawValue: {
|
||||
type: 'compact',
|
||||
session_id: message.session_id,
|
||||
raw: message
|
||||
}
|
||||
})
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ const api = {
|
||||
},
|
||||
system: {
|
||||
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: {
|
||||
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
|
||||
@@ -199,7 +200,8 @@ const api = {
|
||||
}
|
||||
ipcRenderer.on('file-change', listener)
|
||||
return () => ipcRenderer.off('file-change', listener)
|
||||
}
|
||||
},
|
||||
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
|
||||
@@ -286,6 +288,16 @@ const api = {
|
||||
clearAuthCache: (projectId: string, clientEmail?: string) =>
|
||||
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: {
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||
@@ -351,6 +363,7 @@ const api = {
|
||||
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
|
||||
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
|
||||
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
|
||||
installOvmsBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallOvmsBinary),
|
||||
protocol: {
|
||||
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
|
||||
|
||||
@@ -24,6 +24,8 @@ export class AiSdkToChunkAdapter {
|
||||
private isFirstChunk = true
|
||||
private enableWebSearch: boolean = false
|
||||
private onSessionUpdate?: (sessionId: string) => void
|
||||
private responseStartTimestamp: number | null = null
|
||||
private firstTokenTimestamp: number | null = null
|
||||
|
||||
constructor(
|
||||
private onChunk: (chunk: Chunk) => void,
|
||||
@@ -38,6 +40,17 @@ export class AiSdkToChunkAdapter {
|
||||
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 流结果
|
||||
* @param aiSdkResult AI SDK 的流结果对象
|
||||
@@ -65,6 +78,8 @@ export class AiSdkToChunkAdapter {
|
||||
webSearchResults: [],
|
||||
reasoningId: ''
|
||||
}
|
||||
this.resetTimingState()
|
||||
this.responseStartTimestamp = Date.now()
|
||||
// Reset link converter state at the start of stream
|
||||
this.isFirstChunk = true
|
||||
|
||||
@@ -77,6 +92,7 @@ export class AiSdkToChunkAdapter {
|
||||
if (this.enableWebSearch) {
|
||||
const remainingText = flushLinkConverterBuffer()
|
||||
if (remainingText) {
|
||||
this.markFirstTokenIfNeeded()
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: remainingText
|
||||
@@ -91,6 +107,7 @@ export class AiSdkToChunkAdapter {
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
this.resetTimingState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +169,7 @@ export class AiSdkToChunkAdapter {
|
||||
|
||||
// Only emit chunk if there's text to send
|
||||
if (finalText) {
|
||||
this.markFirstTokenIfNeeded()
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: this.accumulate ? final.text : finalText
|
||||
@@ -176,6 +194,9 @@ export class AiSdkToChunkAdapter {
|
||||
break
|
||||
case 'reasoning-delta':
|
||||
final.reasoningContent += chunk.text || ''
|
||||
if (chunk.text) {
|
||||
this.markFirstTokenIfNeeded()
|
||||
}
|
||||
this.onChunk({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: final.reasoningContent || ''
|
||||
@@ -275,44 +296,37 @@ export class AiSdkToChunkAdapter {
|
||||
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({
|
||||
type: ChunkType.BLOCK_COMPLETE,
|
||||
response: {
|
||||
text: final.text || '',
|
||||
reasoning_content: final.reasoningContent || '',
|
||||
usage: {
|
||||
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
|
||||
...baseResponse,
|
||||
usage: { ...usage },
|
||||
metrics: metrics ? { ...metrics } : undefined
|
||||
}
|
||||
})
|
||||
this.onChunk({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
text: final.text || '',
|
||||
reasoning_content: final.reasoningContent || '',
|
||||
usage: {
|
||||
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
|
||||
...baseResponse,
|
||||
usage: { ...usage },
|
||||
metrics: metrics ? { ...metrics } : undefined
|
||||
}
|
||||
})
|
||||
this.resetTimingState()
|
||||
break
|
||||
}
|
||||
|
||||
// === 源和文件相关事件 ===
|
||||
case 'source':
|
||||
@@ -348,6 +362,34 @@ export class AiSdkToChunkAdapter {
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
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 AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
||||
@@ -21,7 +22,6 @@ import LegacyAiProvider from './legacy/index'
|
||||
import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas'
|
||||
import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
|
||||
import { buildPlugins } from './plugins/PluginBuilder'
|
||||
import { buildClaudeCodeSystemMessage } from './provider/config/anthropic'
|
||||
import { createAiSdkProvider } from './provider/factory'
|
||||
import {
|
||||
getActualProvider,
|
||||
@@ -122,13 +122,9 @@ export default class ModernAiProvider {
|
||||
}
|
||||
|
||||
if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') {
|
||||
const claudeCodeSystemMessage = buildClaudeCodeSystemMessage(params.system)
|
||||
const claudeCodeSystemMessage = buildClaudeCodeSystemModelMessage(params.system)
|
||||
params.system = undefined // 清除原有system,避免重复
|
||||
if (Array.isArray(params.messages)) {
|
||||
params.messages = [...claudeCodeSystemMessage, ...params.messages]
|
||||
} else {
|
||||
params.messages = claudeCodeSystemMessage
|
||||
}
|
||||
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
|
||||
}
|
||||
|
||||
if (config.topicId && getEnableDeveloperMode()) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { VertexAPIClient } from './gemini/VertexAPIClient'
|
||||
import { NewAPIClient } from './newapi/NewAPIClient'
|
||||
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||
import { OVMSClient } from './ovms/OVMSClient'
|
||||
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
||||
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
|
||||
|
||||
@@ -63,6 +64,12 @@ export class ApiClientFactory {
|
||||
return instance
|
||||
}
|
||||
|
||||
if (provider.id === 'ovms') {
|
||||
logger.debug(`Creating OVMSClient for provider: ${provider.id}`)
|
||||
instance = new OVMSClient(provider) as BaseApiClient
|
||||
return instance
|
||||
}
|
||||
|
||||
// 然后检查标准的 Provider Type
|
||||
switch (provider.type) {
|
||||
case 'openai':
|
||||
|
||||
56
src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts
Normal file
56
src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts
Normal file
@@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ import { generateText } from 'ai'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { MemoryProcessor } from '../../services/MemoryProcessor'
|
||||
import { exaSearchTool } from '../tools/ExaSearchTool'
|
||||
import { knowledgeSearchTool } from '../tools/KnowledgeSearchTool'
|
||||
import { memorySearchTool } from '../tools/MemorySearchTool'
|
||||
import { tavilySearchTool } from '../tools/TavilySearchTool'
|
||||
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
|
||||
|
||||
const logger = loggerService.withContext('SearchOrchestrationPlugin')
|
||||
@@ -316,13 +318,28 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
|
||||
const needsSearch = analysisResult.websearch.question && analysisResult.websearch.question[0] !== 'not_needed'
|
||||
|
||||
if (needsSearch) {
|
||||
// onChunk({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS })
|
||||
// logger.info('🌐 Adding web search tool with pre-extracted keywords')
|
||||
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
|
||||
assistant.webSearchProviderId,
|
||||
analysisResult.websearch,
|
||||
context.requestId
|
||||
)
|
||||
// 根据 Provider ID 动态选择工具
|
||||
switch (assistant.webSearchProviderId) {
|
||||
case 'exa':
|
||||
logger.info('🌐 Adding Exa search tool (provider-specific)')
|
||||
// Exa 工具直接接受单个查询字符串,使用第一个问题或合并所有问题
|
||||
params.tools['builtin_exa_search'] = exaSearchTool(context.requestId)
|
||||
break
|
||||
case 'tavily':
|
||||
logger.info('🌐 Adding Tavily search tool (provider-specific)')
|
||||
// Tavily 工具直接接受单个查询字符串
|
||||
params.tools['builtin_tavily_search'] = tavilySearchTool(context.requestId)
|
||||
break
|
||||
default:
|
||||
logger.info('🌐 Adding web search tool with pre-extracted keywords')
|
||||
// 其他 Provider 使用通用的 WebSearchTool
|
||||
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
|
||||
assistant.webSearchProviderId,
|
||||
analysisResult.websearch,
|
||||
context.requestId
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
166
src/renderer/src/aiCore/tools/ExaSearchTool.ts
Normal file
166
src/renderer/src/aiCore/tools/ExaSearchTool.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
|
||||
import { ExtractResults } from '@renderer/utils/extract'
|
||||
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
|
||||
const logger = loggerService.withContext('ExaSearchTool')
|
||||
|
||||
/**
|
||||
* Exa 专用搜索工具 - 暴露 Exa 的高级搜索能力给 LLM
|
||||
* 支持 Neural Search、Category Filtering、Date Range 等功能
|
||||
*/
|
||||
export const exaSearchTool = (requestId: string) => {
|
||||
const webSearchProvider = WebSearchService.getWebSearchProvider('exa')
|
||||
|
||||
if (!webSearchProvider) {
|
||||
throw new Error('Exa provider not found or not configured')
|
||||
}
|
||||
|
||||
return tool({
|
||||
name: 'builtin_exa_search',
|
||||
description: `Advanced AI-powered search using Exa.ai with neural understanding and filtering capabilities.
|
||||
|
||||
Key Features:
|
||||
- Neural Search: AI-powered semantic search that understands intent
|
||||
- Search Type: Choose between neural (AI), keyword (traditional), or auto mode
|
||||
- Category Filter: Focus on specific content types (company, research paper, news, etc.)
|
||||
- Date Range: Filter by publication date
|
||||
- Auto-prompt: Let Exa optimize your query automatically
|
||||
|
||||
Best for: Research, finding specific types of content, semantic search, and understanding complex queries.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query - be specific and clear'),
|
||||
numResults: z.number().min(1).max(20).optional().describe('Number of results to return (1-20, default: 5)'),
|
||||
type: z
|
||||
.enum(['neural', 'keyword', 'auto', 'fast'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Search type: neural (embeddings-based), keyword (Google-like SERP), auto (default, intelligently combines both), or fast (streamlined versions)'
|
||||
),
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Filter by content category: company, research paper, news, github, tweet, movie, song, personal site, pdf, etc.'
|
||||
),
|
||||
startPublishedDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Start date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||
endPublishedDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('End date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||
startCrawlDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Start date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||
endCrawlDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('End date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
|
||||
useAutoprompt: z.boolean().optional().describe('Let Exa optimize your query automatically (recommended: true)')
|
||||
}),
|
||||
|
||||
execute: async (params, { abortSignal }) => {
|
||||
// 构建 provider 特定参数(排除 query 和 numResults,这些由系统控制)
|
||||
const providerParams: ProviderSpecificParams = {
|
||||
exa: {
|
||||
type: params.type,
|
||||
category: params.category,
|
||||
startPublishedDate: params.startPublishedDate,
|
||||
endPublishedDate: params.endPublishedDate,
|
||||
startCrawlDate: params.startCrawlDate,
|
||||
endCrawlDate: params.endCrawlDate,
|
||||
useAutoprompt: params.useAutoprompt
|
||||
}
|
||||
}
|
||||
// 构建 ExtractResults 结构
|
||||
const extractResults: ExtractResults = {
|
||||
websearch: {
|
||||
question: [params.query]
|
||||
}
|
||||
}
|
||||
|
||||
// 统一调用 processWebsearch - 保留所有中间件(时间戳、黑名单、tracing、压缩)
|
||||
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
|
||||
webSearchProvider,
|
||||
extractResults,
|
||||
requestId,
|
||||
abortSignal,
|
||||
providerParams
|
||||
)
|
||||
|
||||
logger.info(`Exa search completed: ${finalResults.results.length} results for "${params.query}"`)
|
||||
|
||||
return finalResults
|
||||
},
|
||||
|
||||
toModelOutput: (results) => {
|
||||
let summary = 'No search results found.'
|
||||
if (results.query && results.results.length > 0) {
|
||||
summary = `Found ${results.results.length} relevant sources using Exa AI search. Use [number] format to cite specific information.`
|
||||
}
|
||||
|
||||
const citationData = results.results.map((result, index) => {
|
||||
const citation: any = {
|
||||
number: index + 1,
|
||||
title: result.title,
|
||||
content: result.content,
|
||||
url: result.url
|
||||
}
|
||||
|
||||
// 添加 Exa 特有的元数据
|
||||
if ('favicon' in result && result.favicon) {
|
||||
citation.favicon = result.favicon
|
||||
}
|
||||
if ('author' in result && result.author) {
|
||||
citation.author = result.author
|
||||
}
|
||||
if ('publishedDate' in result && result.publishedDate) {
|
||||
citation.publishedDate = result.publishedDate
|
||||
}
|
||||
if ('score' in result && result.score !== undefined) {
|
||||
citation.score = result.score
|
||||
}
|
||||
if ('highlights' in result && result.highlights) {
|
||||
citation.highlights = result.highlights
|
||||
}
|
||||
|
||||
return citation
|
||||
})
|
||||
|
||||
// 使用 REFERENCE_PROMPT 格式化引用
|
||||
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
|
||||
const fullInstructions = REFERENCE_PROMPT.replace(
|
||||
'{question}',
|
||||
"Based on the Exa search results, please answer the user's question with proper citations."
|
||||
).replace('{references}', referenceContent)
|
||||
|
||||
return {
|
||||
type: 'content',
|
||||
value: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Exa AI Search: Neural search with semantic understanding and rich metadata (author, publish date, highlights).'
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: summary
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: fullInstructions
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ExaSearchToolOutput = InferToolOutput<ReturnType<typeof exaSearchTool>>
|
||||
export type ExaSearchToolInput = InferToolInput<ReturnType<typeof exaSearchTool>>
|
||||
161
src/renderer/src/aiCore/tools/TavilySearchTool.ts
Normal file
161
src/renderer/src/aiCore/tools/TavilySearchTool.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
|
||||
import { ExtractResults } from '@renderer/utils/extract'
|
||||
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
|
||||
const logger = loggerService.withContext('TavilySearchTool')
|
||||
|
||||
/**
|
||||
* Tavily 专用搜索工具 - 暴露 Tavily 的高级搜索能力给 LLM
|
||||
* 支持 AI-powered answers、Search depth control、Topic filtering 等功能
|
||||
*/
|
||||
export const tavilySearchTool = (requestId: string) => {
|
||||
const webSearchProvider = WebSearchService.getWebSearchProvider('tavily')
|
||||
|
||||
if (!webSearchProvider) {
|
||||
throw new Error('Tavily provider not found or not configured')
|
||||
}
|
||||
|
||||
return tool({
|
||||
name: 'builtin_tavily_search',
|
||||
description: `AI-powered search using Tavily with direct answers and comprehensive content extraction.
|
||||
|
||||
Key Features:
|
||||
- Direct AI Answer: Get a concise, factual answer extracted from search results
|
||||
- Search Depth: Choose between basic (fast) or advanced (comprehensive) search
|
||||
- Topic Focus: Filter by general, news, or finance topics
|
||||
- Full Content: Access complete webpage content, not just snippets
|
||||
- Rich Media: Optionally include relevant images from search results
|
||||
|
||||
Best for: Quick factual answers, news monitoring, financial research, and comprehensive content analysis.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query - be specific and clear'),
|
||||
maxResults: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(20)
|
||||
.optional()
|
||||
.describe('Maximum number of results to return (1-20, default: 5)'),
|
||||
topic: z
|
||||
.enum(['general', 'news', 'finance'])
|
||||
.optional()
|
||||
.describe('Topic filter: general (default), news (latest news), or finance (financial/market data)'),
|
||||
searchDepth: z
|
||||
.enum(['basic', 'advanced'])
|
||||
.optional()
|
||||
.describe('Search depth: basic (faster, top results) or advanced (slower, more comprehensive)'),
|
||||
includeAnswer: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Include AI-generated direct answer extracted from results (default: true)'),
|
||||
includeRawContent: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Include full webpage content instead of just snippets (default: true)'),
|
||||
includeImages: z.boolean().optional().describe('Include relevant images from search results (default: false)')
|
||||
}),
|
||||
|
||||
execute: async (params, { abortSignal }) => {
|
||||
try {
|
||||
// 构建 provider 特定参数
|
||||
const providerParams: ProviderSpecificParams = {
|
||||
tavily: {
|
||||
topic: params.topic,
|
||||
searchDepth: params.searchDepth,
|
||||
includeAnswer: params.includeAnswer,
|
||||
includeRawContent: params.includeRawContent,
|
||||
includeImages: params.includeImages
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 ExtractResults 结构
|
||||
const extractResults: ExtractResults = {
|
||||
websearch: {
|
||||
question: [params.query]
|
||||
}
|
||||
}
|
||||
|
||||
// 统一调用 processWebsearch - 保留所有中间件(时间戳、黑名单、tracing、压缩)
|
||||
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
|
||||
webSearchProvider,
|
||||
extractResults,
|
||||
requestId,
|
||||
abortSignal,
|
||||
providerParams
|
||||
)
|
||||
|
||||
logger.info(`Tavily search completed: ${finalResults.results.length} results for "${params.query}"`)
|
||||
|
||||
return finalResults
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
logger.info('Tavily search aborted')
|
||||
throw error
|
||||
}
|
||||
logger.error('Tavily search failed:', error as Error)
|
||||
throw new Error(`Tavily search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
},
|
||||
|
||||
toModelOutput: (results) => {
|
||||
let summary = 'No search results found.'
|
||||
if (results.query && results.results.length > 0) {
|
||||
summary = `Found ${results.results.length} relevant sources using Tavily AI search. Use [number] format to cite specific information.`
|
||||
}
|
||||
|
||||
const citationData = results.results.map((result, index) => {
|
||||
const citation: any = {
|
||||
number: index + 1,
|
||||
title: result.title,
|
||||
content: result.content,
|
||||
url: result.url
|
||||
}
|
||||
|
||||
// 添加 Tavily 特有的元数据
|
||||
if ('answer' in result && result.answer) {
|
||||
citation.answer = result.answer // Tavily 的直接答案
|
||||
}
|
||||
if ('images' in result && result.images && result.images.length > 0) {
|
||||
citation.images = result.images // Tavily 的图片
|
||||
}
|
||||
if ('score' in result && result.score !== undefined) {
|
||||
citation.score = result.score
|
||||
}
|
||||
|
||||
return citation
|
||||
})
|
||||
|
||||
// 使用 REFERENCE_PROMPT 格式化引用
|
||||
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
|
||||
const fullInstructions = REFERENCE_PROMPT.replace(
|
||||
'{question}',
|
||||
"Based on the Tavily search results, please answer the user's question with proper citations."
|
||||
).replace('{references}', referenceContent)
|
||||
|
||||
return {
|
||||
type: 'content',
|
||||
value: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Tavily AI Search: AI-powered with direct answers, full content extraction, and optional image results.'
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: summary
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: fullInstructions
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type TavilySearchToolOutput = InferToolOutput<ReturnType<typeof tavilySearchTool>>
|
||||
export type TavilySearchToolInput = InferToolInput<ReturnType<typeof tavilySearchTool>>
|
||||
@@ -40,7 +40,7 @@ You can use this tool as-is to search with the prepared queries, or provide addi
|
||||
.describe('Optional additional context, keywords, or specific focus to enhance the search')
|
||||
}),
|
||||
|
||||
execute: async ({ additionalContext }) => {
|
||||
execute: async ({ additionalContext }, { abortSignal }) => {
|
||||
let finalQueries = [...extractedKeywords.question]
|
||||
|
||||
if (additionalContext?.trim()) {
|
||||
@@ -67,7 +67,15 @@ You can use this tool as-is to search with the prepared queries, or provide addi
|
||||
links: extractedKeywords.links
|
||||
}
|
||||
}
|
||||
searchResults = await WebSearchService.processWebsearch(webSearchProvider!, extractResults, requestId)
|
||||
// abortSignal?.addEventListener('abort', () => {
|
||||
// console.log('tool_call_abortSignal', abortSignal?.aborted)
|
||||
// })
|
||||
searchResults = await WebSearchService.processWebsearch(
|
||||
webSearchProvider!,
|
||||
extractResults,
|
||||
requestId,
|
||||
abortSignal
|
||||
)
|
||||
|
||||
return searchResults
|
||||
},
|
||||
|
||||
BIN
src/renderer/src/assets/images/models/gpt-5-codex.png
Normal file
BIN
src/renderer/src/assets/images/models/gpt-5-codex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
src/renderer/src/assets/images/providers/intel.png
Normal file
BIN
src/renderer/src/assets/images/providers/intel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -75,10 +75,15 @@ export interface CodeEditorProps {
|
||||
/** CSS class name appended to the default `code-editor` class. */
|
||||
className?: string
|
||||
/**
|
||||
* Whether the editor is editable.
|
||||
* Whether the editor view is editable.
|
||||
* @default true
|
||||
*/
|
||||
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.
|
||||
* If true, the height and maxHeight props are ignored.
|
||||
@@ -114,6 +119,7 @@ const CodeEditor = ({
|
||||
style,
|
||||
className,
|
||||
editable = true,
|
||||
readOnly = false,
|
||||
expanded = true,
|
||||
wrapped = true
|
||||
}: CodeEditorProps) => {
|
||||
@@ -189,6 +195,7 @@ const CodeEditor = ({
|
||||
maxHeight={expanded ? undefined : maxHeight}
|
||||
minHeight={minHeight}
|
||||
editable={editable}
|
||||
readOnly={readOnly}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
extensions={customExtensions}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from '@heroui/react'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -17,6 +18,10 @@ export interface HorizontalScrollContainerProps {
|
||||
dependencies?: readonly unknown[]
|
||||
scrollDistance?: number
|
||||
className?: string
|
||||
classNames?: {
|
||||
container?: string
|
||||
content?: string
|
||||
}
|
||||
gap?: string
|
||||
expandable?: boolean
|
||||
}
|
||||
@@ -26,6 +31,7 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
||||
dependencies = [],
|
||||
scrollDistance = 200,
|
||||
className,
|
||||
classNames,
|
||||
gap = '8px',
|
||||
expandable = false
|
||||
}) => {
|
||||
@@ -95,11 +101,16 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={className}
|
||||
className={cn(className, classNames?.container)}
|
||||
$expandable={expandable}
|
||||
$disableHoverButton={isScrolledToEnd}
|
||||
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}
|
||||
</ScrollContent>
|
||||
{canScroll && !isExpanded && !isScrolledToEnd && (
|
||||
|
||||
@@ -38,6 +38,7 @@ interface PopupContainerProps {
|
||||
message?: Message
|
||||
messages?: Message[]
|
||||
topic?: Topic
|
||||
rawContent?: string
|
||||
}
|
||||
|
||||
// 转换文件信息数组为树形结构
|
||||
@@ -140,7 +141,8 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
||||
resolve,
|
||||
message,
|
||||
messages,
|
||||
topic
|
||||
topic,
|
||||
rawContent
|
||||
}) => {
|
||||
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
|
||||
const [state, setState] = useState({
|
||||
@@ -229,7 +231,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
||||
return
|
||||
}
|
||||
let markdown = ''
|
||||
if (topic) {
|
||||
if (rawContent) {
|
||||
markdown = rawContent
|
||||
} else if (topic) {
|
||||
markdown = await topicToMarkdown(topic, exportReasoning)
|
||||
} else if (messages && messages.length > 0) {
|
||||
markdown = messagesToMarkdown(messages, exportReasoning)
|
||||
@@ -299,7 +303,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={i18n.t('chat.topics.export.obsidian_atributes')}
|
||||
@@ -410,9 +413,11 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
|
||||
<Switch checked={exportReasoning} onChange={setExportReasoning} />
|
||||
</Form.Item>
|
||||
{!rawContent && (
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
|
||||
<Switch checked={exportReasoning} onChange={setExportReasoning} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ interface ObsidianExportOptions {
|
||||
topic?: Topic
|
||||
message?: Message
|
||||
messages?: Message[]
|
||||
rawContent?: string
|
||||
}
|
||||
|
||||
export default class ObsidianExportPopup {
|
||||
@@ -24,6 +25,7 @@ export default class ObsidianExportPopup {
|
||||
topic={options.topic}
|
||||
message={options.message}
|
||||
messages={options.messages}
|
||||
rawContent={options.rawContent}
|
||||
obsidianTags={''}
|
||||
open={true}
|
||||
resolve={(v) => {
|
||||
|
||||
@@ -55,12 +55,15 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
|
||||
footer={null}>
|
||||
{extension !== undefined ? (
|
||||
<Editor
|
||||
editable={false}
|
||||
readOnly={true}
|
||||
expanded={false}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
value={text}
|
||||
language={extension}
|
||||
options={{
|
||||
keymap: true
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text>{text}</Text>
|
||||
|
||||
@@ -48,7 +48,8 @@ const RichEditor = ({
|
||||
enableContentSearch = false,
|
||||
isFullWidth = false,
|
||||
fontFamily = 'default',
|
||||
fontSize = 16
|
||||
fontSize = 16,
|
||||
enableSpellCheck = false
|
||||
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
|
||||
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
|
||||
// Use the rich editor hook for complete editor management
|
||||
@@ -71,6 +72,7 @@ const RichEditor = ({
|
||||
onBlur,
|
||||
placeholder,
|
||||
editable,
|
||||
enableSpellCheck,
|
||||
scrollParent: () => scrollContainerRef.current,
|
||||
onShowTableActionMenu: ({ position, actions }) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
|
||||
@@ -14,6 +14,31 @@ export const RichEditorWrapper = styled.div<{
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
overflow-y: hidden;
|
||||
.ProseMirror table,
|
||||
.tiptap table {
|
||||
table-layout: auto !important;
|
||||
}
|
||||
|
||||
.ProseMirror table th,
|
||||
.ProseMirror table td,
|
||||
.tiptap th,
|
||||
.tiptap td {
|
||||
white-space: normal !important;
|
||||
word-wrap: break-word !important;
|
||||
word-break: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
|
||||
.ProseMirror table th > *,
|
||||
.ProseMirror table td > *,
|
||||
.tiptap td > *,
|
||||
.tiptap th > * {
|
||||
white-space: normal !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
|
||||
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
|
||||
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
|
||||
@@ -21,6 +46,7 @@ export const RichEditorWrapper = styled.div<{
|
||||
|
||||
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
|
||||
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
|
||||
|
||||
`
|
||||
|
||||
export const ToolbarWrapper = styled.div`
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface RichEditorProps {
|
||||
fontFamily?: 'default' | 'serif'
|
||||
/** Font size in pixels */
|
||||
fontSize?: number
|
||||
/** Whether to enable spell check */
|
||||
enableSpellCheck?: boolean
|
||||
}
|
||||
|
||||
export interface ToolbarItem {
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface UseRichEditorOptions {
|
||||
editable?: boolean
|
||||
/** Whether to enable table of contents functionality */
|
||||
enableTableOfContents?: boolean
|
||||
/** Whether to enable spell check */
|
||||
enableSpellCheck?: boolean
|
||||
/** Show table action menu (row/column) with concrete actions and position */
|
||||
onShowTableActionMenu?: (payload: {
|
||||
type: 'row' | 'column'
|
||||
@@ -126,6 +128,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
previewLength = 50,
|
||||
placeholder = '',
|
||||
editable = true,
|
||||
enableSpellCheck = false,
|
||||
onShowTableActionMenu,
|
||||
scrollParent
|
||||
} = options
|
||||
@@ -410,7 +413,9 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
// Allow text selection even when not editable
|
||||
style: editable
|
||||
? ''
|
||||
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;'
|
||||
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;',
|
||||
// Set spellcheck attribute on the contenteditable element
|
||||
spellcheck: enableSpellCheck ? 'true' : 'false'
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
|
||||
@@ -237,7 +237,17 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
onSortEnd={onSortEnd}
|
||||
className="tabs-sortable"
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<Tab
|
||||
key={tab.id}
|
||||
active={tab.id === activeTabId}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button === 1 && tab.id !== 'home') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}
|
||||
}}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
|
||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const DEFAULT_CLAUDE_CODE_CONFIG: Omit<AgentBase, 'model'> = {
|
||||
...DEFAULT_AGENT_CONFIG
|
||||
} as const
|
||||
|
||||
export const getAgentDefaultAvatar = (type: AgentType): string => {
|
||||
export const getAgentTypeAvatar = (type: AgentType): string => {
|
||||
switch (type) {
|
||||
case 'claude-code':
|
||||
return ClaudeAvatar
|
||||
|
||||
@@ -145,7 +145,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'dashscope',
|
||||
name: i18n.t('minapps.qwen'),
|
||||
url: 'https://tongyi.aliyun.com/qianwen/',
|
||||
url: 'https://www.tongyi.com/',
|
||||
logo: QwenModelLogo
|
||||
},
|
||||
{
|
||||
|
||||
@@ -260,6 +260,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
|
||||
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
|
||||
],
|
||||
ovms: [],
|
||||
ollama: [],
|
||||
lmstudio: [],
|
||||
silicon: [
|
||||
@@ -429,6 +430,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
}
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
group: 'Claude 4.5'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
provider: 'anthropic',
|
||||
@@ -697,6 +704,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
name: 'GLM-4.5-Flash',
|
||||
group: 'GLM-4.5'
|
||||
},
|
||||
{
|
||||
id: 'glm-4.6',
|
||||
provider: 'zhipu',
|
||||
name: 'GLM-4.6',
|
||||
group: 'GLM-4.6'
|
||||
},
|
||||
{
|
||||
id: 'glm-4.5',
|
||||
provider: 'zhipu',
|
||||
|
||||
@@ -61,6 +61,7 @@ import ChatGPTImageModelLogo from '@renderer/assets/images/models/gpt_image_1.pn
|
||||
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
|
||||
import GPT5ModelLogo from '@renderer/assets/images/models/gpt-5.png'
|
||||
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
|
||||
import GPT5CodexModelLogo from '@renderer/assets/images/models/gpt-5-codex.png'
|
||||
import GPT5MiniModelLogo from '@renderer/assets/images/models/gpt-5-mini.png'
|
||||
import GPT5NanoModelLogo from '@renderer/assets/images/models/gpt-5-nano.png'
|
||||
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
|
||||
@@ -162,6 +163,7 @@ export function getModelLogo(modelId: string) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// key is regex
|
||||
const logoMap = {
|
||||
pixtral: isLight ? PixtralModelLogo : PixtralModelLogoDark,
|
||||
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
|
||||
@@ -177,6 +179,7 @@ export function getModelLogo(modelId: string) {
|
||||
'gpt-5-mini': GPT5MiniModelLogo,
|
||||
'gpt-5-nano': GPT5NanoModelLogo,
|
||||
'gpt-5-chat': GPT5ChatModelLogo,
|
||||
'gpt-5-codex': GPT5CodexModelLogo,
|
||||
'gpt-5': GPT5ModelLogo,
|
||||
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||
@@ -286,7 +289,7 @@ export function getModelLogo(modelId: string) {
|
||||
longcat: LongCatAppLogo,
|
||||
bytedance: BytedanceModelLogo,
|
||||
'(V_1|V_1_TURBO|V_2|V_2A|V_2_TURBO|DESCRIBE|UPSCALE)': IdeogramModelLogo
|
||||
}
|
||||
} as const
|
||||
|
||||
for (const key in logoMap) {
|
||||
const regex = new RegExp(key, 'i')
|
||||
|
||||
@@ -22,6 +22,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
default: ['low', 'medium', 'high'] as const,
|
||||
o: ['low', 'medium', 'high'] as const,
|
||||
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
||||
gpt5_codex: ['low', 'medium', 'high'] as const,
|
||||
grok: ['low', 'high'] as const,
|
||||
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
||||
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||
@@ -40,6 +41,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
||||
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
||||
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
||||
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
|
||||
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
||||
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
||||
@@ -55,8 +57,13 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
|
||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
let thinkingModelType: ThinkingModelType = 'default'
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
if (isGPT5SeriesModel(model)) {
|
||||
thinkingModelType = 'gpt5'
|
||||
if (modelId.includes('codex')) {
|
||||
thinkingModelType = 'gpt5_codex'
|
||||
} else {
|
||||
thinkingModelType = 'gpt5'
|
||||
}
|
||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
thinkingModelType = 'o'
|
||||
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
@@ -171,9 +178,13 @@ export function isGeminiReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Gemini 支持思考模式的模型正则
|
||||
export const GEMINI_THINKING_MODEL_REGEX =
|
||||
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
|
||||
|
||||
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
if (modelId.includes('gemini-2.5')) {
|
||||
if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
|
||||
if (modelId.includes('image') || modelId.includes('tts')) {
|
||||
return false
|
||||
}
|
||||
@@ -328,14 +339,20 @@ export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean
|
||||
|
||||
export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
return modelId.includes('glm-4.5')
|
||||
return ['glm-4.5', 'glm-4.6'].some((id) => modelId.includes(id))
|
||||
}
|
||||
|
||||
export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||
return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
|
||||
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
|
||||
// until the end of the string.
|
||||
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
|
||||
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
|
||||
// TODO: move to utils and add test cases
|
||||
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
}
|
||||
|
||||
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||
|
||||
@@ -12,6 +12,7 @@ const visionAllowedModels = [
|
||||
'gemini-1\\.5',
|
||||
'gemini-2\\.0',
|
||||
'gemini-2\\.5',
|
||||
'gemini-(flash|pro|flash-lite)-latest',
|
||||
'gemini-exp',
|
||||
'claude-3',
|
||||
'claude-sonnet-4',
|
||||
@@ -21,7 +22,9 @@ const visionAllowedModels = [
|
||||
'qwen-vl',
|
||||
'qwen2-vl',
|
||||
'qwen2.5-vl',
|
||||
'qwen3-vl',
|
||||
'qwen2.5-omni',
|
||||
'qwen3-omni',
|
||||
'qvq',
|
||||
'internvl2',
|
||||
'grok-vision-beta',
|
||||
|
||||
@@ -11,9 +11,12 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||
'i'
|
||||
)
|
||||
|
||||
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
|
||||
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
|
||||
|
||||
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
|
||||
export const GEMINI_SEARCH_REGEX = new RegExp(
|
||||
'gemini-(?:2.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
|
||||
'i'
|
||||
)
|
||||
|
||||
export const PERPLEXITY_SEARCH_MODELS = [
|
||||
'sonar-pro',
|
||||
|
||||
@@ -24,6 +24,7 @@ import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
|
||||
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
|
||||
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
|
||||
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
|
||||
import IntelOvmsLogo from '@renderer/assets/images/providers/intel.png'
|
||||
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
|
||||
import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png'
|
||||
import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png'
|
||||
@@ -74,6 +75,7 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.cherry-ai.com/',
|
||||
anthropicApiHost: 'https://api.cherry-ai.com',
|
||||
models: [glm45FlashModel, qwen38bModel],
|
||||
isSystem: true,
|
||||
enabled: true
|
||||
@@ -112,6 +114,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ovms: {
|
||||
id: 'ovms',
|
||||
name: 'OpenVINO Model Server',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'http://localhost:8000/v3/',
|
||||
models: SYSTEM_MODELS.ovms,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ocoolai: {
|
||||
id: 'ocoolai',
|
||||
name: 'ocoolAI',
|
||||
@@ -280,6 +292,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'http://localhost:3000',
|
||||
anthropicApiHost: 'http://localhost:3000',
|
||||
models: SYSTEM_MODELS['new-api'],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
@@ -657,6 +670,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
yi: ZeroOneProviderLogo,
|
||||
groq: GroqProviderLogo,
|
||||
zhipu: ZhipuProviderLogo,
|
||||
ovms: IntelOvmsLogo,
|
||||
ollama: OllamaProviderLogo,
|
||||
lmstudio: LMStudioProviderLogo,
|
||||
moonshot: MoonshotProviderLogo,
|
||||
@@ -1042,6 +1056,16 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
models: 'https://console.groq.com/docs/models'
|
||||
}
|
||||
},
|
||||
ovms: {
|
||||
api: {
|
||||
url: 'http://localhost:8000/v3/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.intel.com/content/www/us/en/developer/tools/openvino-toolkit/overview.html',
|
||||
docs: 'https://docs.openvino.ai/2025/model-server/ovms_what_is_openvino_model_server.html',
|
||||
models: 'https://www.modelscope.cn/organization/OpenVINO'
|
||||
}
|
||||
},
|
||||
ollama: {
|
||||
api: {
|
||||
url: 'http://localhost:11434'
|
||||
|
||||
@@ -108,7 +108,11 @@ export const useCodeTools = () => {
|
||||
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
|
||||
|
||||
// 检查是否可以启动(所有必需字段都已填写)
|
||||
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
|
||||
const canLaunch = Boolean(
|
||||
codeToolsState.selectedCliTool &&
|
||||
codeToolsState.currentDirectory &&
|
||||
(codeToolsState.selectedCliTool === codeTools.githubCopilotCli || selectedModel)
|
||||
)
|
||||
|
||||
return {
|
||||
// 状态
|
||||
|
||||
@@ -48,6 +48,17 @@ export function useActiveTopic(assistantId: string, topic?: Topic) {
|
||||
}
|
||||
}, [activeTopic?.id, assistant])
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistant?.topics?.length || !activeTopic) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestTopic = assistant.topics.find((item) => item.id === activeTopic.id)
|
||||
if (latestTopic && latestTopic !== activeTopic) {
|
||||
setActiveTopic(latestTopic)
|
||||
}
|
||||
}, [assistant?.topics, activeTopic])
|
||||
|
||||
return { activeTopic, setActiveTopic }
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const providerKeyMap = {
|
||||
nvidia: 'provider.nvidia',
|
||||
o3: 'provider.o3',
|
||||
ocoolai: 'provider.ocoolai',
|
||||
ovms: 'provider.ovms',
|
||||
ollama: 'provider.ollama',
|
||||
openai: 'provider.openai',
|
||||
openrouter: 'provider.openrouter',
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "Added",
|
||||
"case_sensitive": "Case Sensitive",
|
||||
"collapse": "Collapse",
|
||||
"download": "Download",
|
||||
"includes_user_questions": "Include Your Questions",
|
||||
"manage": "Manage",
|
||||
"select_model": "Select Model",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "Document",
|
||||
"edit": "Edit",
|
||||
"error": {
|
||||
"open_path": "Failed to open path {{path}}"
|
||||
},
|
||||
"file": "File",
|
||||
"image": "Image",
|
||||
"name": "Name",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "Go to provider settings"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "Note is empty, cannot generate name",
|
||||
"failed": "Failed to generate note name",
|
||||
"label": "Generate Note Name",
|
||||
"success": "Note name generated successfully"
|
||||
},
|
||||
"characters": "Characters",
|
||||
"collapse": "Collapse",
|
||||
"content_placeholder": "Please enter the note content...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "Update time (oldest first)",
|
||||
"sort_updated_desc": "Update time (newest first)",
|
||||
"sort_z2a": "File name (Z-A)",
|
||||
"spell_check": "Spell Check",
|
||||
"spell_check_tooltip": "Enable/Disable spell check",
|
||||
"star": "Favorite note",
|
||||
"starred_notes": "Collected notes",
|
||||
"title": "Notes",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "Install",
|
||||
"installing": "Installing",
|
||||
"reinstall": "Re-Install",
|
||||
"run": "Run OVMS",
|
||||
"starting": "Starting",
|
||||
"stop": "Stop OVMS",
|
||||
"stopping": "Stopping"
|
||||
},
|
||||
"description": "<div><p>1. Download OV Models.</p><p>2. Add Models in 'Manager'.</p><p>Support Windows Only!</p><p>OVMS Install Path: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Please refer to <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS Guide</a></p></dev>",
|
||||
"download": {
|
||||
"button": "Download",
|
||||
"error": "Download Error",
|
||||
"model_id": {
|
||||
"label": "Model ID:",
|
||||
"model_id_pattern": "Model ID must start with OpenVINO/",
|
||||
"placeholder": "Required e.g. OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "Please enter the model ID"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "Model Name:",
|
||||
"placeholder": "Required e.g. Qwen3-8B-int4-ov",
|
||||
"required": "Please enter the model name"
|
||||
},
|
||||
"model_source": "Model Source:",
|
||||
"model_task": "Model Task:",
|
||||
"success": "Download successful",
|
||||
"success_desc": "Model \"{{modelName}}\"-\"{{modelId}}\" downloaded successfully, please go to the OVMS management interface to add the model",
|
||||
"tip": "The model is downloading, sometimes it takes hours. Please be patient...",
|
||||
"title": "Download Intel OpenVINO Model"
|
||||
},
|
||||
"failed": {
|
||||
"install": "Install OVMS failed:",
|
||||
"install_code_100": "Unknown Error",
|
||||
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "Only supports Windows",
|
||||
"install_code_103": "Download OVMS runtime failed",
|
||||
"install_code_104": "Uncompress OVMS runtime failed",
|
||||
"install_code_105": "Clean OVMS runtime failed",
|
||||
"run": "Run OVMS failed:",
|
||||
"stop": "Stop OVMS failed:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMS is not installed",
|
||||
"not_running": "OVMS is not running",
|
||||
"running": "OVMS is running",
|
||||
"unknown": "OVMS status unknown"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "Aspect Ratio",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "已添加",
|
||||
"case_sensitive": "区分大小写",
|
||||
"collapse": "收起",
|
||||
"download": "下载",
|
||||
"includes_user_questions": "包含用户提问",
|
||||
"manage": "管理",
|
||||
"select_model": "选择模型",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "文档",
|
||||
"edit": "编辑",
|
||||
"error": {
|
||||
"open_path": "无法打开路径: {{path}}"
|
||||
},
|
||||
"file": "文件",
|
||||
"image": "图片",
|
||||
"name": "文件名",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "跳转到服务商设置界面"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "笔记为空,无法生成名称",
|
||||
"failed": "生成笔记名称失败",
|
||||
"label": "生成笔记名称",
|
||||
"success": "笔记名称生成成功"
|
||||
},
|
||||
"characters": "字符",
|
||||
"collapse": "收起",
|
||||
"content_placeholder": "请输入笔记内容...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "更新时间(从旧到新)",
|
||||
"sort_updated_desc": "更新时间(从新到旧)",
|
||||
"sort_z2a": "文件名(Z-A)",
|
||||
"spell_check": "拼写检查",
|
||||
"spell_check_tooltip": "启用/禁用拼写检查",
|
||||
"star": "收藏笔记",
|
||||
"starred_notes": "收藏的笔记",
|
||||
"title": "笔记",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "安装",
|
||||
"installing": "正在安装",
|
||||
"reinstall": "重装",
|
||||
"run": "运行 OVMS",
|
||||
"starting": "启动中",
|
||||
"stop": "停止 OVMS",
|
||||
"stopping": "停止中"
|
||||
},
|
||||
"description": "<div><p>1. 下载 OV 模型.</p><p>2. 在 'Manager' 中添加模型.</p><p>仅支持 Windows!</p><p>OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>请参考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
|
||||
"download": {
|
||||
"button": "下载",
|
||||
"error": "选择失败",
|
||||
"model_id": {
|
||||
"label": "模型 ID",
|
||||
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
|
||||
"placeholder": "必填,例如 OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "请输入模型 ID"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "模型名称",
|
||||
"placeholder": "必填,例如 Qwen3-8B-int4-ov",
|
||||
"required": "请输入模型名称"
|
||||
},
|
||||
"model_source": "模型来源:",
|
||||
"model_task": "模型任务:",
|
||||
"success": "下载成功",
|
||||
"success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下载成功,请前往 OVMS 管理界面添加模型",
|
||||
"tip": "模型正在下载,有时需要几个小时。请耐心等待...",
|
||||
"title": "下载 Intel OpenVINO 模型"
|
||||
},
|
||||
"failed": {
|
||||
"install": "安装 OVMS 失败:",
|
||||
"install_code_100": "未知错误",
|
||||
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "仅支持 Windows",
|
||||
"install_code_103": "下载 OVMS runtime 失败",
|
||||
"install_code_104": "解压 OVMS runtime 失败",
|
||||
"install_code_105": "清理 OVMS runtime 失败",
|
||||
"run": "运行 OVMS 失败:",
|
||||
"stop": "停止 OVMS 失败:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMS 未安装",
|
||||
"not_running": "OVMS 未运行",
|
||||
"running": "OVMS 正在运行",
|
||||
"unknown": "OVMS 状态未知"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "画幅比例",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8 大模型开放平台",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "已新增",
|
||||
"case_sensitive": "區分大小寫",
|
||||
"collapse": "折疊",
|
||||
"download": "下載",
|
||||
"includes_user_questions": "包含使用者提問",
|
||||
"manage": "管理",
|
||||
"select_model": "選擇模型",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "文件",
|
||||
"edit": "編輯",
|
||||
"error": {
|
||||
"open_path": "無法開啟路徑: {{path}}"
|
||||
},
|
||||
"file": "檔案",
|
||||
"image": "圖片",
|
||||
"name": "名稱",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "跳轉到服務商設置界面"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "筆記為空,無法生成名稱",
|
||||
"failed": "生成筆記名稱失敗",
|
||||
"label": "生成筆記名稱",
|
||||
"success": "筆記名稱生成成功"
|
||||
},
|
||||
"characters": "字符",
|
||||
"collapse": "收起",
|
||||
"content_placeholder": "請輸入筆記內容...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "更新時間(從舊到新)",
|
||||
"sort_updated_desc": "更新時間(從新到舊)",
|
||||
"sort_z2a": "文件名(Z-A)",
|
||||
"spell_check": "拼寫檢查",
|
||||
"spell_check_tooltip": "啟用/禁用拼寫檢查",
|
||||
"star": "收藏筆記",
|
||||
"starred_notes": "收藏的筆記",
|
||||
"title": "筆記",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "安裝",
|
||||
"installing": "正在安裝",
|
||||
"reinstall": "重新安裝",
|
||||
"run": "執行 OVMS",
|
||||
"starting": "啟動中",
|
||||
"stop": "停止 OVMS",
|
||||
"stopping": "停止中"
|
||||
},
|
||||
"description": "<div><p>1. 下載 OV 模型。</p><p>2. 在 'Manager' 中新增模型。</p><p>僅支援 Windows!</p><p>OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>請參考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
|
||||
"download": {
|
||||
"button": "下載",
|
||||
"error": "選擇失敗",
|
||||
"model_id": {
|
||||
"label": "模型 ID",
|
||||
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
|
||||
"placeholder": "必填,例如 OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "請輸入模型 ID"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "模型名稱",
|
||||
"placeholder": "必填,例如 Qwen3-8B-int4-ov",
|
||||
"required": "請輸入模型名稱"
|
||||
},
|
||||
"model_source": "模型來源:",
|
||||
"model_task": "模型任務:",
|
||||
"success": "下載成功",
|
||||
"success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下載成功,請前往 OVMS 管理界面添加模型",
|
||||
"tip": "模型正在下載,有時需要幾個小時。請耐心等候...",
|
||||
"title": "下載 Intel OpenVINO 模型"
|
||||
},
|
||||
"failed": {
|
||||
"install": "安裝 OVMS 失敗:",
|
||||
"install_code_100": "未知錯誤",
|
||||
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "僅支援 Windows",
|
||||
"install_code_103": "下載 OVMS runtime 失敗",
|
||||
"install_code_104": "解壓 OVMS runtime 失敗",
|
||||
"install_code_105": "清理 OVMS runtime 失敗",
|
||||
"run": "執行 OVMS 失敗:",
|
||||
"stop": "停止 OVMS 失敗:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMS 未安裝",
|
||||
"not_running": "OVMS 未執行",
|
||||
"running": "OVMS 正在執行",
|
||||
"unknown": "OVMS 狀態未知"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "畫幅比例",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8 大模型開放平台",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "προστέθηκε",
|
||||
"case_sensitive": "Διάκριση πεζών/κεφαλαίων",
|
||||
"collapse": "συμπεριλάβετε",
|
||||
"download": "Λήψη",
|
||||
"includes_user_questions": "Περιλαμβάνει ερωτήσεις χρήστη",
|
||||
"manage": "χειριστείτε",
|
||||
"select_model": "επιλογή μοντέλου",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "Έγγραφο",
|
||||
"edit": "Επεξεργασία",
|
||||
"error": {
|
||||
"open_path": "Δεν είναι δυνατό το άνοιγμα της διαδρομής: {{path}}"
|
||||
},
|
||||
"file": "Αρχείο",
|
||||
"image": "Εικόνα",
|
||||
"name": "Όνομα αρχείου",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "Το σημείωμα είναι κενό, δεν μπορεί να δημιουργηθεί όνομα",
|
||||
"failed": "Αποτυχία δημιουργίας ονόματος σημείωσης",
|
||||
"label": "Δημιουργία ονόματος σημείωσης",
|
||||
"success": "Η δημιουργία του ονόματος σημειώσεων ολοκληρώθηκε με επιτυχία"
|
||||
},
|
||||
"characters": "χαρακτήρας",
|
||||
"collapse": "σύμπτυξη",
|
||||
"content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
|
||||
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
|
||||
"sort_z2a": "όνομα αρχείου (Z-A)",
|
||||
"spell_check": "Έλεγχος ορθογραφίας",
|
||||
"spell_check_tooltip": "Ενεργοποίηση/Απενεργοποίηση ελέγχου ορθογραφίας",
|
||||
"star": "Αγαπημένες σημειώσεις",
|
||||
"starred_notes": "Σημειώσεις συλλογής",
|
||||
"title": "σημειώσεις",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "Εγκατάσταση",
|
||||
"installing": "Εγκατάσταση σε εξέλιξη",
|
||||
"reinstall": "Επανεγκατάσταση",
|
||||
"run": "Εκτέλεση OVMS",
|
||||
"starting": "Εκκίνηση σε εξέλιξη",
|
||||
"stop": "Διακοπή OVMS",
|
||||
"stopping": "Διακοπή σε εξέλιξη"
|
||||
},
|
||||
"description": "<div><p>1. Λήψη μοντέλου OV.</p><p>2. Προσθήκη μοντέλου στο 'Manager'.</p><p>Υποστηρίζεται μόνο στα Windows!</p><p>Διαδρομή εγκατάστασης OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Ανατρέξτε στον <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Οδηγό Intel OVMS</a></p></div>",
|
||||
"download": {
|
||||
"button": "Λήψη",
|
||||
"error": "Η επιλογή απέτυχε",
|
||||
"model_id": {
|
||||
"label": "Αναγνωριστικό μοντέλου:",
|
||||
"model_id_pattern": "Το αναγνωριστικό μοντέλου πρέπει να ξεκινά με OpenVINO/",
|
||||
"placeholder": "Απαιτείται, π.χ. OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "Παρακαλώ εισάγετε το αναγνωριστικό μοντέλου"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "Όνομα μοντέλου:",
|
||||
"placeholder": "Απαιτείται, π.χ. Qwen3-8B-int4-ov",
|
||||
"required": "Παρακαλώ εισάγετε το όνομα του μοντέλου"
|
||||
},
|
||||
"model_source": "Πηγή μοντέλου:",
|
||||
"model_task": "Εργασία μοντέλου:",
|
||||
"success": "Η λήψη ολοκληρώθηκε με επιτυχία",
|
||||
"success_desc": "Το μοντέλο \"{{modelName}}\"-\"{{modelId}}\" λήφθηκε επιτυχώς, παρακαλώ μεταβείτε στη διεπαφή διαχείρισης OVMS για να προσθέσετε το μοντέλο",
|
||||
"tip": "Το μοντέλο κατεβαίνει, μερικές φορές χρειάζονται αρκετές ώρες. Παρακαλώ περιμένετε υπομονετικά...",
|
||||
"title": "Λήψη μοντέλου Intel OpenVINO"
|
||||
},
|
||||
"failed": {
|
||||
"install": "Η εγκατάσταση του OVMS απέτυχε:",
|
||||
"install_code_100": "Άγνωστο σφάλμα",
|
||||
"install_code_101": "Υποστηρίζεται μόνο σε Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "Υποστηρίζεται μόνο στα Windows",
|
||||
"install_code_103": "Η λήψη του OVMS runtime απέτυχε",
|
||||
"install_code_104": "Η αποσυμπίεση του OVMS runtime απέτυχε",
|
||||
"install_code_105": "Ο καθαρισμός του OVMS runtime απέτυχε",
|
||||
"run": "Η εκτέλεση του OVMS απέτυχε:",
|
||||
"stop": "Η διακοπή του OVMS απέτυχε:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "Το OVMS δεν έχει εγκατασταθεί",
|
||||
"not_running": "Το OVMS δεν εκτελείται",
|
||||
"running": "Το OVMS εκτελείται",
|
||||
"unknown": "Άγνωστη κατάσταση OVMS"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "Λόγος διαστάσεων",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "Πλατφόρμα Ανοιχτής Μεγάλης Μοντέλου PH8",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "Agregado",
|
||||
"case_sensitive": "Distingue mayúsculas y minúsculas",
|
||||
"collapse": "Colapsar",
|
||||
"download": "Descargar",
|
||||
"includes_user_questions": "Incluye preguntas del usuario",
|
||||
"manage": "Administrar",
|
||||
"select_model": "Seleccionar Modelo",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "Documento",
|
||||
"edit": "Editar",
|
||||
"error": {
|
||||
"open_path": "No se puede abrir la ruta: {{path}}"
|
||||
},
|
||||
"file": "Archivo",
|
||||
"image": "Imagen",
|
||||
"name": "Nombre del archivo",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "Ir a la configuración del proveedor"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "La nota está vacía, no se puede generar un nombre",
|
||||
"failed": "Error al generar el nombre de la nota",
|
||||
"label": "Generar nombre de nota",
|
||||
"success": "Se ha generado correctamente el nombre de la nota"
|
||||
},
|
||||
"characters": "carácter",
|
||||
"collapse": "ocultar",
|
||||
"content_placeholder": "Introduzca el contenido de la nota...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
|
||||
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
|
||||
"sort_z2a": "Nombre de archivo (Z-A)",
|
||||
"spell_check": "comprobación ortográfica",
|
||||
"spell_check_tooltip": "Habilitar/deshabilitar revisión ortográfica",
|
||||
"star": "Notas guardadas",
|
||||
"starred_notes": "notas guardadas",
|
||||
"title": "notas",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "Instalar",
|
||||
"installing": "Instalando",
|
||||
"reinstall": "Reinstalar",
|
||||
"run": "Ejecutar OVMS",
|
||||
"starting": "Iniciando",
|
||||
"stop": "Detener OVMS",
|
||||
"stopping": "Deteniendo"
|
||||
},
|
||||
"description": "<div><p>1. Descargar modelo OV.</p><p>2. Agregar modelo en 'Administrador'.</p><p>¡Solo compatible con Windows!</p><p>Ruta de instalación de OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Consulte la <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guía de Intel OVMS</a></p></dev>",
|
||||
"download": {
|
||||
"button": "Descargar",
|
||||
"error": "Selección fallida",
|
||||
"model_id": {
|
||||
"label": "ID del modelo:",
|
||||
"model_id_pattern": "El ID del modelo debe comenzar con OpenVINO/",
|
||||
"placeholder": "Requerido, por ejemplo, OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "Por favor, ingrese el ID del modelo"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "Nombre del modelo:",
|
||||
"placeholder": "Requerido, por ejemplo, Qwen3-8B-int4-ov",
|
||||
"required": "Por favor, ingrese el nombre del modelo"
|
||||
},
|
||||
"model_source": "Fuente del modelo:",
|
||||
"model_task": "Tarea del modelo:",
|
||||
"success": "Descarga exitosa",
|
||||
"success_desc": "El modelo \"{{modelName}}\"-\"{{modelId}}\" se descargó exitosamente, por favor vaya a la interfaz de administración de OVMS para agregar el modelo",
|
||||
"tip": "El modelo se está descargando, a veces toma varias horas. Por favor espere pacientemente...",
|
||||
"title": "Descargar modelo Intel OpenVINO"
|
||||
},
|
||||
"failed": {
|
||||
"install": "Error al instalar OVMS:",
|
||||
"install_code_100": "Error desconocido",
|
||||
"install_code_101": "Solo compatible con CPU Intel(R) Core(TM) Ultra",
|
||||
"install_code_102": "Solo compatible con Windows",
|
||||
"install_code_103": "Error al descargar el tiempo de ejecución de OVMS",
|
||||
"install_code_104": "Error al descomprimir el tiempo de ejecución de OVMS",
|
||||
"install_code_105": "Error al limpiar el tiempo de ejecución de OVMS",
|
||||
"run": "Error al ejecutar OVMS:",
|
||||
"stop": "Error al detener OVMS:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMS no instalado",
|
||||
"not_running": "OVMS no está en ejecución",
|
||||
"running": "OVMS en ejecución",
|
||||
"unknown": "Estado de OVMS desconocido"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "Relación de aspecto",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplejidad",
|
||||
"ph8": "Plataforma Abierta de Grandes Modelos PH8",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "Ajouté",
|
||||
"case_sensitive": "Respecter la casse",
|
||||
"collapse": "Réduire",
|
||||
"download": "Télécharger",
|
||||
"includes_user_questions": "Inclure les questions de l'utilisateur",
|
||||
"manage": "Gérer",
|
||||
"select_model": "Sélectionner le Modèle",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "Document",
|
||||
"edit": "Éditer",
|
||||
"error": {
|
||||
"open_path": "Impossible d'ouvrir le chemin : {{path}}"
|
||||
},
|
||||
"file": "Fichier",
|
||||
"image": "Image",
|
||||
"name": "Nom du fichier",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "Aller aux paramètres du fournisseur"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "La note est vide, impossible de générer un nom",
|
||||
"failed": "Échec de la génération du nom de note",
|
||||
"label": "Générer un nom de note",
|
||||
"success": "La génération du nom de note a réussi"
|
||||
},
|
||||
"characters": "caractère",
|
||||
"collapse": "réduire",
|
||||
"content_placeholder": "Veuillez saisir le contenu de la note...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
|
||||
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
|
||||
"sort_z2a": "Nom de fichier (Z-A)",
|
||||
"spell_check": "Vérification orthographique",
|
||||
"spell_check_tooltip": "Activer/Désactiver la vérification orthographique",
|
||||
"star": "Notes enregistrées",
|
||||
"starred_notes": "notes de collection",
|
||||
"title": "notes",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "Installer",
|
||||
"installing": "Installation en cours",
|
||||
"reinstall": "Réinstaller",
|
||||
"run": "Exécuter OVMS",
|
||||
"starting": "Démarrage en cours",
|
||||
"stop": "Arrêter OVMS",
|
||||
"stopping": "Arrêt en cours"
|
||||
},
|
||||
"description": "<div><p>1. Télécharger le modèle OV.</p><p>2. Ajouter le modèle dans 'Manager'.</p><p>Uniquement compatible avec Windows !</p><p>Chemin d'installation d'OVMS : '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Veuillez vous référer au <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guide Intel OVMS</a></p></dev>",
|
||||
"download": {
|
||||
"button": "Télécharger",
|
||||
"error": "Échec de la sélection",
|
||||
"model_id": {
|
||||
"label": "ID du modèle :",
|
||||
"model_id_pattern": "L'ID du modèle doit commencer par OpenVINO/",
|
||||
"placeholder": "Requis, par exemple OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "Veuillez saisir l'ID du modèle"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "Nom du modèle :",
|
||||
"placeholder": "Requis, par exemple Qwen3-8B-int4-ov",
|
||||
"required": "Veuillez saisir le nom du modèle"
|
||||
},
|
||||
"model_source": "Source du modèle :",
|
||||
"model_task": "Tâche du modèle :",
|
||||
"success": "Téléchargement réussi",
|
||||
"success_desc": "Le modèle \"{{modelName}}\"-\"{{modelId}}\" a été téléchargé avec succès, veuillez vous rendre à l'interface de gestion OVMS pour ajouter le modèle",
|
||||
"tip": "Le modèle est en cours de téléchargement, cela peut parfois prendre plusieurs heures. Veuillez patienter...",
|
||||
"title": "Télécharger le modèle Intel OpenVINO"
|
||||
},
|
||||
"failed": {
|
||||
"install": "Échec de l'installation d'OVMS :",
|
||||
"install_code_100": "Erreur inconnue",
|
||||
"install_code_101": "Uniquement compatible avec les processeurs Intel(R) Core(TM) Ultra",
|
||||
"install_code_102": "Uniquement compatible avec Windows",
|
||||
"install_code_103": "Échec du téléchargement du runtime OVMS",
|
||||
"install_code_104": "Échec de la décompression du runtime OVMS",
|
||||
"install_code_105": "Échec du nettoyage du runtime OVMS",
|
||||
"run": "Échec de l'exécution d'OVMS :",
|
||||
"stop": "Échec de l'arrêt d'OVMS :"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMS non installé",
|
||||
"not_running": "OVMS n'est pas en cours d'exécution",
|
||||
"running": "OVMS en cours d'exécution",
|
||||
"unknown": "État d'OVMS inconnu"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "Format d'image",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexité",
|
||||
"ph8": "Plateforme ouverte de grands modèles PH8",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "追加済み",
|
||||
"case_sensitive": "大文字と小文字の区別",
|
||||
"collapse": "折りたたむ",
|
||||
"download": "ダウンロード",
|
||||
"includes_user_questions": "ユーザーからの質問を含む",
|
||||
"manage": "管理",
|
||||
"select_model": "モデルを選択",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "ドキュメント",
|
||||
"edit": "編集",
|
||||
"error": {
|
||||
"open_path": "パスを開けません: {{path}}"
|
||||
},
|
||||
"file": "ファイル",
|
||||
"image": "画像",
|
||||
"name": "名前",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "プロバイダー設定に移動"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "ノートが空です。名前を生成できません。",
|
||||
"failed": "ノート名の生成に失敗しました",
|
||||
"label": "ノート名の生成",
|
||||
"success": "ノート名の生成に成功しました"
|
||||
},
|
||||
"characters": "文字",
|
||||
"collapse": "閉じる",
|
||||
"content_placeholder": "メモの内容を入力してください...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "更新日時(古い順)",
|
||||
"sort_updated_desc": "更新日時(新しい順)",
|
||||
"sort_z2a": "ファイル名(Z-A)",
|
||||
"spell_check": "スペルチェック",
|
||||
"spell_check_tooltip": "スペルチェックの有効/無効",
|
||||
"star": "お気に入りのノート",
|
||||
"starred_notes": "収集したノート",
|
||||
"title": "ノート",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "インストール",
|
||||
"installing": "インストール中",
|
||||
"reinstall": "再インストール",
|
||||
"run": "OVMSを実行",
|
||||
"starting": "起動中",
|
||||
"stop": "OVMSを停止",
|
||||
"stopping": "停止中"
|
||||
},
|
||||
"description": "<div><p>1. OVモデルをダウンロードします。</p><p>2. 'マネージャー'でモデルを追加します。</p><p>Windowsのみサポート!</p><p>OVMSインストールパス: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>詳細は<a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMSガイド</a>をご参照ください。</p></dev>",
|
||||
"download": {
|
||||
"button": "ダウンロード",
|
||||
"error": "ダウンロードエラー",
|
||||
"model_id": {
|
||||
"label": "モデルID",
|
||||
"model_id_pattern": "モデルIDはOpenVINO/で始まる必要があります",
|
||||
"placeholder": "必須 例: OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "モデルIDを入力してください"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "モデル名",
|
||||
"placeholder": "必須 例: Qwen3-8B-int4-ov",
|
||||
"required": "モデル名を入力してください"
|
||||
},
|
||||
"model_source": "モデルソース:",
|
||||
"model_task": "モデルタスク:",
|
||||
"success": "ダウンロード成功",
|
||||
"success_desc": "モデル\"{{modelName}}\"-\"{{modelId}}\"ダウンロード成功、OVMS管理インターフェースに移動してモデルを追加してください",
|
||||
"tip": "モデルはダウンロードされていますが、時には数時間かかります。我慢してください...",
|
||||
"title": "Intel OpenVINOモデルをダウンロード"
|
||||
},
|
||||
"failed": {
|
||||
"install": "OVMSのインストールに失敗しました:",
|
||||
"install_code_100": "不明なエラー",
|
||||
"install_code_101": "Intel(R) Core(TM) Ultra CPUのみサポート",
|
||||
"install_code_102": "Windowsのみサポート",
|
||||
"install_code_103": "OVMSランタイムのダウンロードに失敗しました",
|
||||
"install_code_104": "OVMSランタイムの解凍に失敗しました",
|
||||
"install_code_105": "OVMSランタイムのクリーンアップに失敗しました",
|
||||
"run": "OVMSの実行に失敗しました:",
|
||||
"stop": "OVMSの停止に失敗しました:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMSはインストールされていません",
|
||||
"not_running": "OVMSは実行されていません",
|
||||
"running": "OVMSは実行中です",
|
||||
"unknown": "OVMSのステータスが不明です"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "画幅比例",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "Adicionado",
|
||||
"case_sensitive": "Diferenciar maiúsculas e minúsculas",
|
||||
"collapse": "Recolher",
|
||||
"download": "Baixar",
|
||||
"includes_user_questions": "Incluir perguntas do usuário",
|
||||
"manage": "Gerenciar",
|
||||
"select_model": "Selecionar Modelo",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "Documento",
|
||||
"edit": "Editar",
|
||||
"error": {
|
||||
"open_path": "Não foi possível abrir o caminho: {{path}}"
|
||||
},
|
||||
"file": "Arquivo",
|
||||
"image": "Imagem",
|
||||
"name": "Nome do Arquivo",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "Ir para as configurações do provedor"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "A nota está vazia, não é possível gerar um nome",
|
||||
"failed": "Falha ao gerar o nome da nota",
|
||||
"label": "Gerar nome da nota",
|
||||
"success": "Nome da nota gerado com sucesso"
|
||||
},
|
||||
"characters": "caractere",
|
||||
"collapse": "[minimizar]",
|
||||
"content_placeholder": "Introduza o conteúdo da nota...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
|
||||
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
|
||||
"sort_z2a": "Nome do arquivo (Z-A)",
|
||||
"spell_check": "verificação ortográfica",
|
||||
"spell_check_tooltip": "Ativar/Desativar verificação ortográfica",
|
||||
"star": "Notas favoritas",
|
||||
"starred_notes": "notas salvas",
|
||||
"title": "nota",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "Instalar",
|
||||
"installing": "Instalando",
|
||||
"reinstall": "Reinstalar",
|
||||
"run": "Executar OVMS",
|
||||
"starting": "Iniciando",
|
||||
"stop": "Parar OVMS",
|
||||
"stopping": "Parando"
|
||||
},
|
||||
"description": "<div><p>1. Baixe o modelo OV.</p><p>2. Adicione o modelo no 'Gerenciador'.</p><p>Compatível apenas com Windows!</p><p>Caminho de instalação do OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>Consulte o <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Guia do Intel OVMS</a></p></dev>",
|
||||
"download": {
|
||||
"button": "Baixar",
|
||||
"error": "Falha na seleção",
|
||||
"model_id": {
|
||||
"label": "ID do modelo:",
|
||||
"model_id_pattern": "O ID do modelo deve começar com OpenVINO/",
|
||||
"placeholder": "Obrigatório, por exemplo, OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "Por favor, insira o ID do modelo"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "Nome do modelo:",
|
||||
"placeholder": "Obrigatório, por exemplo, Qwen3-8B-int4-ov",
|
||||
"required": "Por favor, insira o nome do modelo"
|
||||
},
|
||||
"model_source": "Fonte do modelo:",
|
||||
"model_task": "Tarefa do modelo:",
|
||||
"success": "Download concluído com sucesso",
|
||||
"success_desc": "O modelo \"{{modelName}}\"-\"{{modelId}}\" foi baixado com sucesso, por favor vá para a interface de gerenciamento OVMS para adicionar o modelo",
|
||||
"tip": "O modelo está sendo baixado, às vezes leva várias horas. Por favor aguarde pacientemente...",
|
||||
"title": "Baixar modelo Intel OpenVINO"
|
||||
},
|
||||
"failed": {
|
||||
"install": "Falha na instalação do OVMS:",
|
||||
"install_code_100": "Erro desconhecido",
|
||||
"install_code_101": "Compatível apenas com CPU Intel(R) Core(TM) Ultra",
|
||||
"install_code_102": "Compatível apenas com Windows",
|
||||
"install_code_103": "Falha ao baixar o tempo de execução do OVMS",
|
||||
"install_code_104": "Falha ao descompactar o tempo de execução do OVMS",
|
||||
"install_code_105": "Falha ao limpar o tempo de execução do OVMS",
|
||||
"run": "Falha ao executar o OVMS:",
|
||||
"stop": "Falha ao parar o OVMS:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMS não instalado",
|
||||
"not_running": "OVMS não está em execução",
|
||||
"running": "OVMS em execução",
|
||||
"unknown": "Status do OVMS desconhecido"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "Proporção da Imagem",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexidade",
|
||||
"ph8": "Plataforma Aberta de Grandes Modelos PH8",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -449,6 +449,7 @@
|
||||
"added": "Добавлено",
|
||||
"case_sensitive": "Чувствительность к регистру",
|
||||
"collapse": "Свернуть",
|
||||
"download": "Скачать",
|
||||
"includes_user_questions": "Включает вопросы пользователей",
|
||||
"manage": "Редактировать",
|
||||
"select_model": "Выбрать модель",
|
||||
@@ -1177,6 +1178,9 @@
|
||||
},
|
||||
"document": "Документ",
|
||||
"edit": "Редактировать",
|
||||
"error": {
|
||||
"open_path": "Не удалось открыть путь: {{path}}"
|
||||
},
|
||||
"file": "Файл",
|
||||
"image": "Изображение",
|
||||
"name": "Имя",
|
||||
@@ -1916,6 +1920,12 @@
|
||||
"provider_settings": "Перейти к настройкам поставщика"
|
||||
},
|
||||
"notes": {
|
||||
"auto_rename": {
|
||||
"empty_note": "Заметки пусты, имя невозможно сгенерировать",
|
||||
"failed": "Создание названия заметки не удалось",
|
||||
"label": "Создать название заметки",
|
||||
"success": "Имя заметки успешно создано"
|
||||
},
|
||||
"characters": "Символы",
|
||||
"collapse": "Свернуть",
|
||||
"content_placeholder": "Введите содержимое заметки...",
|
||||
@@ -1997,6 +2007,8 @@
|
||||
"sort_updated_asc": "Время обновления (от старого к новому)",
|
||||
"sort_updated_desc": "Время обновления (от нового к старому)",
|
||||
"sort_z2a": "Имя файла (Я-А)",
|
||||
"spell_check": "Проверка орфографии",
|
||||
"spell_check_tooltip": "Включить/отключить проверку орфографии",
|
||||
"star": "Избранные заметки",
|
||||
"starred_notes": "Сохраненные заметки",
|
||||
"title": "заметки",
|
||||
@@ -2046,6 +2058,57 @@
|
||||
},
|
||||
"title": "Ollama"
|
||||
},
|
||||
"ovms": {
|
||||
"action": {
|
||||
"install": "Установить",
|
||||
"installing": "Установка",
|
||||
"reinstall": "Переустановить",
|
||||
"run": "Запустить OVMS",
|
||||
"starting": "Запуск",
|
||||
"stop": "Остановить OVMS",
|
||||
"stopping": "Остановка"
|
||||
},
|
||||
"description": "<div><p>1. Загрузите модели OV.</p><p>2. Добавьте модели в 'Менеджер'.</p><p>Поддерживается только Windows!</p><p>Путь установки OVMS: '%USERPROFILE%\\.cherrystudio\\ovms'.</p><p>Пожалуйста, ознакомьтесь с <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>руководством Intel OVMS</a></p></dev>",
|
||||
"download": {
|
||||
"button": "Скачать",
|
||||
"error": "Ошибка загрузки",
|
||||
"model_id": {
|
||||
"label": "ID модели",
|
||||
"model_id_pattern": "ID модели должен начинаться с OpenVINO/",
|
||||
"placeholder": "Обязательно, например: OpenVINO/Qwen3-8B-int4-ov",
|
||||
"required": "Пожалуйста, введите ID модели"
|
||||
},
|
||||
"model_name": {
|
||||
"label": "Название модели:",
|
||||
"placeholder": "Обязательно, например: Qwen3-8B-int4-ov",
|
||||
"required": "Пожалуйста, введите название модели"
|
||||
},
|
||||
"model_source": "Источник модели:",
|
||||
"model_task": "Задача модели:",
|
||||
"success": "Скачивание успешно",
|
||||
"success_desc": "Модель \"{{modelName}}\"-\"{{modelId}}\" успешно скачана, пожалуйста, перейдите в интерфейс управления OVMS, чтобы добавить модель",
|
||||
"tip": "Модель загружается, иногда это занимает часы. Пожалуйста, будьте терпеливы...",
|
||||
"title": "Скачать модель Intel OpenVINO"
|
||||
},
|
||||
"failed": {
|
||||
"install": "Ошибка установки OVMS:",
|
||||
"install_code_100": "Неизвестная ошибка",
|
||||
"install_code_101": "Поддерживаются только процессоры Intel(R) Core(TM) Ultra CPU",
|
||||
"install_code_102": "Поддерживается только Windows",
|
||||
"install_code_103": "Ошибка загрузки среды выполнения OVMS",
|
||||
"install_code_104": "Ошибка распаковки среды выполнения OVMS",
|
||||
"install_code_105": "Ошибка очистки среды выполнения OVMS",
|
||||
"run": "Ошибка запуска OVMS:",
|
||||
"stop": "Ошибка остановки OVMS:"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "OVMS не установлен",
|
||||
"not_running": "OVMS не запущен",
|
||||
"running": "OVMS запущен",
|
||||
"unknown": "Статус OVMS неизвестен"
|
||||
},
|
||||
"title": "Intel OVMS"
|
||||
},
|
||||
"paintings": {
|
||||
"aspect_ratio": "Пропорции изображения",
|
||||
"aspect_ratios": {
|
||||
@@ -2277,6 +2340,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8",
|
||||
"poe": "Poe",
|
||||
|
||||
@@ -98,6 +98,10 @@ const CodeToolsPage: FC = () => {
|
||||
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.githubCopilotCli) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return ['openai', 'openai-response'].some((type) =>
|
||||
@@ -196,7 +200,7 @@ const CodeToolsPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedModel) {
|
||||
if (!selectedModel && selectedCliTool !== codeTools.githubCopilotCli) {
|
||||
return { isValid: false, message: t('code.model_required') }
|
||||
}
|
||||
|
||||
@@ -205,6 +209,11 @@ const CodeToolsPage: FC = () => {
|
||||
|
||||
// 准备启动环境
|
||||
const prepareLaunchEnvironment = async (): Promise<Record<string, string> | null> => {
|
||||
if (selectedCliTool === codeTools.githubCopilotCli) {
|
||||
const userEnv = parseEnvironmentVariables(environmentVariables)
|
||||
return userEnv
|
||||
}
|
||||
|
||||
if (!selectedModel) return null
|
||||
|
||||
const modelProvider = getProviderByModel(selectedModel)
|
||||
@@ -229,7 +238,9 @@ const CodeToolsPage: FC = () => {
|
||||
|
||||
// 执行启动操作
|
||||
const executeLaunch = async (env: Record<string, string>) => {
|
||||
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
|
||||
const modelId = selectedCliTool === codeTools.githubCopilotCli ? '' : selectedModel?.id!
|
||||
|
||||
window.api.codeTools.run(selectedCliTool, modelId, currentDirectory, env, {
|
||||
autoUpdateToLatest,
|
||||
terminal: selectedTerminal
|
||||
})
|
||||
@@ -316,7 +327,12 @@ const CodeToolsPage: FC = () => {
|
||||
banner
|
||||
style={{ borderRadius: 'var(--list-item-border-radius)' }}
|
||||
message={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>{t('code.bun_required_message')}</span>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -345,46 +361,64 @@ const CodeToolsPage: FC = () => {
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">
|
||||
{t('code.model')}
|
||||
{selectedCliTool === 'claude-code' && (
|
||||
<Popover
|
||||
content={
|
||||
<div style={{ width: 200 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{getClaudeSupportedProviders(allProviders).map((provider) => {
|
||||
return (
|
||||
<Link
|
||||
key={provider.id}
|
||||
style={{ color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
to={`/settings/provider?id=${provider.id}`}>
|
||||
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
|
||||
{getProviderLabel(provider.id)}
|
||||
<ArrowUpRight size={14} />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{selectedCliTool !== codeTools.githubCopilotCli && (
|
||||
<SettingsItem>
|
||||
<div className="settings-label">
|
||||
{t('code.model')}
|
||||
{selectedCliTool === 'claude-code' && (
|
||||
<Popover
|
||||
content={
|
||||
<div style={{ width: 200 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8
|
||||
}}>
|
||||
{getClaudeSupportedProviders(allProviders).map((provider) => {
|
||||
return (
|
||||
<Link
|
||||
key={provider.id}
|
||||
style={{
|
||||
color: 'var(--color-text)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
to={`/settings/provider?id=${provider.id}`}>
|
||||
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
|
||||
{getProviderLabel(provider.id)}
|
||||
<ArrowUpRight size={14} />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger="hover"
|
||||
placement="right">
|
||||
<HelpCircle size={14} style={{ color: 'var(--color-text-3)', cursor: 'pointer' }} />
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<ModelSelector
|
||||
providers={availableProviders}
|
||||
predicate={modelPredicate}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('code.model_placeholder')}
|
||||
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
|
||||
onChange={handleModelChange}
|
||||
allowClear
|
||||
/>
|
||||
</SettingsItem>
|
||||
}
|
||||
trigger="hover"
|
||||
placement="right">
|
||||
<HelpCircle
|
||||
size={14}
|
||||
style={{
|
||||
color: 'var(--color-text-3)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<ModelSelector
|
||||
providers={availableProviders}
|
||||
predicate={modelPredicate}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('code.model_placeholder')}
|
||||
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
|
||||
onChange={handleModelChange}
|
||||
allowClear
|
||||
/>
|
||||
</SettingsItem>
|
||||
)}
|
||||
|
||||
<SettingsItem>
|
||||
<div className="settings-label">{t('code.working_directory')}</div>
|
||||
@@ -403,11 +437,27 @@ const CodeToolsPage: FC = () => {
|
||||
options={directories.map((dir) => ({
|
||||
value: dir,
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{dir}
|
||||
</span>
|
||||
<X
|
||||
size={14}
|
||||
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
cursor: 'pointer',
|
||||
color: '#999'
|
||||
}}
|
||||
onClick={(e) => handleRemoveDirectory(dir, e)}
|
||||
/>
|
||||
</div>
|
||||
@@ -429,7 +479,14 @@ const CodeToolsPage: FC = () => {
|
||||
rows={2}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>{t('code.env_vars_help')}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-3)',
|
||||
marginTop: 4
|
||||
}}>
|
||||
{t('code.env_vars_help')}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
{/* 终端选择 (macOS 和 Windows) */}
|
||||
@@ -464,7 +521,12 @@ const CodeToolsPage: FC = () => {
|
||||
selectedTerminal !== terminalApps.cmd &&
|
||||
selectedTerminal !== terminalApps.powershell &&
|
||||
selectedTerminal !== terminalApps.windowsTerminal && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginTop: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-3)',
|
||||
marginTop: 4
|
||||
}}>
|
||||
{terminalCustomPaths[selectedTerminal]
|
||||
? `${t('code.custom_path')}: ${terminalCustomPaths[selectedTerminal]}`
|
||||
: t('code.custom_path_required')}
|
||||
|
||||
@@ -20,7 +20,8 @@ export const CLI_TOOLS = [
|
||||
{ value: codeTools.qwenCode, label: 'Qwen Code' },
|
||||
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
|
||||
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' },
|
||||
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
|
||||
{ value: codeTools.iFlowCli, label: 'iFlow CLI' },
|
||||
{ value: codeTools.githubCopilotCli, label: 'GitHub Copilot CLI' }
|
||||
]
|
||||
|
||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
||||
@@ -43,7 +44,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
|
||||
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
|
||||
[codeTools.openaiCodex]: (providers) =>
|
||||
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
|
||||
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai'))
|
||||
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')),
|
||||
[codeTools.githubCopilotCli]: () => []
|
||||
}
|
||||
|
||||
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
||||
@@ -158,6 +160,10 @@ export const generateToolEnvironment = ({
|
||||
env.IFLOW_BASE_URL = baseUrl
|
||||
env.IFLOW_MODEL_NAME = model.id
|
||||
break
|
||||
|
||||
case codeTools.githubCopilotCli:
|
||||
env.GITHUB_TOKEN = apiKey || ''
|
||||
break
|
||||
}
|
||||
|
||||
return env
|
||||
|
||||
@@ -16,11 +16,12 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setNarrowMode } from '@renderer/store/settings'
|
||||
import { ApiModel, Assistant, PermissionMode, Topic } from '@renderer/types'
|
||||
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { FC, ReactNode, useCallback } from 'react'
|
||||
import React, { FC, ReactNode, useCallback } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { AgentSettingsPopup } from '../settings/AgentSettings'
|
||||
@@ -131,7 +132,10 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
item: 'h-full'
|
||||
}}>
|
||||
<Chip size="md" variant="light" className="h-full transition-background hover:bg-foreground-100">
|
||||
<AgentLabel agent={agent} classNames={{ name: 'max-w-50 font-bold text-xs' }} />
|
||||
<AgentLabel
|
||||
agent={agent}
|
||||
classNames={{ name: 'max-w-50 font-bold text-xs', avatar: 'h-4.5 w-4.5', container: 'gap-1.5' }}
|
||||
/>
|
||||
</Chip>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
@@ -193,13 +197,24 @@ const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agen
|
||||
|
||||
const infoItems: ReactNode[] = []
|
||||
|
||||
const InfoTag: FC<{ text: string; className?: string }> = ({ text, className }) => (
|
||||
const InfoTag = ({
|
||||
text,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
text: string
|
||||
className?: string
|
||||
classNames?: {}
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-medium border border-default-200 px-2 py-1 text-foreground-500 text-xs dark:text-foreground-400',
|
||||
onClick !== undefined ? 'cursor-pointer' : undefined,
|
||||
className
|
||||
)}
|
||||
title={text}>
|
||||
title={text}
|
||||
onClick={onClick}>
|
||||
<span className="block truncate">{text}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -207,7 +222,22 @@ const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agen
|
||||
// infoItems.push(<InfoTag key="name" text={agent.name ?? ''} className="max-w-60" />)
|
||||
|
||||
if (firstAccessiblePath) {
|
||||
infoItems.push(<InfoTag key="path" text={firstAccessiblePath} className="max-w-60" />)
|
||||
infoItems.push(
|
||||
<InfoTag
|
||||
key="path"
|
||||
text={firstAccessiblePath}
|
||||
className="max-w-60 transition-colors hover:border-primary hover:text-primary"
|
||||
onClick={() => {
|
||||
window.api.file
|
||||
.openPath(firstAccessiblePath)
|
||||
.catch((e) =>
|
||||
window.toast.error(
|
||||
formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath }))
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
infoItems.push(<InfoTag key="permission-mode" text={permissionModeLabel} className="max-w-50" />)
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# Inputbar Unification Plan
|
||||
|
||||
## Goal
|
||||
Create a single configurable input bar that supports chat topics, agent sessions, and other contexts (e.g. mini window) without duplicating UI logic. Remove `AgentSessionInputbar.tsx` in favour of the shared implementation.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Configuration Layer
|
||||
- [ ] Add `InputbarScope` registry (e.g. `src/renderer/src/config/registry/inputbar.ts`).
|
||||
- [ ] Define per-scope options (features toggles, placeholders, min/max rows, token counter, quick panel, attachments, knowledge picker, mention models, translate button, abort button, etc.).
|
||||
- [ ] Register defaults for chat (`TopicType.Chat`), agent session (`TopicType.Session`), and mini window scope.
|
||||
|
||||
### 2. InputbarTools Registry System (NEW)
|
||||
- [ ] Create `ToolDefinition` interface with key, label, icon, condition, dependencies, and render function
|
||||
- [ ] Implement tool registration mechanism in `src/renderer/src/config/registry/inputbarTools.ts`
|
||||
- [ ] Create `InputbarToolsProvider` for shared state management (files, mentionedModels, knowledgeBases, etc.)
|
||||
- [ ] Define tool context interfaces (`ToolContext`, `ToolRenderContext`) for dependency injection
|
||||
- [ ] Migrate existing tools to registry-based definitions:
|
||||
- [ ] new_topic tool
|
||||
- [ ] attachment tool
|
||||
- [ ] thinking tool
|
||||
- [ ] web_search tool
|
||||
- [ ] url_context tool
|
||||
- [ ] knowledge_base tool
|
||||
- [ ] mcp_tools tool
|
||||
- [ ] generate_image tool
|
||||
- [ ] mention_models tool
|
||||
- [ ] quick_phrases tool
|
||||
- [ ] clear_topic tool
|
||||
- [ ] toggle_expand tool
|
||||
- [ ] new_context tool
|
||||
- [ ] Simplify InputbarTools component to use registry (reduce from 19 props to 3-5)
|
||||
- [ ] Integrate tool visibility/order configuration with InputbarScope
|
||||
|
||||
### 3. Shared UI Composer
|
||||
- [ ] Extract common UI from `Inputbar.tsx` into new `InputComposer` component that reads config + callbacks.
|
||||
- [ ] Ensure composer handles textarea sizing, focus, drag/drop, token estimation, attachments, toolbar slots based on config.
|
||||
- [ ] Provide controlled props for text, files, mentioned models, loading states, quick panel interactions.
|
||||
|
||||
### 4. Chat Wrapper Migration
|
||||
- [ ] Refactor `Inputbar.tsx` to:
|
||||
- Resolve scope via topic type.
|
||||
- Fetch config via registry.
|
||||
- Supply send/abort/translate/knowledge handlers to composer.
|
||||
- Remove inline UI duplication now covered by composer.
|
||||
- [ ] Verify chat-specific behaviour (knowledge save, auto translate, quick panel, model mentions) via config flags and callbacks.
|
||||
|
||||
### 5. Agent Session Wrapper Migration
|
||||
- [ ] Rebuild session input bar (currently `AgentSessionInputbar.tsx`) as thin wrapper using composer and session scope config.
|
||||
- [ ] Use session-specific hooks for message creation, model resolution, aborting, and streaming state.
|
||||
- [ ] Once parity confirmed, delete `AgentSessionInputbar.tsx` and update all imports.
|
||||
|
||||
### 6. Cross-cutting Cleanup
|
||||
- [ ] Remove duplicated state caches (`_text`, `_files`, `_mentionedModelsCache`) once wrappers manage persistence appropriately.
|
||||
- [ ] Update typings (`MessageInputBaseParams`, etc.) if composer needs shared interfaces.
|
||||
- [ ] Ensure quick panel integration works for all scopes (guard behind config flag).
|
||||
|
||||
### 7. Verification
|
||||
- [ ] Run `yarn build:check` (after cleaning existing lint issues in WebSearchTool/ReadTool).
|
||||
- [ ] Manual QA for chat topics, agent sessions, and mini window input: send, abort, attachments, translate, quick panel triggers, knowledge save.
|
||||
- [ ] Add doc entry summarising registry usage and scope configuration.
|
||||
|
||||
## Notes
|
||||
- Aligns with the approach taken for `MessageMenubar` scope registry.
|
||||
- Composer should accept refs for external focus triggers (e.g. `MessageGroup` or session auto-focus).
|
||||
- Plan to remove now-unused session-specific styles/components once migration completes.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### InputbarTools Registry Architecture
|
||||
**Problem**: Current InputbarTools has 19 props causing severe prop drilling and coupling.
|
||||
|
||||
**Solution**: Registry-based tool system with dependency injection:
|
||||
|
||||
```typescript
|
||||
// Tool Definition
|
||||
interface ToolDefinition {
|
||||
key: string
|
||||
label: string | ((t: TFunction) => string)
|
||||
icon?: React.ComponentType
|
||||
condition?: (context: ToolContext) => boolean
|
||||
visibleInScopes?: InputbarScope[]
|
||||
dependencies?: { hooks?, refs?, state? }
|
||||
render: (context: ToolRenderContext) => ReactNode
|
||||
}
|
||||
|
||||
// Context Provider for shared state
|
||||
InputbarToolsProvider manages:
|
||||
- files, mentionedModels, knowledgeBases states
|
||||
- setText, resizeTextArea actions
|
||||
- Tool refs management
|
||||
|
||||
// Simplified Component Interface
|
||||
InputbarTools props reduced to:
|
||||
- scope: InputbarScope
|
||||
- assistantId: string
|
||||
- onNewContext?: () => void
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Decoupled tool definitions
|
||||
- Easy to add/remove tools per scope
|
||||
- Type-safe dependency injection
|
||||
- Maintains drag-drop functionality
|
||||
- Reduces component complexity from 19 to 3-5 props
|
||||
@@ -1,4 +1,6 @@
|
||||
import { cn } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
@@ -225,20 +227,28 @@ const MessageItem: FC<Props> = ({
|
||||
</MessageErrorBoundary>
|
||||
</MessageContentContainer>
|
||||
{showMenubar && (
|
||||
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
index={index}
|
||||
topic={topic}
|
||||
isLastMessage={isLastMessage}
|
||||
isAssistantMessage={isAssistantMessage}
|
||||
isGrouped={isGrouped}
|
||||
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
||||
setModel={setModel}
|
||||
onUpdateUseful={onUpdateUseful}
|
||||
/>
|
||||
<MessageFooter className="MessageFooter">
|
||||
<HorizontalScrollContainer
|
||||
classNames={{
|
||||
content: cn(
|
||||
'flex-1 items-center justify-between',
|
||||
isLastMessage && messageStyle === 'plain' ? 'flex-row-reverse' : 'flex-row'
|
||||
)
|
||||
}}>
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
index={index}
|
||||
topic={topic}
|
||||
isLastMessage={isLastMessage}
|
||||
isAssistantMessage={isAssistantMessage}
|
||||
isGrouped={isGrouped}
|
||||
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
||||
setModel={setModel}
|
||||
onUpdateUseful={onUpdateUseful}
|
||||
/>
|
||||
</HorizontalScrollContainer>
|
||||
</MessageFooter>
|
||||
)}
|
||||
</>
|
||||
@@ -282,10 +292,8 @@ const MessageContentContainer = styled(Scrollbar)`
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
|
||||
const MessageFooter = styled.div`
|
||||
display: flex;
|
||||
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
|
||||
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
|
||||
@@ -337,17 +337,30 @@ const GroupContainer = styled.div`
|
||||
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
|
||||
width: 100%;
|
||||
display: grid;
|
||||
overflow-y: visible;
|
||||
gap: 16px;
|
||||
|
||||
&.horizontal {
|
||||
padding-bottom: 4px;
|
||||
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar-thumb);
|
||||
border-radius: var(--scrollbar-thumb-radius);
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
&.fold,
|
||||
&.vertical {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
&.grid {
|
||||
grid-template-columns: repeat(
|
||||
@@ -355,11 +368,15 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
|
||||
minmax(0, 1fr)
|
||||
);
|
||||
grid-template-rows: auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&.multi-select-mode {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
.grid {
|
||||
height: auto;
|
||||
}
|
||||
@@ -385,7 +402,7 @@ interface MessageWrapperProps {
|
||||
const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||
&.horizontal {
|
||||
padding: 1px;
|
||||
overflow-y: auto;
|
||||
/* overflow-y: auto; */
|
||||
.message {
|
||||
height: 100%;
|
||||
border: 0.5px solid var(--color-border);
|
||||
@@ -405,8 +422,9 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||
}
|
||||
}
|
||||
&.grid {
|
||||
display: block;
|
||||
height: 300px;
|
||||
overflow-y: hidden;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NormalToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { TFunction } from 'i18next'
|
||||
import { Pause } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { MessageAgentTools } from './MessageAgentTools'
|
||||
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
|
||||
@@ -35,14 +38,28 @@ const isAgentTool = (toolName: string) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => {
|
||||
const ChooseTool = (
|
||||
toolResponse: NormalToolResponse,
|
||||
status: MessageBlockStatus,
|
||||
t: TFunction
|
||||
): React.ReactNode | null => {
|
||||
let toolName = toolResponse.tool.name
|
||||
const toolType = toolResponse.tool.type
|
||||
if (toolName.startsWith(prefix)) {
|
||||
toolName = toolName.slice(prefix.length)
|
||||
if (status === MessageBlockStatus.PAUSED) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Pause className="h-4 w-4" />
|
||||
<span>{t('message.tools.aborted')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
switch (toolName) {
|
||||
case 'web_search':
|
||||
case 'web_search_preview':
|
||||
case 'exa_search':
|
||||
case 'tavily_search':
|
||||
return toolType === 'provider' ? null : <MessageWebSearchToolTitle toolResponse={toolResponse} />
|
||||
case 'knowledge_search':
|
||||
return <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />
|
||||
@@ -58,12 +75,13 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null =>
|
||||
}
|
||||
|
||||
export default function MessageTool({ block }: Props) {
|
||||
const { t } = useTranslation()
|
||||
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
|
||||
|
||||
if (!toolResponse) return null
|
||||
|
||||
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse)
|
||||
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse, block.status, t)
|
||||
|
||||
if (!toolRenderer) return null
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ExaSearchToolInput, ExaSearchToolOutput } from '@renderer/aiCore/tools/ExaSearchTool'
|
||||
import { TavilySearchToolInput, TavilySearchToolOutput } from '@renderer/aiCore/tools/TavilySearchTool'
|
||||
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
|
||||
import Spinner from '@renderer/components/Spinner'
|
||||
import { NormalToolResponse } from '@renderer/types'
|
||||
@@ -8,17 +10,31 @@ import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
// 联合类型 - 支持多种搜索工具
|
||||
type SearchToolInput = WebSearchToolInput | ExaSearchToolInput | TavilySearchToolInput
|
||||
type SearchToolOutput = WebSearchToolOutput | ExaSearchToolOutput | TavilySearchToolOutput
|
||||
|
||||
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||
const { t } = useTranslation()
|
||||
const toolInput = toolResponse.arguments as WebSearchToolInput
|
||||
const toolOutput = toolResponse.response as WebSearchToolOutput
|
||||
const toolInput = toolResponse.arguments as SearchToolInput
|
||||
const toolOutput = toolResponse.response as SearchToolOutput
|
||||
// 根据不同的工具类型获取查询内容
|
||||
const getQueryText = () => {
|
||||
if ('additionalContext' in toolInput) {
|
||||
return toolInput.additionalContext ?? ''
|
||||
}
|
||||
if ('query' in toolInput) {
|
||||
return toolInput.query ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return toolResponse.status !== 'done' ? (
|
||||
<Spinner
|
||||
text={
|
||||
<PrepareToolWrapper>
|
||||
{t('message.searching')}
|
||||
<span>{toolInput?.additionalContext ?? ''}</span>
|
||||
<span>{getQueryText()}</span>
|
||||
</PrepareToolWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -390,6 +390,7 @@ const Container = styled.div`
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-list-item-hover);
|
||||
|
||||
@@ -130,8 +130,8 @@ const Assistants: FC<AssistantsProps> = ({
|
||||
)}
|
||||
</TagsContainer>
|
||||
))}
|
||||
{renderAddAssistantButton}
|
||||
</div>
|
||||
{renderAddAssistantButton}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { HSpaceBetweenStack } from '@renderer/components/Layout'
|
||||
import RichEditor from '@renderer/components/RichEditor'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setEnableSpellCheck } from '@renderer/store/settings'
|
||||
import { EditorView } from '@renderer/types'
|
||||
import { Empty } from 'antd'
|
||||
import { Empty, Tooltip } from 'antd'
|
||||
import { SpellCheck } from 'lucide-react'
|
||||
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -21,7 +26,9 @@ interface NotesEditorProps {
|
||||
const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { settings } = useNotesSettings()
|
||||
const { enableSpellCheck } = useSettings()
|
||||
const currentViewMode = useMemo(() => {
|
||||
if (settings.defaultViewMode === 'edit') {
|
||||
return settings.defaultEditMode
|
||||
@@ -78,6 +85,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
isFullWidth={settings.isFullWidth}
|
||||
fontFamily={settings.fontFamily}
|
||||
fontSize={settings.fontSize}
|
||||
enableSpellCheck={enableSpellCheck}
|
||||
/>
|
||||
)}
|
||||
</RichEditorContainer>
|
||||
@@ -92,8 +100,21 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
color: 'var(--color-text-3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
gap: 12
|
||||
}}>
|
||||
{tmpViewMode === 'preview' && (
|
||||
<Tooltip placement="top" title={t('notes.spell_check_tooltip')} mouseLeaveDelay={0} arrow>
|
||||
<ActionIconButton
|
||||
active={enableSpellCheck}
|
||||
onClick={() => {
|
||||
const newValue = !enableSpellCheck
|
||||
dispatch(setEnableSpellCheck(newValue))
|
||||
window.api.setEnableSpellCheck(newValue)
|
||||
}}>
|
||||
<SpellCheck size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Selector
|
||||
value={tmpViewMode as EditorView}
|
||||
onChange={(value: EditorView) => setTmpViewMode(value)}
|
||||
|
||||
@@ -6,11 +6,14 @@ import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
||||
import { RootState, useAppSelector } from '@renderer/store'
|
||||
import { selectSortType } from '@renderer/store/note'
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { exportNote } from '@renderer/utils/export'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
||||
import { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -20,11 +23,14 @@ import {
|
||||
FileSearch,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Sparkles,
|
||||
Star,
|
||||
StarOff
|
||||
StarOff,
|
||||
UploadIcon
|
||||
} from 'lucide-react'
|
||||
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface NotesSidebarProps {
|
||||
@@ -50,6 +56,8 @@ interface TreeNodeProps {
|
||||
selectedFolderId?: string | null
|
||||
activeNodeId?: string
|
||||
editingNodeId: string | null
|
||||
renamingNodeIds: Set<string>
|
||||
newlyRenamedNodeIds: Set<string>
|
||||
draggedNodeId: string | null
|
||||
dragOverNodeId: string | null
|
||||
dragPosition: 'before' | 'inside' | 'after'
|
||||
@@ -72,6 +80,8 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
selectedFolderId,
|
||||
activeNodeId,
|
||||
editingNodeId,
|
||||
renamingNodeIds,
|
||||
newlyRenamedNodeIds,
|
||||
draggedNodeId,
|
||||
dragOverNodeId,
|
||||
dragPosition,
|
||||
@@ -92,6 +102,8 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
? node.type === 'folder' && node.id === selectedFolderId
|
||||
: node.id === activeNodeId
|
||||
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||
const isRenaming = renamingNodeIds.has(node.id)
|
||||
const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isDragging = draggedNodeId === node.id
|
||||
const isDragOver = dragOverNodeId === node.id
|
||||
@@ -99,6 +111,12 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||
|
||||
const getNodeNameClassName = () => {
|
||||
if (isRenaming) return 'shimmer'
|
||||
if (isNewlyRenamed) return 'typing'
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
||||
@@ -156,7 +174,7 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<NodeName>{node.name}</NodeName>
|
||||
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName>
|
||||
)}
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
@@ -173,6 +191,8 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNodeId}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
@@ -213,7 +233,10 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const { bases } = useKnowledgeBases()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
|
||||
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
||||
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
||||
@@ -336,6 +359,66 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
[bases.length, t]
|
||||
)
|
||||
|
||||
const handleImageAction = useCallback(
|
||||
async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => {
|
||||
try {
|
||||
if (activeNode?.id !== node.id) {
|
||||
onSelectNode(node)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
await exportNote({ node, platform })
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error)
|
||||
window.toast.error(t('common.copy_failed'))
|
||||
}
|
||||
},
|
||||
[activeNode, onSelectNode, t]
|
||||
)
|
||||
|
||||
const handleAutoRename = useCallback(
|
||||
async (note: NotesTreeNode) => {
|
||||
if (note.type !== 'file') return
|
||||
|
||||
setRenamingNodeIds((prev) => new Set(prev).add(note.id))
|
||||
try {
|
||||
const content = await window.api.file.readExternal(note.externalPath)
|
||||
if (!content || content.trim().length === 0) {
|
||||
window.toast.warning(t('notes.auto_rename.empty_note'))
|
||||
return
|
||||
}
|
||||
|
||||
const summaryText = await fetchNoteSummary({ content })
|
||||
if (summaryText) {
|
||||
onRenameNode(note.id, summaryText)
|
||||
window.toast.success(t('notes.auto_rename.success'))
|
||||
} else {
|
||||
window.toast.error(t('notes.auto_rename.failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
window.toast.error(t('notes.auto_rename.failed'))
|
||||
logger.error(`Failed to auto-rename note: ${error}`)
|
||||
} finally {
|
||||
setRenamingNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(note.id)
|
||||
return next
|
||||
})
|
||||
|
||||
setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
|
||||
|
||||
setTimeout(() => {
|
||||
setNewlyRenamedNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(note.id)
|
||||
return next
|
||||
})
|
||||
}, 700)
|
||||
}
|
||||
},
|
||||
[onRenameNode, t]
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
||||
setDraggedNodeId(node.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -490,7 +573,22 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
const baseMenuItems: MenuProps['items'] = [
|
||||
const baseMenuItems: MenuProps['items'] = []
|
||||
|
||||
// only show auto rename for file for now
|
||||
if (node.type !== 'folder') {
|
||||
baseMenuItems.push({
|
||||
label: t('notes.auto_rename.label'),
|
||||
key: 'auto-rename',
|
||||
icon: <Sparkles size={14} />,
|
||||
disabled: renamingNodeIds.has(node.id),
|
||||
onClick: () => {
|
||||
handleAutoRename(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
baseMenuItems.push(
|
||||
{
|
||||
label: t('notes.rename'),
|
||||
key: 'rename',
|
||||
@@ -507,7 +605,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
window.api.openPath(node.externalPath)
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
if (node.type !== 'folder') {
|
||||
baseMenuItems.push(
|
||||
{
|
||||
@@ -525,6 +623,58 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onClick: () => {
|
||||
handleExportKnowledge(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.title'),
|
||||
key: 'export',
|
||||
icon: <UploadIcon size={14} />,
|
||||
children: [
|
||||
exportMenuOptions.image && {
|
||||
label: t('chat.topics.copy.image'),
|
||||
key: 'copy-image',
|
||||
onClick: () => handleImageAction(node, 'copyImage')
|
||||
},
|
||||
exportMenuOptions.image && {
|
||||
label: t('chat.topics.export.image'),
|
||||
key: 'export-image',
|
||||
onClick: () => handleImageAction(node, 'exportImage')
|
||||
},
|
||||
exportMenuOptions.markdown && {
|
||||
label: t('chat.topics.export.md.label'),
|
||||
key: 'markdown',
|
||||
onClick: () => exportNote({ node, platform: 'markdown' })
|
||||
},
|
||||
exportMenuOptions.docx && {
|
||||
label: t('chat.topics.export.word'),
|
||||
key: 'word',
|
||||
onClick: () => exportNote({ node, platform: 'docx' })
|
||||
},
|
||||
exportMenuOptions.notion && {
|
||||
label: t('chat.topics.export.notion'),
|
||||
key: 'notion',
|
||||
onClick: () => exportNote({ node, platform: 'notion' })
|
||||
},
|
||||
exportMenuOptions.yuque && {
|
||||
label: t('chat.topics.export.yuque'),
|
||||
key: 'yuque',
|
||||
onClick: () => exportNote({ node, platform: 'yuque' })
|
||||
},
|
||||
exportMenuOptions.obsidian && {
|
||||
label: t('chat.topics.export.obsidian'),
|
||||
key: 'obsidian',
|
||||
onClick: () => exportNote({ node, platform: 'obsidian' })
|
||||
},
|
||||
exportMenuOptions.joplin && {
|
||||
label: t('chat.topics.export.joplin'),
|
||||
key: 'joplin',
|
||||
onClick: () => exportNote({ node, platform: 'joplin' })
|
||||
},
|
||||
exportMenuOptions.siyuan && {
|
||||
label: t('chat.topics.export.siyuan'),
|
||||
key: 'siyuan',
|
||||
onClick: () => exportNote({ node, platform: 'siyuan' })
|
||||
}
|
||||
].filter(Boolean) as ItemType<MenuItemType>[]
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -543,7 +693,17 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
return baseMenuItems
|
||||
},
|
||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
|
||||
[
|
||||
t,
|
||||
handleStartEdit,
|
||||
onToggleStar,
|
||||
handleExportKnowledge,
|
||||
handleImageAction,
|
||||
handleDeleteNode,
|
||||
renamingNodeIds,
|
||||
handleAutoRename,
|
||||
exportMenuOptions
|
||||
]
|
||||
)
|
||||
|
||||
const handleDropFiles = useCallback(
|
||||
@@ -680,6 +840,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
@@ -724,6 +886,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
@@ -746,6 +910,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
@@ -933,6 +1099,44 @@ const NodeName = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
position: relative;
|
||||
will-change: background-position, width;
|
||||
|
||||
--color-shimmer-mid: var(--color-text-1);
|
||||
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
||||
|
||||
&.shimmer {
|
||||
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: shimmer 3s linear infinite;
|
||||
}
|
||||
|
||||
&.typing {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
animation: typewriter 0.5s steps(40, end);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typewriter {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const EditInput = styled(Input)`
|
||||
@@ -953,7 +1157,7 @@ const DragOverIndicator = styled.div`
|
||||
`
|
||||
|
||||
const DropHintNode = styled.div`
|
||||
margin: 8px;
|
||||
margin: 6px 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
${TreeNodeContainer} {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Avatar } from '@heroui/react'
|
||||
import { getAgentDefaultAvatar } from '@renderer/config/agent'
|
||||
import { getAgentTypeAvatar } from '@renderer/config/agent'
|
||||
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
|
||||
import { getAgentTypeLabel } from '@renderer/i18n/label'
|
||||
import { GetAgentResponse } from '@renderer/types'
|
||||
@@ -30,7 +30,7 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
|
||||
<SettingsItem inline>
|
||||
<SettingsTitle>{t('agent.type.label')}</SettingsTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar src={getAgentDefaultAvatar(agent.type)} className="h-6 w-6 text-lg" />
|
||||
<Avatar src={getAgentTypeAvatar(agent.type)} className="h-6 w-6 text-lg" />
|
||||
<span>{(agent?.name ?? agent?.type) ? getAgentTypeLabel(agent.type) : ''}</span>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
@@ -104,7 +104,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
|
||||
afterClose={afterClose}
|
||||
maskClosable={false}
|
||||
footer={null}
|
||||
title={<AgentLabel agent={agent} classNames={{ name: 'text-lg font-extrabold' }} avatarProps={{ size: 'sm' }} />}
|
||||
title={<AgentLabel agent={agent} classNames={{ name: 'text-lg font-extrabold' }} />}
|
||||
transitionName="animation-move-down"
|
||||
styles={{
|
||||
content: {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Avatar, Radio, RadioGroup } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
|
||||
import { getAgentDefaultAvatar } from '@renderer/config/agent'
|
||||
import { AgentEntity, isAgentType, UpdateAgentForm } from '@renderer/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import z from 'zod'
|
||||
|
||||
import { SettingsItem, SettingsTitle } from './shared'
|
||||
|
||||
@@ -14,21 +10,11 @@ export interface AvatarSettingsProps {
|
||||
update: (form: UpdateAgentForm) => Promise<void>
|
||||
}
|
||||
|
||||
const optionsSchema = z.enum(['default', 'emoji'])
|
||||
|
||||
type AvatarOption = z.infer<typeof optionsSchema>
|
||||
|
||||
const options = {
|
||||
DEFAULT: 'default',
|
||||
EMOJI: 'emoji'
|
||||
} as const satisfies Record<string, AvatarOption>
|
||||
|
||||
const logger = loggerService.withContext('AvatarSetting')
|
||||
// const logger = loggerService.withContext('AvatarSetting')
|
||||
|
||||
export const AvatarSetting: React.FC<AvatarSettingsProps> = ({ agent, update }) => {
|
||||
const { t } = useTranslation()
|
||||
const isDefault = isAgentType(agent.configuration?.avatar)
|
||||
const [avatarOption, setAvatarOption] = useState<AvatarOption>(isDefault ? options.DEFAULT : options.EMOJI)
|
||||
const [emoji, setEmoji] = useState(isDefault ? '⭐️' : (agent.configuration?.avatar ?? '⭐️'))
|
||||
|
||||
const updateAvatar = useCallback(
|
||||
@@ -48,49 +34,17 @@ export const AvatarSetting: React.FC<AvatarSettingsProps> = ({ agent, update })
|
||||
[agent, update]
|
||||
)
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
(value: string) => {
|
||||
const result = optionsSchema.safeParse(value)
|
||||
if (!result.success) {
|
||||
logger.error('Invalid option', { value })
|
||||
return
|
||||
}
|
||||
const option = result.data
|
||||
setAvatarOption(option)
|
||||
if (option === agent?.configuration?.avatar) return
|
||||
|
||||
switch (option) {
|
||||
case options.DEFAULT:
|
||||
updateAvatar(agent.type)
|
||||
break
|
||||
case options.EMOJI:
|
||||
updateAvatar(emoji)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
[agent?.configuration?.avatar, agent.type, emoji, updateAvatar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsItem inline>
|
||||
<SettingsTitle>{t('common.avatar')}</SettingsTitle>
|
||||
<RadioGroup size="sm" orientation="horizontal" value={avatarOption} onValueChange={handleOptionChange}>
|
||||
<Radio value={options.DEFAULT} classNames={{ label: 'flex flex-row' }}>
|
||||
<Avatar className="h-6 w-6" src={getAgentDefaultAvatar(agent.type)} />
|
||||
</Radio>
|
||||
<Radio value={options.EMOJI}>
|
||||
<EmojiAvatarWithPicker
|
||||
emoji={emoji}
|
||||
onPick={(emoji: string) => {
|
||||
setEmoji(emoji)
|
||||
if (emoji === agent?.configuration?.avatar) return
|
||||
updateAvatar(emoji)
|
||||
}}
|
||||
/>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<EmojiAvatarWithPicker
|
||||
emoji={emoji}
|
||||
onPick={(emoji: string) => {
|
||||
setEmoji(emoji)
|
||||
if (emoji === agent?.configuration?.avatar) return
|
||||
updateAvatar(emoji)
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Avatar, AvatarProps, cn } from '@heroui/react'
|
||||
import { cn } from '@heroui/react'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import EmojiIcon from '@renderer/components/EmojiIcon'
|
||||
import { getAgentDefaultAvatar } from '@renderer/config/agent'
|
||||
import { getAgentTypeLabel } from '@renderer/i18n/label'
|
||||
import { AgentEntity, AgentSessionEntity, isAgentType } from '@renderer/types'
|
||||
import { AgentEntity, AgentSessionEntity } from '@renderer/types'
|
||||
import { Menu, Modal } from 'antd'
|
||||
import React, { ReactNode } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -30,18 +29,14 @@ export type AgentLabelProps = {
|
||||
avatar?: string
|
||||
name?: string
|
||||
}
|
||||
avatarProps?: AvatarProps
|
||||
}
|
||||
|
||||
export const AgentLabel: React.FC<AgentLabelProps> = ({ agent, classNames, avatarProps }) => {
|
||||
const isDefault = isAgentType(agent?.configuration?.avatar)
|
||||
const src = isDefault ? getAgentDefaultAvatar(agent.type) : undefined
|
||||
const emoji = isDefault ? undefined : agent?.configuration?.avatar
|
||||
export const AgentLabel: React.FC<AgentLabelProps> = ({ agent, classNames }) => {
|
||||
const emoji = agent?.configuration?.avatar
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full items-center gap-2', classNames?.container)}>
|
||||
{isDefault && <Avatar src={src} {...avatarProps} className={cn('h-6 w-6 text-lg', classNames?.avatar)} />}
|
||||
{!isDefault && <EmojiIcon emoji={emoji || '⭐️'} className={classNames?.avatar} />}
|
||||
<EmojiIcon emoji={emoji || '⭐️'} className={classNames?.avatar} />
|
||||
<Ellipsis className={classNames?.name}>
|
||||
{agent?.name ?? (agent?.type ? getAgentTypeLabel(agent.type) : '')}
|
||||
</Ellipsis>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { AutoComplete, Button, Flex, Form, FormProps, Input, Modal, Progress, Select } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useTimer } from '../../../../hooks/useTimer'
|
||||
|
||||
const logger = loggerService.withContext('OVMSClient')
|
||||
|
||||
interface ShowParams {
|
||||
title: string
|
||||
provider: Provider
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => unknown
|
||||
}
|
||||
|
||||
type FieldType = {
|
||||
modelName: string
|
||||
modelId: string
|
||||
modelSource: string
|
||||
task: string
|
||||
}
|
||||
|
||||
interface PresetModel {
|
||||
modelId: string
|
||||
modelName: string
|
||||
modelSource: string
|
||||
task: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const PRESET_MODELS: PresetModel[] = [
|
||||
{
|
||||
modelId: 'OpenVINO/Qwen3-8B-int4-ov',
|
||||
modelName: 'Qwen3-8B-int4-ov',
|
||||
modelSource: 'https://www.modelscope.cn/models',
|
||||
task: 'text_generation',
|
||||
label: 'Qwen3-8B-int4-ov (Text Generation)'
|
||||
},
|
||||
{
|
||||
modelId: 'OpenVINO/bge-base-en-v1.5-fp16-ov',
|
||||
modelName: 'bge-base-en-v1.5-fp16-ov',
|
||||
modelSource: 'https://www.modelscope.cn/models',
|
||||
task: 'embeddings',
|
||||
label: 'bge-base-en-v1.5-fp16-ov (Embeddings)'
|
||||
},
|
||||
{
|
||||
modelId: 'OpenVINO/bge-reranker-base-fp16-ov',
|
||||
modelName: 'bge-reranker-base-fp16-ov',
|
||||
modelSource: 'https://www.modelscope.cn/models',
|
||||
task: 'rerank',
|
||||
label: 'bge-reranker-base-fp16-ov (Rerank)'
|
||||
},
|
||||
{
|
||||
modelId: 'OpenVINO/DeepSeek-R1-Distill-Qwen-7B-int4-ov',
|
||||
modelName: 'DeepSeek-R1-Distill-Qwen-7B-int4-ov',
|
||||
modelSource: 'https://www.modelscope.cn/models',
|
||||
task: 'text_generation',
|
||||
label: 'DeepSeek-R1-Distill-Qwen-7B-int4-ov (Text Generation)'
|
||||
},
|
||||
{
|
||||
modelId: 'OpenVINO/stable-diffusion-v1-5-int8-ov',
|
||||
modelName: 'stable-diffusion-v1-5-int8-ov',
|
||||
modelSource: 'https://www.modelscope.cn/models',
|
||||
task: 'image_generation',
|
||||
label: 'stable-diffusion-v1-5-int8-ov (Image Generation)'
|
||||
},
|
||||
{
|
||||
modelId: 'OpenVINO/FLUX.1-schnell-int4-ov',
|
||||
modelName: 'FLUX.1-schnell-int4-ov',
|
||||
modelSource: 'https://www.modelscope.cn/models',
|
||||
task: 'image_generation',
|
||||
label: 'FLUX.1-schnell-int4-ov (Image Generation)'
|
||||
}
|
||||
]
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [cancelled, setCancelled] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
const { setIntervalTimer, clearIntervalTimer, setTimeoutTimer } = useTimer()
|
||||
|
||||
const startFakeProgress = () => {
|
||||
setProgress(0)
|
||||
setIntervalTimer(
|
||||
'progress',
|
||||
() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 95) {
|
||||
return prev // Stop at 95% until actual completion
|
||||
}
|
||||
// Simulate realistic download progress with slowing speed
|
||||
const increment =
|
||||
prev < 30
|
||||
? Math.random() * 1 + 0.25
|
||||
: prev < 60
|
||||
? Math.random() * 0.5 + 0.125
|
||||
: Math.random() * 0.25 + 0.03125
|
||||
|
||||
return Math.min(prev + increment, 95)
|
||||
})
|
||||
},
|
||||
500
|
||||
)
|
||||
}
|
||||
|
||||
const stopFakeProgress = (complete = false) => {
|
||||
clearIntervalTimer('progress')
|
||||
if (complete) {
|
||||
setProgress(100)
|
||||
// Reset progress after a short delay
|
||||
setTimeoutTimer('progress-reset', () => setProgress(0), 1500)
|
||||
} else {
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePresetSelect = (value: string) => {
|
||||
const selectedPreset = PRESET_MODELS.find((model) => model.modelId === value)
|
||||
if (selectedPreset) {
|
||||
form.setFieldsValue({
|
||||
modelId: selectedPreset.modelId,
|
||||
modelName: selectedPreset.modelName,
|
||||
modelSource: selectedPreset.modelSource,
|
||||
task: selectedPreset.task
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelIdChange = (value: string) => {
|
||||
if (value) {
|
||||
// Extract model name from model ID (part after last '/')
|
||||
const lastSlashIndex = value.lastIndexOf('/')
|
||||
if (lastSlashIndex !== -1 && lastSlashIndex < value.length - 1) {
|
||||
const modelName = value.substring(lastSlashIndex + 1)
|
||||
form.setFieldValue('modelName', modelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = async () => {
|
||||
if (loading) {
|
||||
// Stop the download
|
||||
try {
|
||||
setCancelled(true) // Mark as cancelled by user
|
||||
logger.info('Stopping download...')
|
||||
await window.api.ovms.stopAddModel()
|
||||
stopFakeProgress(false)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop download: ${error}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const onFinish: FormProps<FieldType>['onFinish'] = async (values) => {
|
||||
setLoading(true)
|
||||
setCancelled(false) // Reset cancelled state
|
||||
startFakeProgress()
|
||||
try {
|
||||
const { modelName, modelId, modelSource, task } = values
|
||||
logger.info(`🔄 Downloading model: ${modelName} with ID: ${modelId}, source: ${modelSource}, task: ${task}`)
|
||||
const result = await window.api.ovms.addModel(modelName, modelId, modelSource, task)
|
||||
|
||||
if (result.success) {
|
||||
stopFakeProgress(true) // Complete the progress bar
|
||||
Modal.success({
|
||||
title: t('ovms.download.success'),
|
||||
content: t('ovms.download.success_desc', { modelName: modelName, modelId: modelId }),
|
||||
onOk: () => {
|
||||
setOpen(false)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
stopFakeProgress(false) // Reset progress on error
|
||||
logger.error(`Download failed, is it cancelled? ${cancelled}`)
|
||||
// Only show error if not cancelled by user
|
||||
if (!cancelled) {
|
||||
Modal.error({
|
||||
title: t('ovms.download.error'),
|
||||
content: <div dangerouslySetInnerHTML={{ __html: result.message }}></div>,
|
||||
onOk: () => {
|
||||
// Keep the form open for retry
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
stopFakeProgress(false) // Reset progress on error
|
||||
logger.error(`Download crashed, is it cancelled? ${cancelled}`)
|
||||
// Only show error if not cancelled by user
|
||||
if (!cancelled) {
|
||||
Modal.error({
|
||||
title: t('ovms.download.error'),
|
||||
content: error.message,
|
||||
onOk: () => {
|
||||
// Keep the form open for retry
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
maskClosable={false}
|
||||
afterClose={onClose}
|
||||
footer={null}
|
||||
transitionName="animation-move-down"
|
||||
centered
|
||||
closeIcon={!loading}>
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{ flex: '110px' }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
style={{ marginTop: 25 }}
|
||||
onFinish={onFinish}
|
||||
disabled={false}>
|
||||
<Form.Item
|
||||
name="modelId"
|
||||
label={t('ovms.download.model_id.label')}
|
||||
rules={[
|
||||
{ required: true, message: t('ovms.download.model_id.required') },
|
||||
{
|
||||
pattern: /^OpenVINO\/.+/,
|
||||
message: t('ovms.download.model_id.model_id_pattern')
|
||||
}
|
||||
]}>
|
||||
<AutoComplete
|
||||
placeholder={t('ovms.download.model_id.placeholder')}
|
||||
options={PRESET_MODELS.map((model) => ({
|
||||
value: model.modelId,
|
||||
label: model.label
|
||||
}))}
|
||||
onSelect={handlePresetSelect}
|
||||
onChange={handleModelIdChange}
|
||||
disabled={loading}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="modelName"
|
||||
label={t('ovms.download.model_name.label')}
|
||||
rules={[{ required: true, message: t('ovms.download.model_name.required') }]}>
|
||||
<Input
|
||||
placeholder={t('ovms.download.model_name.placeholder')}
|
||||
spellCheck={false}
|
||||
maxLength={200}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="modelSource"
|
||||
label={t('ovms.download.model_source')}
|
||||
initialValue="https://www.modelscope.cn/models"
|
||||
rules={[{ required: false }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'HuggingFace' },
|
||||
{ value: 'https://hf-mirror.com', label: 'HF-Mirror' },
|
||||
{ value: 'https://www.modelscope.cn/models', label: 'ModelScope' }
|
||||
]}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="task"
|
||||
label={t('ovms.download.model_task')}
|
||||
initialValue="text_generation"
|
||||
rules={[{ required: false }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'text_generation', label: 'Text Generation' },
|
||||
{ value: 'embeddings', label: 'Embeddings' },
|
||||
{ value: 'rerank', label: 'Rerank' },
|
||||
{ value: 'image_generation', label: 'Image Generation' }
|
||||
]}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Form.Item>
|
||||
{loading && (
|
||||
<Form.Item style={{ marginBottom: 16 }}>
|
||||
<Progress
|
||||
percent={Math.round(progress)}
|
||||
status={progress === 100 ? 'success' : 'active'}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068'
|
||||
}}
|
||||
showInfo={true}
|
||||
format={(percent) => `${percent}%`}
|
||||
/>
|
||||
<div style={{ textAlign: 'center', marginTop: 8, color: '#666', fontSize: '14px' }}>
|
||||
{t('ovms.download.tip')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item style={{ marginBottom: 8, textAlign: 'center' }}>
|
||||
<Flex justify="end" align="center" style={{ position: 'relative' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType={loading ? 'button' : 'submit'}
|
||||
size="middle"
|
||||
loading={false}
|
||||
onClick={loading ? onCancel : undefined}>
|
||||
{loading ? t('common.cancel') : t('ovms.download.button')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class DownloadOVMSModelPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('DownloadOVMSModelPopup')
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
'DownloadOVMSModelPopup'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '@renderer/pages/settings'
|
||||
import EditModelPopup from '@renderer/pages/settings/ProviderSettings/EditModelPopup/EditModelPopup'
|
||||
import AddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/AddModelPopup'
|
||||
import DownloadOVMSModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup'
|
||||
import ManageModelsPopup from '@renderer/pages/settings/ProviderSettings/ModelList/ManageModelsPopup'
|
||||
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup'
|
||||
import { Model } from '@renderer/types'
|
||||
@@ -93,6 +94,11 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
|
||||
}
|
||||
}, [provider, t])
|
||||
|
||||
const onDownloadModel = useCallback(
|
||||
() => DownloadOVMSModelPopup.show({ title: t('ovms.download.title'), provider }),
|
||||
[provider, t]
|
||||
)
|
||||
|
||||
const isLoading = useMemo(() => displayedModelGroups === null, [displayedModelGroups])
|
||||
|
||||
return (
|
||||
@@ -167,9 +173,15 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
|
||||
<Button type="primary" onClick={onManageModel} icon={<ListCheck size={16} />} disabled={isHealthChecking}>
|
||||
{t('button.manage')}
|
||||
</Button>
|
||||
<Button type="default" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking}>
|
||||
{t('button.add')}
|
||||
</Button>
|
||||
{provider.id !== 'ovms' ? (
|
||||
<Button type="default" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking}>
|
||||
{t('button.add')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="default" onClick={onDownloadModel} icon={<Plus size={16} />}>
|
||||
{t('button.download')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { Alert, Button } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingRow, SettingSubtitle } from '..'
|
||||
|
||||
const OVMSSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [ovmsStatus, setOvmsStatus] = useState<'not-installed' | 'not-running' | 'running'>('not-running')
|
||||
const [isInstallingOvms, setIsInstallingOvms] = useState(false)
|
||||
const [isRunningOvms, setIsRunningOvms] = useState(false)
|
||||
const [isStoppingOvms, setIsStoppingOvms] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
const status = await window.api.ovms.getStatus()
|
||||
setOvmsStatus(status)
|
||||
}
|
||||
checkStatus()
|
||||
}, [])
|
||||
|
||||
const installOvms = async () => {
|
||||
try {
|
||||
setIsInstallingOvms(true)
|
||||
await window.api.installOvmsBinary()
|
||||
// 安装成功后重新检查状态
|
||||
const status = await window.api.ovms.getStatus()
|
||||
setOvmsStatus(status)
|
||||
setIsInstallingOvms(false)
|
||||
} catch (error: any) {
|
||||
const errCodeMsg = {
|
||||
'100': t('ovms.failed.install_code_100'),
|
||||
'101': t('ovms.failed.install_code_101'),
|
||||
'102': t('ovms.failed.install_code_102'),
|
||||
'103': t('ovms.failed.install_code_103'),
|
||||
'104': t('ovms.failed.install_code_104'),
|
||||
'105': t('ovms.failed.install_code_105')
|
||||
}
|
||||
const match = error.message.match(/code (\d+)/)
|
||||
const code = match ? match[1] : 'unknown'
|
||||
const errorMsg = errCodeMsg[code as keyof typeof errCodeMsg] || error.message
|
||||
|
||||
window.toast.error(t('ovms.failed.install') + errorMsg)
|
||||
setIsInstallingOvms(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runOvms = async () => {
|
||||
try {
|
||||
setIsRunningOvms(true)
|
||||
await window.api.ovms.runOvms()
|
||||
// 运行成功后重新检查状态
|
||||
const status = await window.api.ovms.getStatus()
|
||||
setOvmsStatus(status)
|
||||
setIsRunningOvms(false)
|
||||
} catch (error: any) {
|
||||
window.toast.error(t('ovms.failed.run') + error.message)
|
||||
setIsRunningOvms(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopOvms = async () => {
|
||||
try {
|
||||
setIsStoppingOvms(true)
|
||||
await window.api.ovms.stopOvms()
|
||||
// 停止成功后重新检查状态
|
||||
const status = await window.api.ovms.getStatus()
|
||||
setOvmsStatus(status)
|
||||
setIsStoppingOvms(false)
|
||||
} catch (error: any) {
|
||||
window.toast.error(t('ovms.failed.stop') + error.message)
|
||||
setIsStoppingOvms(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getAlertType = () => {
|
||||
switch (ovmsStatus) {
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'not-running':
|
||||
return 'warning'
|
||||
case 'not-installed':
|
||||
return 'error'
|
||||
default:
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (ovmsStatus) {
|
||||
case 'running':
|
||||
return t('ovms.status.running')
|
||||
case 'not-running':
|
||||
return t('ovms.status.not_running')
|
||||
case 'not-installed':
|
||||
return t('ovms.status.not_installed')
|
||||
default:
|
||||
return t('ovms.status.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
type={getAlertType()}
|
||||
banner
|
||||
style={{ borderRadius: 'var(--list-item-border-radius)' }}
|
||||
description={
|
||||
<VStack>
|
||||
<SettingRow style={{ width: '100%' }}>
|
||||
<SettingSubtitle style={{ margin: 0, fontWeight: 'normal' }}>{getStatusMessage()}</SettingSubtitle>
|
||||
{ovmsStatus === 'not-installed' && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={installOvms}
|
||||
loading={isInstallingOvms}
|
||||
disabled={isInstallingOvms}
|
||||
size="small">
|
||||
{isInstallingOvms ? t('ovms.action.installing') : t('ovms.action.install')}
|
||||
</Button>
|
||||
)}
|
||||
{ovmsStatus === 'not-running' && (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={installOvms}
|
||||
loading={isInstallingOvms}
|
||||
disabled={isInstallingOvms || isRunningOvms}
|
||||
size="small">
|
||||
{isInstallingOvms ? t('ovms.action.installing') : t('ovms.action.reinstall')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={runOvms}
|
||||
loading={isRunningOvms}
|
||||
disabled={isRunningOvms}
|
||||
size="small">
|
||||
{isRunningOvms ? t('ovms.action.starting') : t('ovms.action.run')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{ovmsStatus === 'running' && (
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={stopOvms}
|
||||
loading={isStoppingOvms}
|
||||
disabled={isStoppingOvms}
|
||||
size="small">
|
||||
{isStoppingOvms ? t('ovms.action.stopping') : t('ovms.action.stop')}
|
||||
</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
style={{ marginTop: 5 }}
|
||||
message={'Intel OVMS Guide:'}
|
||||
description={<div dangerouslySetInnerHTML={{ __html: t('ovms.description') }}></div>}
|
||||
showIcon
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OVMSSettings
|
||||
@@ -27,6 +27,8 @@ import UrlSchemaInfoPopup from './UrlSchemaInfoPopup'
|
||||
const logger = loggerService.withContext('ProviderList')
|
||||
|
||||
const BUTTON_WRAPPER_HEIGHT = 50
|
||||
const systemType = await window.api.system.getDeviceType()
|
||||
const cpuName = await window.api.system.getCpuName()
|
||||
|
||||
const ProviderList: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -273,6 +275,10 @@ const ProviderList: FC = () => {
|
||||
}
|
||||
|
||||
const filteredProviders = providers.filter((provider) => {
|
||||
if (provider.id === 'ovms' && (systemType !== 'windows' || !cpuName.toLowerCase().includes('intel'))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
const isProviderMatch = matchKeywordsInProvider(keywords, provider)
|
||||
const isModelMatch = provider.models.some((model) => matchKeywordsInModel(keywords, model))
|
||||
|
||||
@@ -14,7 +14,7 @@ import { checkApi } from '@renderer/services/ApiService'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { updateWebSearchProvider } from '@renderer/store/websearch'
|
||||
import { isSystemProvider } from '@renderer/types'
|
||||
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
|
||||
import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import {
|
||||
formatApiHost,
|
||||
@@ -47,6 +47,7 @@ import DMXAPISettings from './DMXAPISettings'
|
||||
import GithubCopilotSettings from './GithubCopilotSettings'
|
||||
import GPUStackSettings from './GPUStackSettings'
|
||||
import LMStudioSettings from './LMStudioSettings'
|
||||
import OVMSSettings from './OVMSSettings'
|
||||
import ProviderOAuth from './ProviderOAuth'
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
import VertexAISettings from './VertexAISettings'
|
||||
@@ -55,7 +56,21 @@ interface Props {
|
||||
providerId: string
|
||||
}
|
||||
|
||||
const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope', 'aihubmix']
|
||||
const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
|
||||
SystemProviderIds.deepseek,
|
||||
SystemProviderIds.moonshot,
|
||||
SystemProviderIds.zhipu,
|
||||
SystemProviderIds.dashscope,
|
||||
SystemProviderIds.modelscope,
|
||||
SystemProviderIds.aihubmix,
|
||||
SystemProviderIds.grok
|
||||
] as const
|
||||
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]
|
||||
|
||||
const ANTHROPIC_COMPATIBLE_PROVIDER_ID_SET = new Set<string>(ANTHROPIC_COMPATIBLE_PROVIDER_IDS)
|
||||
const isAnthropicCompatibleProviderId = (id: string): id is AnthropicCompatibleProviderId => {
|
||||
return ANTHROPIC_COMPATIBLE_PROVIDER_ID_SET.has(id)
|
||||
}
|
||||
|
||||
const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const { provider, updateProvider, models } = useProvider(providerId)
|
||||
@@ -264,7 +279,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
}, [provider.anthropicApiHost])
|
||||
|
||||
const canConfigureAnthropicHost = useMemo(() => {
|
||||
return provider.type !== 'anthropic' && ANTHROPIC_COMPATIBLE_PROVIDER_IDS.includes(provider.id)
|
||||
return (
|
||||
provider.type !== 'anthropic' && isSystemProviderId(provider.id) && isAnthropicCompatibleProviderId(provider.id)
|
||||
)
|
||||
}, [provider])
|
||||
|
||||
const anthropicHostPreview = useMemo(() => {
|
||||
@@ -324,6 +341,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
<Divider style={{ width: '100%', margin: '10px 0' }} />
|
||||
{isProviderSupportAuth(provider) && <ProviderOAuth providerId={provider.id} />}
|
||||
{provider.id === 'openai' && <OpenAIAlert />}
|
||||
{provider.id === 'ovms' && <OVMSSettings />}
|
||||
{isDmxapi && <DMXAPISettings providerId={provider.id} />}
|
||||
{provider.id === 'anthropic' && (
|
||||
<>
|
||||
@@ -394,7 +412,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
<>
|
||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Tooltip title={t('settings.provider.api_host_tooltip')} mouseEnterDelay={0.3}>
|
||||
<span>{t('settings.provider.api_host')}</span>
|
||||
<SubtitleLabel>{t('settings.provider.api_host')}</SubtitleLabel>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -438,7 +456,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Tooltip title={t('settings.provider.anthropic_api_host_tooltip')} mouseEnterDelay={0.3}>
|
||||
<span>{t('settings.provider.anthropic_api_host')}</span>
|
||||
<SubtitleLabel>{t('settings.provider.anthropic_api_host')}</SubtitleLabel>
|
||||
</Tooltip>
|
||||
</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
@@ -449,14 +467,13 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
onBlur={onUpdateAnthropicHost}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<SettingHelpText
|
||||
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
<SettingHelpTextRow style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px' }}>
|
||||
<SettingHelpText style={{ marginLeft: 6, whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
{t('settings.provider.anthropic_api_host_preview', {
|
||||
url: anthropicHostPreview || '—'
|
||||
})}
|
||||
</SettingHelpText>
|
||||
<SettingHelpText style={{ minWidth: 'fit-content', whiteSpace: 'normal' }}>
|
||||
<SettingHelpText style={{ marginLeft: 6 }}>
|
||||
{t('settings.provider.anthropic_api_host_tip')}
|
||||
</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
@@ -494,6 +511,15 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const SubtitleLabel = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
`
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebSearchState } from '@renderer/store/websearch'
|
||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||
|
||||
export default abstract class BaseWebSearchProvider {
|
||||
// @ts-ignore this
|
||||
@@ -16,7 +16,8 @@ export default abstract class BaseWebSearchProvider {
|
||||
abstract search(
|
||||
query: string,
|
||||
websearch: WebSearchState,
|
||||
httpOptions?: RequestInit
|
||||
httpOptions?: RequestInit,
|
||||
providerParams?: ProviderSpecificParams
|
||||
): Promise<WebSearchProviderResponse>
|
||||
|
||||
public getApiHost() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { WebSearchState } from '@renderer/store/websearch'
|
||||
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
|
||||
import { BochaSearchParams, BochaSearchResponse } from '@renderer/utils/bocha'
|
||||
|
||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||
@@ -18,7 +18,12 @@ export default class BochaProvider extends BaseWebSearchProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchProviderResponse> {
|
||||
public async search(
|
||||
query: string,
|
||||
websearch: WebSearchState,
|
||||
httpOptions?: RequestInit,
|
||||
_providerParams?: ProviderSpecificParams
|
||||
): Promise<WebSearchProviderResponse> {
|
||||
try {
|
||||
if (!query.trim()) {
|
||||
throw new Error('Search query cannot be empty')
|
||||
@@ -44,7 +49,8 @@ export default class BochaProvider extends BaseWebSearchProvider {
|
||||
headers: {
|
||||
...this.defaultHeaders(),
|
||||
...headers
|
||||
}
|
||||
},
|
||||
signal: httpOptions?.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { WebSearchProviderResponse } from '@renderer/types'
|
||||
import { WebSearchState } from '@renderer/store/websearch'
|
||||
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
|
||||
|
||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||
|
||||
export default class DefaultProvider extends BaseWebSearchProvider {
|
||||
search(): Promise<WebSearchProviderResponse> {
|
||||
search(
|
||||
_query: string,
|
||||
_websearch: WebSearchState,
|
||||
_httpOptions?: RequestInit,
|
||||
_providerParams?: ProviderSpecificParams
|
||||
): Promise<WebSearchProviderResponse> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user