Compare commits

..

10 Commits

155 changed files with 1449 additions and 7068 deletions

View File

@@ -26,7 +26,7 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
- name: 📦 Setting Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -37,7 +37,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -12,7 +12,7 @@ jobs:
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Delete merged branch
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
github.rest.git.deleteRef({

View File

@@ -56,7 +56,7 @@ jobs:
ref: main
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20

View File

@@ -47,7 +47,7 @@ jobs:
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20

View File

@@ -1,8 +1,8 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -452,7 +452,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {

View File

@@ -11,6 +11,13 @@ electronLanguages:
- en # for macOS
directories:
buildResources: build
extraResources:
- from: 'src/renderer/src/assets/styles'
to: 'app.asar.unpacked/resources/styles'
filter:
- 'font.css'
- 'color.css'
- 'richtext.css'
protocols:
- name: Cherry Studio
@@ -125,39 +132,7 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.6.5
Features:
- Add Claude Haiku 4.5 model support
- Add Mistral provider configuration
- Add Intel OpenVINO (NPU) OCR provider
- Add notes full-text search with highlighting
- Add built-in DiDi MCP server (China only)
- Support NewAPI as generic provider
Bug Fixes:
- Fix webview search (Cmd/Ctrl+F) functionality
- Fix API key rotation for each request
- Fix translate auto copy functionality
- Fix message layout and overflow display
<!--LANG:zh-CN-->
v1.6.5 版本更新
新增功能:
- 新增 Claude Haiku 4.5 模型支持
- 新增 Mistral 提供商配置
- 新增 Intel OpenVINO (NPU) OCR 提取功能
- 新增笔记全文搜索和高亮显示
- 新增内置滴滴 MCP 服务器(仅限中国)
- 支持 NewAPI 作为通用提供商
问题修复:
- 修复 webview 搜索(Cmd/Ctrl+F)功能
- 修复 API 密钥轮换机制
- 修复翻译自动复制功能
- 修复消息布局和溢出显示
<!--LANG:END-->
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

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.6.5",
"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.29",
"@ai-sdk/google-vertex": "^3.0.33",
"@ai-sdk/mistral": "^2.0.17",
"@ai-sdk/perplexity": "^2.0.11",
"@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",
"@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.59",
"ai": "^5.0.44",
"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",
@@ -238,7 +238,7 @@
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"electron": "37.6.0",
"electron": "37.4.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-store": "^8.2.0",
@@ -325,7 +325,6 @@
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swr": "^2.3.6",
"tailwindcss": "^4.1.13",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
@@ -370,7 +369,7 @@
"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",
"@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.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": {

View File

@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.1",
"version": "1.0.0-alpha.18",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -36,14 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@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/anthropic": "^2.0.17",
"@ai-sdk/azure": "^2.0.30",
"@ai-sdk/deepseek": "^1.0.17",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.10",
"@ai-sdk/xai": "^2.0.23",
"@ai-sdk/provider-utils": "^3.0.9",
"@ai-sdk/xai": "^2.0.18",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@@ -5,8 +5,8 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
@@ -34,7 +34,6 @@ 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',
@@ -53,7 +52,6 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
// Open
Open_Path = 'open:path',
@@ -193,6 +191,7 @@ export enum IpcChannel {
FileService_Retrieve = 'file-service:retrieve',
Export_Word = 'export:word',
Export_PDF = 'export:pdf',
Shortcuts_Update = 'shortcuts:update',
@@ -222,7 +221,6 @@ export enum IpcChannel {
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
System_GetCpuName = 'system:getCpuName',
// DevTools
System_ToggleDevTools = 'system:toggleDevTools',
@@ -230,6 +228,7 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -331,16 +330,6 @@ export enum IpcChannel {
// OCR
OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_AddModel = 'ovms:add-model',
Ovms_StopAddModel = 'ovms:stop-addmodel',
Ovms_GetModels = 'ovms:get-models',
Ovms_IsRunning = 'ovms:is-running',
Ovms_GetStatus = 'ovms:get-status',
Ovms_RunOVMS = 'ovms:run-ovms',
Ovms_StopOVMS = 'ovms:stop-ovms',
// CherryAI
Cherryai_GetSignature = 'cherryai:get-signature'

View File

@@ -217,8 +217,7 @@ export enum codeTools {
claudeCode = 'claude-code',
geminiCli = 'gemini-cli',
openaiCodex = 'openai-codex',
iFlowCli = 'iflow-cli',
githubCopilotCli = 'github-copilot-cli'
iFlowCli = 'iflow-cli'
}
export enum terminalApps {

View File

@@ -22,12 +22,3 @@ export type MCPProgressEvent = {
callId: string
progress: number // 0-1 range
}
export type WebviewKeyEvent = {
webviewId: number
key: string
control: boolean
meta: boolean
shift: boolean
alt: boolean
}

View File

@@ -1,7 +1,5 @@
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
@@ -34,39 +32,4 @@ async function downloadWithRedirects(url, destinationPath) {
})
}
/**
* 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 }
module.exports = { downloadWithRedirects }

View File

@@ -1,263 +0,0 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_RELEASE_BASE_URL =
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip'
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
/**
* error code:
* 101: Unsupported CPU (not Intel Ultra)
* 102: Unsupported platform (not Windows)
* 103: Download failed
* 104: Installation failed
* 105: Failed to create ovdnd.exe
* 106: Failed to create run.bat
* 110: Cleanup of old installation failed
*/
/**
* Clean old OVMS installation if it exists
*/
function cleanOldOvmsInstallation() {
console.log('Cleaning the existing OVMS installation...')
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
if (fs.existsSync(csOvmsDir)) {
try {
fs.rmSync(csOvmsDir, { recursive: true })
} catch (error) {
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
return 110
}
}
return 0
}
/**
* Install OVMS Base package
*/
async function installOvmsBase() {
// Download the base package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
try {
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`)
} catch (error) {
console.error(`Download OVMS Base failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
// unzip the base package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
fs.mkdirSync(csOvmsDir, { recursive: true })
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS: ${error.message}`)
fs.unlinkSync(tempFilename)
return 104
}
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
// copy ovms.exe to ovdnd.exe
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
console.log('Copied ovms.exe to ovdnd.exe')
} catch (error) {
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
return 105
}
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
// del %USERPROFILE%\.cherrystudio\ovms_log.log
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
fs.appendFileSync(runBatPath, '\r\n')
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
fs.appendFileSync(
runBatPath,
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
)
console.log(`Created run.bat at: ${runBatPath}`)
} catch (error) {
console.error(`Error creating run.bat: ${error.message}`)
return 106
}
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
console.log(`Created config file: ${configJsonPath}`)
return 0
}
/**
* Install OVMS Extra package
*/
async function installOvmsExtra() {
// Download the extra package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
try {
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
} catch (error) {
console.error(`Download OVMS Extra failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
// unzip the extra package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS Extra: ${error.message}`)
fs.unlinkSync(tempFilename)
return 104
}
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
try {
const files = fs.readdirSync(patchDir)
files.forEach((file) => {
const srcPath = path.join(patchDir, file)
const destPath = path.join(csOvmsBinDir, file)
fs.copyFileSync(srcPath, destPath)
console.log(`Applied patch file: ${file}`)
})
} catch (error) {
console.error(`Error applying OVMS patch: ${error.message}`)
}
return 0
}
/**
* Get the CPU Name and ID
*/
function getCpuInfo() {
const cpuInfo = {
name: '',
id: ''
}
// Use PowerShell to get CPU information
try {
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
const psOutput = execSync(psCommand).toString()
const cpuData = JSON.parse(psOutput)
if (Array.isArray(cpuData)) {
cpuInfo.name = cpuData[0].Name || ''
cpuInfo.id = cpuData[0].DeviceID || ''
} else {
cpuInfo.name = cpuData.Name || ''
cpuInfo.id = cpuData.DeviceID || ''
}
} catch (error) {
console.error(`Failed to get CPU info: ${error.message}`)
}
return cpuInfo
}
/**
* Main function to install OVMS
*/
async function installOvms() {
const platform = os.platform()
console.log(`Detected platform: ${platform}`)
const cpuName = getCpuInfo().name
console.log(`CPU Name: ${cpuName}`)
// Check if CPU name contains "Ultra"
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
return 101
}
// only support windows
if (platform !== 'win32') {
console.error('OVMS installation is only supported on Windows.')
return 102
}
// Clean old installation if it exists
const cleanupCode = cleanOldOvmsInstallation()
if (cleanupCode !== 0) {
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
return cleanupCode
}
const installBaseCode = await installOvmsBase()
if (installBaseCode !== 0) {
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
cleanOldOvmsInstallation()
return installBaseCode
}
const installExtraCode = await installOvmsExtra()
if (installExtraCode !== 0) {
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
return installExtraCode
}
return 0
}
// Run the installation
installOvms()
.then((retcode) => {
if (retcode === 0) {
console.log('OVMS installation successful')
} else {
console.error('OVMS installation failed')
}
process.exit(retcode)
})
.catch((error) => {
console.error('OVMS installation failed:', error)
process.exit(100)
})

View File

@@ -3,3 +3,32 @@ export const isWin = process.platform === 'win32'
export const isLinux = process.platform === 'linux'
export const isDev = process.env.NODE_ENV === 'development'
export const isPortable = isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
export const PRINT_HTML_TEMPLATE = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{filename}}</title>
<style>
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
/* Color variables */
{{colorCss}}
/* Font variables */
{{fontCss}}
/* Richtext styles */
{{richtextCss}}
</style>
</head>
<body theme-mode="light" os=${isMac ? 'mac' : isWin ? 'windows' : 'linux'}>
<div id="root">
<div class="tiptap">{{content}}</div>
</div>
</body>
</html>
`

View File

@@ -28,7 +28,6 @@ import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import process from 'node:process'
import { apiServerService } from './services/ApiServerService'
import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
@@ -107,7 +106,6 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')

View File

@@ -35,7 +35,6 @@ 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'
@@ -82,7 +81,6 @@ 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()
@@ -132,7 +130,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -434,7 +432,6 @@ 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()
@@ -527,6 +524,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// export
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
// PDF export
ipcMain.handle(IpcChannel.Export_PDF, exportService.exportToPDF.bind(exportService))
// open path
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
await shell.openPath(path)
@@ -713,7 +713,6 @@ 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))
@@ -754,6 +753,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
@@ -843,18 +843,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider)
)
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
ovmsManager.addModel(modelName, modelId, modelSource, task)
)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))

View File

@@ -1,473 +0,0 @@
/**
* DiDi MCP Server Implementation
*
* Based on official DiDi MCP API capabilities.
* API Documentation: https://mcp.didichuxing.com/api?tap=api
*
* Provides ride-hailing services including map search, price estimation,
* order management, and driver tracking.
*
* Note: Only available in Mainland China.
*/
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const logger = loggerService.withContext('DiDiMCPServer')
export class DiDiMcpServer {
private _server: Server
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
private apiKey: string
constructor(apiKey?: string) {
this._server = new Server(
{
name: 'didi-mcp-server',
version: '0.1.0'
},
{
capabilities: {
tools: {}
}
}
)
// Get API key from parameter or environment variables
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
if (!this.apiKey) {
logger.warn('DIDI_API_KEY environment variable is not set')
}
this.setupRequestHandlers()
}
get server(): Server {
return this._server
}
private setupRequestHandlers() {
// List available tools
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'maps_textsearch',
description: 'Search for POI locations based on keywords and city',
inputSchema: {
type: 'object',
properties: {
city: {
type: 'string',
description: 'Query city'
},
keywords: {
type: 'string',
description: 'Search keywords'
},
location: {
type: 'string',
description: 'Location coordinates, format: longitude,latitude'
}
},
required: ['keywords', 'city']
}
},
{
name: 'taxi_cancel_order',
description: 'Cancel a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation or query results'
},
reason: {
type: 'string',
description:
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
}
},
required: ['order_id']
}
},
{
name: 'taxi_create_order',
description: 'Create taxi order directly via API without opening any app interface',
inputSchema: {
type: 'object',
properties: {
caller_car_phone: {
type: 'string',
description: 'Caller phone number (optional)'
},
estimate_trace_id: {
type: 'string',
description: 'Estimation trace ID from estimation results'
},
product_category: {
type: 'string',
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
}
},
required: ['product_category', 'estimate_trace_id']
}
},
{
name: 'taxi_estimate',
description: 'Get available ride-hailing vehicle types and fare estimates',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
from_name: {
type: 'string',
description: 'Departure location name'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
},
to_name: {
type: 'string',
description: 'Destination name'
}
},
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
}
},
{
name: 'taxi_generate_ride_app_link',
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
product_category: {
type: 'string',
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
}
},
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
}
},
{
name: 'taxi_get_driver_location',
description: 'Get real-time driver location for a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Taxi order ID'
}
},
required: ['order_id']
}
},
{
name: 'taxi_query_order',
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
}
}
}
}
]
}
})
// Handle tool calls
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'maps_textsearch':
return await this.handleMapsTextSearch(args)
case 'taxi_cancel_order':
return await this.handleTaxiCancelOrder(args)
case 'taxi_create_order':
return await this.handleTaxiCreateOrder(args)
case 'taxi_estimate':
return await this.handleTaxiEstimate(args)
case 'taxi_generate_ride_app_link':
return await this.handleTaxiGenerateRideAppLink(args)
case 'taxi_get_driver_location':
return await this.handleTaxiGetDriverLocation(args)
case 'taxi_query_order':
return await this.handleTaxiQueryOrder(args)
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
logger.error(`Error calling tool ${name}:`, error as Error)
throw error
}
})
}
private async handleMapsTextSearch(args: any) {
const { city, keywords, location } = args
const params = {
name: 'maps_textsearch',
arguments: {
keywords,
city,
...(location && { location })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Maps text search error:', error as Error)
throw error
}
}
private async handleTaxiCancelOrder(args: any) {
const { order_id, reason } = args
const params = {
name: 'taxi_cancel_order',
arguments: {
order_id,
...(reason && { reason })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi cancel order error:', error as Error)
throw error
}
}
private async handleTaxiCreateOrder(args: any) {
const { caller_car_phone, estimate_trace_id, product_category } = args
const params = {
name: 'taxi_create_order',
arguments: {
product_category,
estimate_trace_id,
...(caller_car_phone && { caller_car_phone })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi create order error:', error as Error)
throw error
}
}
private async handleTaxiEstimate(args: any) {
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
const params = {
name: 'taxi_estimate',
arguments: {
from_lng,
from_lat,
from_name,
to_lng,
to_lat,
to_name
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi estimate error:', error as Error)
throw error
}
}
private async handleTaxiGenerateRideAppLink(args: any) {
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
const params = {
name: 'taxi_generate_ride_app_link',
arguments: {
from_lng,
from_lat,
to_lng,
to_lat,
...(product_category && { product_category })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi generate ride app link error:', error as Error)
throw error
}
}
private async handleTaxiGetDriverLocation(args: any) {
const { order_id } = args
const params = {
name: 'taxi_get_driver_location',
arguments: {
order_id
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi get driver location error:', error as Error)
throw error
}
}
private async handleTaxiQueryOrder(args: any) {
const { order_id } = args
const params = {
name: 'taxi_query_order',
arguments: {
...(order_id && { order_id })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi query order error:', error as Error)
throw error
}
}
private async makeRequest(method: string, params: any): Promise<any> {
const requestData = {
jsonrpc: '2.0',
method: method,
id: Date.now(),
...(Object.keys(params).length > 0 && { params })
}
// API key is passed as URL parameter
const url = `${this.baseUrl}?key=${this.apiKey}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
}
return data.result
}
}
export default DiDiMcpServer

View File

@@ -3,7 +3,6 @@ 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'
@@ -43,10 +42,6 @@ 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}`)
}

View File

@@ -1,15 +1,17 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, net } from 'electron'
import { app, BrowserWindow, dialog, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
@@ -24,6 +26,7 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -63,6 +66,7 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
@@ -243,9 +247,37 @@ export default class AppUpdater {
}
}
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
}
/**
@@ -317,9 +349,38 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@@ -31,10 +31,7 @@ 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
@@ -85,8 +82,6 @@ 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}`)
}
@@ -104,8 +99,6 @@ class CodeToolsService {
return 'qwen'
case codeTools.iFlowCli:
return 'iflow'
case codeTools.githubCopilotCli:
return 'copilot'
default:
throw new Error(`Unsupported CLI tool: ${cliTool}`)
}
@@ -151,9 +144,7 @@ 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 {
@@ -393,9 +384,7 @@ 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]
@@ -436,10 +425,7 @@ 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)

