Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be94216f4d | |||
| 38ac42af8c | |||
| d11a2cd95c | |||
| 23f61b0d62 | |||
| 961ee22327 | |||
| c7d2588f1a | |||
| 06ab2822be | |||
| bb0ec0a3ec | |||
| 483b4e090e | |||
| 4975c2d9e8 | |||
| 5365fddec9 | |||
| e401685449 | |||
| e195ad4a8f | |||
| 20f5271682 | |||
| 5524571c80 | |||
| cd3031479c | |||
| 1df6e8c732 | |||
| ed2e01491e | |||
| 228ed474ce | |||
| 6829a03437 | |||
| dabfb8dc0e | |||
| 4aa9c9f225 | |||
| 52a980f751 | |||
| 3b7ab2aec8 | |||
| d41e239b89 | |||
| b85040f579 | |||
| 8bcd229849 | |||
| d12515ccb9 | |||
| 499cb52e28 | |||
| 05a318225c | |||
| caad0bc005 | |||
| 067ecb5e8e | |||
| 0f8cbeed11 | |||
| 2ed99c0cb8 | |||
| 0a149e3d9e | |||
| a3a26c69c5 | |||
| 2bafc53b25 | |||
| 09e9b95e08 | |||
| bf2ffb7465 | |||
| 287c96ea2e | |||
| adacb8c638 | |||
| 7a3d08672a | |||
| ec4d106a59 | |||
| fe0c0fac1e |
@@ -2,8 +2,8 @@ name: Auto I18N
|
||||
|
||||
env:
|
||||
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
||||
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Delete merged branch
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
delete-branch:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
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
|
||||
with:
|
||||
script: |
|
||||
github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `heads/${context.payload.pull_request.head.ref}`,
|
||||
})
|
||||
@@ -99,9 +99,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -110,15 +110,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -128,9 +128,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Rename artifacts with nightly format
|
||||
shell: bash
|
||||
|
||||
@@ -10,12 +10,14 @@ on:
|
||||
- main
|
||||
- develop
|
||||
- v2
|
||||
types: [ready_for_review, synchronize, opened]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PRCI: true
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
|
||||
@@ -86,9 +86,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -98,15 +98,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -116,9 +116,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
|
||||
// src/get-model-path.ts
|
||||
function getModelPath(modelId) {
|
||||
- return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
|
||||
}
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
+4
-13
@@ -125,16 +125,7 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
✨ 新功能:
|
||||
- 新增 CherryIN 服务商
|
||||
- 新增 AiOnly AI 服务商
|
||||
- 更新 MCP 服务器卡片布局和样式,改为列表视图
|
||||
|
||||
🐛 问题修复:
|
||||
- 修复 QwenMT 模型的翻译内容处理逻辑
|
||||
- 修复无法将外部笔记添加到知识库的问题
|
||||
|
||||
🚀 性能优化:
|
||||
- 提升输入框响应速度
|
||||
- 优化模型切换性能
|
||||
- 改进翻译功能的引用和邮件格式处理
|
||||
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
|
||||
|
||||
@@ -34,6 +34,10 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
},
|
||||
sourcemap: isDev
|
||||
@@ -111,6 +115,10 @@ export default defineConfig({
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+8
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.6.0-rc.4",
|
||||
"version": "1.6.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -97,10 +97,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",
|
||||
@@ -215,7 +215,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",
|
||||
@@ -368,7 +368,8 @@
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -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,15 +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/google": "^2.0.14",
|
||||
"@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": {
|
||||
|
||||
@@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
return params
|
||||
}
|
||||
|
||||
context.mcpTools = params.tools
|
||||
// 分离 provider-defined 和其他类型的工具
|
||||
const providerDefinedTools: ToolSet = {}
|
||||
const promptTools: ToolSet = {}
|
||||
|
||||
// 构建系统提示符
|
||||
for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) {
|
||||
if (tool.type === 'provider-defined') {
|
||||
// provider-defined 类型的工具保留在 tools 参数中
|
||||
providerDefinedTools[toolName] = tool
|
||||
} else {
|
||||
// 其他工具转换为 prompt 模式
|
||||
promptTools[toolName] = tool
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当有非 provider-defined 工具时才保存到 context
|
||||
if (Object.keys(promptTools).length > 0) {
|
||||
context.mcpTools = promptTools
|
||||
}
|
||||
|
||||
// 构建系统提示符(只包含非 provider-defined 工具)
|
||||
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
|
||||
let systemMessage: string | null = systemPrompt
|
||||
if (config.createSystemMessage) {
|
||||
// 🎯 如果用户提供了自定义处理函数,使用它
|
||||
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
||||
}
|
||||
|
||||
// 移除 tools,改为 prompt 模式
|
||||
// 保留 provider-defined tools,移除其他 tools
|
||||
const transformedParams = {
|
||||
...params,
|
||||
...(systemMessage ? { system: systemMessage } : {}),
|
||||
tools: undefined
|
||||
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
|
||||
}
|
||||
context.originalParams = transformedParams
|
||||
return transformedParams
|
||||
@@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
let textBuffer = ''
|
||||
// let stepId = ''
|
||||
|
||||
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
|
||||
if (!context.mcpTools) {
|
||||
throw new Error('No tools available')
|
||||
return new TransformStream()
|
||||
}
|
||||
|
||||
// 从 context 中获取或初始化 usage 累加器
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import { InferToolInput, InferToolOutput } from 'ai'
|
||||
|
||||
import { ProviderOptionsMap } from '../../../options/types'
|
||||
import { OpenRouterSearchConfig } from './openrouter'
|
||||
@@ -58,24 +59,31 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
||||
|
||||
export type WebSearchToolOutputSchema = {
|
||||
// Anthropic 工具 - 手动定义
|
||||
anthropicWebSearch: Array<{
|
||||
url: string
|
||||
title: string
|
||||
pageAge: string | null
|
||||
encryptedContent: string
|
||||
type: string
|
||||
}>
|
||||
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||
|
||||
// OpenAI 工具 - 基于实际输出
|
||||
openaiWebSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
|
||||
openai: {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
'openai-chat': {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
|
||||
// Google 工具
|
||||
googleSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
|
||||
google: {
|
||||
webSearchQueries?: string[]
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri: string; title: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export type WebSearchToolInputSchema = {
|
||||
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
|
||||
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
|
||||
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -220,6 +221,7 @@ export enum IpcChannel {
|
||||
// system
|
||||
System_GetDeviceType = 'system:getDeviceType',
|
||||
System_GetHostname = 'system:getHostname',
|
||||
System_GetCpuName = 'system:getCpuName',
|
||||
|
||||
// DevTools
|
||||
System_ToggleDevTools = 'system:toggleDevTools',
|
||||
@@ -330,6 +332,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'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -368,16 +369,27 @@ export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
}
|
||||
]
|
||||
|
||||
// Helper function to escape strings for AppleScript
|
||||
const escapeForAppleScript = (str: string): string => {
|
||||
// In AppleScript strings, backslashes and double quotes need to be escaped
|
||||
// When passed through osascript -e with single quotes, we need:
|
||||
// 1. Backslash: \ -> \\
|
||||
// 2. Double quote: " -> \"
|
||||
return str
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/"/g, '\\"') // Then escape double quotes
|
||||
}
|
||||
|
||||
export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
{
|
||||
id: terminalApps.systemDefault,
|
||||
name: 'Terminal',
|
||||
bundleId: 'com.apple.Terminal',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" in front window'`
|
||||
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapeForAppleScript(fullCommand)}" in front window'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@@ -385,11 +397,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.iterm2,
|
||||
name: 'iTerm2',
|
||||
bundleId: 'com.googlecode.iterm2',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n activate\nend tell'`
|
||||
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "${escapeForAppleScript(fullCommand)}"\n activate\nend tell'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@@ -397,11 +409,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.kitty,
|
||||
name: 'kitty',
|
||||
bundleId: 'net.kovidgoyal.kitty',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`cd "${directory}" && open -na kitty --args --directory="${directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
|
||||
`cd "${_directory}" && open -na kitty --args --directory="${_directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@@ -409,11 +421,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.alacritty,
|
||||
name: 'Alacritty',
|
||||
bundleId: 'org.alacritty',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na Alacritty --args --working-directory "${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
|
||||
`open -na Alacritty --args --working-directory "${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@@ -421,11 +433,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.wezterm,
|
||||
name: 'WezTerm',
|
||||
bundleId: 'com.github.wez.wezterm',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`open -na WezTerm --args start --new-tab --cwd "${directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
|
||||
`open -na WezTerm --args start --new-tab --cwd "${_directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@@ -433,11 +445,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.ghostty,
|
||||
name: 'Ghostty',
|
||||
bundleId: 'com.mitchellh.ghostty',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
`cd "${directory}" && open -na Ghostty --args --working-directory="${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
|
||||
`cd "${_directory}" && open -na Ghostty --args --working-directory="${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
|
||||
]
|
||||
})
|
||||
},
|
||||
@@ -445,7 +457,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
id: terminalApps.tabby,
|
||||
name: 'Tabby',
|
||||
bundleId: 'org.tabby',
|
||||
command: (directory: string, fullCommand: string) => ({
|
||||
command: (_directory: string, fullCommand: string) => ({
|
||||
command: 'sh',
|
||||
args: [
|
||||
'-c',
|
||||
@@ -453,7 +465,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
open -na Tabby --args open && sleep 0.3
|
||||
else
|
||||
open -na Tabby --args open && sleep 2
|
||||
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "cd \\"${directory.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}\\" && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
|
||||
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "${escapeForAppleScript(fullCommand)}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,6 +35,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'
|
||||
@@ -81,6 +82,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()
|
||||
@@ -432,6 +434,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()
|
||||
@@ -710,6 +713,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))
|
||||
@@ -841,6 +845,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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* DiDi MCP Server Implementation
|
||||
*
|
||||
* Based on official DiDi MCP API capabilities.
|
||||
* API Documentation: https://mcp.didichuxing.com/api?tap=api
|
||||
*
|
||||
* Provides ride-hailing services including map search, price estimation,
|
||||
* order management, and driver tracking.
|
||||
*
|
||||
* Note: Only available in Mainland China.
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const logger = loggerService.withContext('DiDiMCPServer')
|
||||
|
||||
export class DiDiMcpServer {
|
||||
private _server: Server
|
||||
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
this._server = new Server(
|
||||
{
|
||||
name: 'didi-mcp-server',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Get API key from parameter or environment variables
|
||||
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
|
||||
if (!this.apiKey) {
|
||||
logger.warn('DIDI_API_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
this.setupRequestHandlers()
|
||||
}
|
||||
|
||||
get server(): Server {
|
||||
return this._server
|
||||
}
|
||||
|
||||
private setupRequestHandlers() {
|
||||
// List available tools
|
||||
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'maps_textsearch',
|
||||
description: 'Search for POI locations based on keywords and city',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: 'Query city'
|
||||
},
|
||||
keywords: {
|
||||
type: 'string',
|
||||
description: 'Search keywords'
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Location coordinates, format: longitude,latitude'
|
||||
}
|
||||
},
|
||||
required: ['keywords', 'city']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_cancel_order',
|
||||
description: 'Cancel a taxi order',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Order ID from order creation or query results'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
|
||||
}
|
||||
},
|
||||
required: ['order_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_create_order',
|
||||
description: 'Create taxi order directly via API without opening any app interface',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
caller_car_phone: {
|
||||
type: 'string',
|
||||
description: 'Caller phone number (optional)'
|
||||
},
|
||||
estimate_trace_id: {
|
||||
type: 'string',
|
||||
description: 'Estimation trace ID from estimation results'
|
||||
},
|
||||
product_category: {
|
||||
type: 'string',
|
||||
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
|
||||
}
|
||||
},
|
||||
required: ['product_category', 'estimate_trace_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_estimate',
|
||||
description: 'Get available ride-hailing vehicle types and fare estimates',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from_lat: {
|
||||
type: 'string',
|
||||
description: 'Departure latitude, must be from map tools'
|
||||
},
|
||||
from_lng: {
|
||||
type: 'string',
|
||||
description: 'Departure longitude, must be from map tools'
|
||||
},
|
||||
from_name: {
|
||||
type: 'string',
|
||||
description: 'Departure location name'
|
||||
},
|
||||
to_lat: {
|
||||
type: 'string',
|
||||
description: 'Destination latitude, must be from map tools'
|
||||
},
|
||||
to_lng: {
|
||||
type: 'string',
|
||||
description: 'Destination longitude, must be from map tools'
|
||||
},
|
||||
to_name: {
|
||||
type: 'string',
|
||||
description: 'Destination name'
|
||||
}
|
||||
},
|
||||
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_generate_ride_app_link',
|
||||
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from_lat: {
|
||||
type: 'string',
|
||||
description: 'Departure latitude, must be from map tools'
|
||||
},
|
||||
from_lng: {
|
||||
type: 'string',
|
||||
description: 'Departure longitude, must be from map tools'
|
||||
},
|
||||
product_category: {
|
||||
type: 'string',
|
||||
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
|
||||
},
|
||||
to_lat: {
|
||||
type: 'string',
|
||||
description: 'Destination latitude, must be from map tools'
|
||||
},
|
||||
to_lng: {
|
||||
type: 'string',
|
||||
description: 'Destination longitude, must be from map tools'
|
||||
}
|
||||
},
|
||||
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_get_driver_location',
|
||||
description: 'Get real-time driver location for a taxi order',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Taxi order ID'
|
||||
}
|
||||
},
|
||||
required: ['order_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_query_order',
|
||||
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Handle tool calls
|
||||
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'maps_textsearch':
|
||||
return await this.handleMapsTextSearch(args)
|
||||
case 'taxi_cancel_order':
|
||||
return await this.handleTaxiCancelOrder(args)
|
||||
case 'taxi_create_order':
|
||||
return await this.handleTaxiCreateOrder(args)
|
||||
case 'taxi_estimate':
|
||||
return await this.handleTaxiEstimate(args)
|
||||
case 'taxi_generate_ride_app_link':
|
||||
return await this.handleTaxiGenerateRideAppLink(args)
|
||||
case 'taxi_get_driver_location':
|
||||
return await this.handleTaxiGetDriverLocation(args)
|
||||
case 'taxi_query_order':
|
||||
return await this.handleTaxiQueryOrder(args)
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error calling tool ${name}:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async handleMapsTextSearch(args: any) {
|
||||
const { city, keywords, location } = args
|
||||
|
||||
const params = {
|
||||
name: 'maps_textsearch',
|
||||
arguments: {
|
||||
keywords,
|
||||
city,
|
||||
...(location && { location })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Maps text search error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiCancelOrder(args: any) {
|
||||
const { order_id, reason } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_cancel_order',
|
||||
arguments: {
|
||||
order_id,
|
||||
...(reason && { reason })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi cancel order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiCreateOrder(args: any) {
|
||||
const { caller_car_phone, estimate_trace_id, product_category } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_create_order',
|
||||
arguments: {
|
||||
product_category,
|
||||
estimate_trace_id,
|
||||
...(caller_car_phone && { caller_car_phone })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi create order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiEstimate(args: any) {
|
||||
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_estimate',
|
||||
arguments: {
|
||||
from_lng,
|
||||
from_lat,
|
||||
from_name,
|
||||
to_lng,
|
||||
to_lat,
|
||||
to_name
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi estimate error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiGenerateRideAppLink(args: any) {
|
||||
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_generate_ride_app_link',
|
||||
arguments: {
|
||||
from_lng,
|
||||
from_lat,
|
||||
to_lng,
|
||||
to_lat,
|
||||
...(product_category && { product_category })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi generate ride app link error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiGetDriverLocation(args: any) {
|
||||
const { order_id } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_get_driver_location',
|
||||
arguments: {
|
||||
order_id
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi get driver location error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiQueryOrder(args: any) {
|
||||
const { order_id } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_query_order',
|
||||
arguments: {
|
||||
...(order_id && { order_id })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi query order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequest(method: string, params: any): Promise<any> {
|
||||
const requestData = {
|
||||
jsonrpc: '2.0',
|
||||
method: method,
|
||||
id: Date.now(),
|
||||
...(Object.keys(params).length > 0 && { params })
|
||||
}
|
||||
|
||||
// API key is passed as URL parameter
|
||||
const url = `${this.baseUrl}?key=${this.apiKey}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
|
||||
}
|
||||
|
||||
return data.result
|
||||
}
|
||||
}
|
||||
|
||||
export default DiDiMcpServer
|
||||
@@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
||||
|
||||
import BraveSearchServer from './brave-search'
|
||||
import DiDiMcpServer from './didi-mcp'
|
||||
import DifyKnowledgeServer from './dify-knowledge'
|
||||
import FetchServer from './fetch'
|
||||
import FileSystemServer from './filesystem'
|
||||
@@ -42,6 +43,10 @@ export function createInMemoryMCPServer(
|
||||
case BuiltinMCPServerNames.python: {
|
||||
return new PythonServer().server
|
||||
}
|
||||
case BuiltinMCPServerNames.didiMCP: {
|
||||
const apiKey = envs.DIDI_API_KEY
|
||||
return new DiDiMcpServer(apiKey).server
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('AppUpdater')
|
||||
|
||||
// Language markers constants for multi-language release notes
|
||||
const LANG_MARKERS = {
|
||||
EN_START: '<!--LANG:en-->',
|
||||
ZH_CN_START: '<!--LANG:zh-CN-->',
|
||||
END: '<!--LANG:END-->'
|
||||
} as const
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
@@ -41,7 +48,8 @@ export default class AppUpdater {
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
logger.info('update available', releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, processedReleaseInfo)
|
||||
})
|
||||
|
||||
// 检测到不需要更新时
|
||||
@@ -56,9 +64,10 @@ export default class AppUpdater {
|
||||
|
||||
// 当需要更新的内容下载完成后
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
|
||||
this.releaseInfo = releaseInfo
|
||||
logger.info('update downloaded', releaseInfo)
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||
this.releaseInfo = processedReleaseInfo
|
||||
logger.info('update downloaded', processedReleaseInfo)
|
||||
})
|
||||
|
||||
if (isWin) {
|
||||
@@ -271,16 +280,99 @@ export default class AppUpdater {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if release notes contain multi-language markers
|
||||
*/
|
||||
private hasMultiLanguageMarkers(releaseNotes: string): boolean {
|
||||
return releaseNotes.includes(LANG_MARKERS.EN_START)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multi-language release notes and return the appropriate language version
|
||||
* @param releaseNotes - Release notes string with language markers
|
||||
* @returns Parsed release notes for the user's language
|
||||
*
|
||||
* Expected format:
|
||||
* <!--LANG:en-->English content<!--LANG:zh-CN-->Chinese content<!--LANG:END-->
|
||||
*/
|
||||
private parseMultiLangReleaseNotes(releaseNotes: string): string {
|
||||
try {
|
||||
const language = configManager.getLanguage()
|
||||
const isChineseUser = language === 'zh-CN' || language === 'zh-TW'
|
||||
|
||||
// Create regex patterns using constants
|
||||
const enPattern = new RegExp(
|
||||
`${LANG_MARKERS.EN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||
)
|
||||
const zhPattern = new RegExp(
|
||||
`${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||
)
|
||||
|
||||
// Extract language sections
|
||||
const enMatch = releaseNotes.match(enPattern)
|
||||
const zhMatch = releaseNotes.match(zhPattern)
|
||||
|
||||
// Return appropriate language version with proper fallback
|
||||
if (isChineseUser && zhMatch) {
|
||||
return zhMatch[1].trim()
|
||||
} else if (enMatch) {
|
||||
return enMatch[1].trim()
|
||||
} else {
|
||||
// Clean fallback: remove all language markers
|
||||
logger.warn('Failed to extract language-specific release notes, using cleaned fallback')
|
||||
return releaseNotes
|
||||
.replace(new RegExp(`${LANG_MARKERS.EN_START}|${LANG_MARKERS.ZH_CN_START}|${LANG_MARKERS.END}`, 'g'), '')
|
||||
.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse multi-language release notes', error as Error)
|
||||
// Return original notes as safe fallback
|
||||
return releaseNotes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process release info to handle multi-language release notes
|
||||
* @param releaseInfo - Original release info from updater
|
||||
* @returns Processed release info with localized release notes
|
||||
*/
|
||||
private processReleaseInfo(releaseInfo: UpdateInfo): UpdateInfo {
|
||||
const processedInfo = { ...releaseInfo }
|
||||
|
||||
// Handle multi-language release notes in string format
|
||||
if (releaseInfo.releaseNotes && typeof releaseInfo.releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseInfo.releaseNotes)) {
|
||||
processedInfo.releaseNotes = this.parseMultiLangReleaseNotes(releaseInfo.releaseNotes)
|
||||
}
|
||||
}
|
||||
|
||||
return processedInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Format release notes for display
|
||||
* @param releaseNotes - Release notes in various formats
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||
}
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
|
||||
@@ -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)
|
||||
@@ -666,7 +680,7 @@ class CodeToolsService {
|
||||
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
|
||||
|
||||
// Combine directory change with the main command to ensure they execute in the same shell session
|
||||
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
|
||||
const fullCommand = `cd "${directory.replace(/"/g, '\\"')}" && clear && ${command}`
|
||||
|
||||
const terminalConfig = await this.getTerminalConfig(options.terminal)
|
||||
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,319 @@
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../ConfigManager', () => ({
|
||||
configManager: {
|
||||
getLanguage: vi.fn(),
|
||||
getAutoUpdate: vi.fn(() => false),
|
||||
getTestPlan: vi.fn(() => false),
|
||||
getTestChannel: vi.fn(),
|
||||
getClientId: vi.fn(() => 'test-client-id')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../WindowService', () => ({
|
||||
windowService: {
|
||||
getMainWindow: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/constant', () => ({
|
||||
isWin: false
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/ipService', () => ({
|
||||
getIpCountry: vi.fn(() => 'US')
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/locales', () => ({
|
||||
locales: {
|
||||
en: { translation: { update: {} } },
|
||||
'zh-CN': { translation: { update: {} } }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/systemInfo', () => ({
|
||||
generateUserAgent: vi.fn(() => 'test-user-agent')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.0'),
|
||||
getPath: vi.fn(() => '/test/path')
|
||||
},
|
||||
dialog: {
|
||||
showMessageBox: vi.fn()
|
||||
},
|
||||
BrowserWindow: vi.fn(),
|
||||
net: {
|
||||
fetch: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron-updater', () => ({
|
||||
autoUpdater: {
|
||||
logger: null,
|
||||
forceDevUpdateConfig: false,
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
requestHeaders: {},
|
||||
on: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
channel: '',
|
||||
allowDowngrade: false,
|
||||
disableDifferentialDownload: false,
|
||||
currentVersion: '1.0.0'
|
||||
},
|
||||
Logger: vi.fn(),
|
||||
NsisUpdater: vi.fn(),
|
||||
AppUpdater: vi.fn()
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import AppUpdater from '../AppUpdater'
|
||||
import { configManager } from '../ConfigManager'
|
||||
|
||||
describe('AppUpdater', () => {
|
||||
let appUpdater: AppUpdater
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appUpdater = new AppUpdater()
|
||||
})
|
||||
|
||||
describe('parseMultiLangReleaseNotes', () => {
|
||||
const sampleReleaseNotes = `<!--LANG:en-->
|
||||
🚀 New Features:
|
||||
- Feature A
|
||||
- Feature B
|
||||
|
||||
🎨 UI Improvements:
|
||||
- Improvement A
|
||||
<!--LANG:zh-CN-->
|
||||
🚀 新功能:
|
||||
- 功能 A
|
||||
- 功能 B
|
||||
|
||||
🎨 界面改进:
|
||||
- 改进 A
|
||||
<!--LANG:END-->`
|
||||
|
||||
it('should return Chinese notes for zh-CN users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('新功能')
|
||||
expect(result).toContain('功能 A')
|
||||
expect(result).not.toContain('New Features')
|
||||
})
|
||||
|
||||
it('should return Chinese notes for zh-TW users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('新功能')
|
||||
expect(result).toContain('功能 A')
|
||||
expect(result).not.toContain('New Features')
|
||||
})
|
||||
|
||||
it('should return English notes for non-Chinese users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('New Features')
|
||||
expect(result).toContain('Feature A')
|
||||
expect(result).not.toContain('新功能')
|
||||
})
|
||||
|
||||
it('should return English notes for other language users', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
expect(result).toContain('New Features')
|
||||
expect(result).not.toContain('新功能')
|
||||
})
|
||||
|
||||
it('should handle missing language sections gracefully', () => {
|
||||
const malformedNotes = 'Simple release notes without markers'
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||
|
||||
expect(result).toBe('Simple release notes without markers')
|
||||
})
|
||||
|
||||
it('should handle malformed markers', () => {
|
||||
const malformedNotes = `<!--LANG:en-->English only`
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
|
||||
|
||||
// Should clean up markers and return cleaned content
|
||||
expect(result).toContain('English only')
|
||||
expect(result).not.toContain('<!--LANG:')
|
||||
})
|
||||
|
||||
it('should handle empty release notes', () => {
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes('')
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
// Force an error by mocking configManager to throw
|
||||
vi.mocked(configManager.getLanguage).mockImplementation(() => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
|
||||
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
|
||||
|
||||
// Should return original notes as fallback
|
||||
expect(result).toBe(sampleReleaseNotes)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMultiLanguageMarkers', () => {
|
||||
it('should return true when markers are present', () => {
|
||||
const notes = '<!--LANG:en-->Test'
|
||||
|
||||
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when no markers are present', () => {
|
||||
const notes = 'Simple text without markers'
|
||||
|
||||
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processReleaseInfo', () => {
|
||||
it('should process multi-language release notes in string format', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
|
||||
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: `<!--LANG:en-->English notes<!--LANG:zh-CN-->中文说明<!--LANG:END-->`
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBe('中文说明')
|
||||
})
|
||||
|
||||
it('should not process release notes without markers', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: 'Simple release notes'
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBe('Simple release notes')
|
||||
})
|
||||
|
||||
it('should handle array format release notes', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toEqual(releaseInfo.releaseNotes)
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const releaseInfo = {
|
||||
version: '1.0.0',
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
releaseNotes: null
|
||||
} as UpdateInfo
|
||||
|
||||
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
|
||||
|
||||
expect(result.releaseNotes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReleaseNotes', () => {
|
||||
it('should format string release notes with markers', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('English')
|
||||
})
|
||||
|
||||
it('should format string release notes without markers', () => {
|
||||
const notes = 'Simple notes'
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Simple notes')
|
||||
})
|
||||
|
||||
it('should format array release notes', () => {
|
||||
const notes = [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Note 1\nNote 2')
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import * as fs from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
@@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
|
||||
if (entry.isDirectory() && options.includeDirectories) {
|
||||
const stats = await fs.promises.stat(entryPath)
|
||||
const externalDirPath = entryPath.replace(/\\/g, '/')
|
||||
const dirTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalDirPath).digest('hex'),
|
||||
name: entry.name,
|
||||
treePath: treePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalDirPath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'folder',
|
||||
@@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
||||
: `/${nameWithoutExt}`
|
||||
|
||||
const externalFilePath = entryPath.replace(/\\/g, '/')
|
||||
const fileTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalFilePath).digest('hex'),
|
||||
name: name,
|
||||
treePath: fileTreePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalFilePath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'file'
|
||||
|
||||
+13
-1
@@ -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)
|
||||
@@ -285,6 +286,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),
|
||||
@@ -350,6 +361,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 }) => {
|
||||
|
||||
@@ -163,15 +163,13 @@ export class AiSdkToChunkAdapter {
|
||||
final.reasoningContent += chunk.text || ''
|
||||
this.onChunk({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: final.reasoningContent || '',
|
||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
||||
text: final.reasoningContent || ''
|
||||
})
|
||||
break
|
||||
case 'reasoning-end':
|
||||
this.onChunk({
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || '',
|
||||
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
|
||||
text: final.reasoningContent || ''
|
||||
})
|
||||
final.reasoningContent = ''
|
||||
break
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isNewApiProvider } from '@renderer/config/providers'
|
||||
import { Provider } from '@renderer/types'
|
||||
|
||||
import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient'
|
||||
@@ -11,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'
|
||||
|
||||
@@ -45,7 +47,7 @@ export class ApiClientFactory {
|
||||
return instance
|
||||
}
|
||||
|
||||
if (provider.id === 'new-api') {
|
||||
if (isNewApiProvider(provider)) {
|
||||
logger.debug(`Creating NewAPIClient for provider: ${provider.id}`)
|
||||
instance = new NewAPIClient(provider) as BaseApiClient
|
||||
return instance
|
||||
@@ -62,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':
|
||||
|
||||
@@ -67,7 +67,9 @@ vi.mock('@renderer/config/models', () => ({
|
||||
silicon: [],
|
||||
defaultModel: []
|
||||
},
|
||||
isOpenAIModel: vi.fn(() => false)
|
||||
isOpenAIModel: vi.fn(() => false),
|
||||
glm45FlashModel: {},
|
||||
qwen38bModel: {}
|
||||
}))
|
||||
|
||||
describe('ApiClientFactory', () => {
|
||||
|
||||
+2
-12
@@ -35,18 +35,8 @@ vi.mock('@renderer/config/models', () => ({
|
||||
findTokenLimit: vi.fn().mockReturnValue(4096),
|
||||
isFunctionCallingModel: vi.fn().mockReturnValue(false),
|
||||
DEFAULT_MAX_TOKENS: 4096,
|
||||
qwen38bModel: {
|
||||
id: 'Qwen/Qwen3-8B',
|
||||
name: 'Qwen3-8B',
|
||||
provider: 'cherryai',
|
||||
group: 'Qwen'
|
||||
},
|
||||
glm45FlashModel: {
|
||||
id: 'glm-4.5-flash',
|
||||
name: 'GLM-4.5-Flash',
|
||||
provider: 'cherryai',
|
||||
group: 'GLM-4.5'
|
||||
}
|
||||
qwen38bModel: {},
|
||||
glm45FlashModel: {}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/services/AssistantService', () => ({
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,12 +143,14 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
|
||||
const tagName = {
|
||||
reasoning: 'reasoning',
|
||||
think: 'think',
|
||||
thought: 'thought'
|
||||
thought: 'thought',
|
||||
seedThink: 'seed:think'
|
||||
}
|
||||
|
||||
function getReasoningTagName(modelId: string | undefined): string {
|
||||
if (modelId?.includes('gpt-oss')) return tagName.reasoning
|
||||
if (modelId?.includes('gemini')) return tagName.thought
|
||||
if (modelId?.includes('seed-oss-36b')) return tagName.seedThink
|
||||
return tagName.think
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { Assistant } from '@renderer/types'
|
||||
|
||||
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
|
||||
import reasoningTimePlugin from './reasoningTimePlugin'
|
||||
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
|
||||
import { createTelemetryPlugin } from './telemetryPlugin'
|
||||
|
||||
@@ -39,9 +38,9 @@ export function buildPlugins(
|
||||
}
|
||||
|
||||
// 3. 推理模型时添加推理插件
|
||||
if (middlewareConfig.enableReasoning) {
|
||||
plugins.push(reasoningTimePlugin)
|
||||
}
|
||||
// if (middlewareConfig.enableReasoning) {
|
||||
// plugins.push(reasoningTimePlugin)
|
||||
// }
|
||||
|
||||
// 4. 启用Prompt工具调用时添加工具插件
|
||||
if (middlewareConfig.isPromptToolUse) {
|
||||
|
||||
@@ -7,18 +7,14 @@ export default definePlugin({
|
||||
transformStream: () => () => {
|
||||
// === 时间跟踪状态 ===
|
||||
let thinkingStartTime = 0
|
||||
let hasStartedThinking = false
|
||||
let accumulatedThinkingContent = ''
|
||||
let reasoningBlockId = ''
|
||||
|
||||
return new TransformStream<TextStreamPart<ToolSet>, TextStreamPart<ToolSet>>({
|
||||
transform(chunk: TextStreamPart<ToolSet>, controller: TransformStreamDefaultController<TextStreamPart<ToolSet>>) {
|
||||
// === 处理 reasoning 类型 ===
|
||||
if (chunk.type === 'reasoning-start') {
|
||||
controller.enqueue(chunk)
|
||||
hasStartedThinking = true
|
||||
thinkingStartTime = performance.now()
|
||||
reasoningBlockId = chunk.id
|
||||
} else if (chunk.type === 'reasoning-delta') {
|
||||
accumulatedThinkingContent += chunk.text
|
||||
controller.enqueue({
|
||||
@@ -32,21 +28,6 @@ export default definePlugin({
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (chunk.type === 'reasoning-end' && hasStartedThinking) {
|
||||
controller.enqueue({
|
||||
type: 'reasoning-end',
|
||||
id: reasoningBlockId,
|
||||
providerMetadata: {
|
||||
metadata: {
|
||||
thinking_millsec: performance.now() - thinkingStartTime,
|
||||
thinking_content: accumulatedThinkingContent
|
||||
}
|
||||
}
|
||||
})
|
||||
accumulatedThinkingContent = ''
|
||||
hasStartedThinking = false
|
||||
thinkingStartTime = 0
|
||||
reasoningBlockId = ''
|
||||
} else {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@@ -134,9 +134,10 @@ export async function buildStreamTextParams(
|
||||
if (aiSdkProviderId === 'google-vertex') {
|
||||
tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
|
||||
} else if (aiSdkProviderId === 'google-vertex-anthropic') {
|
||||
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
tools.web_search = vertexAnthropic.tools.webSearch_20250305({
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
}) as ProviderDefinedTool
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ProviderSettingsMap
|
||||
} from '@cherrystudio/ai-core/provider'
|
||||
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
|
||||
import { isNewApiProvider } from '@renderer/config/providers'
|
||||
import {
|
||||
getAwsBedrockAccessKeyId,
|
||||
getAwsBedrockRegion,
|
||||
@@ -17,7 +18,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
||||
import store from '@renderer/store'
|
||||
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { cloneDeep, isEmpty } from 'lodash'
|
||||
import { cloneDeep, trim } from 'lodash'
|
||||
|
||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||
import { getAiSdkProviderId } from './factory'
|
||||
@@ -65,7 +66,7 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
if (provider.id === 'aihubmix') {
|
||||
return aihubmixProviderCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'new-api') {
|
||||
if (isNewApiProvider(provider)) {
|
||||
return newApiResolverCreator(model, provider)
|
||||
}
|
||||
if (provider.id === 'vertexai') {
|
||||
@@ -119,7 +120,7 @@ export function providerToAiSdkConfig(
|
||||
|
||||
// 构建基础配置
|
||||
const baseConfig = {
|
||||
baseURL: actualProvider.apiHost,
|
||||
baseURL: trim(actualProvider.apiHost),
|
||||
apiKey: getRotatedApiKey(actualProvider)
|
||||
}
|
||||
// 处理OpenAI模式
|
||||
@@ -194,7 +195,10 @@ export function providerToAiSdkConfig(
|
||||
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
||||
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
|
||||
}
|
||||
baseConfig.baseURL = isEmpty(baseConfig.baseURL) ? '' : baseConfig.baseURL
|
||||
|
||||
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
|
||||
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
|
||||
}
|
||||
}
|
||||
|
||||
// 如果AI SDK支持该provider,使用原生配置
|
||||
|
||||
@@ -18,12 +18,13 @@ export const knowledgeSearchTool = (
|
||||
) => {
|
||||
return tool({
|
||||
name: 'builtin_knowledge_search',
|
||||
description: `Search the knowledge base for relevant information using pre-analyzed search intent.
|
||||
description: `Knowledge base search tool for retrieving information from user's private knowledge base. This searches your local collection of documents, web content, notes, and other materials you have stored.
|
||||
|
||||
Pre-extracted search queries: "${extractedKeywords.question.join(', ')}"
|
||||
Rewritten query: "${extractedKeywords.rewrite}"
|
||||
This tool has been configured with search parameters based on the conversation context:
|
||||
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}
|
||||
- Query rewrite: "${extractedKeywords.rewrite}"
|
||||
|
||||
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
||||
You can use this tool as-is, or provide additionalContext to refine the search focus within the knowledge base.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
additionalContext: z
|
||||
|
||||
@@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = (
|
||||
|
||||
return tool({
|
||||
name: 'builtin_web_search',
|
||||
description: `Search the web and return citable sources using pre-analyzed search intent.
|
||||
description: `Web search tool for finding current information, news, and real-time data from the internet.
|
||||
|
||||
Pre-extracted search keywords: "${extractedKeywords.question.join(', ')}"${
|
||||
extractedKeywords.links
|
||||
This tool has been configured with search parameters based on the conversation context:
|
||||
- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}${
|
||||
extractedKeywords.links?.length
|
||||
? `
|
||||
Relevant links: ${extractedKeywords.links.join(', ')}`
|
||||
- Relevant URLs: ${extractedKeywords.links.join(', ')}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
||||
You can use this tool as-is to search with the prepared queries, or provide additionalContext to refine or replace the search terms.`,
|
||||
|
||||
inputSchema: z.object({
|
||||
additionalContext: z
|
||||
|
||||
@@ -52,7 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {}
|
||||
}
|
||||
// Don't disable reasoning for models that require it
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
|
||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
|
||||
return {}
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
@@ -112,6 +112,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {
|
||||
enable_thinking: true
|
||||
}
|
||||
case SystemProviderIds.hunyuan:
|
||||
case SystemProviderIds['tencent-cloud-ti']:
|
||||
case SystemProviderIds.doubao:
|
||||
return {
|
||||
thinking: {
|
||||
|
||||
@@ -61,9 +61,10 @@ export function buildProviderBuiltinWebSearchConfig(
|
||||
}
|
||||
}
|
||||
case 'anthropic': {
|
||||
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
const anthropicSearchOptions: AnthropicSearchConfig = {
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
}
|
||||
return {
|
||||
anthropic: anthropicSearchOptions
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,7 +1,6 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
app: MinAppType
|
||||
@@ -11,31 +10,52 @@ interface Props {
|
||||
}
|
||||
|
||||
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
|
||||
// First try to find in DEFAULT_MIN_APPS for predefined styling
|
||||
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
|
||||
|
||||
if (!_app) {
|
||||
return null
|
||||
// If found in DEFAULT_MIN_APPS, use predefined styling
|
||||
if (_app) {
|
||||
return (
|
||||
<img
|
||||
src={_app.logo}
|
||||
className="select-none rounded-2xl"
|
||||
style={{
|
||||
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: _app.background,
|
||||
userSelect: 'none',
|
||||
...(sidebar ? {} : app.style),
|
||||
...style
|
||||
}}
|
||||
draggable={false}
|
||||
alt={app.name || 'MinApp Icon'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
src={_app.logo}
|
||||
style={{
|
||||
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: _app.background,
|
||||
...(sidebar ? {} : app.style),
|
||||
...style
|
||||
}}
|
||||
/>
|
||||
)
|
||||
// If not found in DEFAULT_MIN_APPS but app has logo, use it (for temporary apps)
|
||||
if (app.logo) {
|
||||
return (
|
||||
<img
|
||||
src={app.logo}
|
||||
className="select-none rounded-2xl"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: 'transparent',
|
||||
userSelect: 'none',
|
||||
...(sidebar ? {} : app.style),
|
||||
...style
|
||||
}}
|
||||
draggable={false}
|
||||
alt={app.name || 'MinApp Icon'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const Container = styled.img`
|
||||
border-radius: 16px;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
`
|
||||
|
||||
export default MinAppIcon
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MinAppIcon > should render correctly with various props 1`] = `
|
||||
.c0 {
|
||||
border-radius: 16px;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
<img
|
||||
class="c0"
|
||||
alt="Test App"
|
||||
class="select-none rounded-2xl"
|
||||
draggable="false"
|
||||
src="/test-logo-1.png"
|
||||
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
|
||||
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); user-select: none; opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -253,12 +253,39 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
let savedCount = 0
|
||||
|
||||
try {
|
||||
// Validate knowledge base configuration before proceeding
|
||||
if (!selectedBaseId) {
|
||||
throw new Error('No knowledge base selected')
|
||||
}
|
||||
|
||||
const selectedBase = bases.find((base) => base.id === selectedBaseId)
|
||||
if (!selectedBase) {
|
||||
throw new Error('Selected knowledge base not found')
|
||||
}
|
||||
|
||||
if (!selectedBase.version) {
|
||||
throw new Error('Knowledge base is not properly configured. Please check the knowledge base settings.')
|
||||
}
|
||||
|
||||
if (isNoteMode) {
|
||||
const note = source.data as NotesTreeNode
|
||||
const content = note.externalPath
|
||||
? await window.api.file.readExternal(note.externalPath)
|
||||
: await window.api.file.read(note.id + '.md')
|
||||
logger.debug('Note content:', content)
|
||||
if (!note.externalPath) {
|
||||
throw new Error('Note external path is required for export')
|
||||
}
|
||||
|
||||
let content = ''
|
||||
try {
|
||||
content = await window.api.file.readExternal(note.externalPath)
|
||||
} catch (error) {
|
||||
logger.error('Failed to read note file:', error as Error)
|
||||
throw new Error('Failed to read note content. Please ensure the file exists and is accessible.')
|
||||
}
|
||||
|
||||
if (!content || content.trim() === '') {
|
||||
throw new Error('Note content is empty. Cannot export empty notes to knowledge base.')
|
||||
}
|
||||
|
||||
logger.debug('Note content loaded', { contentLength: content.length })
|
||||
await addNote(content)
|
||||
savedCount = 1
|
||||
} else {
|
||||
@@ -283,9 +310,23 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
||||
resolve({ success: true, savedCount })
|
||||
} catch (error) {
|
||||
logger.error('save failed:', error as Error)
|
||||
window.toast.error(
|
||||
t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed')
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = t(
|
||||
isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed'
|
||||
)
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('not properly configured')) {
|
||||
errorMessage = error.message
|
||||
} else if (error.message.includes('empty')) {
|
||||
errorMessage = error.message
|
||||
} else if (error.message.includes('read note content')) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
}
|
||||
|
||||
window.toast.error(errorMessage)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ const PopupContainer: React.FC<Props> = ({ model, filter: baseFilter, showTagFil
|
||||
key: `provider-${p.id}`,
|
||||
type: 'group',
|
||||
name: getFancyProviderName(p),
|
||||
actions: (
|
||||
actions: p.id !== 'cherryai' && (
|
||||
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
<Settings2
|
||||
size={12}
|
||||
|
||||
@@ -18,7 +18,8 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
ADD_TAGS: ['animate', 'foreignObject', 'use'],
|
||||
ADD_ATTR: ['from', 'to']
|
||||
ADD_ATTR: ['from', 'to'],
|
||||
HTML_INTEGRATION_POINTS: { foreignobject: true }
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
@@ -36,6 +37,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
border-radius: var(--shadow-host-border-radius);
|
||||
padding: 1em;
|
||||
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
|
||||
white-space: normal;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -457,7 +457,13 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
// 面板可见且未折叠时:拦截所有 Enter 变体;
|
||||
// 纯 Enter 选择项,带修饰键仅拦截不处理
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Don't prevent default or stop propagation - let it create a newline
|
||||
setIsMouseOver(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
|
||||
@@ -87,6 +87,9 @@ const CommandListPopover = ({
|
||||
return true
|
||||
|
||||
case 'Enter':
|
||||
if (event.shiftKey) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
if (items[internalSelectedIndex]) {
|
||||
selectItem(internalSelectedIndex)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { commandSuggestion } from '../command'
|
||||
|
||||
describe('commandSuggestion render', () => {
|
||||
it('has render function', () => {
|
||||
expect(commandSuggestion.render).toBeDefined()
|
||||
expect(typeof commandSuggestion.render).toBe('function')
|
||||
})
|
||||
|
||||
it('render function returns object with onKeyDown', () => {
|
||||
const renderResult = commandSuggestion.render?.()
|
||||
expect(renderResult).toBeDefined()
|
||||
expect(renderResult?.onKeyDown).toBeDefined()
|
||||
expect(typeof renderResult?.onKeyDown).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -628,13 +628,34 @@ export const commandSuggestion: Omit<SuggestionOptions<Command, MentionNodeAttrs
|
||||
},
|
||||
|
||||
onKeyDown: (props) => {
|
||||
// Let CommandListPopover handle events first
|
||||
const popoverHandled = component.ref?.onKeyDown?.(props.event)
|
||||
if (popoverHandled) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle Shift+Enter for newline when popover doesn't handle it
|
||||
if (props.event.key === 'Enter' && props.event.shiftKey) {
|
||||
props.event.preventDefault()
|
||||
// Close the suggestion menu
|
||||
if (cleanup) cleanup()
|
||||
component.destroy()
|
||||
// Use the view from SuggestionKeyDownProps to insert newline
|
||||
const { view } = props
|
||||
const { state, dispatch } = view
|
||||
const { tr } = state
|
||||
tr.insertText('\n')
|
||||
dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
if (props.event.key === 'Escape') {
|
||||
if (cleanup) cleanup()
|
||||
component.destroy()
|
||||
return true
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event)
|
||||
return false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
@@ -12,9 +13,10 @@ import tabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { MinAppType, ThemeMode } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import {
|
||||
FileSearch,
|
||||
Folder,
|
||||
@@ -45,14 +47,40 @@ interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
|
||||
const logger = loggerService.withContext('TabContainer')
|
||||
|
||||
const getTabIcon = (
|
||||
tabId: string,
|
||||
minapps: MinAppType[],
|
||||
minAppsCache?: LRUCache<string, MinAppType>
|
||||
): React.ReactNode | undefined => {
|
||||
// Check if it's a minapp tab (format: apps:appId)
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found in permanent apps, search in temporary apps cache
|
||||
// The cache stores apps opened via openSmartMinapp() for top navbar mode
|
||||
// These are temporary MinApps that were opened but not yet saved to user's config
|
||||
// The cache is LRU (Least Recently Used) with max size from settings
|
||||
// Cache validity: Apps in cache are currently active/recently used, not outdated
|
||||
if (!app && minAppsCache) {
|
||||
app = minAppsCache.get(appId)
|
||||
|
||||
// Defensive programming: If app not found in cache but tab exists,
|
||||
// the cache entry may have been evicted due to LRU policy
|
||||
// Log warning for debugging potential sync issues
|
||||
if (!app) {
|
||||
logger.warn(`MinApp ${appId} not found in cache, using fallback icon`)
|
||||
}
|
||||
}
|
||||
|
||||
if (app) {
|
||||
return <MinAppIcon size={14} app={app} />
|
||||
}
|
||||
|
||||
// Fallback: If no app found (cache evicted), show default icon
|
||||
return <LayoutGrid size={14} />
|
||||
}
|
||||
|
||||
switch (tabId) {
|
||||
@@ -94,7 +122,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||
const isFullscreen = useFullscreen()
|
||||
const { settedTheme, toggleTheme } = useTheme()
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { hideMinappPopup, minAppsCache } = useMinappPopup()
|
||||
const { minapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -112,8 +140,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
// Check if it's a minapp tab
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
return app ? app.name : 'MinApp'
|
||||
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found in permanent apps, search in temporary apps cache
|
||||
// This ensures temporary MinApps display proper titles while being used
|
||||
// The LRU cache automatically manages app lifecycle and prevents memory leaks
|
||||
if (!app && minAppsCache) {
|
||||
app = minAppsCache.get(appId)
|
||||
|
||||
// Defensive programming: If app not found in cache but tab exists,
|
||||
// the cache entry may have been evicted due to LRU policy
|
||||
if (!app) {
|
||||
logger.warn(`MinApp ${appId} not found in cache, using fallback title`)
|
||||
}
|
||||
}
|
||||
|
||||
// Return app name if found, otherwise use fallback with appId
|
||||
return app ? app.name : `MinApp-${appId}`
|
||||
}
|
||||
return getTitleLabel(tabId)
|
||||
}
|
||||
@@ -196,7 +239,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
|
||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
@@ -259,7 +302,7 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-x)' : '15px')};
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'calc(env(titlebar-area-x) + 4px)' : '15px')};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')};
|
||||
height: var(--navbar-height);
|
||||
min-height: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-height)' : '')};
|
||||
|
||||
@@ -88,6 +88,7 @@ const NavbarCenterContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
padding-left: 10px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
position: relative;
|
||||
@@ -108,7 +109,8 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
padding-right: ${isMac ? '20px' : 0};
|
||||
padding-left: 10px;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ItemRendererProps<T> {
|
||||
transform?: Transform | null
|
||||
transition?: string | null
|
||||
listeners?: DraggableSyntheticListeners
|
||||
itemStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function ItemRenderer<T>({
|
||||
@@ -30,6 +31,7 @@ export function ItemRenderer<T>({
|
||||
transform,
|
||||
transition,
|
||||
listeners,
|
||||
itemStyle,
|
||||
...props
|
||||
}: ItemRendererProps<T>) {
|
||||
useEffect(() => {
|
||||
@@ -44,7 +46,7 @@ export function ItemRenderer<T>({
|
||||
}
|
||||
}, [dragOverlay])
|
||||
|
||||
const wrapperStyle = {
|
||||
const style = {
|
||||
transition,
|
||||
transform: CSS.Transform.toString(transform ?? null)
|
||||
} as React.CSSProperties
|
||||
@@ -54,7 +56,7 @@ export function ItemRenderer<T>({
|
||||
ref={ref}
|
||||
data-index={index}
|
||||
className={classNames({ dragOverlay: dragOverlay })}
|
||||
style={{ ...wrapperStyle }}>
|
||||
style={{ ...style, ...itemStyle }}>
|
||||
<DraggableItem
|
||||
className={classNames({ dragging: dragging, dragOverlay: dragOverlay, ghost: ghost })}
|
||||
{...listeners}
|
||||
|
||||
@@ -61,6 +61,8 @@ interface SortableProps<T> {
|
||||
className?: string
|
||||
/** Item list style */
|
||||
listStyle?: React.CSSProperties
|
||||
/** Item style */
|
||||
itemStyle?: React.CSSProperties
|
||||
/** Item gap */
|
||||
gap?: number | string
|
||||
/** Restrictions, shortcuts for some modifiers */
|
||||
@@ -87,6 +89,7 @@ function Sortable<T>({
|
||||
showGhost = false,
|
||||
className,
|
||||
listStyle,
|
||||
itemStyle,
|
||||
gap,
|
||||
restrictions,
|
||||
modifiers: customModifiers
|
||||
@@ -195,19 +198,19 @@ function Sortable<T>({
|
||||
renderItem={renderItem}
|
||||
useDragOverlay={useDragOverlay}
|
||||
showGhost={showGhost}
|
||||
itemStyle={itemStyle}
|
||||
/>
|
||||
))}
|
||||
</ListWrapper>
|
||||
</SortableContext>
|
||||
|
||||
{useDragOverlay
|
||||
? createPortal(
|
||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||
{activeItem ? <ItemRenderer item={activeItem} renderItem={renderItem} dragOverlay /> : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
{useDragOverlay &&
|
||||
createPortal(
|
||||
<DragOverlay adjustScale dropAnimation={dropAnimation}>
|
||||
{activeItem && <ItemRenderer item={activeItem} renderItem={renderItem} itemStyle={itemStyle} dragOverlay />}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SortableItemProps<T> {
|
||||
renderItem: RenderItemType<T>
|
||||
useDragOverlay?: boolean
|
||||
showGhost?: boolean
|
||||
itemStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function SortableItem<T>({
|
||||
@@ -18,7 +19,8 @@ export function SortableItem<T>({
|
||||
index,
|
||||
renderItem,
|
||||
useDragOverlay = true,
|
||||
showGhost = true
|
||||
showGhost = true,
|
||||
itemStyle
|
||||
}: SortableItemProps<T>) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id
|
||||
@@ -36,6 +38,7 @@ export function SortableItem<T>({
|
||||
transform={transform}
|
||||
transition={transition}
|
||||
listeners={listeners}
|
||||
itemStyle={itemStyle}
|
||||
{...attributes}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
// Default quick assistant model
|
||||
glm45FlashModel
|
||||
],
|
||||
cherryin: [],
|
||||
// cherryin: [],
|
||||
vertexai: [],
|
||||
'302ai': [
|
||||
{
|
||||
@@ -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: [
|
||||
@@ -1804,5 +1805,19 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
provider: 'aionly',
|
||||
group: 'gemini'
|
||||
}
|
||||
],
|
||||
longcat: [
|
||||
{
|
||||
id: 'LongCat-Flash-Chat',
|
||||
name: 'LongCat Flash Chat',
|
||||
provider: 'longcat',
|
||||
group: 'LongCat'
|
||||
},
|
||||
{
|
||||
id: 'LongCat-Flash-Thinking',
|
||||
name: 'LongCat Flash Thinking',
|
||||
provider: 'longcat',
|
||||
group: 'LongCat'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
@@ -93,7 +100,17 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
||||
// Specifically for DeepSeek V3.1. White list for now
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
return (
|
||||
['openrouter', 'dashscope', 'modelscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[]
|
||||
[
|
||||
'openrouter',
|
||||
'dashscope',
|
||||
'modelscope',
|
||||
'doubao',
|
||||
'silicon',
|
||||
'nvidia',
|
||||
'ppio',
|
||||
'hunyuan',
|
||||
'tencent-cloud-ti'
|
||||
] satisfies SystemProviderId[]
|
||||
).some((id) => id === model.provider)
|
||||
}
|
||||
|
||||
@@ -325,7 +342,7 @@ 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')
|
||||
return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
}
|
||||
|
||||
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||
@@ -381,7 +398,8 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
modelId.includes('magistral') ||
|
||||
modelId.includes('minimax-m1') ||
|
||||
modelId.includes('pangu-pro-moe')
|
||||
modelId.includes('pangu-pro-moe') ||
|
||||
modelId.includes('seed-oss')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@ 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'
|
||||
import LongCatProviderLogo from '@renderer/assets/images/providers/longcat.png'
|
||||
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
|
||||
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
|
||||
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
|
||||
@@ -78,16 +80,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
|
||||
}
|
||||
|
||||
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
||||
cherryin: {
|
||||
id: 'cherryin',
|
||||
name: 'CherryIN',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://open.cherryin.ai',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: true
|
||||
},
|
||||
// cherryin: {
|
||||
// id: 'cherryin',
|
||||
// name: 'CherryIN',
|
||||
// type: 'openai',
|
||||
// apiKey: '',
|
||||
// apiHost: 'https://open.cherryin.ai',
|
||||
// models: [],
|
||||
// isSystem: true,
|
||||
// enabled: true
|
||||
// },
|
||||
silicon: {
|
||||
id: 'silicon',
|
||||
name: 'Silicon',
|
||||
@@ -108,6 +110,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',
|
||||
@@ -138,16 +150,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ppio: {
|
||||
id: 'ppio',
|
||||
name: 'PPIO',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.ppinfra.com/v3/openai/',
|
||||
models: SYSTEM_MODELS.ppio,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
alayanew: {
|
||||
id: 'alayanew',
|
||||
name: 'AlayaNew',
|
||||
@@ -158,16 +160,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
qiniu: {
|
||||
id: 'qiniu',
|
||||
name: 'Qiniu',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.qnaigc.com',
|
||||
models: SYSTEM_MODELS.qiniu,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
dmxapi: {
|
||||
id: 'dmxapi',
|
||||
name: 'DMXAPI',
|
||||
@@ -178,6 +170,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
aionly: {
|
||||
id: 'aionly',
|
||||
name: 'AIOnly',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.aiionly.com',
|
||||
models: SYSTEM_MODELS.aionly,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
burncloud: {
|
||||
id: 'burncloud',
|
||||
name: 'BurnCloud',
|
||||
@@ -238,6 +240,26 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
ppio: {
|
||||
id: 'ppio',
|
||||
name: 'PPIO',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.ppinfra.com/v3/openai/',
|
||||
models: SYSTEM_MODELS.ppio,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
qiniu: {
|
||||
id: 'qiniu',
|
||||
name: 'Qiniu',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.qnaigc.com',
|
||||
models: SYSTEM_MODELS.qiniu,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
openrouter: {
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
@@ -613,13 +635,13 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
aionly: {
|
||||
id: 'aionly',
|
||||
name: 'AIOnly',
|
||||
longcat: {
|
||||
id: 'longcat',
|
||||
name: 'LongCat',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.aiionly.com',
|
||||
models: SYSTEM_MODELS.aionly,
|
||||
apiHost: 'https://api.longcat.chat/openai',
|
||||
models: SYSTEM_MODELS.longcat,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
}
|
||||
@@ -638,6 +660,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
yi: ZeroOneProviderLogo,
|
||||
groq: GroqProviderLogo,
|
||||
zhipu: ZhipuProviderLogo,
|
||||
ovms: IntelOvmsLogo,
|
||||
ollama: OllamaProviderLogo,
|
||||
lmstudio: LMStudioProviderLogo,
|
||||
moonshot: MoonshotProviderLogo,
|
||||
@@ -684,7 +707,8 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
'new-api': NewAPIProviderLogo,
|
||||
'aws-bedrock': AwsProviderLogo,
|
||||
poe: 'poe', // use svg icon component
|
||||
aionly: AiOnlyProviderLogo
|
||||
aionly: AiOnlyProviderLogo,
|
||||
longcat: LongCatProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -708,17 +732,17 @@ type ProviderUrls = {
|
||||
}
|
||||
|
||||
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
cherryin: {
|
||||
api: {
|
||||
url: 'https://open.cherryin.ai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://open.cherryin.ai',
|
||||
apiKey: 'https://open.cherryin.ai/console/token',
|
||||
docs: 'https://open.cherryin.ai',
|
||||
models: 'https://open.cherryin.ai/pricing'
|
||||
}
|
||||
},
|
||||
// cherryin: {
|
||||
// api: {
|
||||
// url: 'https://open.cherryin.ai'
|
||||
// },
|
||||
// websites: {
|
||||
// official: 'https://open.cherryin.ai',
|
||||
// apiKey: 'https://open.cherryin.ai/console/token',
|
||||
// docs: 'https://open.cherryin.ai',
|
||||
// models: 'https://open.cherryin.ai/pricing'
|
||||
// }
|
||||
// },
|
||||
ph8: {
|
||||
api: {
|
||||
url: 'https://ph8.co'
|
||||
@@ -1022,6 +1046,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'
|
||||
@@ -1290,6 +1324,17 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
docs: 'https://www.aiionly.com/document',
|
||||
models: 'https://www.aiionly.com'
|
||||
}
|
||||
},
|
||||
longcat: {
|
||||
api: {
|
||||
url: 'https://api.longcat.chat/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://longcat.chat',
|
||||
apiKey: 'https://longcat.chat/platform/api_keys',
|
||||
docs: 'https://longcat.chat/platform/docs/zh/',
|
||||
models: 'https://longcat.chat/platform/docs/zh/APIDocs.html'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1375,3 +1420,7 @@ const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as con
|
||||
export const isGeminiWebSearchProvider = (provider: Provider) => {
|
||||
return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id)
|
||||
}
|
||||
|
||||
export const isNewApiProvider = (provider: Provider) => {
|
||||
return ['new-api', 'cherryin'].includes(provider.id)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@renderer/types'
|
||||
// Import necessary types for blocks and new message structure
|
||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { NotesTreeNode } from '@renderer/types/note'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
|
||||
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
||||
@@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', {
|
||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
||||
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
||||
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
@@ -118,8 +116,7 @@ db.version(10).stores({
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||
translate_languages: '&id, langCode',
|
||||
quick_phrases: 'id',
|
||||
message_blocks: 'id, messageId, file.id',
|
||||
notes_tree: '&id'
|
||||
message_blocks: 'id, messageId, file.id'
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
@@ -172,7 +172,10 @@ export function useAssistant(id: string) {
|
||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||
[assistant, dispatch]
|
||||
),
|
||||
updateAssistant: useCallback((assistant: Partial<Assistant>) => dispatch(updateAssistant(assistant)), [dispatch]),
|
||||
updateAssistant: useCallback(
|
||||
(update: Partial<Omit<Assistant, 'id'>>) => dispatch(updateAssistant({ id, ...update })),
|
||||
[dispatch, id]
|
||||
),
|
||||
updateAssistantSettings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 状态
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import TabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
@@ -14,6 +15,8 @@ import { clearWebviewState } from '@renderer/utils/webviewStateManager'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useNavbarPosition } from './useSettings'
|
||||
|
||||
let minAppsCache: LRUCache<string, MinAppType>
|
||||
|
||||
/**
|
||||
@@ -34,6 +37,7 @@ export const useMinappPopup = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
||||
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
const createLRUCache = useCallback(() => {
|
||||
return new LRUCache<string, MinAppType>({
|
||||
@@ -165,6 +169,33 @@ export const useMinappPopup = () => {
|
||||
dispatch(setMinappShow(false))
|
||||
}, [dispatch, minappShow, openedOneOffMinapp])
|
||||
|
||||
/** Smart open minapp that adapts to navbar position */
|
||||
const openSmartMinapp = useCallback(
|
||||
(config: MinAppType, keepAlive: boolean = false) => {
|
||||
if (isTopNavbar) {
|
||||
// For top navbar mode, need to add to cache first for temporary apps
|
||||
const cacheApp = minAppsCache.get(config.id)
|
||||
if (!cacheApp) {
|
||||
// Add temporary app to cache so MinAppPage can find it
|
||||
minAppsCache.set(config.id, config)
|
||||
}
|
||||
|
||||
// Set current minapp and show state
|
||||
dispatch(setCurrentMinappId(config.id))
|
||||
dispatch(setMinappShow(true))
|
||||
|
||||
// Then navigate to the app tab using NavigationService
|
||||
if (NavigationService.navigate) {
|
||||
NavigationService.navigate(`/apps/${config.id}`)
|
||||
}
|
||||
} else {
|
||||
// For side navbar, use the traditional popup system
|
||||
openMinapp(config, keepAlive)
|
||||
}
|
||||
},
|
||||
[isTopNavbar, openMinapp, dispatch]
|
||||
)
|
||||
|
||||
return {
|
||||
openMinapp,
|
||||
openMinappKeepAlive,
|
||||
@@ -172,6 +203,7 @@ export const useMinappPopup = () => {
|
||||
closeMinapp,
|
||||
hideMinappPopup,
|
||||
closeAllMinapps,
|
||||
openSmartMinapp,
|
||||
// Expose cache instance for TabsService integration
|
||||
minAppsCache
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ const providerKeyMap = {
|
||||
nvidia: 'provider.nvidia',
|
||||
o3: 'provider.o3',
|
||||
ocoolai: 'provider.ocoolai',
|
||||
ovms: 'provider.ovms',
|
||||
ollama: 'provider.ollama',
|
||||
openai: 'provider.openai',
|
||||
openrouter: 'provider.openrouter',
|
||||
@@ -321,7 +322,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
|
||||
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
|
||||
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
|
||||
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
|
||||
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
|
||||
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
|
||||
[BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp'
|
||||
} as const
|
||||
|
||||
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"added": "Added",
|
||||
"case_sensitive": "Case Sensitive",
|
||||
"collapse": "Collapse",
|
||||
"download": "Download",
|
||||
"includes_user_questions": "Include Your Questions",
|
||||
"manage": "Manage",
|
||||
"select_model": "Select Model",
|
||||
@@ -1826,6 +1827,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": {
|
||||
@@ -2057,6 +2109,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8",
|
||||
"poe": "Poe",
|
||||
@@ -3284,6 +3337,7 @@
|
||||
"builtinServers": "Builtin Servers",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable",
|
||||
"didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable",
|
||||
"dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key",
|
||||
"fetch": "MCP server for retrieving URL web content",
|
||||
"filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"added": "已添加",
|
||||
"case_sensitive": "区分大小写",
|
||||
"collapse": "收起",
|
||||
"download": "下载",
|
||||
"includes_user_questions": "包含用户提问",
|
||||
"manage": "管理",
|
||||
"select_model": "选择模型",
|
||||
@@ -1826,6 +1827,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": {
|
||||
@@ -2057,6 +2109,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8 大模型开放平台",
|
||||
"poe": "Poe",
|
||||
@@ -3284,6 +3337,7 @@
|
||||
"builtinServers": "内置服务器",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量",
|
||||
"didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量",
|
||||
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
|
||||
"fetch": "用于获取 URL 网页内容的 MCP 服务器",
|
||||
"filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"added": "已新增",
|
||||
"case_sensitive": "區分大小寫",
|
||||
"collapse": "折疊",
|
||||
"download": "下載",
|
||||
"includes_user_questions": "包含使用者提問",
|
||||
"manage": "管理",
|
||||
"select_model": "選擇模型",
|
||||
@@ -1826,6 +1827,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": {
|
||||
@@ -2057,6 +2109,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8 大模型開放平台",
|
||||
"poe": "Poe",
|
||||
@@ -3284,6 +3337,7 @@
|
||||
"builtinServers": "內置伺服器",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數",
|
||||
"didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數",
|
||||
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
|
||||
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
|
||||
"filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"added": "προστέθηκε",
|
||||
"case_sensitive": "Διάκριση πεζών/κεφαλαίων",
|
||||
"collapse": "συμπεριλάβετε",
|
||||
"download": "Λήψη",
|
||||
"includes_user_questions": "Περιλαμβάνει ερωτήσεις χρήστη",
|
||||
"manage": "χειριστείτε",
|
||||
"select_model": "επιλογή μοντέλου",
|
||||
@@ -1825,6 +1826,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": {
|
||||
@@ -2056,6 +2108,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "Πλατφόρμα Ανοιχτής Μεγάλης Μοντέλου PH8",
|
||||
"poe": "Poe",
|
||||
@@ -3283,6 +3336,7 @@
|
||||
"builtinServers": "Ενσωματωμένοι Διακομιστές",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY",
|
||||
"didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY",
|
||||
"dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify",
|
||||
"fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL",
|
||||
"filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους",
|
||||
|
||||
@@ -251,6 +251,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",
|
||||
@@ -1825,6 +1826,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": {
|
||||
@@ -2056,6 +2108,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplejidad",
|
||||
"ph8": "Plataforma Abierta de Grandes Modelos PH8",
|
||||
"poe": "Poe",
|
||||
@@ -3283,6 +3336,7 @@
|
||||
"builtinServers": "Servidores integrados",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY",
|
||||
"didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY",
|
||||
"dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.",
|
||||
"fetch": "Servidor MCP para obtener el contenido de la página web de una URL",
|
||||
"filesystem": "Servidor Node.js que implementa el protocolo de contexto del modelo (MCP) para operaciones del sistema de archivos. Requiere configuración del directorio permitido para el acceso",
|
||||
|
||||
@@ -251,6 +251,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",
|
||||
@@ -1825,6 +1826,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": {
|
||||
@@ -2056,6 +2108,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexité",
|
||||
"ph8": "Plateforme ouverte de grands modèles PH8",
|
||||
"poe": "Poe",
|
||||
@@ -3283,6 +3336,7 @@
|
||||
"builtinServers": "Serveurs intégrés",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY",
|
||||
"didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY",
|
||||
"dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify",
|
||||
"fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL",
|
||||
"filesystem": "Serveur Node.js implémentant le protocole de contexte de modèle (MCP) pour les opérations de système de fichiers. Nécessite une configuration des répertoires autorisés à être accédés.",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"added": "追加済み",
|
||||
"case_sensitive": "大文字と小文字の区別",
|
||||
"collapse": "折りたたむ",
|
||||
"download": "ダウンロード",
|
||||
"includes_user_questions": "ユーザーからの質問を含む",
|
||||
"manage": "管理",
|
||||
"select_model": "モデルを選択",
|
||||
@@ -1825,6 +1826,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": {
|
||||
@@ -2056,6 +2108,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8",
|
||||
"poe": "Poe",
|
||||
@@ -3283,6 +3336,7 @@
|
||||
"builtinServers": "組み込みサーバー",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です",
|
||||
"didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です",
|
||||
"dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。",
|
||||
"fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー",
|
||||
"filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です",
|
||||
|
||||
@@ -251,6 +251,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",
|
||||
@@ -1825,6 +1826,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": {
|
||||
@@ -2056,6 +2108,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexidade",
|
||||
"ph8": "Plataforma Aberta de Grandes Modelos PH8",
|
||||
"poe": "Poe",
|
||||
@@ -3283,6 +3336,7 @@
|
||||
"builtinServers": "Servidores integrados",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY",
|
||||
"didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY",
|
||||
"dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify",
|
||||
"fetch": "servidor MCP para obter o conteúdo da página web do URL",
|
||||
"filesystem": "Servidor Node.js do protocolo de contexto de modelo (MCP) para implementar operações de sistema de ficheiros. Requer configuração do diretório permitido para acesso",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"added": "Добавлено",
|
||||
"case_sensitive": "Чувствительность к регистру",
|
||||
"collapse": "Свернуть",
|
||||
"download": "Скачать",
|
||||
"includes_user_questions": "Включает вопросы пользователей",
|
||||
"manage": "Редактировать",
|
||||
"select_model": "Выбрать модель",
|
||||
@@ -1825,6 +1826,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": {
|
||||
@@ -2056,6 +2108,7 @@
|
||||
"ollama": "Ollama",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ovms": "Intel OVMS",
|
||||
"perplexity": "Perplexity",
|
||||
"ph8": "PH8",
|
||||
"poe": "Poe",
|
||||
@@ -3283,6 +3336,7 @@
|
||||
"builtinServers": "Встроенные серверы",
|
||||
"builtinServersDescriptions": {
|
||||
"brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY",
|
||||
"didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY",
|
||||
"dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify",
|
||||
"fetch": "MCP-сервер для получения содержимого веб-страниц по URL",
|
||||
"filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsBunInstalled } from '@renderer/store/mcp'
|
||||
import { Model } from '@renderer/types'
|
||||
import { EndpointType, Model } from '@renderer/types'
|
||||
import { codeTools, terminalApps, TerminalConfig } from '@shared/config/constant'
|
||||
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space, Tooltip } from 'antd'
|
||||
import { ArrowUpRight, Download, FolderOpen, HelpCircle, Terminal, X } from 'lucide-react'
|
||||
@@ -70,18 +70,47 @@ const CodeToolsPage: FC = () => {
|
||||
if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (m.provider === 'cherryai') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.claudeCode) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return m.supported_endpoint_types.includes('anthropic')
|
||||
}
|
||||
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.geminiCli) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return m.supported_endpoint_types.includes('gemini')
|
||||
}
|
||||
return m.id.includes('gemini')
|
||||
}
|
||||
|
||||
if (selectedCliTool === codeTools.openaiCodex) {
|
||||
if (m.supported_endpoint_types) {
|
||||
return ['openai', 'openai-response'].some((type) =>
|
||||
m.supported_endpoint_types?.includes(type as EndpointType)
|
||||
)
|
||||
}
|
||||
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) =>
|
||||
m.supported_endpoint_types?.includes(type as EndpointType)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[selectedCliTool]
|
||||
@@ -171,7 +200,7 @@ const CodeToolsPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedModel) {
|
||||
if (!selectedModel && selectedCliTool !== codeTools.githubCopilotCli) {
|
||||
return { isValid: false, message: t('code.model_required') }
|
||||
}
|
||||
|
||||
@@ -180,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)
|
||||
@@ -204,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
|
||||
})
|
||||
@@ -291,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"
|
||||
@@ -320,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>
|
||||
@@ -378,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>
|
||||
@@ -404,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) */}
|
||||
@@ -439,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,13 +20,20 @@ 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']
|
||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
|
||||
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
|
||||
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
|
||||
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api']
|
||||
export const CLAUDE_SUPPORTED_PROVIDERS = [
|
||||
'aihubmix',
|
||||
'dmxapi',
|
||||
'new-api',
|
||||
'cherryin',
|
||||
...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS
|
||||
]
|
||||
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
|
||||
|
||||
// Provider 过滤映射
|
||||
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
|
||||
@@ -37,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) => {
|
||||
@@ -152,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
|
||||
|
||||
@@ -250,21 +250,19 @@ const MentionModelsButton: FC<Props> = ({
|
||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
||||
if (action === 'esc') {
|
||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
||||
if (
|
||||
hasModelActionRef.current &&
|
||||
ctx.triggerInfo?.type === 'input' &&
|
||||
ctx.triggerInfo?.position !== undefined
|
||||
) {
|
||||
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
|
||||
if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) {
|
||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
||||
setText((currentText) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', ctx.triggerInfo?.position!)
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', triggerInfo.position!)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Backspace删除@的情况(delete-symbol):
|
||||
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
||||
triggerInfoRef.current = undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -103,7 +103,8 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onRemoveBlock = () => {
|
||||
const onRemoveBlock = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import { Spinner } from '@heroui/react'
|
||||
import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -10,7 +10,7 @@ const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ block }) => {
|
||||
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
|
||||
return (
|
||||
<MessageContentLoading>
|
||||
<LoadingIcon />
|
||||
<Spinner color="current" variant="dots" />
|
||||
</MessageContentLoading>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import { Collapse, message as antdMessage, Tooltip } from 'antd'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -105,30 +105,37 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
|
||||
const ThinkingTimeSeconds = memo(
|
||||
({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
// const [thinkingTime, setThinkingTime] = useState(blockThinkingTime || 0)
|
||||
const [displayTime, setDisplayTime] = useState(blockThinkingTime)
|
||||
|
||||
// FIXME: 这里统计的和请求处统计的有一定误差
|
||||
// useEffect(() => {
|
||||
// let timer: NodeJS.Timeout | null = null
|
||||
// if (isThinking) {
|
||||
// timer = setInterval(() => {
|
||||
// setThinkingTime((prev) => prev + 100)
|
||||
// }, 100)
|
||||
// } else if (timer) {
|
||||
// // 立即清除计时器
|
||||
// clearInterval(timer)
|
||||
// timer = null
|
||||
// }
|
||||
const timer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// return () => {
|
||||
// if (timer) {
|
||||
// clearInterval(timer)
|
||||
// timer = null
|
||||
// }
|
||||
// }
|
||||
// }, [isThinking])
|
||||
useEffect(() => {
|
||||
if (isThinking) {
|
||||
if (!timer.current) {
|
||||
timer.current = setInterval(() => {
|
||||
setDisplayTime((prev) => prev + 100)
|
||||
}, 100)
|
||||
}
|
||||
} else {
|
||||
if (timer.current) {
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
setDisplayTime(blockThinkingTime)
|
||||
}
|
||||
|
||||
const thinkingTimeSeconds = useMemo(() => (blockThinkingTime / 1000).toFixed(1), [blockThinkingTime])
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
}
|
||||
}, [isThinking, blockThinkingTime])
|
||||
|
||||
const thinkingTimeSeconds = useMemo(
|
||||
() => ((displayTime < 1000 ? 100 : displayTime) / 1000).toFixed(1),
|
||||
[displayTime]
|
||||
)
|
||||
|
||||
return isThinking
|
||||
? t('chat.thinking', {
|
||||
|
||||
@@ -235,13 +235,12 @@ describe('ThinkingBlock', () => {
|
||||
renderThinkingBlock(thinkingBlock)
|
||||
|
||||
const activeTimeText = getThinkingTimeText()
|
||||
expect(activeTimeText).toHaveTextContent('1.0s')
|
||||
expect(activeTimeText).toHaveTextContent('Thinking...')
|
||||
})
|
||||
|
||||
it('should handle extreme thinking times correctly', () => {
|
||||
const testCases = [
|
||||
{ thinking_millsec: 0, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: 0, expectedTime: '0.1s' }, // New logic: values < 1000ms display as 0.1s
|
||||
{ thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day
|
||||
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { isMainTextBlock, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
||||
import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is'
|
||||
import { AnimatePresence, motion, type Variants } from 'motion/react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@@ -107,6 +107,9 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean)
|
||||
const groupedBlocks = useMemo(() => groupSimilarBlocks(renderedBlocks), [renderedBlocks])
|
||||
|
||||
// Check if message is still processing
|
||||
const isProcessing = isMessageProcessing(message)
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="sync">
|
||||
{groupedBlocks.map((block) => {
|
||||
@@ -151,9 +154,6 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
|
||||
switch (block.type) {
|
||||
case MessageBlockType.UNKNOWN:
|
||||
if (block.status === MessageBlockStatus.PROCESSING) {
|
||||
blockComponent = <PlaceholderBlock key={block.id} block={block} />
|
||||
}
|
||||
break
|
||||
case MessageBlockType.MAIN_TEXT:
|
||||
case MessageBlockType.CODE: {
|
||||
@@ -213,6 +213,19 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
})}
|
||||
{isProcessing && (
|
||||
<AnimatedBlockWrapper key="message-loading-placeholder" enableAnimation={true}>
|
||||
<PlaceholderBlock
|
||||
block={{
|
||||
id: `loading-${message.id}`,
|
||||
messageId: message.id,
|
||||
type: MessageBlockType.UNKNOWN,
|
||||
status: MessageBlockStatus.PROCESSING,
|
||||
createdAt: new Date().toISOString()
|
||||
}}
|
||||
/>
|
||||
</AnimatedBlockWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { KnowledgeSearchToolInput, KnowledgeSearchToolOutput } from '@renderer/aiCore/tools/KnowledgeSearchTool'
|
||||
import Spinner from '@renderer/components/Spinner'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { MCPToolResponse } from '@renderer/types'
|
||||
import { NormalToolResponse } from '@renderer/types'
|
||||
import { Typography } from 'antd'
|
||||
import { FileSearch } from 'lucide-react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: MCPToolResponse }) {
|
||||
export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const toolInput = toolResponse.arguments as KnowledgeSearchToolInput
|
||||
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
|
||||
|
||||
@@ -28,7 +28,7 @@ export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: MCPToolResponse }) {
|
||||
export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const toolOutput = toolResponse.response as KnowledgeSearchToolOutput
|
||||
|
||||
return toolResponse.status === 'done' ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { MCPToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
|
||||
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
|
||||
@@ -57,7 +58,7 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
|
||||
const [progress, setProgress] = useState<number>(0)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse
|
||||
|
||||
const { id, tool, status, response } = toolResponse!
|
||||
const isPending = status === 'pending'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MemorySearchToolInput, MemorySearchToolOutput } from '@renderer/aiCore/tools/MemorySearchTool'
|
||||
import Spinner from '@renderer/components/Spinner'
|
||||
import { MCPToolResponse } from '@renderer/types'
|
||||
import { NormalToolResponse } from '@renderer/types'
|
||||
import { Typography } from 'antd'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -8,7 +8,7 @@ import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
|
||||
export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||
const { t } = useTranslation()
|
||||
const toolInput = toolResponse.arguments as MemorySearchToolInput
|
||||
const toolOutput = toolResponse.response as MemorySearchToolOutput
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MCPToolResponse } from '@renderer/types'
|
||||
import { NormalToolResponse } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
import { Collapse } from 'antd'
|
||||
|
||||
@@ -11,8 +11,9 @@ interface Props {
|
||||
}
|
||||
const prefix = 'builtin_'
|
||||
|
||||
const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => {
|
||||
const ChooseTool = (toolResponse: NormalToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => {
|
||||
let toolName = toolResponse.tool.name
|
||||
const toolType = toolResponse.tool.type
|
||||
if (toolName.startsWith(prefix)) {
|
||||
toolName = toolName.slice(prefix.length)
|
||||
}
|
||||
@@ -20,10 +21,12 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
|
||||
switch (toolName) {
|
||||
case 'web_search':
|
||||
case 'web_search_preview':
|
||||
return {
|
||||
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
|
||||
body: null
|
||||
}
|
||||
return toolType === 'provider'
|
||||
? null
|
||||
: {
|
||||
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
|
||||
body: null
|
||||
}
|
||||
case 'knowledge_search':
|
||||
return {
|
||||
label: <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />,
|
||||
@@ -41,7 +44,7 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo
|
||||
|
||||
export default function MessageTool({ block }: Props) {
|
||||
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse
|
||||
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
|
||||
|
||||
if (!toolResponse) return null
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
|
||||
import Spinner from '@renderer/components/Spinner'
|
||||
import { MCPToolResponse } from '@renderer/types'
|
||||
import { NormalToolResponse } from '@renderer/types'
|
||||
import { Typography } from 'antd'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -8,7 +8,7 @@ import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => {
|
||||
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
|
||||
const { t } = useTranslation()
|
||||
const toolInput = toolResponse.arguments as WebSearchToolInput
|
||||
const toolOutput = toolResponse.response as WebSearchToolOutput
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { isLinux, isWin } from '@renderer/config/constant'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -86,7 +86,14 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{!showAssistants && (
|
||||
<NavbarLeft style={{ justifyContent: 'flex-start', borderRight: 'none', padding: '0 10px', minWidth: 'auto' }}>
|
||||
<NavbarLeft
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
borderRight: 'none',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 10,
|
||||
minWidth: 'auto'
|
||||
}}>
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
<PanelRightClose size={18} />
|
||||
@@ -106,7 +113,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
</AnimatePresence>
|
||||
</NavbarLeft>
|
||||
)}
|
||||
<HStack alignItems="center" gap={6}>
|
||||
<HStack alignItems="center" gap={6} ml={!isMac ? 16 : 0}>
|
||||
<SelectModelButton assistant={assistant} />
|
||||
</HStack>
|
||||
<NavbarRight
|
||||
@@ -114,7 +121,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
justifyContent: 'flex-end',
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
paddingRight: isWin || isLinux ? '144px' : '6px'
|
||||
paddingRight: isWin || isLinux ? '144px' : '15px'
|
||||
}}
|
||||
className="home-navbar-right">
|
||||
<HStack alignItems="center" gap={6}>
|
||||
|
||||
@@ -410,7 +410,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
</SettingGroup>
|
||||
</CollapsibleSettingGroup>
|
||||
<CollapsibleSettingGroup title={t('settings.math.title')} defaultExpanded={true}>
|
||||
<CollapsibleSettingGroup title={t('settings.math.title')} defaultExpanded={false}>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.math.engine.label')}</SettingRowTitleSmall>
|
||||
@@ -439,7 +439,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
</SettingGroup>
|
||||
</CollapsibleSettingGroup>
|
||||
<CollapsibleSettingGroup title={t('chat.settings.code.title')} defaultExpanded={true}>
|
||||
<CollapsibleSettingGroup title={t('chat.settings.code.title')} defaultExpanded={false}>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
|
||||
@@ -583,7 +583,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
</SettingGroup>
|
||||
<SettingDivider />
|
||||
</CollapsibleSettingGroup>
|
||||
<CollapsibleSettingGroup title={t('settings.messages.input.title')} defaultExpanded={true}>
|
||||
<CollapsibleSettingGroup title={t('settings.messages.input.title')} defaultExpanded={false}>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.input.show_estimated_tokens')}</SettingRowTitleSmall>
|
||||
|
||||
@@ -44,11 +44,20 @@ const MinAppPage: FC = () => {
|
||||
}
|
||||
}, [isTopNavbar])
|
||||
|
||||
// Find the app from all available apps
|
||||
// Find the app from all available apps (including cached ones)
|
||||
const app = useMemo(() => {
|
||||
if (!appId) return null
|
||||
return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
}, [appId, minapps])
|
||||
|
||||
// First try to find in default and custom mini-apps
|
||||
let foundApp = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found and we have cache, try to find in cache (for temporary apps)
|
||||
if (!foundApp && minAppsCache) {
|
||||
foundApp = minAppsCache.get(appId)
|
||||
}
|
||||
|
||||
return foundApp
|
||||
}, [appId, minapps, minAppsCache])
|
||||
|
||||
useEffect(() => {
|
||||
// If app not found, redirect to apps list
|
||||
|
||||
@@ -5,24 +5,25 @@ import { HStack } from '@renderer/components/Layout'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { NotesTreeNode } from '@types'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { findNode } from '@renderer/services/NotesTreeService'
|
||||
import { Dropdown, Input, Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { menuItems } from './MenuConfig'
|
||||
|
||||
const logger = loggerService.withContext('HeaderNavbar')
|
||||
|
||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
|
||||
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
|
||||
>([])
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
const titleInputRef = useRef<any>(null)
|
||||
const { settings, updateSettings } = useNotesSettings()
|
||||
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
|
||||
|
||||
@@ -52,37 +53,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
}, [getCurrentNoteContent])
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
async (item: { treePath: string; isFolder: boolean }) => {
|
||||
if (item.isFolder && notesTree) {
|
||||
try {
|
||||
// 获取从根目录到点击目录的所有路径片段
|
||||
const pathParts = item.treePath.split('/').filter(Boolean)
|
||||
const expandPromises: Promise<NotesTreeNode>[] = []
|
||||
|
||||
// 逐级展开从根到目标路径的所有文件夹
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const currentPath = '/' + pathParts.slice(0, i + 1).join('/')
|
||||
const folderNode = findNodeByPath(notesTree, currentPath)
|
||||
|
||||
if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) {
|
||||
expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true }))
|
||||
}
|
||||
}
|
||||
|
||||
// 并行执行所有展开操作
|
||||
if (expandPromises.length > 0) {
|
||||
await Promise.all(expandPromises)
|
||||
logger.info('Expanded folder path from breadcrumb:', {
|
||||
targetPath: item.treePath,
|
||||
expandedCount: expandPromises.length
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to expand folder path from breadcrumb:', error as Error)
|
||||
}
|
||||
(item: { treePath: string; isFolder: boolean }) => {
|
||||
if (item.isFolder && onExpandPath) {
|
||||
onExpandPath(item.treePath)
|
||||
}
|
||||
},
|
||||
[notesTree]
|
||||
[onExpandPath]
|
||||
)
|
||||
|
||||
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitleValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleTitleBlur = useCallback(() => {
|
||||
if (activeNode && titleValue.trim() && titleValue.trim() !== activeNode.name.replace('.md', '')) {
|
||||
onRenameNode?.(activeNode.id, titleValue.trim())
|
||||
} else if (activeNode) {
|
||||
// 如果没有更改或为空,恢复原始值
|
||||
setTitleValue(activeNode.name.replace('.md', ''))
|
||||
}
|
||||
}, [activeNode, titleValue, onRenameNode])
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
titleInputRef.current?.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (activeNode) {
|
||||
setTitleValue(activeNode.name.replace('.md', ''))
|
||||
}
|
||||
titleInputRef.current?.blur()
|
||||
}
|
||||
},
|
||||
[activeNode]
|
||||
)
|
||||
|
||||
const buildMenuItem = (item: any) => {
|
||||
@@ -133,13 +138,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 同步标题值
|
||||
useEffect(() => {
|
||||
if (activeNode?.type === 'file') {
|
||||
setTitleValue(activeNode.name.replace('.md', ''))
|
||||
}
|
||||
}, [activeNode])
|
||||
|
||||
// 构建面包屑路径
|
||||
useEffect(() => {
|
||||
if (!activeNode || !notesTree) {
|
||||
setBreadcrumbItems([])
|
||||
return
|
||||
}
|
||||
const node = findNodeInTree(notesTree, activeNode.id)
|
||||
const node = findNode(notesTree, activeNode.id)
|
||||
if (!node) return
|
||||
|
||||
const pathParts = node.treePath.split('/').filter(Boolean)
|
||||
@@ -179,16 +191,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
</HStack>
|
||||
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
|
||||
<BreadcrumbsContainer>
|
||||
<Breadcrumbs>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
|
||||
<BreadcrumbTitle
|
||||
onClick={() => handleBreadcrumbClick(item)}
|
||||
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
|
||||
{item.title}
|
||||
</BreadcrumbTitle>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
<Breadcrumbs style={{ borderRadius: 0 }}>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLastItem = index === breadcrumbItems.length - 1
|
||||
const isCurrentNote = isLastItem && !item.isFolder
|
||||
|
||||
return (
|
||||
<BreadcrumbItem key={item.key} isCurrent={isLastItem}>
|
||||
{isCurrentNote ? (
|
||||
<TitleInputWrapper>
|
||||
<TitleInput
|
||||
ref={titleInputRef}
|
||||
value={titleValue}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
size="small"
|
||||
variant="borderless"
|
||||
style={{
|
||||
fontSize: 'inherit',
|
||||
padding: 0,
|
||||
height: 'auto',
|
||||
lineHeight: 'inherit'
|
||||
}}
|
||||
/>
|
||||
</TitleInputWrapper>
|
||||
) : (
|
||||
<BreadcrumbTitle
|
||||
onClick={() => handleBreadcrumbClick(item)}
|
||||
$clickable={item.isFolder && !isLastItem}>
|
||||
{item.title}
|
||||
</BreadcrumbTitle>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
)
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</BreadcrumbsContainer>
|
||||
</NavbarCenter>
|
||||
@@ -303,6 +340,30 @@ export const BreadcrumbsContainer = styled.div`
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 最后一个面包屑项(当前笔记)可以扩展 */
|
||||
& li:last-child {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* 覆盖 HeroUI BreadcrumbItem 的样式 */
|
||||
& li:last-child [data-slot="item"] {
|
||||
flex: 1 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* 更强的样式覆盖 */
|
||||
& li:last-child * {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
& li:last-child > * {
|
||||
flex: 1 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 确保分隔符不会与标题重叠 */
|
||||
& li:not(:last-child)::after {
|
||||
flex-shrink: 0;
|
||||
@@ -330,4 +391,64 @@ export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>`
|
||||
`}
|
||||
`
|
||||
|
||||
export const TitleInputWrapper = styled.div`
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
export const TitleInput = styled(Input)`
|
||||
&&& {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
font-family: inherit !important;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
line-height: inherit !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
flex: 1 !important;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-3) !important;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
font-family: inherit !important;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
line-height: inherit !important;
|
||||
width: 100% !important;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default HeaderNavbar
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { EditorView } from '@renderer/types'
|
||||
import { Empty, Spin } from 'antd'
|
||||
import { Empty } from 'antd'
|
||||
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -14,13 +14,12 @@ interface NotesEditorProps {
|
||||
activeNodeId?: string
|
||||
currentContent: string
|
||||
tokenCount: number
|
||||
isLoading: boolean
|
||||
editorRef: RefObject<RichEditorRef | null>
|
||||
onMarkdownChange: (content: string) => void
|
||||
}
|
||||
|
||||
const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
|
||||
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
|
||||
const { t } = useTranslation()
|
||||
const { settings } = useNotesSettings()
|
||||
const currentViewMode = useMemo(() => {
|
||||
@@ -47,14 +46,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<Spin tip={t('common.loading')} />
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RichEditorContainer>
|
||||
@@ -122,14 +113,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
||||
|
||||
NotesEditor.displayName = 'NotesEditor'
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import {
|
||||
createFolder,
|
||||
createNote,
|
||||
deleteNode,
|
||||
initWorkSpace,
|
||||
moveNode,
|
||||
renameNode,
|
||||
sortAllLevels,
|
||||
uploadFiles
|
||||
addDir,
|
||||
addNote,
|
||||
delNode,
|
||||
loadTree,
|
||||
renameNode as renameEntry,
|
||||
sortTree,
|
||||
uploadNotes
|
||||
} from '@renderer/services/NotesService'
|
||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
|
||||
import {
|
||||
addUniquePath,
|
||||
findNode,
|
||||
findNodeByPath,
|
||||
findParent,
|
||||
normalizePathValue,
|
||||
removePathEntries,
|
||||
reorderTreeNodes,
|
||||
replacePathEntries,
|
||||
updateTreeNode
|
||||
} from '@renderer/services/NotesTreeService'
|
||||
import { useAppDispatch, useAppSelector, useAppStore } from '@renderer/store'
|
||||
import {
|
||||
selectActiveFilePath,
|
||||
selectExpandedPaths,
|
||||
selectSortType,
|
||||
selectStarredPaths,
|
||||
setActiveFilePath,
|
||||
setExpandedPaths,
|
||||
setSortType,
|
||||
setStarredPaths
|
||||
} from '@renderer/store/note'
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { FileChangeEvent } from '@shared/config/types'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { debounce } from 'lodash'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -37,27 +54,98 @@ const NotesPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { showWorkspace } = useShowWorkspace()
|
||||
const dispatch = useAppDispatch()
|
||||
const store = useAppStore()
|
||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
const starredPaths = useAppSelector(selectStarredPaths)
|
||||
const expandedPaths = useAppSelector(selectExpandedPaths)
|
||||
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
||||
|
||||
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
||||
const notesTreeQuery = useLiveQuery(() => getNotesTree(), [])
|
||||
const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery])
|
||||
const [notesTree, setNotesTree] = useState<NotesTreeNode[]>([])
|
||||
const starredSet = useMemo(() => new Set(starredPaths), [starredPaths])
|
||||
const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths])
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const { invalidateFileContent } = useFileContentSync()
|
||||
const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath)
|
||||
const { data: currentContent = '' } = useFileContent(activeFilePath)
|
||||
|
||||
const [tokenCount, setTokenCount] = useState(0)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const watcherRef = useRef<(() => void) | null>(null)
|
||||
const isSyncingTreeRef = useRef(false)
|
||||
const lastContentRef = useRef<string>('')
|
||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||
const isInitialSortApplied = useRef(false)
|
||||
const isRenamingRef = useRef(false)
|
||||
const isCreatingNoteRef = useRef(false)
|
||||
|
||||
const activeFilePathRef = useRef<string | undefined>(activeFilePath)
|
||||
const currentContentRef = useRef(currentContent)
|
||||
|
||||
const updateStarredPaths = useCallback(
|
||||
(updater: (paths: string[]) => string[]) => {
|
||||
const current = store.getState().note.starredPaths
|
||||
const safeCurrent = Array.isArray(current) ? current : []
|
||||
const next = updater(safeCurrent) ?? []
|
||||
if (!Array.isArray(next)) {
|
||||
return
|
||||
}
|
||||
if (next !== safeCurrent) {
|
||||
dispatch(setStarredPaths(next))
|
||||
}
|
||||
},
|
||||
[dispatch, store]
|
||||
)
|
||||
|
||||
const updateExpandedPaths = useCallback(
|
||||
(updater: (paths: string[]) => string[]) => {
|
||||
const current = store.getState().note.expandedPaths
|
||||
const safeCurrent = Array.isArray(current) ? current : []
|
||||
const next = updater(safeCurrent) ?? []
|
||||
if (!Array.isArray(next)) {
|
||||
return
|
||||
}
|
||||
if (next !== safeCurrent) {
|
||||
dispatch(setExpandedPaths(next))
|
||||
}
|
||||
},
|
||||
[dispatch, store]
|
||||
)
|
||||
|
||||
const mergeTreeState = useCallback(
|
||||
(nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
const normalizedPath = normalizePathValue(node.externalPath)
|
||||
const merged: NotesTreeNode = {
|
||||
...node,
|
||||
externalPath: normalizedPath,
|
||||
isStarred: starredSet.has(normalizedPath)
|
||||
}
|
||||
|
||||
if (node.type === 'folder') {
|
||||
merged.expanded = expandedSet.has(normalizedPath)
|
||||
merged.children = node.children ? mergeTreeState(node.children) : []
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
},
|
||||
[starredSet, expandedSet]
|
||||
)
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!notesPath) {
|
||||
setNotesTree([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rawTree = await loadTree(notesPath)
|
||||
const sortedTree = sortTree(rawTree, sortType)
|
||||
setNotesTree(mergeTreeState(sortedTree))
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh notes tree:', error as Error)
|
||||
}
|
||||
}, [mergeTreeState, notesPath, sortType])
|
||||
|
||||
useEffect(() => {
|
||||
const updateCharCount = () => {
|
||||
const textContent = editorRef.current?.getContent() || currentContent
|
||||
@@ -67,19 +155,16 @@ const NotesPage: FC = () => {
|
||||
updateCharCount()
|
||||
}, [currentContent])
|
||||
|
||||
// 查找树节点 by ID
|
||||
const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => {
|
||||
for (const node of tree) {
|
||||
if (node.id === nodeId) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, nodeId)
|
||||
if (found) return found
|
||||
}
|
||||
useEffect(() => {
|
||||
refreshTree()
|
||||
}, [refreshTree])
|
||||
|
||||
// Re-merge tree state when starred or expanded paths change
|
||||
useEffect(() => {
|
||||
if (notesTree.length > 0) {
|
||||
setNotesTree((prev) => mergeTreeState(prev))
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
}, [starredPaths, expandedPaths, mergeTreeState, notesTree.length])
|
||||
|
||||
// 保存当前笔记内容
|
||||
const saveCurrentNote = useCallback(
|
||||
@@ -107,6 +192,11 @@ const NotesPage: FC = () => {
|
||||
[saveCurrentNote]
|
||||
)
|
||||
|
||||
const saveCurrentNoteRef = useRef(saveCurrentNote)
|
||||
const debouncedSaveRef = useRef(debouncedSave)
|
||||
const invalidateFileContentRef = useRef(invalidateFileContent)
|
||||
const refreshTreeRef = useRef(refreshTree)
|
||||
|
||||
const handleMarkdownChange = useCallback(
|
||||
(newMarkdown: string) => {
|
||||
// 记录最新内容和文件路径,用于兜底保存
|
||||
@@ -118,6 +208,30 @@ const NotesPage: FC = () => {
|
||||
[debouncedSave, activeFilePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
activeFilePathRef.current = activeFilePath
|
||||
}, [activeFilePath])
|
||||
|
||||
useEffect(() => {
|
||||
currentContentRef.current = currentContent
|
||||
}, [currentContent])
|
||||
|
||||
useEffect(() => {
|
||||
saveCurrentNoteRef.current = saveCurrentNote
|
||||
}, [saveCurrentNote])
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSaveRef.current = debouncedSave
|
||||
}, [debouncedSave])
|
||||
|
||||
useEffect(() => {
|
||||
invalidateFileContentRef.current = invalidateFileContent
|
||||
}, [invalidateFileContent])
|
||||
|
||||
useEffect(() => {
|
||||
refreshTreeRef.current = refreshTree
|
||||
}, [refreshTree])
|
||||
|
||||
useEffect(() => {
|
||||
async function initialize() {
|
||||
if (!notesPath) {
|
||||
@@ -133,29 +247,12 @@ const NotesPage: FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [notesPath])
|
||||
|
||||
// 应用初始排序
|
||||
useEffect(() => {
|
||||
async function applyInitialSort() {
|
||||
if (notesTree.length > 0 && !isInitialSortApplied.current) {
|
||||
try {
|
||||
await sortAllLevels(sortType)
|
||||
isInitialSortApplied.current = true
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply initial sorting:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyInitialSort()
|
||||
}, [notesTree.length, sortType])
|
||||
|
||||
// 处理树同步时的状态管理
|
||||
useEffect(() => {
|
||||
if (notesTree.length === 0) return
|
||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||
const shouldClearPath =
|
||||
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||
const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||
|
||||
if (shouldClearPath) {
|
||||
logger.warn('Clearing activeFilePath - node not found in tree', {
|
||||
@@ -167,7 +264,7 @@ const NotesPage: FC = () => {
|
||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notesPath || notesTree.length === 0) return
|
||||
if (!notesPath) return
|
||||
|
||||
async function startFileWatcher() {
|
||||
// 清理之前的监控
|
||||
@@ -181,31 +278,14 @@ const NotesPage: FC = () => {
|
||||
try {
|
||||
if (!notesPath) return
|
||||
const { eventType, filePath } = data
|
||||
const normalizedEventPath = normalizePathValue(filePath)
|
||||
|
||||
switch (eventType) {
|
||||
case 'change': {
|
||||
// 处理文件内容变化 - 只有内容真正改变时才触发更新
|
||||
if (activeFilePath === filePath) {
|
||||
try {
|
||||
// 读取文件最新内容
|
||||
// const newFileContent = await window.api.file.readExternal(filePath)
|
||||
// // 获取当前编辑器/缓存中的内容
|
||||
// const currentEditorContent = editorRef.current?.getMarkdown()
|
||||
// // 如果编辑器还未初始化完成,忽略FileWatcher事件
|
||||
// if (!isEditorInitialized.current) {
|
||||
// return
|
||||
// }
|
||||
// // 比较内容是否真正发生变化
|
||||
// if (newFileContent.trim() !== currentEditorContent?.trim()) {
|
||||
// invalidateFileContent(filePath)
|
||||
// }
|
||||
} catch (error) {
|
||||
logger.error('Failed to read file for content comparison:', error as Error)
|
||||
// 读取失败时,还是执行原来的逻辑
|
||||
invalidateFileContent(filePath)
|
||||
}
|
||||
} else {
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
const activePath = activeFilePathRef.current
|
||||
if (activePath && normalizePathValue(activePath) === normalizedEventPath) {
|
||||
invalidateFileContentRef.current?.(normalizedEventPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -215,20 +295,18 @@ const NotesPage: FC = () => {
|
||||
case 'unlink':
|
||||
case 'unlinkDir': {
|
||||
// 如果删除的是当前活动文件,清空选择
|
||||
if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) {
|
||||
if (
|
||||
(eventType === 'unlink' || eventType === 'unlinkDir') &&
|
||||
activeFilePathRef.current &&
|
||||
normalizePathValue(activeFilePathRef.current) === normalizedEventPath
|
||||
) {
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
editorRef.current?.clear()
|
||||
}
|
||||
|
||||
// 设置同步标志,避免竞态条件
|
||||
isSyncingTreeRef.current = true
|
||||
|
||||
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
||||
try {
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync database:', error as Error)
|
||||
} finally {
|
||||
isSyncingTreeRef.current = false
|
||||
const refresh = refreshTreeRef.current
|
||||
if (refresh) {
|
||||
await refresh()
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -261,26 +339,19 @@ const NotesPage: FC = () => {
|
||||
})
|
||||
|
||||
// 如果有未保存的内容,立即保存
|
||||
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save failed:', error as Error)
|
||||
})
|
||||
if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) {
|
||||
const saveFn = saveCurrentNoteRef.current
|
||||
if (saveFn) {
|
||||
saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save failed:', error as Error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 清理防抖函数
|
||||
debouncedSave.cancel()
|
||||
debouncedSaveRef.current?.cancel()
|
||||
}
|
||||
}, [
|
||||
notesPath,
|
||||
notesTree.length,
|
||||
activeFilePath,
|
||||
invalidateFileContent,
|
||||
dispatch,
|
||||
currentContent,
|
||||
debouncedSave,
|
||||
saveCurrentNote,
|
||||
sortType
|
||||
])
|
||||
}, [dispatch, notesPath])
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
@@ -316,13 +387,13 @@ const NotesPage: FC = () => {
|
||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||
const getTargetFolderPath = useCallback(() => {
|
||||
if (selectedFolderId) {
|
||||
const selectedNode = findNodeById(notesTree, selectedFolderId)
|
||||
const selectedNode = findNode(notesTree, selectedFolderId)
|
||||
if (selectedNode && selectedNode.type === 'folder') {
|
||||
return selectedNode.externalPath
|
||||
}
|
||||
}
|
||||
return notesPath // 默认返回根目录
|
||||
}, [selectedFolderId, notesTree, notesPath, findNodeById])
|
||||
}, [selectedFolderId, notesTree, notesPath])
|
||||
|
||||
// 创建文件夹
|
||||
const handleCreateFolder = useCallback(
|
||||
@@ -332,12 +403,14 @@ const NotesPage: FC = () => {
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
await createFolder(name, targetPath)
|
||||
await addDir(name, targetPath)
|
||||
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetPath)))
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', error as Error)
|
||||
}
|
||||
},
|
||||
[getTargetFolderPath]
|
||||
[getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 创建笔记
|
||||
@@ -350,11 +423,13 @@ const NotesPage: FC = () => {
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
const newNote = await createNote(name, '', targetPath)
|
||||
dispatch(setActiveFilePath(newNote.externalPath))
|
||||
const { path: notePath } = await addNote(name, '', targetPath)
|
||||
const normalizedParent = normalizePathValue(targetPath)
|
||||
updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent))
|
||||
dispatch(setActiveFilePath(notePath))
|
||||
setSelectedFolderId(null)
|
||||
|
||||
await sortAllLevels(sortType)
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create note:', error as Error)
|
||||
} finally {
|
||||
@@ -364,73 +439,41 @@ const NotesPage: FC = () => {
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[dispatch, getTargetFolderPath, sortType]
|
||||
)
|
||||
|
||||
// 切换展开状态
|
||||
const toggleNodeExpanded = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.type === 'folder') {
|
||||
await updateNodeInTree(tree, nodeId, {
|
||||
expanded: !node.expanded
|
||||
})
|
||||
}
|
||||
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle expanded:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[findNodeById]
|
||||
[dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
await toggleNodeExpanded(nodeId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle expanded:', error as Error)
|
||||
(nodeId: string) => {
|
||||
const targetNode = findNode(notesTree, nodeId)
|
||||
if (!targetNode || targetNode.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextExpanded = !targetNode.expanded
|
||||
// Update Redux state first, then let mergeTreeState handle the UI update
|
||||
updateExpandedPaths((prev) =>
|
||||
nextExpanded
|
||||
? addUniquePath(prev, targetNode.externalPath)
|
||||
: removePathEntries(prev, targetNode.externalPath, false)
|
||||
)
|
||||
},
|
||||
[toggleNodeExpanded]
|
||||
)
|
||||
|
||||
// 切换收藏状态
|
||||
const toggleStarred = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.type === 'file') {
|
||||
await updateNodeInTree(tree, nodeId, {
|
||||
isStarred: !node.isStarred
|
||||
})
|
||||
}
|
||||
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle star:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[findNodeById]
|
||||
[notesTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
const handleToggleStar = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
await toggleStarred(nodeId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle star:', error as Error)
|
||||
(nodeId: string) => {
|
||||
const node = findNode(notesTree, nodeId)
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextStarred = !node.isStarred
|
||||
// Update Redux state first, then let mergeTreeState handle the UI update
|
||||
updateStarredPaths((prev) =>
|
||||
nextStarred ? addUniquePath(prev, node.externalPath) : removePathEntries(prev, node.externalPath, false)
|
||||
)
|
||||
},
|
||||
[toggleStarred]
|
||||
[notesTree, updateStarredPaths]
|
||||
)
|
||||
|
||||
// 选择节点
|
||||
@@ -447,7 +490,7 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
} else if (node.type === 'folder') {
|
||||
setSelectedFolderId(node.id)
|
||||
await handleToggleExpanded(node.id)
|
||||
handleToggleExpanded(node.id)
|
||||
}
|
||||
},
|
||||
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||
@@ -457,28 +500,35 @@ const NotesPage: FC = () => {
|
||||
const handleDeleteNode = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const nodeToDelete = findNodeById(notesTree, nodeId)
|
||||
const nodeToDelete = findNode(notesTree, nodeId)
|
||||
if (!nodeToDelete) return
|
||||
|
||||
const isActiveNodeOrParent =
|
||||
activeFilePath &&
|
||||
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
||||
await delNode(nodeToDelete)
|
||||
|
||||
await deleteNode(nodeId)
|
||||
await sortAllLevels(sortType)
|
||||
updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder'))
|
||||
updateExpandedPaths((prev) =>
|
||||
removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')
|
||||
)
|
||||
|
||||
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
||||
if (isActiveNodeOrParent) {
|
||||
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
|
||||
const isActiveNode = normalizedActivePath === normalizedDeletePath
|
||||
const isActiveDescendant =
|
||||
nodeToDelete.type === 'folder' &&
|
||||
normalizedActivePath &&
|
||||
normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
|
||||
|
||||
if (isActiveNode || isActiveDescendant) {
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
if (editorRef.current) {
|
||||
editorRef.current.clear()
|
||||
}
|
||||
editorRef.current?.clear()
|
||||
}
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete node:', error as Error)
|
||||
}
|
||||
},
|
||||
[findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch]
|
||||
[notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 重命名节点
|
||||
@@ -487,29 +537,30 @@ const NotesPage: FC = () => {
|
||||
try {
|
||||
isRenamingRef.current = true
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.name !== newName) {
|
||||
const oldExternalPath = node.externalPath
|
||||
const renamedNode = await renameNode(nodeId, newName)
|
||||
|
||||
if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) {
|
||||
dispatch(setActiveFilePath(renamedNode.externalPath))
|
||||
} else if (
|
||||
renamedNode.type === 'folder' &&
|
||||
activeFilePath &&
|
||||
activeFilePath.startsWith(oldExternalPath + '/')
|
||||
) {
|
||||
const relativePath = activeFilePath.substring(oldExternalPath.length)
|
||||
const newFilePath = renamedNode.externalPath + relativePath
|
||||
dispatch(setActiveFilePath(newFilePath))
|
||||
}
|
||||
await sortAllLevels(sortType)
|
||||
if (renamedNode.name !== newName) {
|
||||
window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name }))
|
||||
}
|
||||
const node = findNode(notesTree, nodeId)
|
||||
if (!node || node.name === newName) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldPath = node.externalPath
|
||||
const renamed = await renameEntry(node, newName)
|
||||
|
||||
if (node.type === 'file' && activeFilePath === oldPath) {
|
||||
debouncedSaveRef.current?.cancel()
|
||||
lastFilePathRef.current = renamed.path
|
||||
dispatch(setActiveFilePath(renamed.path))
|
||||
} else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) {
|
||||
const suffix = activeFilePath.slice(oldPath.length)
|
||||
const nextActivePath = `${renamed.path}${suffix}`
|
||||
debouncedSaveRef.current?.cancel()
|
||||
lastFilePathRef.current = nextActivePath
|
||||
dispatch(setActiveFilePath(nextActivePath))
|
||||
}
|
||||
|
||||
updateStarredPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
|
||||
updateExpandedPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename node:', error as Error)
|
||||
} finally {
|
||||
@@ -518,7 +569,7 @@ const NotesPage: FC = () => {
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[activeFilePath, dispatch, findNodeById, sortType, t]
|
||||
[activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 处理文件上传
|
||||
@@ -535,7 +586,7 @@ const NotesPage: FC = () => {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
|
||||
const result = await uploadFiles(files, targetFolderPath)
|
||||
const result = await uploadNotes(files, targetFolderPath)
|
||||
|
||||
// 检查上传结果
|
||||
if (result.fileCount === 0) {
|
||||
@@ -544,7 +595,8 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
|
||||
// 排序并显示成功信息
|
||||
await sortAllLevels(sortType)
|
||||
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath)))
|
||||
await refreshTree()
|
||||
|
||||
const successMessage = t('notes.upload_success')
|
||||
|
||||
@@ -554,37 +606,141 @@ const NotesPage: FC = () => {
|
||||
window.toast.error(t('notes.upload_failed'))
|
||||
}
|
||||
},
|
||||
[getTargetFolderPath, sortType, t]
|
||||
[getTargetFolderPath, refreshTree, t, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 处理节点移动
|
||||
const handleMoveNode = useCallback(
|
||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||
if (!notesPath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await moveNode(sourceNodeId, targetNodeId, position)
|
||||
if (result.success && result.type !== 'manual_reorder') {
|
||||
await sortAllLevels(sortType)
|
||||
const sourceNode = findNode(notesTree, sourceNodeId)
|
||||
const targetNode = findNode(notesTree, targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (position === 'inside' && targetNode.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
|
||||
const rootPath = normalizePathValue(notesPath)
|
||||
const sourceParentNode = findParent(notesTree, sourceNodeId)
|
||||
const targetParentNode = position === 'inside' ? targetNode : findParent(notesTree, targetNodeId)
|
||||
|
||||
const sourceParentPath = sourceParentNode ? sourceParentNode.externalPath : rootPath
|
||||
const targetParentPath =
|
||||
position === 'inside' ? targetNode.externalPath : targetParentNode ? targetParentNode.externalPath : rootPath
|
||||
|
||||
const normalizedSourceParent = normalizePathValue(sourceParentPath)
|
||||
const normalizedTargetParent = normalizePathValue(targetParentPath)
|
||||
|
||||
const isManualReorder = position !== 'inside' && normalizedSourceParent === normalizedTargetParent
|
||||
|
||||
if (isManualReorder) {
|
||||
// For manual reordering within the same parent, we can optimize by only updating the affected parent
|
||||
setNotesTree((prev) =>
|
||||
reorderTreeNodes(prev, sourceNodeId, targetNodeId, position === 'before' ? 'before' : 'after')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const { safeName } = await window.api.file.checkFileName(
|
||||
normalizedTargetParent,
|
||||
sourceNode.name,
|
||||
sourceNode.type === 'file'
|
||||
)
|
||||
|
||||
const destinationPath =
|
||||
sourceNode.type === 'file'
|
||||
? `${normalizedTargetParent}/${safeName}.md`
|
||||
: `${normalizedTargetParent}/${safeName}`
|
||||
|
||||
if (destinationPath === sourceNode.externalPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceNode.type === 'file') {
|
||||
await window.api.file.move(sourceNode.externalPath, destinationPath)
|
||||
} else {
|
||||
await window.api.file.moveDir(sourceNode.externalPath, destinationPath)
|
||||
}
|
||||
|
||||
updateStarredPaths((prev) =>
|
||||
replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
|
||||
)
|
||||
updateExpandedPaths((prev) => {
|
||||
let next = replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
|
||||
next = addUniquePath(next, normalizedTargetParent)
|
||||
return next
|
||||
})
|
||||
|
||||
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||
if (normalizedActivePath) {
|
||||
if (normalizedActivePath === sourceNode.externalPath) {
|
||||
dispatch(setActiveFilePath(destinationPath))
|
||||
} else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) {
|
||||
const suffix = normalizedActivePath.slice(sourceNode.externalPath.length)
|
||||
dispatch(setActiveFilePath(`${destinationPath}${suffix}`))
|
||||
}
|
||||
}
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to move nodes:', error as Error)
|
||||
}
|
||||
},
|
||||
[sortType]
|
||||
[activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||
)
|
||||
|
||||
// 处理节点排序
|
||||
const handleSortNodes = useCallback(
|
||||
async (newSortType: NotesSortType) => {
|
||||
try {
|
||||
// 更新Redux中的排序类型
|
||||
dispatch(setSortType(newSortType))
|
||||
await sortAllLevels(newSortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort notes:', error as Error)
|
||||
throw error
|
||||
dispatch(setSortType(newSortType))
|
||||
setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType)))
|
||||
},
|
||||
[dispatch, mergeTreeState]
|
||||
)
|
||||
|
||||
const handleExpandPath = useCallback(
|
||||
(treePath: string) => {
|
||||
if (!treePath) {
|
||||
return
|
||||
}
|
||||
|
||||
const segments = treePath.split('/').filter(Boolean)
|
||||
if (segments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let nextTree = notesTree
|
||||
const pathsToAdd: string[] = []
|
||||
|
||||
segments.forEach((_, index) => {
|
||||
const currentPath = '/' + segments.slice(0, index + 1).join('/')
|
||||
const node = findNodeByPath(nextTree, currentPath)
|
||||
if (node && node.type === 'folder' && !node.expanded) {
|
||||
pathsToAdd.push(node.externalPath)
|
||||
nextTree = updateTreeNode(nextTree, node.id, (current) => ({ ...current, expanded: true }))
|
||||
}
|
||||
})
|
||||
|
||||
if (pathsToAdd.length > 0) {
|
||||
setNotesTree(nextTree)
|
||||
updateExpandedPaths((prev) => {
|
||||
let updated = prev
|
||||
pathsToAdd.forEach((path) => {
|
||||
updated = addUniquePath(updated, path)
|
||||
})
|
||||
return updated
|
||||
})
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[notesTree, updateExpandedPaths]
|
||||
)
|
||||
|
||||
const getCurrentNoteContent = useCallback(() => {
|
||||
@@ -631,12 +787,13 @@ const NotesPage: FC = () => {
|
||||
notesTree={notesTree}
|
||||
getCurrentNoteContent={getCurrentNoteContent}
|
||||
onToggleStar={handleToggleStar}
|
||||
onExpandPath={handleExpandPath}
|
||||
onRenameNode={handleRenameNode}
|
||||
/>
|
||||
<NotesEditor
|
||||
activeNodeId={activeNode?.id}
|
||||
currentContent={currentContent}
|
||||
tokenCount={tokenCount}
|
||||
isLoading={isContentLoading}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectSortType } from '@renderer/store/note'
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
Star,
|
||||
StarOff
|
||||
} from 'lucide-react'
|
||||
import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -43,6 +44,157 @@ interface NotesSidebarProps {
|
||||
|
||||
const logger = loggerService.withContext('NotesSidebar')
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: NotesTreeNode
|
||||
depth: number
|
||||
selectedFolderId?: string | null
|
||||
activeNodeId?: string
|
||||
editingNodeId: string | null
|
||||
draggedNodeId: string | null
|
||||
dragOverNodeId: string | null
|
||||
dragPosition: 'before' | 'inside' | 'after'
|
||||
inPlaceEdit: any
|
||||
getMenuItems: (node: NotesTreeNode) => any[]
|
||||
onSelectNode: (node: NotesTreeNode) => void
|
||||
onToggleExpanded: (nodeId: string) => void
|
||||
onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragEnd: () => void
|
||||
renderChildren?: boolean // 控制是否渲染子节点
|
||||
}
|
||||
|
||||
const TreeNode = memo<TreeNodeProps>(
|
||||
({
|
||||
node,
|
||||
depth,
|
||||
selectedFolderId,
|
||||
activeNodeId,
|
||||
editingNodeId,
|
||||
draggedNodeId,
|
||||
dragOverNodeId,
|
||||
dragPosition,
|
||||
inPlaceEdit,
|
||||
getMenuItems,
|
||||
onSelectNode,
|
||||
onToggleExpanded,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
renderChildren = true
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isActive = selectedFolderId
|
||||
? node.type === 'folder' && node.id === selectedFolderId
|
||||
: node.id === activeNodeId
|
||||
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isDragging = draggedNodeId === node.id
|
||||
const isDragOver = dragOverNodeId === node.id
|
||||
const isDragBefore = isDragOver && dragPosition === 'before'
|
||||
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
||||
<div>
|
||||
<TreeNodeContainer
|
||||
active={isActive}
|
||||
depth={depth}
|
||||
isDragging={isDragging}
|
||||
isDragOver={isDragOver}
|
||||
isDragBefore={isDragBefore}
|
||||
isDragInside={isDragInside}
|
||||
isDragAfter={isDragAfter}
|
||||
draggable={!isEditing}
|
||||
data-node-id={node.id}
|
||||
onDragStart={(e) => onDragStart(e, node)}
|
||||
onDragOver={(e) => onDragOver(e, node)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, node)}
|
||||
onDragEnd={onDragEnd}>
|
||||
<TreeNodeContent onClick={() => onSelectNode(node)}>
|
||||
<NodeIndent depth={depth} />
|
||||
|
||||
{node.type === 'folder' && (
|
||||
<ExpandIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpanded(node.id)
|
||||
}}
|
||||
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
|
||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ExpandIcon>
|
||||
)}
|
||||
|
||||
<NodeIcon>
|
||||
{node.type === 'folder' ? (
|
||||
node.expanded ? (
|
||||
<FolderOpen size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
)
|
||||
) : (
|
||||
<File size={16} />
|
||||
)}
|
||||
</NodeIcon>
|
||||
|
||||
{isEditing ? (
|
||||
<EditInput
|
||||
ref={inPlaceEdit.inputRef as Ref<InputRef>}
|
||||
value={inPlaceEdit.editValue}
|
||||
onChange={inPlaceEdit.handleInputChange}
|
||||
onBlur={inPlaceEdit.saveEdit}
|
||||
onKeyDown={inPlaceEdit.handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<NodeName>{node.name}</NodeName>
|
||||
)}
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNodeId}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
renderChildren={renderChildren}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onCreateFolder,
|
||||
onCreateNote,
|
||||
@@ -111,7 +263,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, targetScrollTop),
|
||||
behavior: 'smooth'
|
||||
behavior: 'instant'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -268,9 +420,26 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
setIsShowSearch(!isShowSearch)
|
||||
}, [isShowSearch])
|
||||
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!isShowStarred && !isShowSearch) return notesTree
|
||||
const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
||||
// Flatten tree nodes for virtualization and filtering
|
||||
const flattenedNodes = useMemo(() => {
|
||||
const flattenForVirtualization = (
|
||||
nodes: NotesTreeNode[],
|
||||
depth: number = 0
|
||||
): Array<{ node: NotesTreeNode; depth: number }> => {
|
||||
let result: Array<{ node: NotesTreeNode; depth: number }> = []
|
||||
|
||||
for (const node of nodes) {
|
||||
result.push({ node, depth })
|
||||
|
||||
// Include children only if the folder is expanded
|
||||
if (node.type === 'folder' && node.expanded && node.children && node.children.length > 0) {
|
||||
result = [...result, ...flattenForVirtualization(node.children, depth + 1)]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const flattenForFiltering = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
||||
let result: NotesTreeNode[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
@@ -284,15 +453,41 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
}
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
result = [...result, ...flattenNodes(node.children)]
|
||||
result = [...result, ...flattenForFiltering(node.children)]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return flattenNodes(notesTree)
|
||||
if (isShowStarred || isShowSearch) {
|
||||
// For filtered views, return flat list without virtualization for simplicity
|
||||
const filteredNodes = flattenForFiltering(notesTree)
|
||||
return filteredNodes.map((node) => ({ node, depth: 0 }))
|
||||
}
|
||||
|
||||
// For normal tree view, use hierarchical flattening for virtualization
|
||||
return flattenForVirtualization(notesTree)
|
||||
}, [notesTree, isShowStarred, isShowSearch, searchKeyword])
|
||||
|
||||
// Use virtualization only for normal tree view with many items
|
||||
const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flattenedNodes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 28, // Estimated height of each tree item
|
||||
overscan: 10
|
||||
})
|
||||
|
||||
const filteredTree = useMemo(() => {
|
||||
if (isShowStarred || isShowSearch) {
|
||||
return flattenedNodes.map(({ node }) => node)
|
||||
}
|
||||
return notesTree
|
||||
}, [flattenedNodes, isShowStarred, isShowSearch, notesTree])
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
const baseMenuItems: MenuProps['items'] = [
|
||||
@@ -351,115 +546,6 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
|
||||
)
|
||||
|
||||
const renderTreeNode = useCallback(
|
||||
(node: NotesTreeNode, depth: number = 0) => {
|
||||
const isActive = selectedFolderId
|
||||
? node.type === 'folder' && node.id === selectedFolderId
|
||||
: node.id === activeNode?.id
|
||||
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isDragging = draggedNodeId === node.id
|
||||
const isDragOver = dragOverNodeId === node.id
|
||||
const isDragBefore = isDragOver && dragPosition === 'before'
|
||||
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
||||
<div>
|
||||
<TreeNodeContainer
|
||||
active={isActive}
|
||||
depth={depth}
|
||||
isDragging={isDragging}
|
||||
isDragOver={isDragOver}
|
||||
isDragBefore={isDragBefore}
|
||||
isDragInside={isDragInside}
|
||||
isDragAfter={isDragAfter}
|
||||
draggable={!isEditing}
|
||||
data-node-id={node.id}
|
||||
onDragStart={(e) => handleDragStart(e, node)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<TreeNodeContent onClick={() => onSelectNode(node)}>
|
||||
<NodeIndent depth={depth} />
|
||||
|
||||
{node.type === 'folder' && (
|
||||
<ExpandIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpanded(node.id)
|
||||
}}
|
||||
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
|
||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ExpandIcon>
|
||||
)}
|
||||
|
||||
<NodeIcon>
|
||||
{node.type === 'folder' ? (
|
||||
node.expanded ? (
|
||||
<FolderOpen size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
)
|
||||
) : (
|
||||
<File size={16} />
|
||||
)}
|
||||
</NodeIcon>
|
||||
|
||||
{isEditing ? (
|
||||
<EditInput
|
||||
ref={inPlaceEdit.inputRef as Ref<InputRef>}
|
||||
value={inPlaceEdit.editValue}
|
||||
onChange={inPlaceEdit.handleInputChange}
|
||||
onPressEnter={inPlaceEdit.saveEdit}
|
||||
onBlur={inPlaceEdit.saveEdit}
|
||||
onKeyDown={inPlaceEdit.handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<NodeName>{node.name}</NodeName>
|
||||
)}
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{node.type === 'folder' && node.expanded && hasChildren && (
|
||||
<div>{node.children!.map((child) => renderTreeNode(child, depth + 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
selectedFolderId,
|
||||
activeNode?.id,
|
||||
editingNodeId,
|
||||
inPlaceEdit.isEditing,
|
||||
inPlaceEdit.inputRef,
|
||||
inPlaceEdit.editValue,
|
||||
inPlaceEdit.handleInputChange,
|
||||
inPlaceEdit.saveEdit,
|
||||
inPlaceEdit.handleKeyDown,
|
||||
draggedNodeId,
|
||||
dragOverNodeId,
|
||||
dragPosition,
|
||||
getMenuItems,
|
||||
handleDragLeave,
|
||||
handleDragEnd,
|
||||
t,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
onSelectNode,
|
||||
onToggleExpanded
|
||||
]
|
||||
)
|
||||
|
||||
const handleDropFiles = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -565,9 +651,54 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
/>
|
||||
|
||||
<NotesTreeContainer>
|
||||
<StyledScrollbar ref={scrollbarRef}>
|
||||
<TreeContent>
|
||||
{filteredTree.map((node) => renderTreeNode(node))}
|
||||
{shouldUseVirtualization ? (
|
||||
<VirtualizedTreeContainer ref={parentRef}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const { node, depth } = flattenedNodes[virtualItem.index]
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`
|
||||
}}>
|
||||
<div style={{ padding: '0 8px' }}>
|
||||
<TreeNode
|
||||
node={node}
|
||||
depth={depth}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
renderChildren={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
@@ -580,8 +711,70 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</TreeContent>
|
||||
</StyledScrollbar>
|
||||
</VirtualizedTreeContainer>
|
||||
) : (
|
||||
<StyledScrollbar ref={scrollbarRef}>
|
||||
<TreeContent>
|
||||
{isShowStarred || isShowSearch
|
||||
? filteredTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))
|
||||
: notesTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
<TreeNodeContent>
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</TreeContent>
|
||||
</StyledScrollbar>
|
||||
)}
|
||||
</NotesTreeContainer>
|
||||
|
||||
{isDragOverSidebar && <DragOverIndicator />}
|
||||
@@ -592,7 +785,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const SidebarContainer = styled.div`
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
height: 100vh;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
background-color: var(--color-background);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
@@ -606,7 +799,15 @@ const NotesTreeContainer = styled.div`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 45px);
|
||||
height: calc(100vh - var(--navbar-height) - 45px);
|
||||
`
|
||||
|
||||
const VirtualizedTreeContainer = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
padding-top: 10px;
|
||||
`
|
||||
|
||||
const StyledScrollbar = styled(Scrollbar)`
|
||||
@@ -752,7 +953,8 @@ const DragOverIndicator = styled.div`
|
||||
`
|
||||
|
||||
const DropHintNode = styled.div`
|
||||
margin-top: 8px;
|
||||
margin: 8px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
${TreeNodeContainer} {
|
||||
background-color: transparent;
|
||||
@@ -773,4 +975,4 @@ const DropHintText = styled.div`
|
||||
font-style: italic;
|
||||
`
|
||||
|
||||
export default NotesSidebar
|
||||
export default memo(NotesSidebar)
|
||||
|
||||
@@ -307,7 +307,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const createNewPainting = () => {
|
||||
const handleAddPainting = () => {
|
||||
if (generating) return
|
||||
const newPainting = getNewPainting()
|
||||
const addedPainting = addPainting('zhipu_paintings', newPainting)
|
||||
@@ -342,12 +342,12 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarCenter>
|
||||
<Title>{t('title.paintings')}</Title>
|
||||
</NavbarCenter>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
|
||||
{isMac && (
|
||||
<NavbarRight>
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={createNewPainting} disabled={generating} />
|
||||
<NavbarRight style={{ justifyContent: 'flex-end' }}>
|
||||
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={handleAddPainting}>
|
||||
{t('paintings.button.new.image')}
|
||||
</Button>
|
||||
</NavbarRight>
|
||||
)}
|
||||
</Navbar>
|
||||
@@ -482,7 +482,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
selectedPainting={painting}
|
||||
onSelectPainting={onSelectPainting}
|
||||
onDeletePainting={onDeletePainting}
|
||||
onNewPainting={createNewPainting}
|
||||
onNewPainting={handleAddPainting}
|
||||
/>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
@@ -556,12 +556,6 @@ const ToolbarMenu = styled.div`
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
`
|
||||
|
||||
const ProviderTitleContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { runAsyncFunction } from '@renderer/utils'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { Bug, FileCheck, Github, Globe, Mail, Rss } from 'lucide-react'
|
||||
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
|
||||
import { BadgeQuestionMark } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -32,7 +32,7 @@ const AboutSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { update } = useRuntime()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
const { openSmartMinapp } = useMinappPopup()
|
||||
|
||||
const onCheckUpdate = debounce(
|
||||
async () => {
|
||||
@@ -79,7 +79,7 @@ const AboutSettings: FC = () => {
|
||||
|
||||
const showLicense = async () => {
|
||||
const { appPath } = await window.api.getAppInfo()
|
||||
openMinapp({
|
||||
openSmartMinapp({
|
||||
id: 'cherrystudio-license',
|
||||
name: t('settings.about.license.title'),
|
||||
url: `file://${appPath}/resources/cherry-studio/license.html`,
|
||||
@@ -89,7 +89,7 @@ const AboutSettings: FC = () => {
|
||||
|
||||
const showReleases = async () => {
|
||||
const { appPath } = await window.api.getAppInfo()
|
||||
openMinapp({
|
||||
openSmartMinapp({
|
||||
id: 'cherrystudio-releases',
|
||||
name: t('settings.about.releases.title'),
|
||||
url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`,
|
||||
@@ -273,7 +273,7 @@ const AboutSettings: FC = () => {
|
||||
<IndicatorLight color="green" />
|
||||
</SettingRowTitle>
|
||||
</SettingRow>
|
||||
<UpdateNotesWrapper>
|
||||
<UpdateNotesWrapper className="markdown">
|
||||
<Markdown>
|
||||
{typeof update.info.releaseNotes === 'string'
|
||||
? update.info.releaseNotes.replace(/\n/g, '\n\n')
|
||||
@@ -309,7 +309,7 @@ const AboutSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
<Github size={18} />
|
||||
<GithubOutlined size={18} />
|
||||
{t('settings.about.feedback.title')}
|
||||
</SettingRowTitle>
|
||||
<Button onClick={() => onOpenWebsite('https://github.com/CherryHQ/cherry-studio/issues/new/choose')}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
@@ -16,7 +17,7 @@ const JoplinSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
const { openSmartMinapp } = useMinappPopup()
|
||||
|
||||
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
|
||||
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
|
||||
@@ -66,10 +67,11 @@ const JoplinSettings: FC = () => {
|
||||
}
|
||||
|
||||
const handleJoplinHelpClick = () => {
|
||||
openMinapp({
|
||||
openSmartMinapp({
|
||||
id: 'joplin-help',
|
||||
name: 'Joplin Help',
|
||||
url: 'https://joplinapp.org/help/apps/clipper'
|
||||
url: 'https://joplinapp.org/help/apps/clipper',
|
||||
logo: AppLogo
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { Client } from '@notionhq/client'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
@@ -21,7 +22,7 @@ const NotionSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
const { openSmartMinapp } = useMinappPopup()
|
||||
|
||||
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
|
||||
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
|
||||
@@ -67,10 +68,11 @@ const NotionSettings: FC = () => {
|
||||
}
|
||||
|
||||
const handleNotionTitleClick = () => {
|
||||
openMinapp({
|
||||
openSmartMinapp({
|
||||
id: 'notion-help',
|
||||
name: 'Notion Help',
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/notion'
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/notion',
|
||||
logo: AppLogo
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout'
|
||||
import { S3BackupManager } from '@renderer/components/S3BackupManager'
|
||||
import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -47,7 +48,7 @@ const S3Settings: FC = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
const { openSmartMinapp } = useMinappPopup()
|
||||
|
||||
const { s3Sync } = useAppSelector((state) => state.backup)
|
||||
|
||||
@@ -62,10 +63,11 @@ const S3Settings: FC = () => {
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
openMinapp({
|
||||
openSmartMinapp({
|
||||
id: 's3-help',
|
||||
name: 'S3 Compatible Storage Help',
|
||||
url: 'https://docs.cherry-ai.com/data-settings/s3-compatible'
|
||||
url: 'https://docs.cherry-ai.com/data-settings/s3-compatible',
|
||||
logo: AppLogo
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
@@ -16,7 +17,7 @@ import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle
|
||||
const logger = loggerService.withContext('SiyuanSettings')
|
||||
|
||||
const SiyuanSettings: FC = () => {
|
||||
const { openMinapp } = useMinappPopup()
|
||||
const { openSmartMinapp } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -43,10 +44,11 @@ const SiyuanSettings: FC = () => {
|
||||
}
|
||||
|
||||
const handleSiyuanHelpClick = () => {
|
||||
openMinapp({
|
||||
openSmartMinapp({
|
||||
id: 'siyuan-help',
|
||||
name: 'Siyuan Help',
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan'
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan',
|
||||
logo: AppLogo
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user