View File

@@ -1,7 +1,12 @@
/* oxlint-disable no-case-declarations */
// ExportService
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
import { PRINT_HTML_TEMPLATE } from '@main/constant'
import {
AlignmentType,
BorderStyle,
@@ -18,10 +23,11 @@ import {
VerticalAlign,
WidthType
} from 'docx'
import { dialog } from 'electron'
import { app, BrowserWindow, dialog } from 'electron'
import MarkdownIt from 'markdown-it'
import { fileStorage } from './FileStorage'
import { windowService } from './WindowService'
const logger = loggerService.withContext('ExportService')
export class ExportService {
@@ -405,4 +411,116 @@ export class ExportService {
throw error
}
}
public exportToPDF = async (_: Electron.IpcMainInvokeEvent, content: string, filename: string): Promise<any> => {
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
throw new Error('Main window not set')
}
try {
const loadCssFile = async (filename: string): Promise<string> => {
try {
let cssPath: string
if (app.isPackaged) {
cssPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'styles', filename)
} else {
cssPath = path.join(app.getAppPath(), 'src', 'renderer', 'src', 'assets', 'styles', filename)
}
return await fs.promises.readFile(cssPath, 'utf-8')
} catch (error) {
logger.warn(`Could not load ${filename}, using fallback:`, error as Error)
return ''
}
}
const colorCss = await loadCssFile('color.css')
const fontCss = await loadCssFile('font.css')
const richtextCss = await loadCssFile('richtext.css')
// PDF专用样式解决代码块溢出问题
const pdfSpecificCss = `
@media print {
.tiptap pre {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
.tiptap pre code {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.tiptap {
max-width: 100% !important;
overflow: hidden !important;
}
}
`
const tempHtmlPath = path.join(os.tmpdir(), `temp_${Date.now()}.html`)
await fs.promises.writeFile(
tempHtmlPath,
PRINT_HTML_TEMPLATE.replace('{{filename}}', filename.replace('.pdf', ''))
.replace('{{colorCss}}', colorCss)
.replace('{{richtextCss}}', richtextCss + pdfSpecificCss)
.replace('{{fontCss}}', fontCss)
.replace('{{content}}', content)
)
const printWindow = new BrowserWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true
}
})
await printWindow.loadFile(tempHtmlPath)
// Show save dialog for PDF
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: filename,
filters: [{ name: 'PDF Files', extensions: ['pdf'] }]
})
if (result.canceled || !result.filePath) {
printWindow.close()
await fs.promises.unlink(tempHtmlPath)
return { success: false, message: 'Export cancelled' }
}
// Generate PDF using printToPDF for vector output
const pdfData = await printWindow.webContents.printToPDF({
margins: {
top: 0.5,
bottom: 0.5,
left: 0.5,
right: 0.5
},
pageSize: 'A4',
printBackground: true,
scale: 1.0,
preferCSSPageSize: false,
landscape: false
})
await fs.promises.writeFile(result.filePath, pdfData)
// Clean up
printWindow.close()
await fs.promises.unlink(tempHtmlPath)
return { success: true, filePath: result.filePath }
} catch (error: any) {
logger.error('Failed to export PDF:', error)
return { success: false, error: error.message }
}
}
}

View File

@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
import { getAllFiles } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
@@ -147,16 +147,11 @@ class KnowledgeService {
}
}
private getDbPath = (id: string): string => {
// 消除网络搜索requestI d中的特殊字符
return path.join(this.storageDir, sanitizeFilename(id, '_'))
}
/**
* Delete knowledge base file
*/
private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = this.getDbPath(id)
const dbPath = path.join(this.storageDir, id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -249,8 +244,7 @@ class KnowledgeService {
dimensions
})
try {
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)

View File

@@ -1,586 +0,0 @@
import { exec } from 'node:child_process'
import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { loggerService } from '@logger'
import * as fs from 'fs-extra'
import * as path from 'path'
const logger = loggerService.withContext('OvmsManager')
const execAsync = promisify(exec)
interface OvmsProcess {
pid: number
path: string
workingDirectory: string
}
interface ModelConfig {
name: string
base_path: string
}
interface OvmsConfig {
mediapipe_config_list: ModelConfig[]
}
class OvmsManager {
private ovms: OvmsProcess | null = null
/**
* Recursively terminate a process and all its child processes
* @param pid Process ID to terminate
* @returns Promise<{ success: boolean; message?: string }>
*/
private async terminalProcess(pid: number): Promise<{ success: boolean; message?: string }> {
try {
// Check if the process is running
const processCheckCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
const { stdout: processStdout } = await execAsync(`powershell -Command "${processCheckCommand}"`)
if (!processStdout.trim()) {
logger.info(`Process with PID ${pid} is not running`)
return { success: true, message: `Process with PID ${pid} is not running` }
}
// Find child processes
const childProcessCommand = `Get-WmiObject -Class Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | Select-Object ProcessId | ConvertTo-Json`
const { stdout: childStdout } = await execAsync(`powershell -Command "${childProcessCommand}"`)
// If there are child processes, terminate them first
if (childStdout.trim()) {
const childProcesses = JSON.parse(childStdout)
const childList = Array.isArray(childProcesses) ? childProcesses : [childProcesses]
logger.info(`Found ${childList.length} child processes for PID ${pid}`)
// Recursively terminate each child process
for (const childProcess of childList) {
const childPid = childProcess.ProcessId
logger.info(`Terminating child process PID: ${childPid}`)
await this.terminalProcess(childPid)
}
} else {
logger.info(`No child processes found for PID ${pid}`)
}
// Finally, terminate the parent process
const killCommand = `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`
await execAsync(`powershell -Command "${killCommand}"`)
logger.info(`Terminated process with PID: ${pid}`)
// Wait for the process to disappear with 5-second timeout
const timeout = 5000 // 5 seconds
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
const checkCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
const { stdout: checkStdout } = await execAsync(`powershell -Command "${checkCommand}"`)
if (!checkStdout.trim()) {
logger.info(`Process with PID ${pid} has disappeared`)
return { success: true, message: `Process ${pid} and all child processes terminated successfully` }
}
// Wait 300ms before checking again
await new Promise((resolve) => setTimeout(resolve, 300))
}
logger.warn(`Process with PID ${pid} did not disappear within timeout`)
return { success: false, message: `Process ${pid} did not disappear within 5 seconds` }
} catch (error) {
logger.error(`Failed to terminate process ${pid}:`, error as Error)
return { success: false, message: `Failed to terminate process ${pid}` }
}
}
/**
* Stop OVMS process if it's running
* @returns Promise<{ success: boolean; message?: string }>
*/
public async stopOvms(): Promise<{ success: boolean; message?: string }> {
try {
// Check if OVMS process is running
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('OVMS process is not running')
return { success: true, message: 'OVMS process is not running' }
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length === 0) {
logger.info('OVMS process is not running')
return { success: true, message: 'OVMS process is not running' }
}
// Terminate all OVMS processes using terminalProcess
for (const process of processList) {
const result = await this.terminalProcess(process.Id)
if (!result.success) {
logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`)
return { success: false, message: `Failed to terminate OVMS process: ${result.message}` }
}
logger.info(`Terminated OVMS process with PID: ${process.Id}`)
}
// Reset the ovms instance
this.ovms = null
logger.info('OVMS process stopped successfully')
return { success: true, message: 'OVMS process stopped successfully' }
} catch (error) {
logger.error(`Failed to stop OVMS process: ${error}`)
return { success: false, message: 'Failed to stop OVMS process' }
}
}
/**
* Run OVMS by ensuring config.json exists and executing run.bat
* @returns Promise<{ success: boolean; message?: string }>
*/
public async runOvms(): Promise<{ success: boolean; message?: string }> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
const runBatPath = path.join(ovmsDir, 'run.bat')
try {
// Check if config.json exists, if not create it with default content
if (!(await fs.pathExists(configPath))) {
logger.info(`Config file does not exist, creating: ${configPath}`)
// Ensure the models directory exists
await fs.ensureDir(path.dirname(configPath))
// Create config.json with default content
const defaultConfig = {
mediapipe_config_list: [],
model_config_list: []
}
await fs.writeJson(configPath, defaultConfig, { spaces: 2 })
logger.info(`Config file created: ${configPath}`)
}
// Check if run.bat exists
if (!(await fs.pathExists(runBatPath))) {
logger.error(`run.bat not found at: ${runBatPath}`)
return { success: false, message: 'run.bat not found' }
}
// Run run.bat without waiting for it to complete
logger.info(`Starting OVMS with run.bat: ${runBatPath}`)
exec(`"${runBatPath}"`, { cwd: ovmsDir }, (error) => {
if (error) {
logger.error(`Error running run.bat: ${error}`)
}
})
logger.info('OVMS started successfully')
return { success: true }
} catch (error) {
logger.error(`Failed to run OVMS: ${error}`)
return { success: false, message: 'Failed to run OVMS' }
}
}
/**
* Get OVMS status - checks installation and running status
* @returns 'not-installed' | 'not-running' | 'running'
*/
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
const homeDir = homedir()
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
try {
// Check if OVMS executable exists
if (!(await fs.pathExists(ovmsPath))) {
logger.info(`OVMS executable not found at: ${ovmsPath}`)
return 'not-installed'
}
// Check if OVMS process is running
//const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq "${ovmsPath.replace(/\\/g, '\\\\')}" } | Select-Object Id | ConvertTo-Json`;
//const { stdout } = await execAsync(`powershell -Command "${psCommand}"`);
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('OVMS process not running')
return 'not-running'
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length > 0) {
logger.info('OVMS process is running')
return 'running'
} else {
logger.info('OVMS process not running')
return 'not-running'
}
} catch (error) {
logger.info(`Failed to check OVMS status: ${error}`)
return 'not-running'
}
}
/**
* Initialize OVMS by finding the executable path and working directory
*/
public async initializeOvms(): Promise<boolean> {
// Use PowerShell to find ovms.exe processes with their paths
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.error('Command to find OVMS process returned no output')
return false
}
logger.debug(`OVMS process output: ${stdout}`)
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
// Find the first process with a valid path
for (const process of processList) {
this.ovms = {
pid: process.Id,
path: process.Path,
workingDirectory: path.dirname(process.Path)
}
return true
}
return this.ovms !== null
}
/**
* Check if the Model Name and ID are valid, they are valid only if they are not used in the config.json
* @param modelName Name of the model to check
* @param modelId ID of the model to check
*/
public async isNameAndIDAvalid(modelName: string, modelId: string): Promise<boolean> {
if (!modelName || !modelId) {
logger.error('Model name and ID cannot be empty')
return false
}
const homeDir = homedir()
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return false
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn(`No mediapipe_config_list found in config: ${configPath}`)
return false
}
// Check if the model name or ID already exists in the config
const exists = config.mediapipe_config_list.some(
(model) => model.name === modelName || model.base_path === modelId
)
if (exists) {
logger.warn(`Model with name "${modelName}" or ID "${modelId}" already exists in the config`)
return false
}
} catch (error) {
logger.error(`Failed to check model existence: ${error}`)
return false
}
return true
}
private async applyModelPath(modelDirPath: string): Promise<boolean> {
const homeDir = homedir()
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
if (!(await fs.pathExists(patchDir))) {
return true
}
const modelId = path.basename(modelDirPath)
// get all sub directories in patchDir
const patchs = await fs.readdir(patchDir)
for (const patch of patchs) {
const fullPatchPath = path.join(patchDir, patch)
if (fs.lstatSync(fullPatchPath).isDirectory()) {
if (modelId.toLowerCase().includes(patch.toLowerCase())) {
// copy all files from fullPath to modelDirPath
try {
const files = await fs.readdir(fullPatchPath)
for (const file of files) {
const srcFile = path.join(fullPatchPath, file)
const destFile = path.join(modelDirPath, file)
await fs.copyFile(srcFile, destFile)
}
} catch (error) {
logger.error(`Failed to copy files from ${fullPatchPath} to ${modelDirPath}: ${error}`)
return false
}
logger.info(`Applied patchs for model ${modelId}`)
return true
}
}
}
return true
}
/**
* Add a model to OVMS by downloading it
* @param modelName Name of the model to add
* @param modelId ID of the model to download
* @param modelSource Model Source: huggingface, hf-mirror and modelscope, default is huggingface
* @param task Task type: text_generation, embedding, rerank, image_generation
*/
public async addModel(
modelName: string,
modelId: string,
modelSource: string,
task: string = 'text_generation'
): Promise<{ success: boolean; message?: string }> {
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
const homeDir = homedir()
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const pathModel = path.join(ovdndDir, 'models', modelId)
try {
// check the ovdnDir+'models'+modelId exist or not
if (await fs.pathExists(pathModel)) {
logger.error(`Model with ID ${modelId} already exists`)
return { success: false, message: 'Model ID already exists!' }
}
// remove the model directory if it exists
if (await fs.pathExists(pathModel)) {
logger.info(`Removing existing model directory: ${pathModel}`)
await fs.remove(pathModel)
}
// Use ovdnd.exe for downloading instead of ovms.exe
const ovdndPath = path.join(ovdndDir, 'ovdnd.exe')
const command =
`"${ovdndPath}" --pull ` +
`--model_repository_path "${ovdndDir}/models" ` +
`--source_model "${modelId}" ` +
`--model_name "${modelName}" ` +
`--target_device GPU ` +
`--task ${task} ` +
`--overwrite_models`
const env: Record<string, string | undefined> = {
...process.env,
OVMS_DIR: ovdndDir,
PYTHONHOME: path.join(ovdndDir, 'python'),
PATH: `${process.env.PATH};${ovdndDir};${path.join(ovdndDir, 'python')}`
}
if (modelSource) {
env.HF_ENDPOINT = modelSource
}
logger.info(`Running command: ${command} from ${modelSource}`)
const { stdout } = await execAsync(command, { env: env, cwd: ovdndDir })
logger.info('Model download completed')
logger.debug(`Command output: ${stdout}`)
} catch (error) {
// remove ovdnDir+'models'+modelId if it exists
if (await fs.pathExists(pathModel)) {
logger.info(`Removing failed model directory: ${pathModel}`)
await fs.remove(pathModel)
}
logger.error(`Failed to add model: ${error}`)
return {
success: false,
message: `Download model ${modelId} failed, please check following items and try it again:<p>- the model id</p><p>- network connection and proxy</p>`
}
}
// Update config file
if (!(await this.updateModelConfig(modelName, modelId))) {
logger.error('Failed to update model config')
return { success: false, message: 'Failed to update model config' }
}
if (!(await this.applyModelPath(pathModel))) {
logger.error('Failed to apply model patchs')
return { success: false, message: 'Failed to apply model patchs' }
}
logger.info(`Model ${modelName} added successfully with ID ${modelId}`)
return { success: true }
}
/**
* Stop the model download process if it's running
* @returns Promise<{ success: boolean; message?: string }>
*/
public async stopAddModel(): Promise<{ success: boolean; message?: string }> {
try {
// Check if ovdnd.exe process is running
const psCommand = `Get-Process -Name "ovdnd" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
if (!stdout.trim()) {
logger.info('ovdnd process is not running')
return { success: true, message: 'Model download process is not running' }
}
const processes = JSON.parse(stdout)
const processList = Array.isArray(processes) ? processes : [processes]
if (processList.length === 0) {
logger.info('ovdnd process is not running')
return { success: true, message: 'Model download process is not running' }
}
// Terminate all ovdnd processes
for (const process of processList) {
this.terminalProcess(process.Id)
}
logger.info('Model download process stopped successfully')
return { success: true, message: 'Model download process stopped successfully' }
} catch (error) {
logger.error(`Failed to stop model download process: ${error}`)
return { success: false, message: 'Failed to stop model download process' }
}
}
/**
* check if the model id exists in the OVMS configuration
* @param modelId ID of the model to check
*/
public async checkModelExists(modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return false
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn('No mediapipe_config_list found in config')
return false
}
return config.mediapipe_config_list.some((model) => model.base_path === modelId)
} catch (error) {
logger.error(`Failed to check model existence: ${error}`)
return false
}
}
/**
* Update the model configuration file
*/
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
// Ensure the models directory exists
await fs.ensureDir(path.dirname(configPath))
let config: OvmsConfig
// Read existing config or create new one
if (await fs.pathExists(configPath)) {
config = await fs.readJson(configPath)
} else {
config = { mediapipe_config_list: [] }
}
// Ensure mediapipe_config_list exists
if (!config.mediapipe_config_list) {
config.mediapipe_config_list = []
}
// Add new model config
const newModelConfig: ModelConfig = {
name: modelName,
base_path: modelId
}
// Check if model already exists, if so, update it
const existingIndex = config.mediapipe_config_list.findIndex((model) => model.base_path === modelId)
if (existingIndex >= 0) {
config.mediapipe_config_list[existingIndex] = newModelConfig
logger.info(`Updated existing model config: ${modelName}`)
} else {
config.mediapipe_config_list.push(newModelConfig)
logger.info(`Added new model config: ${modelName}`)
}
// Write config back to file
await fs.writeJson(configPath, config, { spaces: 2 })
logger.info(`Config file updated: ${configPath}`)
} catch (error) {
logger.error(`Failed to update model config: ${error}`)
return false
}
return true
}
/**
* Get all models from OVMS config, filtered for image generation models
* @returns Array of model configurations
*/
public async getModels(): Promise<ModelConfig[]> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
return []
}
const config: OvmsConfig = await fs.readJson(configPath)
if (!config.mediapipe_config_list) {
logger.warn('No mediapipe_config_list found in config')
return []
}
// Filter models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
const imageGenerationModels = config.mediapipe_config_list.filter((model) => {
const modelName = model.name.toLowerCase()
return (
modelName.startsWith('sd') ||
modelName.startsWith('stable-diffusion') ||
modelName.startsWith('stable diffusion') ||
modelName.startsWith('flux')
)
})
logger.info(`Found ${imageGenerationModels.length} image generation models`)
return imageGenerationModels
} catch (error) {
logger.error(`Failed to get models: ${error}`)
return []
}
}
}
export default OvmsManager

View File

@@ -1,5 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
import { session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
@@ -37,66 +36,3 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
}
})
}
const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (contents.getType?.() !== 'webview') {
return
}
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
if (!input) {
return
}
const key = input.key?.toLowerCase()
if (!key) {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
if (!isFindShortcut && !isEscape && !isEnter) {
return
}
const host = contents.hostWebContents
if (!host || host.isDestroyed()) {
return
}
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
if (isFindShortcut) {
event.preventDefault()
}
// Send the hotkey event to the renderer
// The renderer will decide whether to preventDefault for Escape and Enter
// based on whether the search bar is visible
host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id,
key,
control: Boolean(input.control),
meta: Boolean(input.meta),
shift: Boolean(input.shift),
alt: Boolean(input.alt)
})
}
contents.on('before-input-event', handleBeforeInput)
contents.once('destroyed', () => {
contents.removeListener('before-input-event', handleBeforeInput)
})
}
export function initWebviewHotkeys() {
webContents.getAllWebContents().forEach((contents) => {
if (contents.isDestroyed()) return
attachKeyboardHandler(contents)
})
app.on('web-contents-created', (_, contents) => {
attachKeyboardHandler(contents)
})
}

View File

@@ -274,4 +274,46 @@ describe('AppUpdater', () => {
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('')
})
})
})

View File

@@ -2,7 +2,6 @@ import { loggerService } from '@logger'
import { isLinux } from '@main/constant'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { ovOcrService } from './builtin/OvOcrService'
import { ppocrService } from './builtin/PpocrService'
import { systemOcrService } from './builtin/SystemOcrService'
import { tesseractService } from './builtin/TesseractService'
@@ -23,10 +22,6 @@ export class OcrService {
this.registry.delete(providerId)
}
public listProviderIds(): string[] {
return Array.from(this.registry.keys())
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id)
if (!handler) {
@@ -44,5 +39,3 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))

View File

@@ -1,128 +0,0 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
import { exec } from 'child_process'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { promisify } from 'util'
import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('OvOcrService')
const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
export class OvOcrService extends OcrBaseService {
constructor() {
super()
}
public isAvailable(): boolean {
return (
isWin &&
os.cpus()[0].model.toLowerCase().includes('intel') &&
os.cpus()[0].model.toLowerCase().includes('ultra') &&
fs.existsSync(PATH_BAT_FILE)
)
}
private getOvOcrPath(): string {
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
}
private getImgDir(): string {
return path.join(this.getOvOcrPath(), 'img')
}
private getOutputDir(): string {
return path.join(this.getOvOcrPath(), 'output')
}
private async clearDirectory(dirPath: string): Promise<void> {
if (fs.existsSync(dirPath)) {
const files = await fs.promises.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) {
await this.clearDirectory(filePath)
await fs.promises.rmdir(filePath)
} else {
await fs.promises.unlink(filePath)
}
}
} else {
// If the directory does not exist, create it
await fs.promises.mkdir(dirPath, { recursive: true })
}
}
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
const imgDir = this.getImgDir()
const targetFilePath = path.join(imgDir, targetFileName)
await fs.promises.copyFile(sourceFilePath, targetFilePath)
}
private async runOcrBatch(): Promise<void> {
const ovOcrPath = this.getOvOcrPath()
try {
// Execute run.bat in the ov-ocr directory
await execAsync(`"${PATH_BAT_FILE}"`, {
cwd: ovOcrPath,
timeout: 60000 // 60 second timeout
})
} catch (error) {
logger.error(`Error running ovocr batch: ${error}`)
throw new Error(`Failed to run OCR batch: ${error}`)
}
}
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
try {
// 1. Clear img directory and output directory
await this.clearDirectory(this.getImgDir())
await this.clearDirectory(this.getOutputDir())
// 2. Copy file to img directory
const fileName = path.basename(filePath)
await this.copyFileToImgDir(filePath, fileName)
logger.info(`File copied to img directory: ${fileName}`)
// 3. Run run.bat
logger.info('Running OV OCR batch process...')
await this.runOcrBatch()
// 4. Check that output/[basename].txt file exists
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
if (!fs.existsSync(outputFilePath)) {
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
}
// 5. Read output/[basename].txt file content
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
// 6. Return result
return { text: ocrText }
} catch (error) {
logger.error(`Error during OV OCR process: ${error}`)
throw error
}
}
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
if (isImageFileMetadata(file)) {
return this.ocrImage(file.path, options)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const ovOcrService = new OvOcrService()

View File

@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
@@ -51,7 +51,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -95,8 +95,7 @@ const api = {
},
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname),
getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName)
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
},
devTools: {
toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools)
@@ -206,7 +205,8 @@ const api = {
readText: (pathOrUrl: string): Promise<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName),
toPDF: (content: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_PDF, content, fileName)
},
obsidian: {
getVaults: () => ipcRenderer.invoke(IpcChannel.Obsidian_GetVaults),
@@ -221,7 +221,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
add: ({
base,
item,
@@ -286,16 +286,6 @@ 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),
@@ -361,7 +351,6 @@ 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 }) => {
@@ -388,16 +377,7 @@ const api = {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
@@ -474,8 +454,7 @@ const api = {
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
},
cherryai: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>

View File

@@ -6,10 +6,8 @@
import { loggerService } from '@logger'
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage } from '@renderer/utils/error'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
import type { TextStreamPart, ToolSet } from 'ai'
import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -24,8 +22,6 @@ export class AiSdkToChunkAdapter {
private accumulate: boolean | undefined
private isFirstChunk = true
private enableWebSearch: boolean = false
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
constructor(
private onChunk: (chunk: Chunk) => void,
@@ -38,17 +34,6 @@ export class AiSdkToChunkAdapter {
this.enableWebSearch = enableWebSearch || false
}
private markFirstTokenIfNeeded() {
if (this.firstTokenTimestamp === null && this.responseStartTimestamp !== null) {
this.firstTokenTimestamp = Date.now()
}
}
private resetTimingState() {
this.responseStartTimestamp = null
this.firstTokenTimestamp = null
}
/**
* 处理 AI SDK 流结果
* @param aiSdkResult AI SDK 的流结果对象
@@ -76,8 +61,6 @@ export class AiSdkToChunkAdapter {
webSearchResults: [],
reasoningId: ''
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset link converter state at the start of stream
this.isFirstChunk = true
@@ -90,7 +73,6 @@ export class AiSdkToChunkAdapter {
if (this.enableWebSearch) {
const remainingText = flushLinkConverterBuffer()
if (remainingText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: remainingText
@@ -105,7 +87,6 @@ export class AiSdkToChunkAdapter {
}
} finally {
reader.releaseLock()
this.resetTimingState()
}
}
@@ -156,7 +137,6 @@ export class AiSdkToChunkAdapter {
// Only emit chunk if there's text to send
if (finalText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: this.accumulate ? final.text : finalText
@@ -181,9 +161,6 @@ export class AiSdkToChunkAdapter {
break
case 'reasoning-delta':
final.reasoningContent += chunk.text || ''
if (chunk.text) {
this.markFirstTokenIfNeeded()
}
this.onChunk({
type: ChunkType.THINKING_DELTA,
text: final.reasoningContent || ''
@@ -283,37 +260,44 @@ export class AiSdkToChunkAdapter {
break
}
case 'finish': {
const usage = {
completion_tokens: chunk.totalUsage?.outputTokens || 0,
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
total_tokens: chunk.totalUsage?.totalTokens || 0
}
const metrics = this.buildMetrics(chunk.totalUsage)
const baseResponse = {
text: final.text || '',
reasoning_content: final.reasoningContent || ''
}
case 'finish':
this.onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
}
})
this.onChunk({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
}
})
this.resetTimingState()
break
}
// === 源和文件相关事件 ===
case 'source':
@@ -342,48 +326,13 @@ export class AiSdkToChunkAdapter {
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error:
chunk.error instanceof AISDKError
? chunk.error
: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
error: chunk.error as Record<string, any>
})
break
default:
}
}
private buildMetrics(totalUsage?: {
inputTokens?: number | null
outputTokens?: number | null
totalTokens?: number | null
}) {
if (!totalUsage) {
return undefined
}
const completionTokens = totalUsage.outputTokens ?? 0
const now = Date.now()
const start = this.responseStartTimestamp ?? now
const firstToken = this.firstTokenTimestamp
const timeFirstToken = Math.max(firstToken != null ? firstToken - start : 0, 0)
const baseForCompletion = firstToken ?? start
let timeCompletion = Math.max(now - baseForCompletion, 0)
if (timeCompletion === 0 && completionTokens > 0) {
timeCompletion = 1
}
return {
completion_tokens: completionTokens,
time_first_token_millsec: timeFirstToken,
time_completion_millsec: timeCompletion
}
}
}
export default AiSdkToChunkAdapter

View File

@@ -83,8 +83,10 @@ export default class ModernAiProvider {
throw new Error('Model is required for completions. Please use constructor with model parameter.')
}
// 每次请求时重新生成配置以确保API key轮换生效
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
// 确保配置存在
if (!this.config) {
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
}
// 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config)

View File

@@ -12,7 +12,6 @@ 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'
@@ -64,12 +63,6 @@ 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':

View File

@@ -70,19 +70,13 @@ export abstract class BaseApiClient<
{
public provider: Provider
protected host: string
protected apiKey: string
protected sdkInstance?: TSdkInstance
constructor(provider: Provider) {
this.provider = provider
this.host = this.getBaseURL()
}
/**
* Get the current API key with rotation support
* This getter ensures API keys rotate on each access when multiple keys are configured
*/
protected get apiKey(): string {
return this.getApiKey()
this.apiKey = this.getApiKey()
}
/**

View File

@@ -1,56 +0,0 @@
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 []
}
}
}

View File

@@ -23,7 +23,6 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
@@ -167,7 +166,7 @@ export async function buildStreamTextParams(
params.tools = tools
}
if (assistant.prompt) {
params.system = await replacePromptVariables(assistant.prompt, model.name)
params.system = assistant.prompt
}
logger.debug('params', params)
return {

View File

@@ -62,14 +62,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider)
}
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider)
}

View File

@@ -47,14 +47,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createPerplexity',
supportsImageGeneration: false,
aliases: ['perplexity']
},
{
id: 'mistral',
name: 'Mistral',
import: () => import('@ai-sdk/mistral'),
creatorFunctionName: 'createMistral',
supportsImageGeneration: false,
aliases: ['mistral']
}
] as const

View File

@@ -6,7 +6,6 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -53,12 +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) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -106,7 +100,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// reasoningEffort有效的情况
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(provider)) {
switch (provider.id) {
@@ -149,16 +142,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
// Grok 4 Fast doesn't support effort levels, always use enabled: true
if (isGrok4FastReasoningModel(model)) {
return {
reasoning: {
enabled: true // Ignore effort level, just enable reasoning
}
}
}
// Other OpenRouter models that support effort levels
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -429,13 +412,6 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
/**
* Get XAI-specific reasoning parameters
* This function should only be called for XAI provider models
* @param assistant - The assistant configuration
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
@@ -443,11 +419,6 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort) {
return {}
}
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
*/
min-width: 35ch;
min-width: 45ch;
.code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};

View File

@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger'
import { Extension, keymap } from '@uiw/react-codemirror'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
@@ -203,80 +203,3 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
})
}, [onHeightChange])
}
interface UseScrollToLineOptions {
highlight?: boolean
}
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
const domAtPos = view.domAtPos(position)
let node: Node | null = domAtPos.node
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement
}
while (node) {
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
return node
}
node = node.parentElement
}
return null
}, [])
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
if (previousHighlight) {
previousHighlight.classList.remove('animation-locate-highlight')
}
element.classList.add('animation-locate-highlight')
const handleAnimationEnd = () => {
element.classList.remove('animation-locate-highlight')
element.removeEventListener('animationend', handleAnimationEnd)
}
element.addEventListener('animationend', handleAnimationEnd)
}, [])
return useCallback(
(lineNumber: number, options?: UseScrollToLineOptions) => {
const view = editorViewRef.current
if (!view) return
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
const lineElement = findLineElement(view, targetLine.from)
if (lineElement) {
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
if (options?.highlight) {
requestAnimationFrame(() => highlightLine(view, lineElement))
}
return
}
view.dispatch({
effects: EditorView.scrollIntoView(targetLine.from, {
y: 'start'
})
})
if (!options?.highlight) {
return
}
setTimeout(() => {
const fallbackElement = findLineElement(view, targetLine.from)
if (fallbackElement) {
highlightLine(view, fallbackElement)
}
}, 200)
},
[editorViewRef, findLineElement, highlightLine]
)
}

View File

@@ -5,14 +5,13 @@ import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
export interface CodeEditorHandles {
save?: () => void
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
}
export interface CodeEditorProps {
@@ -76,15 +75,10 @@ export interface CodeEditorProps {
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor view is editable.
* Whether the editor is editable.
* @default true
*/
editable?: boolean
/**
* Set the editor state to read only but keep some user interactions, e.g., keymaps.
* @default false
*/
readOnly?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
@@ -120,7 +114,6 @@ const CodeEditor = ({
style,
className,
editable = true,
readOnly = false,
expanded = true,
wrapped = true
}: CodeEditorProps) => {
@@ -182,11 +175,8 @@ const CodeEditor = ({
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
const scrollToLine = useScrollToLine(editorViewRef)
useImperativeHandle(ref, () => ({
save: handleSave,
scrollToLine
save: handleSave
}))
return (
@@ -199,7 +189,6 @@ const CodeEditor = ({
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
editable={editable}
readOnly={readOnly}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}

View File

@@ -1,48 +0,0 @@
import { FC, memo, useMemo } from 'react'
interface HighlightTextProps {
text: string
keyword: string
caseSensitive?: boolean
className?: string
}
/**
* Text highlighting component that marks keyword matches
*/
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
const highlightedText = useMemo(() => {
if (!keyword || !text) {
return <span>{text}</span>
}
// Escape regex special characters
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(`(${escapedKeyword})`, flags)
// Split text by keyword matches
const parts = text.split(regex)
return (
<>
{parts.map((part, index) => {
// Check if part matches keyword
const isMatch = regex.test(part)
regex.lastIndex = 0 // Reset regex state
if (isMatch) {
return <mark key={index}>{part}</mark>
}
return <span key={index}>{part}</span>
})}
</>
)
}, [text, keyword, caseSensitive])
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
return <span className={combinedClassName}>{highlightedText}</span>
}
export default memo(HighlightText)

View File

@@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
@@ -18,10 +17,6 @@ export interface HorizontalScrollContainerProps {
dependencies?: readonly unknown[]
scrollDistance?: number
className?: string
classNames?: {
container?: string
content?: string
}
gap?: string
expandable?: boolean
}
@@ -31,7 +26,6 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
dependencies = [],
scrollDistance = 200,
className,
classNames,
gap = '8px',
expandable = false
}) => {
@@ -101,16 +95,11 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
return (
<Container
className={cn(className, classNames?.container)}
className={className}
$expandable={expandable}
$disableHoverButton={isScrolledToEnd}
onClick={expandable ? handleContainerClick : undefined}>
<ScrollContent
ref={scrollRef}
$gap={gap}
$isExpanded={isExpanded}
$expandable={expandable}
className={cn(classNames?.content)}>
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
{children}
</ScrollContent>
{canScroll && !isExpanded && !isScrolledToEnd && (

View File

@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
{!isReady && (
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
<EmptyView>
<Avatar
src={currentAppInfo?.logo}
size={80}

View File

@@ -25,7 +25,7 @@ const WebviewContainer = memo(
onNavigateCallback: (appid: string, url: string) => void
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
const { enableSpellCheck } = useSettings()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@@ -76,8 +76,6 @@ const WebviewContainer = memo(
const webviewId = webviewRef.current?.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
// Set link opening behavior for this webview
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
}
@@ -106,22 +104,6 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
// Update webview settings when they change
useEffect(() => {
if (!webviewRef.current) return
try {
const webviewId = webviewRef.current.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView may not be ready yet, settings will be applied in dom-ready event
logger.debug(`WebView ${appid} not ready for settings update`)
}
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
const WebviewStyle: React.CSSProperties = {
width: '100%',
height: '100%',

View File

@@ -38,7 +38,6 @@ interface PopupContainerProps {
message?: Message
messages?: Message[]
topic?: Topic
rawContent?: string
}
// 转换文件信息数组为树形结构
@@ -141,8 +140,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
resolve,
message,
messages,
topic,
rawContent
topic
}) => {
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
const [state, setState] = useState({
@@ -231,9 +229,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
return
}
let markdown = ''
if (rawContent) {
markdown = rawContent
} else if (topic) {
if (topic) {
markdown = await topicToMarkdown(topic, exportReasoning)
} else if (messages && messages.length > 0) {
markdown = messagesToMarkdown(messages, exportReasoning)
@@ -303,6 +299,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
}
}
}
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
@@ -413,11 +410,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
</Option>
</Select>
</Form.Item>
{!rawContent && (
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
)}
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
</Form>
</Modal>
)

View File

@@ -9,7 +9,6 @@ interface ObsidianExportOptions {
topic?: Topic
message?: Message
messages?: Message[]
rawContent?: string
}
export default class ObsidianExportPopup {
@@ -25,7 +24,6 @@ export default class ObsidianExportPopup {
topic={options.topic}
message={options.message}
messages={options.messages}
rawContent={options.rawContent}
obsidianTags={''}
open={true}
resolve={(v) => {

View File

@@ -55,15 +55,12 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
footer={null}>
{extension !== undefined ? (
<Editor
readOnly={true}
editable={false}
expanded={false}
height="100%"
style={{ height: '100%' }}
value={text}
language={extension}
options={{
keymap: true
}}
/>
) : (
<Text>{text}</Text>

View File

@@ -1,2 +0,0 @@
// Attribute used to store the original source line number in markdown editors
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'

View File

@@ -1,6 +1,5 @@
import { loggerService } from '@logger'
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent } from '@tiptap/react'
import { Tooltip } from 'antd'
@@ -30,156 +29,6 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
import { useRichEditor } from './useRichEditor'
const logger = loggerService.withContext('RichEditor')
/**
* Find element by line number with fallback strategies:
* 1. Exact line + content match
* 2. Exact line match
* 3. Closest line <= target
*/
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
if (allElements.length === 0) {
logger.warn('No elements with data-source-line attribute found')
return null
}
const exactMatches = editorDom.querySelectorAll(
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
) as NodeListOf<HTMLElement>
// Strategy 1: Exact line + content match
if (exactMatches.length > 1 && lineContent) {
for (const match of Array.from(exactMatches)) {
if (match.textContent?.includes(lineContent)) {
return match
}
}
}
// Strategy 2: Exact line match
if (exactMatches.length > 0) {
return exactMatches[0]
}
// Strategy 3: Closest line <= target
let closestElement: HTMLElement | null = null
let closestLine = 0
for (const el of allElements) {
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
if (sourceLine <= lineNumber && sourceLine > closestLine) {
closestLine = sourceLine
closestElement = el
}
}
return closestElement
}
/**
* Create fixed-position highlight overlay at element location
* with boundary detection to prevent overflow and toolbar overlap
*/
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
try {
// Remove previous overlay
const previousOverlay = document.body.querySelector('.highlight-overlay')
if (previousOverlay) {
previousOverlay.remove()
}
const editorWrapper = container.closest('.rich-editor-wrapper')
// Create overlay at element position
const rect = element.getBoundingClientRect()
const overlay = document.createElement('div')
overlay.className = 'highlight-overlay animation-locate-highlight'
overlay.style.position = 'fixed'
overlay.style.left = `${rect.left}px`
overlay.style.top = `${rect.top}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
overlay.style.pointerEvents = 'none'
overlay.style.zIndex = '9999'
overlay.style.borderRadius = '4px'
document.body.appendChild(overlay)
// Update overlay position and visibility on scroll
const updatePosition = () => {
const newRect = element.getBoundingClientRect()
const newContainerRect = container.getBoundingClientRect()
// Update position
overlay.style.left = `${newRect.left}px`
overlay.style.top = `${newRect.top}px`
overlay.style.width = `${newRect.width}px`
overlay.style.height = `${newRect.height}px`
// Get current toolbar bottom (it might change)
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
// Check if overlay is within visible bounds
const overlayTop = newRect.top
const overlayBottom = newRect.bottom
const visibleTop = currentToolbarBottom // Don't overlap toolbar
const visibleBottom = newContainerRect.bottom
// Hide overlay if any part is outside the visible container area
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
overlay.style.opacity = '0'
overlay.style.visibility = 'hidden'
} else {
overlay.style.opacity = '1'
overlay.style.visibility = 'visible'
}
}
container.addEventListener('scroll', updatePosition)
// Auto-remove after animation
const handleAnimationEnd = () => {
overlay.remove()
container.removeEventListener('scroll', updatePosition)
overlay.removeEventListener('animationend', handleAnimationEnd)
}
overlay.addEventListener('animationend', handleAnimationEnd)
} catch (error) {
logger.error('Failed to create highlight overlay:', error as Error)
}
}
/**
* Scroll to element and show highlight after scroll completes
*/
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
let scrollTimeout: NodeJS.Timeout
const handleScroll = () => {
clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(() => {
container.removeEventListener('scroll', handleScroll)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}, 150)
}
container.addEventListener('scroll', handleScroll)
// Fallback: if element already in view (no scroll happens)
setTimeout(() => {
const initialScrollTop = container.scrollTop
setTimeout(() => {
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
container.removeEventListener('scroll', handleScroll)
clearTimeout(scrollTimeout)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}
}, 200)
}, 50)
}
const RichEditor = ({
ref,
initialContent = '',
@@ -199,8 +48,7 @@ const RichEditor = ({
enableContentSearch = false,
isFullWidth = false,
fontFamily = 'default',
fontSize = 16,
enableSpellCheck = false
fontSize = 16
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
// Use the rich editor hook for complete editor management
@@ -223,7 +71,6 @@ const RichEditor = ({
onBlur,
placeholder,
editable,
enableSpellCheck,
scrollParent: () => scrollContainerRef.current,
onShowTableActionMenu: ({ position, actions }) => {
const iconMap: Record<string, React.ReactNode> = {
@@ -523,22 +370,6 @@ const RichEditor = ({
scrollContainerRef.current.scrollTop = value
}
},
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
if (!editor || !scrollContainerRef.current) return
try {
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
if (!element) return
if (options?.highlight) {
scrollAndHighlight(element, scrollContainerRef.current)
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
}
} catch (error) {
logger.error('Failed in scrollToLine:', error as Error)
}
},
// Dynamic command management
registerCommand,
registerToolbarCommand,

View File

@@ -14,31 +14,6 @@ export const RichEditorWrapper = styled.div<{
border-radius: 6px;
background: var(--color-background);
overflow-y: hidden;
.ProseMirror table,
.tiptap table {
table-layout: auto !important;
}
.ProseMirror table th,
.ProseMirror table td,
.tiptap th,
.tiptap td {
white-space: normal !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
overflow: visible !important;
text-overflow: clip !important;
}
.ProseMirror table th > *,
.ProseMirror table td > *,
.tiptap td > *,
.tiptap th > * {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
}
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
@@ -46,7 +21,6 @@ export const RichEditorWrapper = styled.div<{
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
`
export const ToolbarWrapper = styled.div`

View File

@@ -50,8 +50,6 @@ export interface RichEditorProps {
fontFamily?: 'default' | 'serif'
/** Font size in pixels */
fontSize?: number
/** Whether to enable spell check */
enableSpellCheck?: boolean
}
export interface ToolbarItem {
@@ -111,8 +109,6 @@ export interface RichEditorRef {
getScrollTop: () => number
/** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void
/** Scroll to specific line number in markdown */
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
// Dynamic command management
/** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void

View File

@@ -2,7 +2,6 @@ import 'katex/dist/katex.min.css'
import { TableKit } from '@cherrystudio/extension-table-plus'
import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import {
@@ -12,7 +11,6 @@ import {
markdownToPreviewText
} from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { migrateMathStrings } from '@tiptap/extension-mathematics'
import Mention from '@tiptap/extension-mention'
@@ -38,31 +36,6 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
const logger = loggerService.withContext('useRichEditor')
// Create extension to preserve data-source-line attribute
const SourceLineAttribute = Extension.create({
name: 'sourceLineAttribute',
addGlobalAttributes() {
return [
{
types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'],
attributes: {
dataSourceLine: {
default: null,
parseHTML: (element) => {
const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR)
return value
},
renderHTML: (attributes) => {
if (!attributes.dataSourceLine) return {}
return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine }
}
}
}
}
]
}
})
export interface UseRichEditorOptions {
/** Initial markdown content */
initialContent?: string
@@ -84,8 +57,6 @@ export interface UseRichEditorOptions {
editable?: boolean
/** Whether to enable table of contents functionality */
enableTableOfContents?: boolean
/** Whether to enable spell check */
enableSpellCheck?: boolean
/** Show table action menu (row/column) with concrete actions and position */
onShowTableActionMenu?: (payload: {
type: 'row' | 'column'
@@ -155,7 +126,6 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
previewLength = 50,
placeholder = '',
editable = true,
enableSpellCheck = false,
onShowTableActionMenu,
scrollParent
} = options
@@ -223,7 +193,6 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions
const extensions = useMemo(
() => [
SourceLineAttribute,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6]
@@ -441,9 +410,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// Allow text selection even when not editable
style: editable
? ''
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;',
// Set spellcheck attribute on the contenteditable element
spellcheck: enableSpellCheck ? 'true' : 'false'
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;'
}
},
onUpdate: ({ editor }) => {

View File

@@ -237,17 +237,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
onSortEnd={onSortEnd}
className="tabs-sortable"
renderItem={(tab) => (
<Tab
key={tab.id}
active={tab.id === activeTabId}
onClick={() => handleTabClick(tab)}
onAuxClick={(e) => {
if (e.button === 1 && tab.id !== 'home') {
e.preventDefault()
e.stopPropagation()
closeTab(tab.id)
}
}}>
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>

View File

@@ -1,101 +0,0 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store'
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
const logger = loggerService.withContext('UpdateDialog')
interface UpdateDialogProps {
isOpen: boolean
onClose: () => void
releaseInfo: UpdateInfo | null
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (isOpen && releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [isOpen, releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[85vh]',
header: 'border-b border-divider',
footer: 'border-t border-divider'
}}>
<ModalContent>
{(onModalClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
<p className="text-default-500 text-small">
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
</p>
</ModalHeader>
<ModalBody>
<ScrollShadow className="max-h-[450px]" hideScrollBar>
<div className="markdown rounded-lg bg-default-50 p-4">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</div>
</ScrollShadow>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
{t('update.later')}
</Button>
<Button
color="primary"
onPress={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
export default UpdateDialog

View File

@@ -22,6 +22,8 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
@@ -30,13 +32,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
@@ -44,6 +46,7 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
@@ -142,14 +145,14 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'dashscope',
name: i18n.t('minapps.qwen'),
url: 'https://www.tongyi.com/',
url: 'https://tongyi.aliyun.com/qianwen/',
logo: QwenModelLogo
},
{
id: 'stepfun',
name: i18n.t('minapps.stepfun'),
url: 'https://stepfun.com',
logo: StepfunAppLogo,
name: i18n.t('minapps.yuewen'),
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo,
bodered: true
},
{
@@ -260,6 +263,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://www.tiangong.cn/',
bodered: true
},
{
id: 'hugging-chat',
name: 'HuggingChat',
logo: HuggingChatLogo,
url: 'https://huggingface.co/chat/',
bodered: true
},
{
id: 'Felo',
name: 'Felo',
@@ -287,6 +297,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://bot.n.cn/',
bodered: true
},
{
id: 'nm-search',
name: i18n.t('minapps.nami-ai-search'),
logo: NamiAiSearchLogo,
url: 'https://www.n.cn/',
bodered: true
},
{
id: 'thinkany',
name: 'ThinkAny',
@@ -297,6 +314,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
padding: 5
}
},
{
id: 'hika',
name: 'Hika',
logo: HikaLogo,
url: 'https://hika.fyi/',
bodered: true
},
{
id: 'github-copilot',
name: 'GitHub Copilot',

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
cherryin: [],
// cherryin: [],
vertexai: [],
'302ai': [
{
@@ -260,7 +260,6 @@ 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: [
@@ -430,18 +429,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
}
],
anthropic: [
{
id: 'claude-haiku-4-5-20251001',
provider: 'anthropic',
name: 'Claude Haiku 4.5',
group: 'Claude 4.5'
},
{
id: 'claude-sonnet-4-5-20250929',
provider: 'anthropic',
name: 'Claude Sonnet 4.5',
group: 'Claude 4.5'
},
{
id: 'claude-sonnet-4-20250514',
provider: 'anthropic',
@@ -710,12 +697,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
name: 'GLM-4.5-Flash',
group: 'GLM-4.5'
},
{
id: 'glm-4.6',
provider: 'zhipu',
name: 'GLM-4.6',
group: 'GLM-4.6'
},
{
id: 'glm-4.5',
provider: 'zhipu',

View File

@@ -61,7 +61,6 @@ 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'
@@ -163,7 +162,6 @@ export function getModelLogo(modelId: string) {
return undefined
}
// key is regex
const logoMap = {
pixtral: isLight ? PixtralModelLogo : PixtralModelLogoDark,
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
@@ -179,7 +177,6 @@ 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,
@@ -289,7 +286,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')

View File

@@ -14,7 +14,7 @@ import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
// Reasoning models
export const REASONING_REGEX =
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
@@ -22,9 +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,
grok4_fast: ['auto'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
qwen: ['low', 'medium', 'high'] as const,
@@ -42,9 +40,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,
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
@@ -59,17 +55,10 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
export const getThinkModelType = (model: Model): ThinkingModelType => {
let thinkingModelType: ThinkingModelType = 'default'
const modelId = getLowerBaseModelName(model.id)
if (isGPT5SeriesModel(model)) {
if (modelId.includes('codex')) {
thinkingModelType = 'gpt5_codex'
} else {
thinkingModelType = 'gpt5'
}
thinkingModelType = 'gpt5'
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isGrok4FastReasoningModel(model)) {
thinkingModelType = 'grok4_fast'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
thinkingModelType = 'gemini'
@@ -146,46 +135,19 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
}
const modelId = getLowerBaseModelName(model.id)
const providerId = model.provider.toLowerCase()
if (modelId.includes('grok-3-mini')) {
return true
}
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
return true
}
return false
}
/**
* Checks if the model is Grok 4 Fast reasoning version
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
*
* Note: XAI official uses different model IDs for reasoning vs non-reasoning
* Third-party providers like OpenRouter expose a single ID with reasoning parameters, while first-party providers require separate IDs. Only the OpenRouter variant supports toggling.
*
* @param model - The model to check
* @returns true if the model is a reasoning-enabled Grok 4 Fast model
*/
export function isGrok4FastReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('grok-4-fast') && !modelId.includes('non-reasoning')
}
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
if (
isSupportedReasoningEffortGrokModel(model) ||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
) {
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
return true
}
@@ -209,13 +171,9 @@ export function isGeminiReasoningModel(model?: Model): boolean {
return false
}
// Gemini 支持思考模式的模型正则
export const GEMINI_THINKING_MODEL_REGEX =
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
if (modelId.includes('gemini-2.5')) {
if (modelId.includes('image') || modelId.includes('tts')) {
return false
}
@@ -296,11 +254,7 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
return (
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
)
return modelId.startsWith('qwen3') && modelId.includes('thinking')
}
// Doubao 支持思考模式的模型正则
@@ -335,8 +289,7 @@ export function isClaudeReasoningModel(model?: Model): boolean {
modelId.includes('claude-3-7-sonnet') ||
modelId.includes('claude-3.7-sonnet') ||
modelId.includes('claude-sonnet-4') ||
modelId.includes('claude-opus-4') ||
modelId.includes('claude-haiku-4')
modelId.includes('claude-opus-4')
)
}
@@ -365,10 +318,7 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
return (
isSupportedReasoningEffortPerplexityModel(model) ||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
)
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
}
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
@@ -378,20 +328,14 @@ export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean
export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
return ['glm-4.5', 'glm-4.6'].some((id) => modelId.includes(id))
return modelId.includes('glm-4.5')
}
export const isDeepSeekHybridInferenceModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
// deepseek官方使用chat和reasoner做推理控制其他provider需要单独判断id可能会有所差别
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型这里有风险
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
// until the end of the string.
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
// TODO: move to utils and add test cases
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
}
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
@@ -482,8 +426,6 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-vl-235b-a22b-thinking$': { min: 0, max: 81_920 },
'qwen3-vl-30b-a3b-thinking$': { min: 0, max: 81_920 },
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
'qwen3-1\\.7b$': { min: 0, max: 30_720 },
@@ -494,9 +436,8 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
// Claude models
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 },
'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 },
'claude-opus-4-1.*$': { min: 1024, max: 32_000 }
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {

View File

@@ -12,10 +12,8 @@ const visionAllowedModels = [
'gemini-1\\.5',
'gemini-2\\.0',
'gemini-2\\.5',
'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp',
'claude-3',
'claude-haiku-4',
'claude-sonnet-4',
'claude-opus-4',
'vision',
@@ -23,9 +21,7 @@ const visionAllowedModels = [
'qwen-vl',
'qwen2-vl',
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni(?:-[\\w-]+)?',
'qvq',
'internvl2',
'grok-vision-beta',
@@ -83,14 +79,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
'gemini-2.0-flash-preview-image-generation'
]
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
// Models that should auto-enable image generation button when selected
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
@@ -108,7 +104,7 @@ export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
...DEDICATED_IMAGE_MODELS
]

View File

@@ -7,16 +7,13 @@ import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`,
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
'i'
)
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
export const GEMINI_SEARCH_REGEX = new RegExp(
'gemini-(?:2.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
'i'
)
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
export const PERPLEXITY_SEARCH_MODELS = [
'sonar-pro',

View File

@@ -1,7 +1,6 @@
import {
BuiltinOcrProvider,
BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider,
OcrProviderCapability,
OcrSystemProvider,
@@ -51,23 +50,10 @@ const ppocrOcr: OcrPpocrProvider = {
}
} as const
const ovOcr: OcrOvProvider = {
id: 'ovocr',
name: 'Intel OV(NPU) OCR',
config: {
langs: isWin ? ['en-us', 'zh-cn'] : undefined
},
capabilities: {
image: true
// pdf: true
}
} as const satisfies OcrOvProvider
export const BUILTIN_OCR_PROVIDERS_MAP = {
tesseract,
system: systemOcr,
paddleocr: ppocrOcr,
ovocr: ovOcr
paddleocr: ppocrOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)

View File

@@ -24,7 +24,6 @@ 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'
@@ -80,16 +79,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.net',
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',
@@ -110,16 +109,6 @@ 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',
@@ -283,7 +272,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': {
id: 'new-api',
name: 'New API',
type: 'new-api',
type: 'openai',
apiKey: '',
apiHost: 'http://localhost:3000',
models: SYSTEM_MODELS['new-api'],
@@ -660,7 +649,6 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
yi: ZeroOneProviderLogo,
groq: GroqProviderLogo,
zhipu: ZhipuProviderLogo,
ovms: IntelOvmsLogo,
ollama: OllamaProviderLogo,
lmstudio: LMStudioProviderLogo,
moonshot: MoonshotProviderLogo,
@@ -732,17 +720,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
cherryin: {
api: {
url: 'https://open.cherryin.net'
},
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'
@@ -1046,16 +1034,6 @@ 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'
@@ -1422,5 +1400,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
}
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
return ['new-api', 'cherryin'].includes(provider.id)
}

View File

@@ -108,11 +108,7 @@ export const useCodeTools = () => {
const environmentVariables = codeToolsState?.environmentVariables?.[codeToolsState.selectedCliTool] || ''
// 检查是否可以启动(所有必需字段都已填写)
const canLaunch = Boolean(
codeToolsState.selectedCliTool &&
codeToolsState.currentDirectory &&
(codeToolsState.selectedCliTool === codeTools.githubCopilotCli || selectedModel)
)
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
return {
// 状态

View File

@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
const deleteKnowledgeBase = (baseId: string) => {
const base = bases.find((b) => b.id === baseId)
if (!base) return
dispatch(deleteBase({ baseId }))
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
// remove assistant knowledge_base
const _assistants = assistants.map((assistant) => {

View File

@@ -1,5 +1,4 @@
import { loggerService } from '@logger'
import IntelLogo from '@renderer/assets/images/providers/intel.png'
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
@@ -84,8 +83,6 @@ export const useOcrProviders = () => {
return <MonitorIcon size={size} />
case 'paddleocr':
return <Avatar size={size} src={PaddleocrLogo} />
case 'ovocr':
return <Avatar size={size} src={IntelLogo} />
}
}
return <FileQuestionMarkIcon size={size} />

View File

@@ -48,17 +48,6 @@ export function useActiveTopic(assistantId: string, topic?: Topic) {
}
}, [activeTopic?.id, assistant])
useEffect(() => {
if (!assistant?.topics?.length || !activeTopic) {
return
}
const latestTopic = assistant.topics.find((item) => item.id === activeTopic.id)
if (latestTopic && latestTopic !== activeTopic) {
setActiveTopic(latestTopic)
}
}, [assistant?.topics, activeTopic])
return { activeTopic, setActiveTopic }
}

View File

@@ -61,7 +61,6 @@ const providerKeyMap = {
nvidia: 'provider.nvidia',
o3: 'provider.o3',
ocoolai: 'provider.ocoolai',
ovms: 'provider.ovms',
ollama: 'provider.ollama',
openai: 'provider.openai',
openrouter: 'provider.openrouter',
@@ -322,8 +321,7 @@ 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.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp'
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
} as const
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
@@ -333,13 +331,11 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
const builtinOcrProviderKeyMap = {
system: 'ocr.builtin.system',
tesseract: '',
paddleocr: '',
ovocr: ''
paddleocr: ''
} as const satisfies Record<BuiltinOcrProviderId, string>
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
if (key === 'tesseract') return 'Tesseract'
else if (key == 'paddleocr') return 'PaddleOCR'
else if (key == 'ovocr') return 'Intel OV(NPU) OCR'
else return getLabel(builtinOcrProviderKeyMap, key)
}

View File

@@ -251,7 +251,6 @@
"added": "Added",
"case_sensitive": "Case Sensitive",
"collapse": "Collapse",
"download": "Download",
"includes_user_questions": "Include Your Questions",
"manage": "Manage",
"select_model": "Select Model",
@@ -1386,6 +1385,9 @@
"no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
"no_content": "There is nothing to export to Notion."
},
"pdf": {
"export": "Failed to export to PDF"
},
"siyuan": {
"export": "Failed to export to Siyuan Note, please check connection status and configuration according to documentation",
"no_config": "Siyuan Note API address or token is not configured"
@@ -1488,6 +1490,9 @@
"notion": {
"export": "Successfully exported to Notion"
},
"pdf": {
"export": "Successfully exported to PDF"
},
"siyuan": {
"export": "Successfully exported to Siyuan Note"
},
@@ -1583,13 +1588,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1702,6 @@
"provider_settings": "Go to provider settings"
},
"notes": {
"auto_rename": {
"empty_note": "Note is empty, cannot generate name",
"failed": "Failed to generate note name",
"label": "Generate Note Name",
"success": "Note name generated successfully"
},
"characters": "Characters",
"collapse": "Collapse",
"content_placeholder": "Please enter the note content...",
@@ -1714,6 +1713,7 @@
"drop_markdown_hint": "Drop .md files or folders here to import",
"empty": "No notes available yet",
"expand": "unfold",
"exportPDF": "Export to PDF",
"export_failed": "Failed to export to knowledge base",
"export_knowledge": "Export notes to knowledge base",
"export_success": "Successfully exported to the knowledge base",
@@ -1728,14 +1728,6 @@
"rename": "Rename",
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
"save": "Save to Notes",
"search": {
"both": "Name+Content",
"content": "Content",
"found_results": "Found {{count}} results (Name: {{nameCount}}, Content: {{contentCount}})",
"more_matches": "more matches",
"searching": "Searching...",
"show_less": "Show less"
},
"settings": {
"data": {
"apply": "Apply",
@@ -1792,8 +1784,6 @@
"sort_updated_asc": "Update time (oldest first)",
"sort_updated_desc": "Update time (newest first)",
"sort_z2a": "File name (Z-A)",
"spell_check": "Spell Check",
"spell_check_tooltip": "Enable/Disable spell check",
"star": "Favorite note",
"starred_notes": "Collected notes",
"title": "Notes",
@@ -1820,7 +1810,6 @@
"provider": {
"cannot_remove_builtin": "Cannot delete built-in provider",
"existing": "The provider already exists",
"get_providers": "Failed to get available providers",
"not_found": "OCR provider does not exist",
"update_failed": "Failed to update configuration"
},
@@ -1844,59 +1833,6 @@
},
"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": "Failed to install OVMS runtime",
"install_code_105": "Failed to create ovdnd.exe",
"install_code_106": "Failed to create run.bat",
"install_code_110": "Failed to clean old OVMS runtime",
"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": {
@@ -2128,7 +2064,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8",
"poe": "Poe",
@@ -3356,7 +3291,6 @@
"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.",
@@ -3856,7 +3790,7 @@
"api_host": "API Host",
"api_key": {
"label": "API Key",
"tip": "Use commas to separate multiple keys"
"tip": "Multiple keys separated by commas or spaces"
},
"api_version": "API Version",
"aws-bedrock": {
@@ -4424,7 +4358,6 @@
"later": "Later",
"message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"added": "已添加",
"case_sensitive": "区分大小写",
"collapse": "收起",
"download": "下载",
"includes_user_questions": "包含用户提问",
"manage": "管理",
"select_model": "选择模型",
@@ -1386,6 +1385,9 @@
"no_api_key": "未配置 Notion API Key 或 Notion Database ID",
"no_content": "无可导出到 Notion 的内容"
},
"pdf": {
"export": "导出 PDF 失败"
},
"siyuan": {
"export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
"no_config": "未配置思源笔记 API 地址或令牌"
@@ -1488,6 +1490,9 @@
"notion": {
"export": "成功导出到 Notion"
},
"pdf": {
"export": "成功导出为 PDF"
},
"siyuan": {
"export": "导出到思源笔记成功"
},
@@ -1583,13 +1588,13 @@
"nami-ai-search": "纳米AI搜索",
"qwen": "通义千问",
"sensechat": "商量",
"stepfun": "阶跃AI",
"tencent-yuanbao": "腾讯元宝",
"tiangong-ai": "天工AI",
"wanzhi": "万知",
"wenxin": "文心一言",
"wps-copilot": "WPS灵犀",
"xiaoyi": "小艺",
"yuewen": "跃问",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -1697,12 +1702,6 @@
"provider_settings": "跳转到服务商设置界面"
},
"notes": {
"auto_rename": {
"empty_note": "笔记为空,无法生成名称",
"failed": "生成笔记名称失败",
"label": "生成笔记名称",
"success": "笔记名称生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "请输入笔记内容...",
@@ -1714,6 +1713,7 @@
"drop_markdown_hint": "拖拽 .md 文件或目录到此处导入",
"empty": "暂无笔记",
"expand": "展开",
"exportPDF": "导出为PDF",
"export_failed": "导出到知识库失败",
"export_knowledge": "导出笔记到知识库",
"export_success": "成功导出到知识库",
@@ -1728,14 +1728,6 @@
"rename": "重命名",
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
"save": "保存到笔记",
"search": {
"both": "名称+内容",
"content": "内容",
"found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "个匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": {
"data": {
"apply": "应用",
@@ -1792,8 +1784,6 @@
"sort_updated_asc": "更新时间(从旧到新)",
"sort_updated_desc": "更新时间(从新到旧)",
"sort_z2a": "文件名Z-A",
"spell_check": "拼写检查",
"spell_check_tooltip": "启用/禁用拼写检查",
"star": "收藏笔记",
"starred_notes": "收藏的笔记",
"title": "笔记",
@@ -1820,7 +1810,6 @@
"provider": {
"cannot_remove_builtin": "不能删除内置提供商",
"existing": "提供商已存在",
"get_providers": "获取可用提供商失败",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失败"
},
@@ -1844,59 +1833,6 @@
},
"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": "创建 ovdnd.exe 失败",
"install_code_106": "创建 run.bat 失败",
"install_code_110": "清理旧 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": {
@@ -2128,7 +2064,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8 大模型开放平台",
"poe": "Poe",
@@ -3356,7 +3291,6 @@
"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 服务器。需要配置允许访问的目录",
@@ -3856,7 +3790,7 @@
"api_host": "API 地址",
"api_key": {
"label": "API 密钥",
"tip": "多个密钥使用逗号分隔"
"tip": "多个密钥使用逗号或空格分隔"
},
"api_version": "API 版本",
"aws-bedrock": {
@@ -4424,7 +4358,6 @@
"later": "稍后",
"message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"added": "已新增",
"case_sensitive": "區分大小寫",
"collapse": "折疊",
"download": "下載",
"includes_user_questions": "包含使用者提問",
"manage": "管理",
"select_model": "選擇模型",
@@ -1386,6 +1385,9 @@
"no_api_key": "未設定 Notion API Key 或 Notion Database ID",
"no_content": "沒有可匯出至 Notion 的內容"
},
"pdf": {
"export": "導出 PDF 失敗"
},
"siyuan": {
"export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
"no_config": "未配置思源筆記 API 地址或令牌"
@@ -1488,6 +1490,9 @@
"notion": {
"export": "成功匯出到 Notion"
},
"pdf": {
"export": "成功導出為 PDF"
},
"siyuan": {
"export": "導出到思源筆記成功"
},
@@ -1583,13 +1588,13 @@
"nami-ai-search": "納米AI搜索",
"qwen": "通義千問",
"sensechat": "商量",
"stepfun": "階躍AI",
"tencent-yuanbao": "騰訊元寶",
"tiangong-ai": "天工AI",
"wanzhi": "萬知",
"wenxin": "文心一言",
"wps-copilot": "WPS靈犀",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -1697,12 +1702,6 @@
"provider_settings": "跳轉到服務商設置界面"
},
"notes": {
"auto_rename": {
"empty_note": "筆記為空,無法生成名稱",
"failed": "生成筆記名稱失敗",
"label": "生成筆記名稱",
"success": "筆記名稱生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "請輸入筆記內容...",
@@ -1714,6 +1713,7 @@
"drop_markdown_hint": "拖拽 .md 文件或資料夾到此處導入",
"empty": "暫無筆記",
"expand": "展開",
"exportPDF": "匯出為PDF",
"export_failed": "匯出至知識庫失敗",
"export_knowledge": "匯出筆記至知識庫",
"export_success": "成功匯出至知識庫",
@@ -1728,14 +1728,6 @@
"rename": "重命名",
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
"save": "儲存到筆記",
"search": {
"both": "名稱+內容",
"content": "內容",
"found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})",
"more_matches": "個匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": {
"data": {
"apply": "應用",
@@ -1792,8 +1784,6 @@
"sort_updated_asc": "更新時間(從舊到新)",
"sort_updated_desc": "更新時間(從新到舊)",
"sort_z2a": "文件名Z-A",
"spell_check": "拼寫檢查",
"spell_check_tooltip": "啟用/禁用拼寫檢查",
"star": "收藏筆記",
"starred_notes": "收藏的筆記",
"title": "筆記",
@@ -1819,9 +1809,8 @@
"error": {
"provider": {
"cannot_remove_builtin": "不能刪除內建提供者",
"existing": "提供已存在",
"get_providers": "取得可用提供者失敗",
"not_found": "OCR 提供者不存在",
"existing": "提供已存在",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失敗"
},
"unknown": "OCR過程發生錯誤"
@@ -1844,59 +1833,6 @@
},
"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": "創建 ovdnd.exe 失敗",
"install_code_106": "創建 run.bat 失敗",
"install_code_110": "清理舊 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": {
@@ -2128,7 +2064,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8 大模型開放平台",
"poe": "Poe",
@@ -3356,7 +3291,6 @@
"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 伺服器。需要配置允許訪問的目錄",
@@ -3856,7 +3790,7 @@
"api_host": "API 主機地址",
"api_key": {
"label": "API 金鑰",
"tip": "多個金鑰使用逗號分隔"
"tip": "多個金鑰使用逗號或空格分隔"
},
"api_version": "API 版本",
"aws-bedrock": {
@@ -4424,7 +4358,6 @@
"later": "稍後",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"added": "προστέθηκε",
"case_sensitive": "Διάκριση πεζών/κεφαλαίων",
"collapse": "συμπεριλάβετε",
"download": "Λήψη",
"includes_user_questions": "Περιλαμβάνει ερωτήσεις χρήστη",
"manage": "χειριστείτε",
"select_model": "επιλογή μοντέλου",
@@ -334,7 +333,6 @@
"new_topic": "Νέο θέμα {{Command}}",
"pause": "Παύση",
"placeholder": "Εισάγετε μήνυμα εδώ...",
"placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή",
"send": "Αποστολή",
"settings": "Ρυθμίσεις",
"thinking": {
@@ -1583,13 +1581,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1695,6 @@
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
},
"notes": {
"auto_rename": {
"empty_note": "Το σημείωμα είναι κενό, δεν μπορεί να δημιουργηθεί όνομα",
"failed": "Αποτυχία δημιουργίας ονόματος σημείωσης",
"label": "Δημιουργία ονόματος σημείωσης",
"success": "Η δημιουργία του ονόματος σημειώσεων ολοκληρώθηκε με επιτυχία"
},
"characters": "χαρακτήρας",
"collapse": "σύμπτυξη",
"content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...",
@@ -1784,8 +1776,6 @@
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
"sort_z2a": "όνομα αρχείου (Z-A)",
"spell_check": "Έλεγχος ορθογραφίας",
"spell_check_tooltip": "Ενεργοποίηση/Απενεργοποίηση ελέγχου ορθογραφίας",
"star": "Αγαπημένες σημειώσεις",
"starred_notes": "Σημειώσεις συλλογής",
"title": "σημειώσεις",
@@ -1812,7 +1802,6 @@
"provider": {
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
"get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων",
"not_found": "Ο πάροχος OCR δεν υπάρχει",
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
},
@@ -1836,57 +1825,6 @@
},
"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": {
@@ -2118,7 +2056,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "Πλατφόρμα Ανοιχτής Μεγάλης Μοντέλου PH8",
"poe": "Poe",
@@ -3346,7 +3283,6 @@
"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) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους",
@@ -4414,7 +4350,6 @@
"later": "Μετά",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"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",
@@ -334,7 +333,6 @@
"new_topic": "Nuevo tema {{Command}}",
"pause": "Pausar",
"placeholder": "Escribe aquí tu mensaje...",
"placeholder_without_triggers": "Escriba un mensaje aquí y presione {{key}} para enviar",
"send": "Enviar",
"settings": "Configuración",
"thinking": {
@@ -1583,13 +1581,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1695,6 @@
"provider_settings": "Ir a la configuración del proveedor"
},
"notes": {
"auto_rename": {
"empty_note": "La nota está vacía, no se puede generar un nombre",
"failed": "Error al generar el nombre de la nota",
"label": "Generar nombre de nota",
"success": "Se ha generado correctamente el nombre de la nota"
},
"characters": "carácter",
"collapse": "ocultar",
"content_placeholder": "Introduzca el contenido de la nota...",
@@ -1784,8 +1776,6 @@
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
"sort_z2a": "Nombre de archivo (Z-A)",
"spell_check": "comprobación ortográfica",
"spell_check_tooltip": "Habilitar/deshabilitar revisión ortográfica",
"star": "Notas guardadas",
"starred_notes": "notas guardadas",
"title": "notas",
@@ -1812,7 +1802,6 @@
"provider": {
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
"existing": "El proveedor ya existe",
"get_providers": "Error al obtener proveedores disponibles",
"not_found": "El proveedor de OCR no existe",
"update_failed": "Actualización de la configuración fallida"
},
@@ -1836,57 +1825,6 @@
},
"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": {
@@ -2118,7 +2056,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplejidad",
"ph8": "Plataforma Abierta de Grandes Modelos PH8",
"poe": "Poe",
@@ -3346,7 +3283,6 @@
"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",
@@ -4414,7 +4350,6 @@
"later": "Más tarde",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"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",
@@ -334,7 +333,6 @@
"new_topic": "Nouveau sujet {{Command}}",
"pause": "Pause",
"placeholder": "Entrez votre message ici...",
"placeholder_without_triggers": "Entrez votre message ici, appuyez sur {{key}} pour envoyer",
"send": "Envoyer",
"settings": "Paramètres",
"thinking": {
@@ -1583,13 +1581,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1695,6 @@
"provider_settings": "Aller aux paramètres du fournisseur"
},
"notes": {
"auto_rename": {
"empty_note": "La note est vide, impossible de générer un nom",
"failed": "Échec de la génération du nom de note",
"label": "Générer un nom de note",
"success": "La génération du nom de note a réussi"
},
"characters": "caractère",
"collapse": "réduire",
"content_placeholder": "Veuillez saisir le contenu de la note...",
@@ -1784,8 +1776,6 @@
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
"sort_z2a": "Nom de fichier (Z-A)",
"spell_check": "Vérification orthographique",
"spell_check_tooltip": "Activer/Désactiver la vérification orthographique",
"star": "Notes enregistrées",
"starred_notes": "notes de collection",
"title": "notes",
@@ -1812,7 +1802,6 @@
"provider": {
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
"existing": "Le fournisseur existe déjà",
"get_providers": "Échec de l'obtention des fournisseurs disponibles",
"not_found": "Le fournisseur OCR n'existe pas",
"update_failed": "Échec de la mise à jour de la configuration"
},
@@ -1836,57 +1825,6 @@
},
"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": {
@@ -2118,7 +2056,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexité",
"ph8": "Plateforme ouverte de grands modèles PH8",
"poe": "Poe",
@@ -3346,7 +3283,6 @@
"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.",
@@ -4414,7 +4350,6 @@
"later": "Plus tard",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"added": "追加済み",
"case_sensitive": "大文字と小文字の区別",
"collapse": "折りたたむ",
"download": "ダウンロード",
"includes_user_questions": "ユーザーからの質問を含む",
"manage": "管理",
"select_model": "モデルを選択",
@@ -334,7 +333,6 @@
"new_topic": "新しいトピック {{Command}}",
"pause": "一時停止",
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください",
"send": "送信",
"settings": "設定",
"thinking": {
@@ -542,7 +540,7 @@
"title": "コード実行"
},
"code_fancy_block": {
"label": "<translate_input>\n装飾的なコードブロック\n</translate_input>",
"label": "装飾的なコードブロック",
"tip": "より見栄えの良いコードブロックスタイルを使用する、例えばHTMLカード"
},
"code_image_tools": {
@@ -1583,13 +1581,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "通義千問",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "騰訊元宝",
"tiangong-ai": "Skywork",
"wanzhi": "万知",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -1697,12 +1695,6 @@
"provider_settings": "プロバイダー設定に移動"
},
"notes": {
"auto_rename": {
"empty_note": "ノートが空です。名前を生成できません。",
"failed": "ノート名の生成に失敗しました",
"label": "ノート名の生成",
"success": "ノート名の生成に成功しました"
},
"characters": "文字",
"collapse": "閉じる",
"content_placeholder": "メモの内容を入力してください...",
@@ -1784,8 +1776,6 @@
"sort_updated_asc": "更新日時(古い順)",
"sort_updated_desc": "更新日時(新しい順)",
"sort_z2a": "ファイル名Z-A",
"spell_check": "スペルチェック",
"spell_check_tooltip": "スペルチェックの有効/無効",
"star": "お気に入りのノート",
"starred_notes": "収集したノート",
"title": "ノート",
@@ -1812,7 +1802,6 @@
"provider": {
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
"existing": "プロバイダーはすでに存在します",
"get_providers": "利用可能なプロバイダーの取得に失敗しました",
"not_found": "OCRプロバイダーが存在しません",
"update_failed": "更新構成に失敗しました"
},
@@ -1836,57 +1825,6 @@
},
"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": {
@@ -2118,7 +2056,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8",
"poe": "Poe",
@@ -3346,7 +3283,6 @@
"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。アクセスを許可するディレクトリの設定が必要です",
@@ -4414,7 +4350,6 @@
"later": "後で",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"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",
@@ -334,7 +333,6 @@
"new_topic": "Novo tópico {{Command}}",
"pause": "Pausar",
"placeholder": "Digite sua mensagem aqui...",
"placeholder_without_triggers": "Digite a mensagem aqui, pressione {{key}} para enviar",
"send": "Enviar",
"settings": "Configurações",
"thinking": {
@@ -1583,13 +1581,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1695,6 @@
"provider_settings": "Ir para as configurações do provedor"
},
"notes": {
"auto_rename": {
"empty_note": "A nota está vazia, não é possível gerar um nome",
"failed": "Falha ao gerar o nome da nota",
"label": "Gerar nome da nota",
"success": "Nome da nota gerado com sucesso"
},
"characters": "caractere",
"collapse": "[minimizar]",
"content_placeholder": "Introduza o conteúdo da nota...",
@@ -1784,8 +1776,6 @@
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
"sort_z2a": "Nome do arquivo (Z-A)",
"spell_check": "verificação ortográfica",
"spell_check_tooltip": "Ativar/Desativar verificação ortográfica",
"star": "Notas favoritas",
"starred_notes": "notas salvas",
"title": "nota",
@@ -1812,7 +1802,6 @@
"provider": {
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
"existing": "O provedor já existe",
"get_providers": "Falha ao obter provedores disponíveis",
"not_found": "O provedor OCR não existe",
"update_failed": "Falha ao atualizar a configuração"
},
@@ -1836,57 +1825,6 @@
},
"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": {
@@ -2118,7 +2056,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexidade",
"ph8": "Plataforma Aberta de Grandes Modelos PH8",
"poe": "Poe",
@@ -3346,7 +3283,6 @@
"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",
@@ -4414,7 +4350,6 @@
"later": "Mais tarde",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"warning": {

View File

@@ -251,7 +251,6 @@
"added": "Добавлено",
"case_sensitive": "Чувствительность к регистру",
"collapse": "Свернуть",
"download": "Скачать",
"includes_user_questions": "Включает вопросы пользователей",
"manage": "Редактировать",
"select_model": "Выбрать модель",
@@ -334,7 +333,6 @@
"new_topic": "Новый топик {{Command}}",
"pause": "Остановить",
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"placeholder_without_triggers": "Введите сообщение здесь, нажмите {{key}}, чтобы отправить",
"send": "Отправить",
"settings": "Настройки",
"thinking": {
@@ -1583,13 +1581,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Tencent Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1695,6 @@
"provider_settings": "Перейти к настройкам поставщика"
},
"notes": {
"auto_rename": {
"empty_note": "Заметки пусты, имя невозможно сгенерировать",
"failed": "Создание названия заметки не удалось",
"label": "Создать название заметки",
"success": "Имя заметки успешно создано"
},
"characters": "Символы",
"collapse": "Свернуть",
"content_placeholder": "Введите содержимое заметки...",
@@ -1784,8 +1776,6 @@
"sort_updated_asc": "Время обновления (от старого к новому)",
"sort_updated_desc": "Время обновления (от нового к старому)",
"sort_z2a": "Имя файла (Я-А)",
"spell_check": "Проверка орфографии",
"spell_check_tooltip": "Включить/отключить проверку орфографии",
"star": "Избранные заметки",
"starred_notes": "Сохраненные заметки",
"title": "заметки",
@@ -1812,7 +1802,6 @@
"provider": {
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
"existing": "Поставщик уже существует",
"get_providers": "Не удалось получить доступных поставщиков",
"not_found": "Поставщик OCR отсутствует",
"update_failed": "Обновление конфигурации не удалось"
},
@@ -1836,57 +1825,6 @@
},
"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": {
@@ -2118,7 +2056,6 @@
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"ovms": "Intel OVMS",
"perplexity": "Perplexity",
"ph8": "PH8",
"poe": "Poe",
@@ -3346,7 +3283,6 @@
"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) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ",
@@ -4414,7 +4350,6 @@
"later": "Позже",
"message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"warning": {

View File

@@ -98,10 +98,6 @@ const CodeToolsPage: FC = () => {
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === codeTools.githubCopilotCli) {
return false
}
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
@@ -200,7 +196,7 @@ const CodeToolsPage: FC = () => {
}
}
if (!selectedModel && selectedCliTool !== codeTools.githubCopilotCli) {
if (!selectedModel) {
return { isValid: false, message: t('code.model_required') }
}
@@ -209,11 +205,6 @@ 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)
@@ -238,9 +229,7 @@ const CodeToolsPage: FC = () => {
// 执行启动操作
const executeLaunch = async (env: Record<string, string>) => {
const modelId = selectedCliTool === codeTools.githubCopilotCli ? '' : selectedModel?.id!
window.api.codeTools.run(selectedCliTool, modelId, currentDirectory, env, {
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
autoUpdateToLatest,
terminal: selectedTerminal
})
@@ -327,12 +316,7 @@ 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"
@@ -361,64 +345,46 @@ const CodeToolsPage: FC = () => {
/>
</SettingsItem>
{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>
<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>
}
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>
)}
</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>
<SettingsItem>
<div className="settings-label">{t('code.working_directory')}</div>
@@ -437,27 +403,11 @@ 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>
@@ -479,14 +429,7 @@ 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) */}
@@ -521,12 +464,7 @@ 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')}

View File

@@ -20,8 +20,7 @@ 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.githubCopilotCli, label: 'GitHub Copilot CLI' }
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
@@ -44,8 +43,7 @@ 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.githubCopilotCli]: () => []
[codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai'))
}
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
@@ -160,10 +158,6 @@ 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

View File

@@ -1,6 +1,5 @@
import { Button } from '@heroui/button'
import CodeViewer from '@renderer/components/CodeViewer'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
import { getProviderById } from '@renderer/services/ProviderService'
@@ -36,7 +35,7 @@ import {
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
import { Alert as AntdAlert, Modal } from 'antd'
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
@@ -306,36 +305,14 @@ const BuiltinError = ({ error }: { error: SerializedError }) => {
// 作为 base渲染公共字段应当在 ErrorDetailList 中渲染
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
const { t } = useTranslation()
const { highlightCode } = useCodeStyle()
const [highlightedString, setHighlightedString] = useState('')
const cause = error.cause
useEffect(() => {
const highlight = async () => {
try {
const result = await highlightCode(JSON.stringify(JSON.parse(cause || '{}'), null, 2), 'json')
setHighlightedString(result)
} catch {
setHighlightedString(cause || '')
}
}
const timer = setTimeout(highlight, 0)
return () => clearTimeout(timer)
}, [highlightCode, cause])
return (
<>
<BuiltinError error={error} />
{cause && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
<ErrorDetailValue>
<div
className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: highlightedString }}
/>
</ErrorDetailValue>
<ErrorDetailValue>{error.cause}</ErrorDetailValue>
</ErrorDetailItem>
)}
</>

View File

@@ -1,6 +1,4 @@
import { cn } from '@heroui/react'
import { loggerService } from '@logger'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useAssistant } from '@renderer/hooks/useAssistant'
@@ -227,28 +225,20 @@ const MessageItem: FC<Props> = ({
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter">
<HorizontalScrollContainer
classNames={{
content: cn(
'flex-1 items-center justify-between',
isLastMessage && messageStyle === 'plain' ? 'flex-row-reverse' : 'flex-row'
)
}}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
</HorizontalScrollContainer>
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
</MessageFooter>
)}
</>
@@ -292,8 +282,10 @@ const MessageContentContainer = styled(Scrollbar)`
overflow-y: auto;
`
const MessageFooter = styled.div`
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
display: flex;
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
align-items: center;
justify-content: space-between;
gap: 10px;

View File

@@ -339,7 +339,6 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
display: grid;
overflow-y: visible;
gap: 16px;
&.horizontal {
padding-bottom: 4px;
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
@@ -406,7 +405,6 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
}
}
&.grid {
display: block;
height: 300px;
overflow-y: hidden;
border: 0.5px solid var(--color-border);

View File

@@ -525,11 +525,6 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onContextMenu={() => setTargetTopic(topic)}
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
onDoubleClick={() => {
if (editingTopicId === topic.id && topicEdit.isEditing) return
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}
style={{
borderRadius,
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
@@ -546,7 +541,13 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onClick={(e) => e.stopPropagation()}
/>
) : (
<TopicName className={getTopicNameClassName()} title={topicName}>
<TopicName
className={getTopicNameClassName()}
title={topicName}
onDoubleClick={() => {
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}>
{topicName}
</TopicName>
)}
@@ -570,8 +571,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
} else {
handleDeleteClick(topic.id, e)
}
}}
onDoubleClick={(e) => e.stopPropagation()}>
}}>
{deletingTopicId === topic.id ? (
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (

View File

@@ -386,7 +386,6 @@ const Container = styled.div`
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
&:hover {
background-color: var(--color-list-item-hover);

View File

@@ -1,6 +1,4 @@
import { SyncOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Button } from 'antd'
@@ -12,7 +10,6 @@ const UpdateAppButton: FC = () => {
const { update } = useRuntime()
const { autoCheckUpdate } = useSettings()
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
if (!update) {
return null
@@ -26,15 +23,13 @@ const UpdateAppButton: FC = () => {
<Container>
<UpdateButton
className="nodrag"
onClick={onOpen}
onClick={() => window.api.showUpdateDialog()}
icon={<SyncOutlined />}
color="orange"
variant="outlined"
size="small">
{t('button.update_available')}
</UpdateButton>
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
</Container>
)
}

View File

@@ -14,7 +14,6 @@ import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar'
import WebviewSearch from './components/WebviewSearch'
const logger = loggerService.withContext('MinAppPage')
@@ -185,7 +184,6 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools}
/>
</ToolbarWrapper>
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
{!isReady && (
<LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />

View File

@@ -8,7 +8,6 @@ import {
PushpinOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { loggerService } from '@logger'
import { isDev } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useMinapps } from '@renderer/hooks/useMinapps'
@@ -18,21 +17,11 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
const logger = loggerService.withContext('MinimalToolbar')
// Constants for timing delays
const WEBVIEW_CHECK_INITIAL_MS = 100 // Initial check interval
const WEBVIEW_CHECK_MAX_MS = 1000 // Maximum check interval (1 second)
const WEBVIEW_CHECK_MULTIPLIER = 2 // Exponential backoff multiplier
const WEBVIEW_CHECK_MAX_ATTEMPTS = 30 // Stop after ~30 seconds total
const NAVIGATION_UPDATE_DELAY_MS = 50
const NAVIGATION_COMPLETE_DELAY_MS = 100
interface Props {
app: MinAppType
webviewRef: React.RefObject<WebviewTag | null>
@@ -53,166 +42,27 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
const isPinned = pinned.some((item) => item.id === app.id)
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
// Ref to track navigation update timeout
const navigationUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Update navigation state
const updateNavigationState = useCallback(() => {
if (webviewRef.current) {
try {
setCanGoBack(webviewRef.current.canGoBack())
setCanGoForward(webviewRef.current.canGoForward())
} catch (error) {
logger.debug('WebView not ready for navigation state update', { appId: app.id })
setCanGoBack(false)
setCanGoForward(false)
}
} else {
setCanGoBack(false)
setCanGoForward(false)
setCanGoBack(webviewRef.current.canGoBack())
setCanGoForward(webviewRef.current.canGoForward())
}
}, [app.id, webviewRef])
// Schedule navigation state update with debouncing
const scheduleNavigationUpdate = useCallback(
(delay: number) => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
navigationUpdateTimeoutRef.current = setTimeout(() => {
updateNavigationState()
navigationUpdateTimeoutRef.current = null
}, delay)
},
[updateNavigationState]
)
// Cleanup navigation timeout on unmount
useEffect(() => {
return () => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
}
}, [])
// Monitor webviewRef changes and update navigation state
useEffect(() => {
let checkTimeout: NodeJS.Timeout | null = null
let navigationListener: (() => void) | null = null
let listenersAttached = false
let currentInterval = WEBVIEW_CHECK_INITIAL_MS
let attemptCount = 0
const attachListeners = () => {
if (webviewRef.current && !listenersAttached) {
// Update state immediately
updateNavigationState()
// Add navigation event listeners
const handleNavigation = () => {
scheduleNavigationUpdate(NAVIGATION_UPDATE_DELAY_MS)
}
webviewRef.current.addEventListener('did-navigate', handleNavigation)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigation)
listenersAttached = true
navigationListener = () => {
if (webviewRef.current) {
webviewRef.current.removeEventListener('did-navigate', handleNavigation)
webviewRef.current.removeEventListener('did-navigate-in-page', handleNavigation)
}
listenersAttached = false
}
if (checkTimeout) {
clearTimeout(checkTimeout)
checkTimeout = null
}
logger.debug('Navigation listeners attached', { appId: app.id, attempts: attemptCount })
return true
}
return false
}
const scheduleCheck = () => {
checkTimeout = setTimeout(() => {
// Use requestAnimationFrame to avoid blocking the main thread
requestAnimationFrame(() => {
attemptCount++
if (!attachListeners()) {
// Stop checking after max attempts to prevent infinite loops
if (attemptCount >= WEBVIEW_CHECK_MAX_ATTEMPTS) {
logger.warn('WebView attachment timeout', {
appId: app.id,
attempts: attemptCount,
totalTimeMs: currentInterval * attemptCount
})
return
}
// Exponential backoff: double the interval up to the maximum
currentInterval = Math.min(currentInterval * WEBVIEW_CHECK_MULTIPLIER, WEBVIEW_CHECK_MAX_MS)
// Log only on first few attempts or when interval changes significantly
if (attemptCount <= 3 || attemptCount % 10 === 0) {
logger.debug('WebView not ready, scheduling next check', {
appId: app.id,
nextCheckMs: currentInterval,
attempt: attemptCount
})
}
scheduleCheck()
}
})
}, currentInterval)
}
// Check for webview attachment
if (!webviewRef.current) {
scheduleCheck()
} else {
attachListeners()
}
// Cleanup
return () => {
if (checkTimeout) clearTimeout(checkTimeout)
if (navigationListener) navigationListener()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app.id, updateNavigationState, scheduleNavigationUpdate]) // webviewRef excluded as it's a ref object
}, [webviewRef])
const handleGoBack = useCallback(() => {
if (webviewRef.current) {
try {
if (webviewRef.current.canGoBack()) {
webviewRef.current.goBack()
// Delay update to ensure navigation completes
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
}
} catch (error) {
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goBack' })
}
if (webviewRef.current && webviewRef.current.canGoBack()) {
webviewRef.current.goBack()
updateNavigationState()
}
}, [app.id, webviewRef, scheduleNavigationUpdate])
}, [webviewRef, updateNavigationState])
const handleGoForward = useCallback(() => {
if (webviewRef.current) {
try {
if (webviewRef.current.canGoForward()) {
webviewRef.current.goForward()
// Delay update to ensure navigation completes
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
}
} catch (error) {
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goForward' })
}
if (webviewRef.current && webviewRef.current.canGoForward()) {
webviewRef.current.goForward()
updateNavigationState()
}
}, [app.id, webviewRef, scheduleNavigationUpdate])
}, [webviewRef, updateNavigationState])
const handleMinimize = useCallback(() => {
navigate('/apps')

View File

@@ -1,377 +0,0 @@
import { Button, Input } from '@heroui/react'
import { loggerService } from '@logger'
import type { WebviewTag } from 'electron'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type FoundInPageResult = Electron.FoundInPageResult
interface WebviewSearchProps {
webviewRef: React.RefObject<WebviewTag | null>
isWebviewReady: boolean
appId: string
}
const logger = loggerService.withContext('WebviewSearch')
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
const [query, setQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null)
const activeWebview = webviewRef.current ?? null
const focusInput = useCallback(() => {
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
focusFrameRef.current = window.requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}, [])
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
if (!options?.keepQuery) {
setQuery('')
}
setMatchCount(0)
setActiveIndex(0)
}, [])
const ensureWebviewReady = useCallback(
(candidate: WebviewTag | null) => {
if (!candidate) return null
try {
const webContentsId = candidate.getWebContentsId?.()
if (!webContentsId) {
logger.debug('WebviewSearch: missing webContentsId before action', { appId })
return null
}
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed before action', { appId, error })
return null
}
return candidate
},
[appId]
)
const stopFindOnWebview = useCallback(
(webview: WebviewTag | null) => {
const usable = ensureWebviewReady(webview)
if (!usable) return false
try {
usable.stopFindInPage('clearSelection')
return true
} catch (error) {
logger.debug('stopFindInPage failed', { appId, error })
return false
}
},
[appId, ensureWebviewReady]
)
const getUsableWebview = useCallback(() => {
const candidates = [webviewRef.current, attachedWebviewRef.current]
for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate)
if (usable) {
return usable
}
}
return null
}, [ensureWebviewReady, webviewRef])
const stopSearch = useCallback(() => {
const target = getUsableWebview()
if (!target) return
stopFindOnWebview(target)
}, [getUsableWebview, stopFindOnWebview])
const closeSearch = useCallback(() => {
setIsVisible(false)
stopSearch()
resetSearchState({ keepQuery: true })
}, [resetSearchState, stopSearch])
const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => {
const target = getUsableWebview()
if (!target) {
logger.debug('Skip performSearch: webview not attached')
return
}
if (!text) {
stopSearch()
resetSearchState({ keepQuery: true })
return
}
try {
target.findInPage(text, options)
} catch (error) {
logger.error('findInPage failed', { error })
window.toast?.error(t('common.error'))
}
},
[getUsableWebview, resetSearchState, stopSearch, t]
)
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
if (!event.result) return
const { activeMatchOrdinal, matches } = event.result
if (matches !== undefined) {
setMatchCount(matches)
}
if (activeMatchOrdinal !== undefined) {
setActiveIndex(activeMatchOrdinal)
}
}, [])
const openSearch = useCallback(() => {
if (!isWebviewReady) {
logger.debug('Skip openSearch: webview not ready')
return
}
setIsVisible(true)
focusInput()
}, [focusInput, isWebviewReady])
const goToNext = useCallback(() => {
if (!query) return
performSearch(query, { forward: true, findNext: true })
}, [performSearch, query])
const goToPrevious = useCallback(() => {
if (!query) return
performSearch(query, { forward: false, findNext: true })
}, [performSearch, query])
useEffect(() => {
attachedWebviewRef.current = activeWebview
if (!activeWebview) {
return
}
const handle = handleFoundInPage
activeWebview.addEventListener('found-in-page', handle)
return () => {
activeWebview.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === activeWebview) {
stopFindOnWebview(activeWebview)
attachedWebviewRef.current = null
}
}
}, [activeWebview, handleFoundInPage, stopFindOnWebview])
useEffect(() => {
if (!activeWebview) return
if (!isWebviewReady) return
const onFindShortcut = window.api?.webview?.onFindShortcut
if (!onFindShortcut) return
let webContentsId: number | undefined
try {
webContentsId = activeWebview.getWebContentsId?.()
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed', { appId, error })
return
}
if (!webContentsId) {
logger.warn('WebviewSearch: missing webContentsId', { appId })
return
}
const unsubscribe = onFindShortcut(({ webviewId, key, control, meta, shift }) => {
if (webviewId !== webContentsId) return
if ((control || meta) && key === 'f') {
openSearch()
return
}
if (!isVisible) return
if (key === 'escape') {
closeSearch()
return
}
if (key === 'enter') {
if (shift) {
goToPrevious()
} else {
goToNext()
}
}
})
return () => {
unsubscribe?.()
}
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch])
useEffect(() => {
if (!isVisible) return
focusInput()
}, [focusInput, isVisible])
useEffect(() => {
if (!isVisible) return
if (!query) {
performSearch('')
return
}
performSearch(query)
}, [activeWebview, isVisible, performSearch, query])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
openSearch()
return
}
if (!isVisible) return
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (event.shiftKey) {
goToPrevious()
} else {
goToNext()
}
}
}
window.addEventListener('keydown', handleKeydown, true)
return () => {
window.removeEventListener('keydown', handleKeydown, true)
}
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
useEffect(() => {
if (!isWebviewReady) {
setIsVisible(false)
resetSearchState()
stopSearch()
return
}
}, [isWebviewReady, resetSearchState, stopSearch])
useEffect(() => {
if (!appId) return
if (lastAppIdRef.current === appId) return
lastAppIdRef.current = appId
setIsVisible(false)
resetSearchState()
stopSearch()
}, [appId, resetSearchState, stopSearch])
useEffect(() => {
return () => {
stopSearch()
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}
}, [stopSearch])
if (!isVisible) {
return null
}
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
const disableNavigation = !query || matchCount === 0
return (
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
<Input
ref={inputRef}
autoFocus
value={query}
onValueChange={setQuery}
spellCheck={'false'}
placeholder={t('common.search')}
size="sm"
radius="sm"
variant="flat"
classNames={{
base: 'w-[240px]',
inputWrapper:
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
input: 'text-small focus:outline-none focus-visible:outline-none',
innerWrapper: 'gap-0'
}}
/>
<span
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
title={noResultTitle}
role="status"
aria-live="polite"
aria-atomic="true">
{matchLabel}
</span>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToPrevious}
isDisabled={disableNavigation}
aria-label="Previous match"
className="text-default-500 hover:text-default-900">
<ChevronUp size={16} />
</Button>
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToNext}
isDisabled={disableNavigation}
aria-label="Next match"
className="text-default-500 hover:text-default-900">
<ChevronDown size={16} />
</Button>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={closeSearch}
aria-label={t('common.close')}
className="text-default-500 hover:text-default-900">
<X size={16} />
</Button>
</div>
)
}
export default WebviewSearch

View File

@@ -1,396 +0,0 @@
import type { WebviewKeyEvent } from '@shared/config/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { WebviewTag } from 'electron'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import WebviewSearch from '../WebviewSearch'
const translations: Record<string, string> = {
'common.close': 'Close',
'common.error': 'Error',
'common.no_results': 'No results',
'common.search': 'Search'
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => translations[key] ?? key
})
}))
const createWebviewMock = () => {
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
const findInPageMock = vi.fn()
const stopFindInPageMock = vi.fn()
const webview = {
addEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
if (!listeners.has(type)) {
listeners.set(type, new Set())
}
listeners.get(type)!.add(listener)
}
),
removeEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
listeners.get(type)?.delete(listener)
}
),
getWebContentsId: vi.fn(() => 1),
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
} as unknown as WebviewTag
const emit = (type: string, result?: Electron.FoundInPageResult) => {
listeners.get(type)?.forEach((listener) => {
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
event.result = result
listener(event)
})
}
return {
emit,
findInPageMock,
stopFindInPageMock,
webview
}
}
const openSearchOverlay = async () => {
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
}
const originalRAF = window.requestAnimationFrame
const originalCAF = window.cancelAnimationFrame
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
})
const cancelAnimationFrameMock = vi.fn()
beforeAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: requestAnimationFrameMock,
writable: true
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: cancelAnimationFrameMock,
writable: true
})
})
afterAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: originalRAF
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: originalCAF
})
})
describe('WebviewSearch', () => {
const toastMock = {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
addToast: vi.fn()
}
let removeFindShortcutListenerMock: ReturnType<typeof vi.fn>
let onFindShortcutMock: ReturnType<typeof vi.fn>
const invokeLatestShortcut = (payload: WebviewKeyEvent) => {
const handler = onFindShortcutMock.mock.calls.at(-1)?.[0] as ((args: WebviewKeyEvent) => void) | undefined
if (!handler) {
throw new Error('Shortcut handler not registered')
}
act(() => {
handler(payload)
})
}
beforeEach(() => {
removeFindShortcutListenerMock = vi.fn()
onFindShortcutMock = vi.fn(() => removeFindShortcutListenerMock)
Object.assign(window as any, {
api: {
webview: {
onFindShortcut: onFindShortcutMock
}
}
})
Object.assign(window, { toast: toastMock })
})
afterEach(() => {
vi.clearAllMocks()
Reflect.deleteProperty(window, 'api')
})
it('opens the search overlay with keyboard shortcut', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
await openSearchOverlay()
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
it('opens the search overlay when webview shortcut is forwarded', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
})
it('skips shortcut wiring when getWebContentsId throws', async () => {
const { webview } = createWebviewMock()
const error = new Error('not ready')
;(webview as any).getWebContentsId = vi.fn(() => {
throw error
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
expect(onFindShortcutMock).not.toHaveBeenCalled()
;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
})
it('does not call stopFindInPage when webview is not ready', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const error = new Error('loading')
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
stopFindInPageMock.mockImplementation(() => {
throw new Error('should not be called')
})
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount()
expect(stopFindInPageMock).not.toHaveBeenCalled()
})
it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
invokeLatestShortcut({ webviewId: 1, key: 'escape', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
})
it('performs searches and navigates between results', async () => {
const { emit, findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
await act(async () => {
emit('found-in-page', {
requestId: 1,
matches: 3,
activeMatchOrdinal: 1,
selectionArea: undefined as unknown as Electron.Rectangle,
finalUpdate: false
} as Electron.FoundInPageResult)
})
const nextButton = screen.getByRole('button', { name: 'Next match' })
await waitFor(() => {
expect(nextButton).not.toBeDisabled()
})
await user.click(nextButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
})
const previousButton = screen.getByRole('button', { name: 'Previous match' })
await user.click(previousButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('navigates results when enter is forwarded from the webview', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: true, findNext: true })
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: true, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('clears search state when appId changes', async () => {
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalled()
})
await act(async () => {
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
})
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
})
it('shows toast error when search fails', async () => {
const { findInPageMock, webview } = createWebviewMock()
findInPageMock.mockImplementation(() => {
throw new Error('findInPage failed')
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(toastMock.error).toHaveBeenCalledWith('Error')
})
})
it('stops search when component unmounts', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
stopFindInPageMock.mockClear()
unmount()
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
expect(removeFindShortcutListenerMock).toHaveBeenCalled()
})
it('ignores keyboard shortcut when webview is not ready', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
await act(async () => {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
})
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
expect(findInPageMock).not.toHaveBeenCalled()
})
})

View File

@@ -13,10 +13,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { menuItems } from './MenuConfig'
import { ExportContext, handleExportPDF } from './utils/exportUtils'
const logger = loggerService.withContext('HeaderNavbar')
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
const HeaderNavbar = ({
notesTree,
editorRef,
currentContent,
getCurrentNoteContent,
onToggleStar,
onExpandPath,
onRenameNode,
}) => {
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const { activeNode } = useActiveNode(notesTree)
const [breadcrumbItems, setBreadcrumbItems] = useState<
@@ -90,6 +99,15 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
[activeNode]
)
const handleExportPDFAction = useCallback(async () => {
const menuContext: ExportContext = {
editorRef,
currentContent,
fileName: activeNode?.name || t('notes.title')
}
await handleExportPDF(menuContext)
}, [editorRef, currentContent, activeNode])
const buildMenuItem = (item: any) => {
if (item.type === 'divider') {
return { type: 'divider' as const, key: item.key }
@@ -131,6 +149,8 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
onClick: () => {
if (item.copyAction) {
handleCopyContent()
} else if (item.exportPdfAction) {
handleExportPDFAction()
} else if (item.action) {
item.action(settings, updateSettings)
}

Some files were not shown because too many files have changed in this diff Show More