Compare commits
65 Commits
fix/react-
...
v1.6.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ed1e2fd5 | ||
|
|
3745593080 | ||
|
|
7fb6b1377b | ||
|
|
b8da00a99f | ||
|
|
e6d9758ab7 | ||
|
|
76574bc70d | ||
|
|
ff05179b1c | ||
|
|
3531fa5c3b | ||
|
|
7db69ee042 | ||
|
|
8d5d76cc00 | ||
|
|
ce5829c24d | ||
|
|
daecac80fa | ||
|
|
0d10f08295 | ||
|
|
47ec3da94b | ||
|
|
aa7145a442 | ||
|
|
c7f90be82d | ||
|
|
7b3cce91ea | ||
|
|
88ffee3f38 | ||
|
|
2173b677e7 | ||
|
|
40d7ef0712 | ||
|
|
2fa2e98a02 | ||
|
|
c10f6c029c | ||
|
|
8b3b79f633 | ||
|
|
0d86e16e28 | ||
|
|
a8600a5426 | ||
|
|
e208d60c10 | ||
|
|
6968302961 | ||
|
|
dd65fa2f71 | ||
|
|
679043f26b | ||
|
|
6a1fb9bc7e | ||
|
|
07619c11e5 | ||
|
|
b71ea99738 | ||
|
|
5a29e45b88 | ||
|
|
45195bb57d | ||
|
|
bb003c071c | ||
|
|
80c0fca963 | ||
|
|
c5629da46d | ||
|
|
8b16170b16 | ||
|
|
182321a1bd | ||
|
|
cacabfa56d | ||
|
|
aaef458010 | ||
|
|
43dff80211 | ||
|
|
1dfae45a12 | ||
|
|
26c7dd9976 | ||
|
|
5ce9209334 | ||
|
|
0502ff48f1 | ||
|
|
726b2570e2 | ||
|
|
a33e3971d9 | ||
|
|
98faa80835 | ||
|
|
49deece835 | ||
|
|
eccdd7643e | ||
|
|
7c8894616c | ||
|
|
170632a199 | ||
|
|
cd5841cdd4 | ||
|
|
763afc5ca2 | ||
|
|
45f033ff4e | ||
|
|
f8fadcc73f | ||
|
|
a94e5dad5f | ||
|
|
632fd4c567 | ||
|
|
401e17eb0e | ||
|
|
80fc118465 | ||
|
|
9a8d7640f5 | ||
|
|
2b3f6d5640 | ||
|
|
a2d81e6204 | ||
|
|
b6107c5fb1 |
@@ -1,8 +1,8 @@
|
|||||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
|
index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644
|
||||||
--- a/dist/index.mjs
|
--- a/dist/index.mjs
|
||||||
+++ b/dist/index.mjs
|
+++ b/dist/index.mjs
|
||||||
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
@@ -452,7 +452,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||||
|
|
||||||
// src/get-model-path.ts
|
// src/get-model-path.ts
|
||||||
function getModelPath(modelId) {
|
function getModelPath(modelId) {
|
||||||
@@ -125,21 +125,8 @@ afterSign: scripts/notarize.js
|
|||||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
What's New in v1.6.3
|
<!--LANG:en-->
|
||||||
|
Error Fixes and Stability Improvements
|
||||||
Features:
|
<!--LANG:zh-CN-->
|
||||||
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
|
错误修复和稳定性改善
|
||||||
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
|
<!--LANG:END-->
|
||||||
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
|
|
||||||
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
|
|
||||||
- Code Tools: Add GitHub Copilot CLI integration
|
|
||||||
|
|
||||||
Bug Fixes:
|
|
||||||
- Fix migration for missing providers
|
|
||||||
- Fix forked topic retaining old name after rename
|
|
||||||
- Restore first token latency reporting in metrics
|
|
||||||
- Fix UI scrollbar and overflow issues
|
|
||||||
|
|
||||||
Technical Updates:
|
|
||||||
- Upgrade to Electron 37.6.0
|
|
||||||
- Update dependencies across packages
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.6.3",
|
"version": "1.6.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@@ -86,8 +86,10 @@
|
|||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"officeparser": "^4.2.0",
|
"officeparser": "^4.2.0",
|
||||||
"os-proxy-config": "^1.1.2",
|
"os-proxy-config": "^1.1.2",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"selection-hook": "^1.0.12",
|
"selection-hook": "^1.0.12",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||||
@@ -325,6 +327,7 @@
|
|||||||
"string-width": "^7.2.0",
|
"string-width": "^7.2.0",
|
||||||
"striptags": "^3.2.0",
|
"striptags": "^3.2.0",
|
||||||
"styled-components": "^6.1.11",
|
"styled-components": "^6.1.11",
|
||||||
|
"swr": "^2.3.6",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"tiny-pinyin": "^1.3.2",
|
"tiny-pinyin": "^1.3.2",
|
||||||
@@ -369,7 +372,7 @@
|
|||||||
"undici": "6.21.2",
|
"undici": "6.21.2",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"vite": "npm:rolldown-vite@latest",
|
||||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||||
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
"@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.9.1",
|
"packageManager": "yarn@4.9.1",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export enum IpcChannel {
|
|||||||
App_SetLanguage = 'app:set-language',
|
App_SetLanguage = 'app:set-language',
|
||||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
|
||||||
App_CheckForUpdate = 'app:check-for-update',
|
App_CheckForUpdate = 'app:check-for-update',
|
||||||
|
App_QuitAndInstall = 'app:quit-and-install',
|
||||||
App_Reload = 'app:reload',
|
App_Reload = 'app:reload',
|
||||||
App_Quit = 'app:quit',
|
App_Quit = 'app:quit',
|
||||||
App_Info = 'app:info',
|
App_Info = 'app:info',
|
||||||
@@ -53,6 +53,7 @@ export enum IpcChannel {
|
|||||||
|
|
||||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||||
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
||||||
|
Webview_SearchHotkey = 'webview:search-hotkey',
|
||||||
|
|
||||||
// Open
|
// Open
|
||||||
Open_Path = 'open:path',
|
Open_Path = 'open:path',
|
||||||
@@ -229,7 +230,6 @@ export enum IpcChannel {
|
|||||||
// events
|
// events
|
||||||
BackupProgress = 'backup-progress',
|
BackupProgress = 'backup-progress',
|
||||||
ThemeUpdated = 'theme:updated',
|
ThemeUpdated = 'theme:updated',
|
||||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
|
||||||
RestoreProgress = 'restore-progress',
|
RestoreProgress = 'restore-progress',
|
||||||
UpdateError = 'update-error',
|
UpdateError = 'update-error',
|
||||||
UpdateAvailable = 'update-available',
|
UpdateAvailable = 'update-available',
|
||||||
@@ -331,6 +331,7 @@ export enum IpcChannel {
|
|||||||
|
|
||||||
// OCR
|
// OCR
|
||||||
OCR_ocr = 'ocr:ocr',
|
OCR_ocr = 'ocr:ocr',
|
||||||
|
OCR_ListProviders = 'ocr:list-providers',
|
||||||
|
|
||||||
// OVMS
|
// OVMS
|
||||||
Ovms_AddModel = 'ovms:add-model',
|
Ovms_AddModel = 'ovms:add-model',
|
||||||
@@ -342,5 +343,12 @@ export enum IpcChannel {
|
|||||||
Ovms_StopOVMS = 'ovms:stop-ovms',
|
Ovms_StopOVMS = 'ovms:stop-ovms',
|
||||||
|
|
||||||
// CherryAI
|
// CherryAI
|
||||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
Cherryai_GetSignature = 'cherryai:get-signature',
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
WebSocket_Start = 'webSocket:start',
|
||||||
|
WebSocket_Stop = 'webSocket:stop',
|
||||||
|
WebSocket_Status = 'webSocket:status',
|
||||||
|
WebSocket_SendFile = 'webSocket:send-file',
|
||||||
|
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,3 +22,25 @@ export type MCPProgressEvent = {
|
|||||||
callId: string
|
callId: string
|
||||||
progress: number // 0-1 range
|
progress: number // 0-1 range
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WebviewKeyEvent = {
|
||||||
|
webviewId: number
|
||||||
|
key: string
|
||||||
|
control: boolean
|
||||||
|
meta: boolean
|
||||||
|
shift: boolean
|
||||||
|
alt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketStatusResponse {
|
||||||
|
isRunning: boolean
|
||||||
|
port?: number
|
||||||
|
ip?: string
|
||||||
|
clientConnected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketCandidatesResponse {
|
||||||
|
host: string
|
||||||
|
interface: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,105 +5,171 @@ const { execSync } = require('child_process')
|
|||||||
const { downloadWithPowerShell } = require('./download')
|
const { downloadWithPowerShell } = require('./download')
|
||||||
|
|
||||||
// Base URL for downloading OVMS binaries
|
// Base URL for downloading OVMS binaries
|
||||||
const OVMS_PKG_NAME = 'ovms250911.zip'
|
const OVMS_RELEASE_BASE_URL =
|
||||||
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
|
'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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads and extracts the OVMS binary for the specified platform
|
* 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
|
||||||
*/
|
*/
|
||||||
async function downloadOvmsBinary() {
|
|
||||||
// Create output directory structure - OVMS goes into its own subdirectory
|
/**
|
||||||
|
* Clean old OVMS installation if it exists
|
||||||
|
*/
|
||||||
|
function cleanOldOvmsInstallation() {
|
||||||
|
console.log('Cleaning the existing OVMS installation...')
|
||||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||||
|
|
||||||
// Ensure directories exist
|
|
||||||
fs.mkdirSync(csDir, { recursive: true })
|
|
||||||
|
|
||||||
const csOvmsDir = path.join(csDir, 'ovms')
|
const csOvmsDir = path.join(csDir, 'ovms')
|
||||||
// Delete existing OVMS directory if it exists
|
|
||||||
if (fs.existsSync(csOvmsDir)) {
|
if (fs.existsSync(csOvmsDir)) {
|
||||||
fs.rmSync(csOvmsDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempdir = os.tmpdir()
|
|
||||||
const tempFilename = path.join(tempdir, 'ovms.zip')
|
|
||||||
|
|
||||||
// Try each URL until one succeeds
|
|
||||||
let downloadSuccess = false
|
|
||||||
let lastError = null
|
|
||||||
|
|
||||||
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
|
|
||||||
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
|
|
||||||
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
|
fs.rmSync(csOvmsDir, { recursive: true })
|
||||||
|
|
||||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
|
||||||
await downloadWithPowerShell(downloadUrl, tempFilename)
|
|
||||||
|
|
||||||
// If we get here, download was successful
|
|
||||||
downloadSuccess = true
|
|
||||||
console.log(`Successfully downloaded from: ${downloadUrl}`)
|
|
||||||
break
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
|
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
|
||||||
lastError = error
|
return 110
|
||||||
|
|
||||||
// Clean up failed download file if it exists
|
|
||||||
if (fs.existsSync(tempFilename)) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue to next URL if this one failed
|
|
||||||
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
|
|
||||||
console.log(`Trying next URL...`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any download succeeded
|
return 0
|
||||||
if (!downloadSuccess) {
|
}
|
||||||
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
|
|
||||||
|
/**
|
||||||
|
* 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
|
return 103
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// unzip the base package to the target directory
|
||||||
console.log(`Extracting to ${csDir}...`)
|
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
|
// Use tar.exe to extract the ZIP file
|
||||||
console.log(`Extracting OVMS to ${csDir}...`)
|
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
|
||||||
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
|
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
|
||||||
console.log(`OVMS extracted to ${csDir}`)
|
console.log(`OVMS extracted to ${csOvmsDir}`)
|
||||||
|
|
||||||
// Clean up temporary file
|
// Clean up temporary file
|
||||||
fs.unlinkSync(tempFilename)
|
fs.unlinkSync(tempFilename)
|
||||||
console.log(`Installation directory: ${csDir}`)
|
console.log(`Installation directory: ${csDir}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error installing OVMS: ${error.message}`)
|
console.error(`Error installing OVMS: ${error.message}`)
|
||||||
if (fs.existsSync(tempFilename)) {
|
fs.unlinkSync(tempFilename)
|
||||||
fs.unlinkSync(tempFilename)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ovmsDir is empty and remove it if so
|
|
||||||
try {
|
|
||||||
const ovmsDir = path.join(csDir, 'ovms')
|
|
||||||
const files = fs.readdirSync(ovmsDir)
|
|
||||||
if (files.length === 0) {
|
|
||||||
fs.rmSync(ovmsDir, { recursive: true })
|
|
||||||
console.log(`Removed empty directory: ${ovmsDir}`)
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
|
|
||||||
return 105
|
|
||||||
}
|
|
||||||
|
|
||||||
return 104
|
return 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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +224,27 @@ async function installOvms() {
|
|||||||
return 102
|
return 102
|
||||||
}
|
}
|
||||||
|
|
||||||
return await downloadOvmsBinary()
|
// 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
|
// Run the installation
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { TrayService } from './services/TrayService'
|
|||||||
import { windowService } from './services/WindowService'
|
import { windowService } from './services/WindowService'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
import { apiServerService } from './services/ApiServerService'
|
import { apiServerService } from './services/ApiServerService'
|
||||||
|
import { initWebviewHotkeys } from './services/WebviewService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MainEntry')
|
const logger = loggerService.withContext('MainEntry')
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
// Some APIs can only be used after this event occurs.
|
// Some APIs can only be used after this event occurs.
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
initWebviewHotkeys()
|
||||||
// Set app user model id for windows
|
// Set app user model id for windows
|
||||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
import storeSyncService from './services/StoreSyncService'
|
import storeSyncService from './services/StoreSyncService'
|
||||||
import { themeService } from './services/ThemeService'
|
import { themeService } from './services/ThemeService'
|
||||||
import VertexAIService from './services/VertexAIService'
|
import VertexAIService from './services/VertexAIService'
|
||||||
|
import WebSocketService from './services/WebSocketService'
|
||||||
import { setOpenLinkExternal } from './services/WebviewService'
|
import { setOpenLinkExternal } from './services/WebviewService'
|
||||||
import { windowService } from './services/WindowService'
|
import { windowService } from './services/WindowService'
|
||||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||||
@@ -132,7 +133,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
|
||||||
|
|
||||||
// language
|
// language
|
||||||
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||||
@@ -754,7 +755,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
|
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
|
||||||
setOpenLinkExternal(webviewId, isExternal)
|
setOpenLinkExternal(webviewId, isExternal)
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
||||||
const webview = webContents.fromId(webviewId)
|
const webview = webContents.fromId(webviewId)
|
||||||
if (!webview) return
|
if (!webview) return
|
||||||
@@ -844,6 +844,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
||||||
ocrService.ocr(file, provider)
|
ocrService.ocr(file, provider)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
|
||||||
|
|
||||||
// OVMS
|
// OVMS
|
||||||
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||||
@@ -858,4 +859,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
|
|
||||||
// CherryAI
|
// CherryAI
|
||||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start)
|
||||||
|
ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop)
|
||||||
|
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
|
||||||
|
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
|
||||||
|
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
|
||||||
}
|
}
|
||||||
|
|||||||
473
src/main/mcpServers/didi-mcp.ts
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
/**
|
||||||
|
* DiDi MCP Server Implementation
|
||||||
|
*
|
||||||
|
* Based on official DiDi MCP API capabilities.
|
||||||
|
* API Documentation: https://mcp.didichuxing.com/api?tap=api
|
||||||
|
*
|
||||||
|
* Provides ride-hailing services including map search, price estimation,
|
||||||
|
* order management, and driver tracking.
|
||||||
|
*
|
||||||
|
* Note: Only available in Mainland China.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('DiDiMCPServer')
|
||||||
|
|
||||||
|
export class DiDiMcpServer {
|
||||||
|
private _server: Server
|
||||||
|
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
|
||||||
|
private apiKey: string
|
||||||
|
|
||||||
|
constructor(apiKey?: string) {
|
||||||
|
this._server = new Server(
|
||||||
|
{
|
||||||
|
name: 'didi-mcp-server',
|
||||||
|
version: '0.1.0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get API key from parameter or environment variables
|
||||||
|
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
|
||||||
|
if (!this.apiKey) {
|
||||||
|
logger.warn('DIDI_API_KEY environment variable is not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupRequestHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
get server(): Server {
|
||||||
|
return this._server
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRequestHandlers() {
|
||||||
|
// List available tools
|
||||||
|
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
return {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'maps_textsearch',
|
||||||
|
description: 'Search for POI locations based on keywords and city',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
city: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Query city'
|
||||||
|
},
|
||||||
|
keywords: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Search keywords'
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Location coordinates, format: longitude,latitude'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['keywords', 'city']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'taxi_cancel_order',
|
||||||
|
description: 'Cancel a taxi order',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Order ID from order creation or query results'
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['order_id']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'taxi_create_order',
|
||||||
|
description: 'Create taxi order directly via API without opening any app interface',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
caller_car_phone: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Caller phone number (optional)'
|
||||||
|
},
|
||||||
|
estimate_trace_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Estimation trace ID from estimation results'
|
||||||
|
},
|
||||||
|
product_category: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['product_category', 'estimate_trace_id']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'taxi_estimate',
|
||||||
|
description: 'Get available ride-hailing vehicle types and fare estimates',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from_lat: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Departure latitude, must be from map tools'
|
||||||
|
},
|
||||||
|
from_lng: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Departure longitude, must be from map tools'
|
||||||
|
},
|
||||||
|
from_name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Departure location name'
|
||||||
|
},
|
||||||
|
to_lat: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Destination latitude, must be from map tools'
|
||||||
|
},
|
||||||
|
to_lng: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Destination longitude, must be from map tools'
|
||||||
|
},
|
||||||
|
to_name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Destination name'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'taxi_generate_ride_app_link',
|
||||||
|
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
from_lat: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Departure latitude, must be from map tools'
|
||||||
|
},
|
||||||
|
from_lng: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Departure longitude, must be from map tools'
|
||||||
|
},
|
||||||
|
product_category: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
|
||||||
|
},
|
||||||
|
to_lat: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Destination latitude, must be from map tools'
|
||||||
|
},
|
||||||
|
to_lng: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Destination longitude, must be from map tools'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'taxi_get_driver_location',
|
||||||
|
description: 'Get real-time driver location for a taxi order',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Taxi order ID'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['order_id']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'taxi_query_order',
|
||||||
|
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle tool calls
|
||||||
|
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (name) {
|
||||||
|
case 'maps_textsearch':
|
||||||
|
return await this.handleMapsTextSearch(args)
|
||||||
|
case 'taxi_cancel_order':
|
||||||
|
return await this.handleTaxiCancelOrder(args)
|
||||||
|
case 'taxi_create_order':
|
||||||
|
return await this.handleTaxiCreateOrder(args)
|
||||||
|
case 'taxi_estimate':
|
||||||
|
return await this.handleTaxiEstimate(args)
|
||||||
|
case 'taxi_generate_ride_app_link':
|
||||||
|
return await this.handleTaxiGenerateRideAppLink(args)
|
||||||
|
case 'taxi_get_driver_location':
|
||||||
|
return await this.handleTaxiGetDriverLocation(args)
|
||||||
|
case 'taxi_query_order':
|
||||||
|
return await this.handleTaxiQueryOrder(args)
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown tool: ${name}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error calling tool ${name}:`, error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMapsTextSearch(args: any) {
|
||||||
|
const { city, keywords, location } = args
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: 'maps_textsearch',
|
||||||
|
arguments: {
|
||||||
|
keywords,
|
||||||
|
city,
|
||||||
|
...(location && { location })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('tools/call', params)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Maps text search error:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTaxiCancelOrder(args: any) {
|
||||||
|
const { order_id, reason } = args
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: 'taxi_cancel_order',
|
||||||
|
arguments: {
|
||||||
|
order_id,
|
||||||
|
...(reason && { reason })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('tools/call', params)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Taxi cancel order error:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTaxiCreateOrder(args: any) {
|
||||||
|
const { caller_car_phone, estimate_trace_id, product_category } = args
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: 'taxi_create_order',
|
||||||
|
arguments: {
|
||||||
|
product_category,
|
||||||
|
estimate_trace_id,
|
||||||
|
...(caller_car_phone && { caller_car_phone })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('tools/call', params)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Taxi create order error:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTaxiEstimate(args: any) {
|
||||||
|
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: 'taxi_estimate',
|
||||||
|
arguments: {
|
||||||
|
from_lng,
|
||||||
|
from_lat,
|
||||||
|
from_name,
|
||||||
|
to_lng,
|
||||||
|
to_lat,
|
||||||
|
to_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('tools/call', params)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Taxi estimate error:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTaxiGenerateRideAppLink(args: any) {
|
||||||
|
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: 'taxi_generate_ride_app_link',
|
||||||
|
arguments: {
|
||||||
|
from_lng,
|
||||||
|
from_lat,
|
||||||
|
to_lng,
|
||||||
|
to_lat,
|
||||||
|
...(product_category && { product_category })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('tools/call', params)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Taxi generate ride app link error:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTaxiGetDriverLocation(args: any) {
|
||||||
|
const { order_id } = args
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: 'taxi_get_driver_location',
|
||||||
|
arguments: {
|
||||||
|
order_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('tools/call', params)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Taxi get driver location error:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTaxiQueryOrder(args: any) {
|
||||||
|
const { order_id } = args
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: 'taxi_query_order',
|
||||||
|
arguments: {
|
||||||
|
...(order_id && { order_id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('tools/call', params)
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Taxi query order error:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async makeRequest(method: string, params: any): Promise<any> {
|
||||||
|
const requestData = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: method,
|
||||||
|
id: Date.now(),
|
||||||
|
...(Object.keys(params).length > 0 && { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// API key is passed as URL parameter
|
||||||
|
const url = `${this.baseUrl}?key=${this.apiKey}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiDiMcpServer
|
||||||
@@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|||||||
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
||||||
|
|
||||||
import BraveSearchServer from './brave-search'
|
import BraveSearchServer from './brave-search'
|
||||||
|
import DiDiMcpServer from './didi-mcp'
|
||||||
import DifyKnowledgeServer from './dify-knowledge'
|
import DifyKnowledgeServer from './dify-knowledge'
|
||||||
import FetchServer from './fetch'
|
import FetchServer from './fetch'
|
||||||
import FileSystemServer from './filesystem'
|
import FileSystemServer from './filesystem'
|
||||||
@@ -42,6 +43,10 @@ export function createInMemoryMCPServer(
|
|||||||
case BuiltinMCPServerNames.python: {
|
case BuiltinMCPServerNames.python: {
|
||||||
return new PythonServer().server
|
return new PythonServer().server
|
||||||
}
|
}
|
||||||
|
case BuiltinMCPServerNames.didiMCP: {
|
||||||
|
const apiKey = envs.DIDI_API_KEY
|
||||||
|
return new DiDiMcpServer(apiKey).server
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { isWin } from '@main/constant'
|
import { isWin } from '@main/constant'
|
||||||
import { getIpCountry } from '@main/utils/ipService'
|
import { getIpCountry } from '@main/utils/ipService'
|
||||||
import { locales } from '@main/utils/locales'
|
|
||||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||||
import { app, BrowserWindow, dialog, net } from 'electron'
|
import { app, net } from 'electron'
|
||||||
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import semver from 'semver'
|
import semver from 'semver'
|
||||||
|
|
||||||
import icon from '../../../build/icon.png?asset'
|
|
||||||
import { configManager } from './ConfigManager'
|
import { configManager } from './ConfigManager'
|
||||||
import { windowService } from './WindowService'
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
|
|||||||
|
|
||||||
export default class AppUpdater {
|
export default class AppUpdater {
|
||||||
autoUpdater: _AppUpdater = autoUpdater
|
autoUpdater: _AppUpdater = autoUpdater
|
||||||
private releaseInfo: UpdateInfo | undefined
|
|
||||||
private cancellationToken: CancellationToken = new CancellationToken()
|
private cancellationToken: CancellationToken = new CancellationToken()
|
||||||
private updateCheckResult: UpdateCheckResult | null = null
|
private updateCheckResult: UpdateCheckResult | null = null
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ export default class AppUpdater {
|
|||||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||||
this.releaseInfo = processedReleaseInfo
|
|
||||||
logger.info('update downloaded', processedReleaseInfo)
|
logger.info('update downloaded', processedReleaseInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,37 +243,9 @@ export default class AppUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
public quitAndInstall() {
|
||||||
if (!this.releaseInfo) {
|
app.isQuitting = true
|
||||||
return
|
setImmediate(() => autoUpdater.quitAndInstall())
|
||||||
}
|
|
||||||
const locale = locales[configManager.getLanguage()]
|
|
||||||
const { update: updateLocale } = locale.translation
|
|
||||||
|
|
||||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
|
||||||
if (detail === '') {
|
|
||||||
detail = updateLocale.noReleaseNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog
|
|
||||||
.showMessageBox({
|
|
||||||
type: 'info',
|
|
||||||
title: updateLocale.title,
|
|
||||||
icon,
|
|
||||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
|
||||||
detail,
|
|
||||||
buttons: [updateLocale.later, updateLocale.install],
|
|
||||||
defaultId: 1,
|
|
||||||
cancelId: 0
|
|
||||||
})
|
|
||||||
.then(({ response }) => {
|
|
||||||
if (response === 1) {
|
|
||||||
app.isQuitting = true
|
|
||||||
setImmediate(() => autoUpdater.quitAndInstall())
|
|
||||||
} else {
|
|
||||||
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -349,38 +317,9 @@ export default class AppUpdater {
|
|||||||
|
|
||||||
return processedInfo
|
return processedInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format release notes for display
|
|
||||||
* @param releaseNotes - Release notes in various formats
|
|
||||||
* @returns Formatted string for display
|
|
||||||
*/
|
|
||||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
|
||||||
if (!releaseNotes) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof releaseNotes === 'string') {
|
|
||||||
// Check if it contains multi-language markers
|
|
||||||
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
|
||||||
return this.parseMultiLangReleaseNotes(releaseNotes)
|
|
||||||
}
|
|
||||||
return releaseNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(releaseNotes)) {
|
|
||||||
return releaseNotes.map((note) => note.note).join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
interface GithubReleaseInfo {
|
interface GithubReleaseInfo {
|
||||||
draft: boolean
|
draft: boolean
|
||||||
prerelease: boolean
|
prerelease: boolean
|
||||||
tag_name: string
|
tag_name: string
|
||||||
}
|
}
|
||||||
interface ReleaseNoteInfo {
|
|
||||||
readonly version: string
|
|
||||||
readonly note: string | null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
|
|||||||
import { fileStorage } from '@main/services/FileStorage'
|
import { fileStorage } from '@main/services/FileStorage'
|
||||||
import { windowService } from '@main/services/WindowService'
|
import { windowService } from '@main/services/WindowService'
|
||||||
import { getDataPath } from '@main/utils'
|
import { getDataPath } from '@main/utils'
|
||||||
import { getAllFiles } from '@main/utils/file'
|
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
|
||||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||||
import { MB } from '@shared/config/constant'
|
import { MB } from '@shared/config/constant'
|
||||||
import type { LoaderReturn } from '@shared/config/types'
|
import type { LoaderReturn } from '@shared/config/types'
|
||||||
@@ -147,11 +147,16 @@ class KnowledgeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDbPath = (id: string): string => {
|
||||||
|
// 消除网络搜索requestI d中的特殊字符
|
||||||
|
return path.join(this.storageDir, sanitizeFilename(id, '_'))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete knowledge base file
|
* Delete knowledge base file
|
||||||
*/
|
*/
|
||||||
private deleteKnowledgeFile = (id: string): boolean => {
|
private deleteKnowledgeFile = (id: string): boolean => {
|
||||||
const dbPath = path.join(this.storageDir, id)
|
const dbPath = this.getDbPath(id)
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(dbPath, { recursive: true })
|
fs.rmSync(dbPath, { recursive: true })
|
||||||
@@ -244,7 +249,8 @@ class KnowledgeService {
|
|||||||
dimensions
|
dimensions
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
const dbPath = this.getDbPath(id)
|
||||||
|
const libSqlDb = new LibSqlDb({ path: dbPath })
|
||||||
// Save database instance for later closing
|
// Save database instance for later closing
|
||||||
this.dbInstances.set(id, libSqlDb)
|
this.dbInstances.set(id, libSqlDb)
|
||||||
|
|
||||||
|
|||||||
358
src/main/services/WebSocketService.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { WebSocketCandidatesResponse, WebSocketStatusResponse } from '@shared/config/types'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import { networkInterfaces } from 'os'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { Server, Socket } from 'socket.io'
|
||||||
|
|
||||||
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('WebSocketService')
|
||||||
|
|
||||||
|
class WebSocketService {
|
||||||
|
private io: Server | null = null
|
||||||
|
private isStarted = false
|
||||||
|
private port = 7017
|
||||||
|
private connectedClients = new Set<string>()
|
||||||
|
|
||||||
|
private getLocalIpAddress(): string | undefined {
|
||||||
|
const interfaces = networkInterfaces()
|
||||||
|
|
||||||
|
// 按优先级排序的网络接口名称模式
|
||||||
|
const interfacePriority = [
|
||||||
|
// macOS: 以太网/Wi-Fi 优先
|
||||||
|
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
|
||||||
|
/^(en|eth)[0-9]+$/, // 以太网接口
|
||||||
|
/^wlan[0-9]+$/, // 无线接口
|
||||||
|
// Windows: 以太网/Wi-Fi 优先
|
||||||
|
/^(Ethernet|Wi-Fi|Local Area Connection)/,
|
||||||
|
/^(Wi-Fi|无线网络连接)/,
|
||||||
|
// Linux: 以太网/Wi-Fi 优先
|
||||||
|
/^(eth|enp|wlp|wlan)[0-9]+/,
|
||||||
|
// 虚拟化接口(低优先级)
|
||||||
|
/^bridge[0-9]+$/, // Docker bridge
|
||||||
|
/^veth[0-9]+$/, // Docker veth
|
||||||
|
/^docker[0-9]+/, // Docker interfaces
|
||||||
|
/^br-[0-9a-f]+/, // Docker bridge
|
||||||
|
/^vmnet[0-9]+$/, // VMware
|
||||||
|
/^vboxnet[0-9]+$/, // VirtualBox
|
||||||
|
// VPN 隧道接口(低优先级)
|
||||||
|
/^utun[0-9]+$/, // macOS VPN
|
||||||
|
/^tun[0-9]+$/, // Linux/Unix VPN
|
||||||
|
/^tap[0-9]+$/, // TAP interfaces
|
||||||
|
/^tailscale[0-9]*$/, // Tailscale VPN
|
||||||
|
/^wg[0-9]+$/ // WireGuard VPN
|
||||||
|
]
|
||||||
|
|
||||||
|
const candidates: Array<{ interface: string; address: string; priority: number }> = []
|
||||||
|
|
||||||
|
for (const [name, ifaces] of Object.entries(interfaces)) {
|
||||||
|
for (const iface of ifaces || []) {
|
||||||
|
if (iface.family === 'IPv4' && !iface.internal) {
|
||||||
|
// 计算接口优先级
|
||||||
|
let priority = 999 // 默认最低优先级
|
||||||
|
for (let i = 0; i < interfacePriority.length; i++) {
|
||||||
|
if (interfacePriority[i].test(name)) {
|
||||||
|
priority = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
interface: name,
|
||||||
|
address: iface.address,
|
||||||
|
priority
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1')
|
||||||
|
return '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级排序,选择优先级最高的
|
||||||
|
candidates.sort((a, b) => a.priority - b.priority)
|
||||||
|
const best = candidates[0]
|
||||||
|
|
||||||
|
logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`)
|
||||||
|
return best.address
|
||||||
|
}
|
||||||
|
|
||||||
|
public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => {
|
||||||
|
if (this.isStarted && this.io) {
|
||||||
|
return { success: true, port: this.port }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.io = new Server(this.port, {
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST']
|
||||||
|
},
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
allowEIO3: true,
|
||||||
|
pingTimeout: 60000,
|
||||||
|
pingInterval: 25000
|
||||||
|
})
|
||||||
|
|
||||||
|
this.io.on('connection', (socket: Socket) => {
|
||||||
|
this.connectedClients.add(socket.id)
|
||||||
|
|
||||||
|
const mainWindow = windowService.getMainWindow()
|
||||||
|
if (!mainWindow) {
|
||||||
|
logger.error('Main window is null, cannot send connection event')
|
||||||
|
} else {
|
||||||
|
mainWindow.webContents.send('websocket-client-connected', {
|
||||||
|
connected: true,
|
||||||
|
clientId: socket.id
|
||||||
|
})
|
||||||
|
logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
logger.info('Received message from mobile:', data)
|
||||||
|
mainWindow?.webContents.send('websocket-message-received', data)
|
||||||
|
socket.emit('message_received', { success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
logger.info(`Client disconnected: ${socket.id}`)
|
||||||
|
this.connectedClients.delete(socket.id)
|
||||||
|
|
||||||
|
if (this.connectedClients.size === 0) {
|
||||||
|
mainWindow?.webContents.send('websocket-client-connected', {
|
||||||
|
connected: false,
|
||||||
|
clientId: socket.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Engine 层面的事件监听
|
||||||
|
this.io.engine.on('connection_error', (err) => {
|
||||||
|
logger.error('Engine connection error:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.io.engine.on('connection', (rawSocket) => {
|
||||||
|
const remoteAddr = rawSocket.request.connection.remoteAddress
|
||||||
|
logger.info(`[Engine] Raw connection from: ${remoteAddr}`)
|
||||||
|
logger.info(`[Engine] Transport: ${rawSocket.transport.name}`)
|
||||||
|
|
||||||
|
rawSocket.on('packet', (packet: { type: string; data?: any }) => {
|
||||||
|
logger.info(
|
||||||
|
`[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`,
|
||||||
|
packet.data ? { data: packet.data } : {}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => {
|
||||||
|
logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
rawSocket.on('close', (reason: string) => {
|
||||||
|
logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
rawSocket.on('error', (error: Error) => {
|
||||||
|
logger.error(`[Engine] Connection error from ${remoteAddr}:`, error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Socket.IO 握手失败监听
|
||||||
|
this.io.on('connection_error', (err) => {
|
||||||
|
logger.error('[Socket.IO] Connection error during handshake:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.isStarted = true
|
||||||
|
logger.info(`WebSocket server started on port ${this.port}`)
|
||||||
|
|
||||||
|
return { success: true, port: this.port }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start WebSocket server:', error as Error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop = async (): Promise<{ success: boolean }> => {
|
||||||
|
if (!this.isStarted || !this.io) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.io!.close(() => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.io = null
|
||||||
|
this.isStarted = false
|
||||||
|
this.connectedClients.clear()
|
||||||
|
logger.info('WebSocket server stopped')
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to stop WebSocket server:', error as Error)
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus = async (): Promise<WebSocketStatusResponse> => {
|
||||||
|
return {
|
||||||
|
isRunning: this.isStarted,
|
||||||
|
port: this.isStarted ? this.port : undefined,
|
||||||
|
ip: this.isStarted ? this.getLocalIpAddress() : undefined,
|
||||||
|
clientConnected: this.connectedClients.size > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllCandidates = async (): Promise<WebSocketCandidatesResponse[]> => {
|
||||||
|
const interfaces = networkInterfaces()
|
||||||
|
|
||||||
|
// 按优先级排序的网络接口名称模式
|
||||||
|
const interfacePriority = [
|
||||||
|
// macOS: 以太网/Wi-Fi 优先
|
||||||
|
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
|
||||||
|
/^(en|eth)[0-9]+$/, // 以太网接口
|
||||||
|
/^wlan[0-9]+$/, // 无线接口
|
||||||
|
// Windows: 以太网/Wi-Fi 优先
|
||||||
|
/^(Ethernet|Wi-Fi|Local Area Connection)/,
|
||||||
|
/^(Wi-Fi|无线网络连接)/,
|
||||||
|
// Linux: 以太网/Wi-Fi 优先
|
||||||
|
/^(eth|enp|wlp|wlan)[0-9]+/,
|
||||||
|
// 虚拟化接口(低优先级)
|
||||||
|
/^bridge[0-9]+$/, // Docker bridge
|
||||||
|
/^veth[0-9]+$/, // Docker veth
|
||||||
|
/^docker[0-9]+/, // Docker interfaces
|
||||||
|
/^br-[0-9a-f]+/, // Docker bridge
|
||||||
|
/^vmnet[0-9]+$/, // VMware
|
||||||
|
/^vboxnet[0-9]+$/, // VirtualBox
|
||||||
|
// VPN 隧道接口(低优先级)
|
||||||
|
/^utun[0-9]+$/, // macOS VPN
|
||||||
|
/^tun[0-9]+$/, // Linux/Unix VPN
|
||||||
|
/^tap[0-9]+$/, // TAP interfaces
|
||||||
|
/^tailscale[0-9]*$/, // Tailscale VPN
|
||||||
|
/^wg[0-9]+$/ // WireGuard VPN
|
||||||
|
]
|
||||||
|
|
||||||
|
const candidates: Array<{ host: string; interface: string; priority: number }> = []
|
||||||
|
|
||||||
|
for (const [name, ifaces] of Object.entries(interfaces)) {
|
||||||
|
for (const iface of ifaces || []) {
|
||||||
|
if (iface.family === 'IPv4' && !iface.internal) {
|
||||||
|
// 计算接口优先级
|
||||||
|
let priority = 999 // 默认最低优先级
|
||||||
|
for (let i = 0; i < interfacePriority.length; i++) {
|
||||||
|
if (interfacePriority[i].test(name)) {
|
||||||
|
priority = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
host: iface.address,
|
||||||
|
interface: name,
|
||||||
|
priority
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级排序返回
|
||||||
|
candidates.sort((a, b) => a.priority - b.priority)
|
||||||
|
logger.info(
|
||||||
|
`Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}`
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendFile = async (
|
||||||
|
_: Electron.IpcMainInvokeEvent,
|
||||||
|
filePath: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
if (!this.isStarted || !this.io) {
|
||||||
|
const errorMsg = 'WebSocket server is not running.'
|
||||||
|
logger.error(errorMsg)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.connectedClients.size === 0) {
|
||||||
|
const errorMsg = 'No client connected.'
|
||||||
|
logger.error(errorMsg)
|
||||||
|
return { success: false, error: errorMsg }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = windowService.getMainWindow()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const stats = fs.statSync(filePath)
|
||||||
|
const totalSize = stats.size
|
||||||
|
const filename = path.basename(filePath)
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
let bytesSent = 0
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`)
|
||||||
|
|
||||||
|
// 向客户端发送文件开始的信号,包含文件名和总大小
|
||||||
|
this.io!.emit('zip-file-start', { filename, totalSize })
|
||||||
|
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
bytesSent += chunk.length
|
||||||
|
const progress = (bytesSent / totalSize) * 100
|
||||||
|
|
||||||
|
// 向客户端发送文件块
|
||||||
|
this.io!.emit('zip-file-chunk', chunk)
|
||||||
|
|
||||||
|
// 向渲染进程发送进度更新
|
||||||
|
mainWindow?.webContents.send('file-send-progress', { progress })
|
||||||
|
|
||||||
|
// 每10%记录一次进度
|
||||||
|
if (Math.floor(progress) % 10 === 0) {
|
||||||
|
const elapsed = (Date.now() - startTime) / 1000
|
||||||
|
const speed = elapsed > 0 ? bytesSent / elapsed : 0
|
||||||
|
logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
const totalTime = (Date.now() - startTime) / 1000
|
||||||
|
const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0
|
||||||
|
logger.info(
|
||||||
|
`File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 确保发送100%的进度
|
||||||
|
mainWindow?.webContents.send('file-send-progress', { progress: 100 })
|
||||||
|
// 向客户端发送文件结束的信号
|
||||||
|
this.io!.emit('zip-file-end')
|
||||||
|
resolve({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
stream.on('error', (error) => {
|
||||||
|
logger.error(`File transfer failed: ${filename}`, error)
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WebSocketService()
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { session, shell, webContents } from 'electron'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
|
import { app, session, shell, webContents } from 'electron'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* init the useragent of the webview session
|
* init the useragent of the webview session
|
||||||
@@ -36,3 +37,66 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachKeyboardHandler = (contents: Electron.WebContents) => {
|
||||||
|
if (contents.getType?.() !== 'webview') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
|
||||||
|
if (!input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = input.key?.toLowerCase()
|
||||||
|
if (!key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFindShortcut = (input.control || input.meta) && key === 'f'
|
||||||
|
const isEscape = key === 'escape'
|
||||||
|
const isEnter = key === 'enter'
|
||||||
|
|
||||||
|
if (!isFindShortcut && !isEscape && !isEnter) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = contents.hostWebContents
|
||||||
|
if (!host || host.isDestroyed()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
|
||||||
|
if (isFindShortcut) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the hotkey event to the renderer
|
||||||
|
// The renderer will decide whether to preventDefault for Escape and Enter
|
||||||
|
// based on whether the search bar is visible
|
||||||
|
host.send(IpcChannel.Webview_SearchHotkey, {
|
||||||
|
webviewId: contents.id,
|
||||||
|
key,
|
||||||
|
control: Boolean(input.control),
|
||||||
|
meta: Boolean(input.meta),
|
||||||
|
shift: Boolean(input.shift),
|
||||||
|
alt: Boolean(input.alt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
contents.on('before-input-event', handleBeforeInput)
|
||||||
|
contents.once('destroyed', () => {
|
||||||
|
contents.removeListener('before-input-event', handleBeforeInput)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initWebviewHotkeys() {
|
||||||
|
webContents.getAllWebContents().forEach((contents) => {
|
||||||
|
if (contents.isDestroyed()) return
|
||||||
|
attachKeyboardHandler(contents)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('web-contents-created', (_, contents) => {
|
||||||
|
attachKeyboardHandler(contents)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
|
|||||||
expect(result.releaseNotes).toBeNull()
|
expect(result.releaseNotes).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('formatReleaseNotes', () => {
|
|
||||||
it('should format string release notes with markers', () => {
|
|
||||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
|
||||||
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
|
||||||
|
|
||||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
|
||||||
|
|
||||||
expect(result).toBe('English')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should format string release notes without markers', () => {
|
|
||||||
const notes = 'Simple notes'
|
|
||||||
|
|
||||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
|
||||||
|
|
||||||
expect(result).toBe('Simple notes')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should format array release notes', () => {
|
|
||||||
const notes = [
|
|
||||||
{ version: '1.0.0', note: 'Note 1' },
|
|
||||||
{ version: '1.0.1', note: 'Note 2' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
|
||||||
|
|
||||||
expect(result).toBe('Note 1\nNote 2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle null release notes', () => {
|
|
||||||
const result = (appUpdater as any).formatReleaseNotes(null)
|
|
||||||
|
|
||||||
expect(result).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle undefined release notes', () => {
|
|
||||||
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
|
||||||
|
|
||||||
expect(result).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { loggerService } from '@logger'
|
|||||||
import { isLinux } from '@main/constant'
|
import { isLinux } from '@main/constant'
|
||||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||||
|
|
||||||
|
import { ovOcrService } from './builtin/OvOcrService'
|
||||||
import { ppocrService } from './builtin/PpocrService'
|
import { ppocrService } from './builtin/PpocrService'
|
||||||
import { systemOcrService } from './builtin/SystemOcrService'
|
import { systemOcrService } from './builtin/SystemOcrService'
|
||||||
import { tesseractService } from './builtin/TesseractService'
|
import { tesseractService } from './builtin/TesseractService'
|
||||||
@@ -22,6 +23,10 @@ export class OcrService {
|
|||||||
this.registry.delete(providerId)
|
this.registry.delete(providerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public listProviderIds(): string[] {
|
||||||
|
return Array.from(this.registry.keys())
|
||||||
|
}
|
||||||
|
|
||||||
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
|
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
|
||||||
const handler = this.registry.get(provider.id)
|
const handler = this.registry.get(provider.id)
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
@@ -39,3 +44,5 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
|
|||||||
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||||
|
|
||||||
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
|
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
|
||||||
|
|
||||||
|
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))
|
||||||
|
|||||||
128
src/main/services/ocr/builtin/OvOcrService.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { isWin } from '@main/constant'
|
||||||
|
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as os from 'os'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
import { OcrBaseService } from './OcrBaseService'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('OvOcrService')
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
|
||||||
|
|
||||||
|
export class OvOcrService extends OcrBaseService {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
public isAvailable(): boolean {
|
||||||
|
return (
|
||||||
|
isWin &&
|
||||||
|
os.cpus()[0].model.toLowerCase().includes('intel') &&
|
||||||
|
os.cpus()[0].model.toLowerCase().includes('ultra') &&
|
||||||
|
fs.existsSync(PATH_BAT_FILE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOvOcrPath(): string {
|
||||||
|
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImgDir(): string {
|
||||||
|
return path.join(this.getOvOcrPath(), 'img')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOutputDir(): string {
|
||||||
|
return path.join(this.getOvOcrPath(), 'output')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearDirectory(dirPath: string): Promise<void> {
|
||||||
|
if (fs.existsSync(dirPath)) {
|
||||||
|
const files = await fs.promises.readdir(dirPath)
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(dirPath, file)
|
||||||
|
const stats = await fs.promises.stat(filePath)
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await this.clearDirectory(filePath)
|
||||||
|
await fs.promises.rmdir(filePath)
|
||||||
|
} else {
|
||||||
|
await fs.promises.unlink(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the directory does not exist, create it
|
||||||
|
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
|
||||||
|
const imgDir = this.getImgDir()
|
||||||
|
const targetFilePath = path.join(imgDir, targetFileName)
|
||||||
|
await fs.promises.copyFile(sourceFilePath, targetFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runOcrBatch(): Promise<void> {
|
||||||
|
const ovOcrPath = this.getOvOcrPath()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute run.bat in the ov-ocr directory
|
||||||
|
await execAsync(`"${PATH_BAT_FILE}"`, {
|
||||||
|
cwd: ovOcrPath,
|
||||||
|
timeout: 60000 // 60 second timeout
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error running ovocr batch: ${error}`)
|
||||||
|
throw new Error(`Failed to run OCR batch: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
|
||||||
|
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Clear img directory and output directory
|
||||||
|
await this.clearDirectory(this.getImgDir())
|
||||||
|
await this.clearDirectory(this.getOutputDir())
|
||||||
|
|
||||||
|
// 2. Copy file to img directory
|
||||||
|
const fileName = path.basename(filePath)
|
||||||
|
await this.copyFileToImgDir(filePath, fileName)
|
||||||
|
logger.info(`File copied to img directory: ${fileName}`)
|
||||||
|
|
||||||
|
// 3. Run run.bat
|
||||||
|
logger.info('Running OV OCR batch process...')
|
||||||
|
await this.runOcrBatch()
|
||||||
|
|
||||||
|
// 4. Check that output/[basename].txt file exists
|
||||||
|
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
|
||||||
|
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
|
||||||
|
if (!fs.existsSync(outputFilePath)) {
|
||||||
|
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Read output/[basename].txt file content
|
||||||
|
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
|
||||||
|
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
|
||||||
|
|
||||||
|
// 6. Return result
|
||||||
|
return { text: ocrText }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error during OV OCR process: ${error}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
|
||||||
|
if (isImageFileMetadata(file)) {
|
||||||
|
return this.ocrImage(file.path, options)
|
||||||
|
} else {
|
||||||
|
throw new Error('Unsupported file type, currently only image files are supported')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ovOcrService = new OvOcrService()
|
||||||
@@ -9,13 +9,20 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl')
|
|||||||
|
|
||||||
function installMCPServer(server: MCPServer) {
|
function installMCPServer(server: MCPServer) {
|
||||||
const mainWindow = windowService.getMainWindow()
|
const mainWindow = windowService.getMainWindow()
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
if (!server.id) {
|
const payload: MCPServer = {
|
||||||
server.id = nanoid()
|
...server,
|
||||||
|
id: server.id ?? nanoid(),
|
||||||
|
installSource: 'protocol',
|
||||||
|
isTrusted: false,
|
||||||
|
isActive: false,
|
||||||
|
trustedAt: undefined,
|
||||||
|
installedAt: server.installedAt ?? now
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server)
|
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
|||||||
import { SpanContext } from '@opentelemetry/api'
|
import { SpanContext } from '@opentelemetry/api'
|
||||||
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
|
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
|
||||||
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
||||||
import type { FileChangeEvent } from '@shared/config/types'
|
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import type { Notification } from '@types'
|
import type { Notification } from '@types'
|
||||||
import {
|
import {
|
||||||
@@ -51,7 +51,7 @@ const api = {
|
|||||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
|
||||||
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||||
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
||||||
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
||||||
@@ -221,7 +221,7 @@ const api = {
|
|||||||
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
||||||
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
||||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
||||||
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
|
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
|
||||||
add: ({
|
add: ({
|
||||||
base,
|
base,
|
||||||
item,
|
item,
|
||||||
@@ -388,7 +388,16 @@ const api = {
|
|||||||
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
|
||||||
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
|
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
|
||||||
|
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
storeSync: {
|
storeSync: {
|
||||||
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
|
||||||
@@ -465,7 +474,8 @@ const api = {
|
|||||||
},
|
},
|
||||||
ocr: {
|
ocr: {
|
||||||
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
|
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
|
||||||
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
|
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
|
||||||
|
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
|
||||||
},
|
},
|
||||||
cherryai: {
|
cherryai: {
|
||||||
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
||||||
@@ -485,6 +495,13 @@ const api = {
|
|||||||
ipcRenderer.removeListener(channel, listener)
|
ipcRenderer.removeListener(channel, listener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
webSocket: {
|
||||||
|
start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start),
|
||||||
|
stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop),
|
||||||
|
status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status),
|
||||||
|
sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath),
|
||||||
|
getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
|
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
|
||||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||||
|
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
|
||||||
|
import { formatErrorMessage } from '@renderer/utils/error'
|
||||||
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
|
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
|
||||||
import type { TextStreamPart, ToolSet } from 'ai'
|
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
|
||||||
|
|
||||||
import { ToolCallChunkHandler } from './handleToolCallChunk'
|
import { ToolCallChunkHandler } from './handleToolCallChunk'
|
||||||
|
|
||||||
@@ -340,7 +342,14 @@ export class AiSdkToChunkAdapter {
|
|||||||
case 'error':
|
case 'error':
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.ERROR,
|
type: ChunkType.ERROR,
|
||||||
error: chunk.error as Record<string, any>
|
error:
|
||||||
|
chunk.error instanceof AISDKError
|
||||||
|
? chunk.error
|
||||||
|
: new ProviderSpecificError({
|
||||||
|
message: formatErrorMessage(chunk.error),
|
||||||
|
provider: 'unknown',
|
||||||
|
cause: chunk.error
|
||||||
|
})
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -83,10 +83,8 @@ export default class ModernAiProvider {
|
|||||||
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保配置存在
|
// 每次请求时重新生成配置以确保API key轮换生效
|
||||||
if (!this.config) {
|
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
||||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 准备特殊配置
|
// 准备特殊配置
|
||||||
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
||||||
|
|||||||
@@ -70,13 +70,19 @@ export abstract class BaseApiClient<
|
|||||||
{
|
{
|
||||||
public provider: Provider
|
public provider: Provider
|
||||||
protected host: string
|
protected host: string
|
||||||
protected apiKey: string
|
|
||||||
protected sdkInstance?: TSdkInstance
|
protected sdkInstance?: TSdkInstance
|
||||||
|
|
||||||
constructor(provider: Provider) {
|
constructor(provider: Provider) {
|
||||||
this.provider = provider
|
this.provider = provider
|
||||||
this.host = this.getBaseURL()
|
this.host = this.getBaseURL()
|
||||||
this.apiKey = this.getApiKey()
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current API key with rotation support
|
||||||
|
* This getter ensures API keys rotate on each access when multiple keys are configured
|
||||||
|
*/
|
||||||
|
protected get apiKey(): string {
|
||||||
|
return this.getApiKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
isGPT5SeriesModel,
|
isGPT5SeriesModel,
|
||||||
isGrokReasoningModel,
|
isGrokReasoningModel,
|
||||||
isNotSupportSystemMessageModel,
|
isNotSupportSystemMessageModel,
|
||||||
|
isOpenAIDeepResearchModel,
|
||||||
isOpenAIOpenWeightModel,
|
isOpenAIOpenWeightModel,
|
||||||
isOpenAIReasoningModel,
|
isOpenAIReasoningModel,
|
||||||
isQwenAlwaysThinkModel,
|
isQwenAlwaysThinkModel,
|
||||||
@@ -125,6 +126,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOpenAIDeepResearchModel(model)) {
|
||||||
|
return {
|
||||||
|
reasoning_effort: 'medium'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||||
|
|
||||||
if (isSupportedThinkingTokenZhipuModel(model)) {
|
if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { isVisionModel } from '@renderer/config/models'
|
import { isVisionModel } from '@renderer/config/models'
|
||||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||||
import type { Model } from '@renderer/types'
|
import type { EndpointType, Model } from '@renderer/types'
|
||||||
import { FileTypes } from '@renderer/types'
|
import { FileTypes } from '@renderer/types'
|
||||||
|
|
||||||
import { getAiSdkProviderId } from '../provider/factory'
|
import { getAiSdkProviderId } from '../provider/factory'
|
||||||
@@ -17,12 +17,14 @@ function modelSupportValidator(
|
|||||||
supportedModels = [],
|
supportedModels = [],
|
||||||
unsupportedModels = [],
|
unsupportedModels = [],
|
||||||
supportedProviders = [],
|
supportedProviders = [],
|
||||||
unsupportedProviders = []
|
unsupportedProviders = [],
|
||||||
|
supportedEndpointTypes = []
|
||||||
}: {
|
}: {
|
||||||
supportedModels?: string[]
|
supportedModels?: string[]
|
||||||
unsupportedModels?: string[]
|
unsupportedModels?: string[]
|
||||||
supportedProviders?: string[]
|
supportedProviders?: string[]
|
||||||
unsupportedProviders?: string[]
|
unsupportedProviders?: string[]
|
||||||
|
supportedEndpointTypes?: EndpointType[]
|
||||||
}
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
const provider = getProviderByModel(model)
|
const provider = getProviderByModel(model)
|
||||||
@@ -43,6 +45,11 @@ function modelSupportValidator(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据 EndpointType 进行判断
|
||||||
|
if (model.endpoint_type && supportedEndpointTypes.includes(model.endpoint_type)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// 回退到提供商判断
|
// 回退到提供商判断
|
||||||
return supportedProviders.includes(aiSdkId)
|
return supportedProviders.includes(aiSdkId)
|
||||||
}
|
}
|
||||||
@@ -54,6 +61,7 @@ export function supportsPdfInput(model: Model): boolean {
|
|||||||
// 基于AI SDK文档,以下模型或提供商支持PDF输入
|
// 基于AI SDK文档,以下模型或提供商支持PDF输入
|
||||||
return modelSupportValidator(model, {
|
return modelSupportValidator(model, {
|
||||||
supportedModels: ['qwen-long', 'qwen-doc'],
|
supportedModels: ['qwen-long', 'qwen-doc'],
|
||||||
|
supportedEndpointTypes: ['anthropic', 'gemini'],
|
||||||
supportedProviders: [
|
supportedProviders: [
|
||||||
'openai',
|
'openai',
|
||||||
'azure-openai',
|
'azure-openai',
|
||||||
@@ -85,6 +93,19 @@ export function supportsLargeFileUpload(model: Model): boolean {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模型是否支持TopP
|
||||||
|
*/
|
||||||
|
export function supportsTopP(model: Model): boolean {
|
||||||
|
const provider = getProviderByModel(model)
|
||||||
|
|
||||||
|
if (provider?.type === 'anthropic' || model?.endpoint_type === 'anthropic') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取提供商特定的文件大小限制
|
* 获取提供商特定的文件大小限制
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
isClaude45ReasoningModel,
|
||||||
isClaudeReasoningModel,
|
isClaudeReasoningModel,
|
||||||
isNotSupportTemperatureAndTopP,
|
isNotSupportTemperatureAndTopP,
|
||||||
isSupportedFlexServiceTier
|
isSupportedFlexServiceTier
|
||||||
@@ -19,7 +20,10 @@ export function getTemperature(assistant: Assistant, model: Model): number | und
|
|||||||
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
|
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
if (isNotSupportTemperatureAndTopP(model)) {
|
if (
|
||||||
|
isNotSupportTemperatureAndTopP(model) ||
|
||||||
|
(isClaude45ReasoningModel(model) && assistant.settings?.enableTopP && !assistant.settings?.enableTemperature)
|
||||||
|
) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const assistantSettings = getAssistantSettings(assistant)
|
const assistantSettings = getAssistantSettings(assistant)
|
||||||
@@ -33,7 +37,10 @@ export function getTopP(assistant: Assistant, model: Model): number | undefined
|
|||||||
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
|
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
if (isNotSupportTemperatureAndTopP(model)) {
|
if (
|
||||||
|
isNotSupportTemperatureAndTopP(model) ||
|
||||||
|
(isClaude45ReasoningModel(model) && assistant.settings?.enableTemperature)
|
||||||
|
) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const assistantSettings = getAssistantSettings(assistant)
|
const assistantSettings = getAssistantSettings(assistant)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
|||||||
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
|
||||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||||
|
import { replacePromptVariables } from '@renderer/utils/prompt'
|
||||||
import type { ModelMessage, Tool } from 'ai'
|
import type { ModelMessage, Tool } from 'ai'
|
||||||
import { stepCountIs } from 'ai'
|
import { stepCountIs } from 'ai'
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ import { setupToolsConfig } from '../utils/mcp'
|
|||||||
import { buildProviderOptions } from '../utils/options'
|
import { buildProviderOptions } from '../utils/options'
|
||||||
import { getAnthropicThinkingBudget } from '../utils/reasoning'
|
import { getAnthropicThinkingBudget } from '../utils/reasoning'
|
||||||
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
|
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
|
||||||
|
import { supportsTopP } from './modelCapabilities'
|
||||||
import { getTemperature, getTopP } from './modelParameters'
|
import { getTemperature, getTopP } from './modelParameters'
|
||||||
|
|
||||||
const logger = loggerService.withContext('parameterBuilder')
|
const logger = loggerService.withContext('parameterBuilder')
|
||||||
@@ -126,7 +128,7 @@ export async function buildStreamTextParams(
|
|||||||
let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined
|
let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined
|
||||||
if (enableWebSearch) {
|
if (enableWebSearch) {
|
||||||
if (isBaseProvider(aiSdkProviderId)) {
|
if (isBaseProvider(aiSdkProviderId)) {
|
||||||
webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig)
|
webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig, model)
|
||||||
}
|
}
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
tools = {}
|
tools = {}
|
||||||
@@ -155,20 +157,27 @@ export async function buildStreamTextParams(
|
|||||||
messages: sdkMessages,
|
messages: sdkMessages,
|
||||||
maxOutputTokens: maxTokens,
|
maxOutputTokens: maxTokens,
|
||||||
temperature: getTemperature(assistant, model),
|
temperature: getTemperature(assistant, model),
|
||||||
topP: getTopP(assistant, model),
|
|
||||||
abortSignal: options.requestOptions?.signal,
|
abortSignal: options.requestOptions?.signal,
|
||||||
headers: options.requestOptions?.headers,
|
headers: options.requestOptions?.headers,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
stopWhen: stepCountIs(10),
|
stopWhen: stepCountIs(10),
|
||||||
maxRetries: 0
|
maxRetries: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (supportsTopP(model)) {
|
||||||
|
params.topP = getTopP(assistant, model)
|
||||||
|
}
|
||||||
|
|
||||||
if (tools) {
|
if (tools) {
|
||||||
params.tools = tools
|
params.tools = tools
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assistant.prompt) {
|
if (assistant.prompt) {
|
||||||
params.system = assistant.prompt
|
params.system = await replacePromptVariables(assistant.prompt, model.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('params', params)
|
logger.debug('params', params)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
params,
|
params,
|
||||||
modelId: model.id,
|
modelId: model.id,
|
||||||
|
|||||||
@@ -62,13 +62,14 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
|||||||
// return createVertexProvider(provider)
|
// return createVertexProvider(provider)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
if (isNewApiProvider(provider)) {
|
||||||
|
return newApiResolverCreator(model, provider)
|
||||||
|
}
|
||||||
|
|
||||||
if (isSystemProvider(provider)) {
|
if (isSystemProvider(provider)) {
|
||||||
if (provider.id === 'aihubmix') {
|
if (provider.id === 'aihubmix') {
|
||||||
return aihubmixProviderCreator(model, provider)
|
return aihubmixProviderCreator(model, provider)
|
||||||
}
|
}
|
||||||
if (isNewApiProvider(provider)) {
|
|
||||||
return newApiResolverCreator(model, provider)
|
|
||||||
}
|
|
||||||
if (provider.id === 'vertexai') {
|
if (provider.id === 'vertexai') {
|
||||||
return vertexAnthropicProviderCreator(model, provider)
|
return vertexAnthropicProviderCreator(model, provider)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
|
|||||||
creatorFunctionName: 'createPerplexity',
|
creatorFunctionName: 'createPerplexity',
|
||||||
supportsImageGeneration: false,
|
supportsImageGeneration: false,
|
||||||
aliases: ['perplexity']
|
aliases: ['perplexity']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mistral',
|
||||||
|
name: 'Mistral',
|
||||||
|
import: () => import('@ai-sdk/mistral'),
|
||||||
|
creatorFunctionName: 'createMistral',
|
||||||
|
supportsImageGeneration: false,
|
||||||
|
aliases: ['mistral']
|
||||||
}
|
}
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
getThinkModelType,
|
getThinkModelType,
|
||||||
isDeepSeekHybridInferenceModel,
|
isDeepSeekHybridInferenceModel,
|
||||||
isDoubaoThinkingAutoModel,
|
isDoubaoThinkingAutoModel,
|
||||||
|
isGrok4FastReasoningModel,
|
||||||
isGrokReasoningModel,
|
isGrokReasoningModel,
|
||||||
|
isOpenAIDeepResearchModel,
|
||||||
isOpenAIReasoningModel,
|
isOpenAIReasoningModel,
|
||||||
isQwenAlwaysThinkModel,
|
isQwenAlwaysThinkModel,
|
||||||
isQwenReasoningModel,
|
isQwenReasoningModel,
|
||||||
@@ -42,6 +44,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
if (!isReasoningModel(model)) {
|
if (!isReasoningModel(model)) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOpenAIDeepResearchModel(model)) {
|
||||||
|
return {
|
||||||
|
reasoning_effort: 'medium'
|
||||||
|
}
|
||||||
|
}
|
||||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||||
|
|
||||||
if (!reasoningEffort) {
|
if (!reasoningEffort) {
|
||||||
@@ -52,7 +60,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
// Don't disable reasoning for models that require it
|
// Don't disable reasoning for models that require it
|
||||||
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
|
if (
|
||||||
|
isGrokReasoningModel(model) ||
|
||||||
|
isOpenAIReasoningModel(model) ||
|
||||||
|
isQwenAlwaysThinkModel(model) ||
|
||||||
|
model.id.includes('seed-oss')
|
||||||
|
) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return { reasoning: { enabled: false, exclude: true } }
|
return { reasoning: { enabled: false, exclude: true } }
|
||||||
@@ -100,6 +113,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
// reasoningEffort有效的情况
|
// reasoningEffort有效的情况
|
||||||
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
|
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
|
||||||
// 不同的 provider 有不同的思考控制方式,在这里统一解决
|
// 不同的 provider 有不同的思考控制方式,在这里统一解决
|
||||||
|
|
||||||
if (isDeepSeekHybridInferenceModel(model)) {
|
if (isDeepSeekHybridInferenceModel(model)) {
|
||||||
if (isSystemProvider(provider)) {
|
if (isSystemProvider(provider)) {
|
||||||
switch (provider.id) {
|
switch (provider.id) {
|
||||||
@@ -142,6 +156,16 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
|
|
||||||
// OpenRouter models
|
// OpenRouter models
|
||||||
if (model.provider === SystemProviderIds.openrouter) {
|
if (model.provider === SystemProviderIds.openrouter) {
|
||||||
|
// Grok 4 Fast doesn't support effort levels, always use enabled: true
|
||||||
|
if (isGrok4FastReasoningModel(model)) {
|
||||||
|
return {
|
||||||
|
reasoning: {
|
||||||
|
enabled: true // Ignore effort level, just enable reasoning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other OpenRouter models that support effort levels
|
||||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||||
return {
|
return {
|
||||||
reasoning: {
|
reasoning: {
|
||||||
@@ -295,7 +319,11 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
|
|||||||
reasoningSummary = summaryText
|
reasoningSummary = summaryText
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
let reasoningEffort = assistant?.settings?.reasoning_effort
|
||||||
|
|
||||||
|
if (isOpenAIDeepResearchModel(model)) {
|
||||||
|
reasoningEffort = 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
if (!reasoningEffort) {
|
if (!reasoningEffort) {
|
||||||
return {}
|
return {}
|
||||||
@@ -412,6 +440,13 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get XAI-specific reasoning parameters
|
||||||
|
* This function should only be called for XAI provider models
|
||||||
|
* @param assistant - The assistant configuration
|
||||||
|
* @param model - The model being used
|
||||||
|
* @returns XAI-specific reasoning parameters
|
||||||
|
*/
|
||||||
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||||
if (!isSupportedReasoningEffortGrokModel(model)) {
|
if (!isSupportedReasoningEffortGrokModel(model)) {
|
||||||
return {}
|
return {}
|
||||||
@@ -419,6 +454,11 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
|
|||||||
|
|
||||||
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
||||||
|
|
||||||
|
if (!reasoningEffort) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For XAI provider Grok models, use reasoningEffort parameter directly
|
||||||
return {
|
return {
|
||||||
reasoningEffort
|
reasoningEffort
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
WebSearchPluginConfig
|
WebSearchPluginConfig
|
||||||
} from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper'
|
} from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper'
|
||||||
import { BaseProviderId } from '@cherrystudio/ai-core/provider'
|
import { BaseProviderId } from '@cherrystudio/ai-core/provider'
|
||||||
import { isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models'
|
import { isOpenAIDeepResearchModel, isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models'
|
||||||
import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
import { CherryWebSearchConfig } from '@renderer/store/websearch'
|
||||||
import { Model } from '@renderer/types'
|
import { Model } from '@renderer/types'
|
||||||
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
|
||||||
@@ -43,20 +43,27 @@ function mapMaxResultToOpenAIContextSize(maxResults: number): OpenAISearchConfig
|
|||||||
|
|
||||||
export function buildProviderBuiltinWebSearchConfig(
|
export function buildProviderBuiltinWebSearchConfig(
|
||||||
providerId: BaseProviderId,
|
providerId: BaseProviderId,
|
||||||
webSearchConfig: CherryWebSearchConfig
|
webSearchConfig: CherryWebSearchConfig,
|
||||||
|
model?: Model
|
||||||
): WebSearchPluginConfig | undefined {
|
): WebSearchPluginConfig | undefined {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'openai': {
|
case 'openai': {
|
||||||
|
const searchContextSize = isOpenAIDeepResearchModel(model)
|
||||||
|
? 'medium'
|
||||||
|
: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||||
return {
|
return {
|
||||||
openai: {
|
openai: {
|
||||||
searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
searchContextSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'openai-chat': {
|
case 'openai-chat': {
|
||||||
|
const searchContextSize = isOpenAIDeepResearchModel(model)
|
||||||
|
? 'medium'
|
||||||
|
: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
||||||
return {
|
return {
|
||||||
'openai-chat': {
|
'openai-chat': {
|
||||||
searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults)
|
searchContextSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +78,7 @@ export function buildProviderBuiltinWebSearchConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'xai': {
|
case 'xai': {
|
||||||
|
const excludeDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||||
return {
|
return {
|
||||||
xai: {
|
xai: {
|
||||||
maxSearchResults: webSearchConfig.maxResults,
|
maxSearchResults: webSearchConfig.maxResults,
|
||||||
@@ -78,7 +86,7 @@ export function buildProviderBuiltinWebSearchConfig(
|
|||||||
sources: [
|
sources: [
|
||||||
{
|
{
|
||||||
type: 'web',
|
type: 'web',
|
||||||
excludedWebsites: mapRegexToPatterns(webSearchConfig.excludeDomains)
|
excludedWebsites: excludeDomains.slice(0, Math.min(excludeDomains.length, 5))
|
||||||
},
|
},
|
||||||
{ type: 'news' },
|
{ type: 'news' },
|
||||||
{ type: 'x' }
|
{ type: 'x' }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 9.3 KiB |
BIN
src/renderer/src/assets/images/apps/stepfun.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 1.5 KiB |
13
src/renderer/src/assets/images/providers/sophnet.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="38" viewBox="0 0 33 38" fill="none">
|
||||||
|
<g clip-path="url(#clip0_4321_9943)">
|
||||||
|
<path d="M1.51221 6.59813C1.51221 4.09263 3.54331 2.06152 6.04881 2.06152H27.9757C30.4812 2.06152 32.5123 4.09263 32.5123 6.59813C32.5123 9.10362 30.4812 11.1347 27.9757 11.1347H6.04881C3.54331 11.1347 1.51221 9.10362 1.51221 6.59813Z" fill="#6200EE"/>
|
||||||
|
<path d="M3.38905 3.56467C5.26076 1.89906 8.12831 2.06615 9.79391 3.93785L22.1493 17.8221C23.8149 19.6938 23.6478 22.5614 21.7761 24.227C19.9044 25.8926 17.0369 25.7255 15.3713 23.8538L3.01586 9.96953C1.35026 8.09782 1.51734 5.23027 3.38905 3.56467Z" fill="#6200EE"/>
|
||||||
|
<path d="M1.51221 20.9643C1.51221 18.4588 3.54331 16.4277 6.04881 16.4277H18.9025C21.408 16.4277 23.4391 18.4588 23.4391 20.9643C23.4391 23.4698 21.408 25.5009 18.9025 25.5009H6.04881C3.54331 25.5009 1.51221 23.4698 1.51221 20.9643Z" fill="#6200EE"/>
|
||||||
|
<path d="M10.5854 32.3052C10.5854 34.8107 8.55431 36.8418 6.04881 36.8418C3.54331 36.8418 1.51221 34.8107 1.51221 32.3052C1.51221 29.7997 3.54331 27.7686 6.04881 27.7686C8.55431 27.7686 10.5854 29.7997 10.5854 32.3052Z" fill="#BF7AFF"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_4321_9943">
|
||||||
|
<rect width="32.5124" height="36.9029" fill="white" transform="translate(0 0.548828)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
|||||||
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
|
* 一是 CodeViewer 在气泡样式下的用户消息中无法撑开气泡,
|
||||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||||
*/
|
*/
|
||||||
min-width: 45ch;
|
min-width: 35ch;
|
||||||
|
|
||||||
.code-toolbar {
|
.code-toolbar {
|
||||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
|
|||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { Extension, keymap } from '@uiw/react-codemirror'
|
import { Extension, keymap } from '@uiw/react-codemirror'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { getNormalizedExtension } from './utils'
|
import { getNormalizedExtension } from './utils'
|
||||||
|
|
||||||
@@ -203,3 +203,80 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
|
|||||||
})
|
})
|
||||||
}, [onHeightChange])
|
}, [onHeightChange])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UseScrollToLineOptions {
|
||||||
|
highlight?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
|
||||||
|
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
|
||||||
|
const domAtPos = view.domAtPos(position)
|
||||||
|
let node: Node | null = domAtPos.node
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
node = node.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
node = node.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
|
||||||
|
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
|
||||||
|
if (previousHighlight) {
|
||||||
|
previousHighlight.classList.remove('animation-locate-highlight')
|
||||||
|
}
|
||||||
|
|
||||||
|
element.classList.add('animation-locate-highlight')
|
||||||
|
|
||||||
|
const handleAnimationEnd = () => {
|
||||||
|
element.classList.remove('animation-locate-highlight')
|
||||||
|
element.removeEventListener('animationend', handleAnimationEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
element.addEventListener('animationend', handleAnimationEnd)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(lineNumber: number, options?: UseScrollToLineOptions) => {
|
||||||
|
const view = editorViewRef.current
|
||||||
|
if (!view) return
|
||||||
|
|
||||||
|
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
|
||||||
|
|
||||||
|
const lineElement = findLineElement(view, targetLine.from)
|
||||||
|
if (lineElement) {
|
||||||
|
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||||
|
|
||||||
|
if (options?.highlight) {
|
||||||
|
requestAnimationFrame(() => highlightLine(view, lineElement))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
effects: EditorView.scrollIntoView(targetLine.from, {
|
||||||
|
y: 'start'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!options?.highlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const fallbackElement = findLineElement(view, targetLine.from)
|
||||||
|
if (fallbackElement) {
|
||||||
|
highlightLine(view, fallbackElement)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
[editorViewRef, findLineElement, highlightLine]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import diff from 'fast-diff'
|
|||||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
|
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
|
||||||
|
|
||||||
// 标记非用户编辑的变更
|
// 标记非用户编辑的变更
|
||||||
const External = Annotation.define<boolean>()
|
const External = Annotation.define<boolean>()
|
||||||
|
|
||||||
export interface CodeEditorHandles {
|
export interface CodeEditorHandles {
|
||||||
save?: () => void
|
save?: () => void
|
||||||
|
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodeEditorProps {
|
export interface CodeEditorProps {
|
||||||
@@ -181,8 +182,11 @@ const CodeEditor = ({
|
|||||||
].flat()
|
].flat()
|
||||||
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
||||||
|
|
||||||
|
const scrollToLine = useScrollToLine(editorViewRef)
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
save: handleSave
|
save: handleSave,
|
||||||
|
scrollToLine
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
48
src/renderer/src/components/HighlightText.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { FC, memo, useMemo } from 'react'
|
||||||
|
|
||||||
|
interface HighlightTextProps {
|
||||||
|
text: string
|
||||||
|
keyword: string
|
||||||
|
caseSensitive?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text highlighting component that marks keyword matches
|
||||||
|
*/
|
||||||
|
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
|
||||||
|
const highlightedText = useMemo(() => {
|
||||||
|
if (!keyword || !text) {
|
||||||
|
return <span>{text}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape regex special characters
|
||||||
|
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const flags = caseSensitive ? 'g' : 'gi'
|
||||||
|
const regex = new RegExp(`(${escapedKeyword})`, flags)
|
||||||
|
|
||||||
|
// Split text by keyword matches
|
||||||
|
const parts = text.split(regex)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
// Check if part matches keyword
|
||||||
|
const isMatch = regex.test(part)
|
||||||
|
regex.lastIndex = 0 // Reset regex state
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
return <mark key={index}>{part}</mark>
|
||||||
|
}
|
||||||
|
return <span key={index}>{part}</span>
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [text, keyword, caseSensitive])
|
||||||
|
|
||||||
|
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
|
||||||
|
|
||||||
|
return <span className={combinedClassName}>{highlightedText}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(HighlightText)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
import { restoreFromLocal } from '@renderer/services/BackupService'
|
import { restoreFromLocal } from '@renderer/services/BackupService'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { Button, message, Modal, Table, Tooltip } from 'antd'
|
import { Button, message, Modal, Space, Table, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -214,6 +214,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const footerContent = (
|
||||||
|
<Space align="center">
|
||||||
|
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||||
|
{t('settings.data.local.backup.manager.refresh')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
key="delete"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
disabled={selectedRowKeys.length === 0 || deleting}
|
||||||
|
loading={deleting}>
|
||||||
|
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||||
|
</Button>
|
||||||
|
<Button key="close" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t('settings.data.local.backup.manager.title')}
|
title={t('settings.data.local.backup.manager.title')}
|
||||||
@@ -222,23 +242,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
|||||||
width={800}
|
width={800}
|
||||||
centered
|
centered
|
||||||
transitionName="animation-move-down"
|
transitionName="animation-move-down"
|
||||||
footer={[
|
footer={footerContent}>
|
||||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
|
||||||
{t('settings.data.local.backup.manager.refresh')}
|
|
||||||
</Button>,
|
|
||||||
<Button
|
|
||||||
key="delete"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={handleDeleteSelected}
|
|
||||||
disabled={selectedRowKeys.length === 0 || deleting}
|
|
||||||
loading={deleting}>
|
|
||||||
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
|
||||||
</Button>,
|
|
||||||
<Button key="close" onClick={onClose}>
|
|
||||||
{t('common.close')}
|
|
||||||
</Button>
|
|
||||||
]}>
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="fileName"
|
rowKey="fileName"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
|
|||||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||||
{!isReady && (
|
{!isReady && (
|
||||||
<EmptyView>
|
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentAppInfo?.logo}
|
src={currentAppInfo?.logo}
|
||||||
size={80}
|
size={80}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const WebviewContainer = memo(
|
|||||||
onNavigateCallback: (appid: string, url: string) => void
|
onNavigateCallback: (appid: string, url: string) => void
|
||||||
}) => {
|
}) => {
|
||||||
const webviewRef = useRef<WebviewTag | null>(null)
|
const webviewRef = useRef<WebviewTag | null>(null)
|
||||||
const { enableSpellCheck } = useSettings()
|
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
|
||||||
|
|
||||||
const setRef = (appid: string) => {
|
const setRef = (appid: string) => {
|
||||||
onSetRefCallback(appid, null)
|
onSetRefCallback(appid, null)
|
||||||
@@ -76,6 +76,8 @@ const WebviewContainer = memo(
|
|||||||
const webviewId = webviewRef.current?.getWebContentsId()
|
const webviewId = webviewRef.current?.getWebContentsId()
|
||||||
if (webviewId) {
|
if (webviewId) {
|
||||||
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||||
|
// Set link opening behavior for this webview
|
||||||
|
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +106,22 @@ const WebviewContainer = memo(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [appid, url])
|
}, [appid, url])
|
||||||
|
|
||||||
|
// Update webview settings when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webviewRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webviewId = webviewRef.current.getWebContentsId()
|
||||||
|
if (webviewId) {
|
||||||
|
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
|
||||||
|
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// WebView may not be ready yet, settings will be applied in dom-ready event
|
||||||
|
logger.debug(`WebView ${appid} not ready for settings update`)
|
||||||
|
}
|
||||||
|
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
|
||||||
|
|
||||||
const WebviewStyle: React.CSSProperties = {
|
const WebviewStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|||||||
604
src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal'
|
||||||
|
import { Progress } from '@heroui/progress'
|
||||||
|
import { Spinner } from '@heroui/spinner'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { AppLogo } from '@renderer/config/env'
|
||||||
|
import { SettingHelpText, SettingRow } from '@renderer/pages/settings'
|
||||||
|
import { WebSocketCandidatesResponse } from '@shared/config/types'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { TopView } from '../TopView'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ExportToPhoneLanPopup')
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
resolve: (data: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
|
type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error'
|
||||||
|
|
||||||
|
const LoadingQRCode: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<Spinner />
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.generating_qr')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<QRCodeSVG
|
||||||
|
marginSize={2}
|
||||||
|
value={qrCodeValue}
|
||||||
|
level="H"
|
||||||
|
size={200}
|
||||||
|
imageSettings={{
|
||||||
|
src: AppLogo,
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
excavate: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.scan_qr')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectingAnimation: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '160px',
|
||||||
|
height: '160px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '2px dashed var(--color-status-warning)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: 'var(--color-status-warning)'
|
||||||
|
}}>
|
||||||
|
<Spinner size="lg" color="warning" />
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.status.connecting')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectedDisplay: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '160px',
|
||||||
|
height: '160px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '2px dashed var(--color-status-success)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: 'var(--color-status-success)'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '48px' }}>📱</span>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '8px' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.connected')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '20px',
|
||||||
|
border: `1px solid var(--color-error)`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--color-error)'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '48px' }}>⚠️</span>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.connection_failed')}
|
||||||
|
</span>
|
||||||
|
{error && <span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
const [connectionPhase, setConnectionPhase] = useState<ConnectionPhase>('initializing')
|
||||||
|
const [transferPhase, setTransferPhase] = useState<TransferPhase>('idle')
|
||||||
|
const [qrCodeValue, setQrCodeValue] = useState('')
|
||||||
|
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||||
|
const [sendProgress, setSendProgress] = useState(0)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||||
|
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// 派生状态
|
||||||
|
const isConnected = connectionPhase === 'connected'
|
||||||
|
const canSend = isConnected && selectedFolderPath && transferPhase === 'idle'
|
||||||
|
const isSending = transferPhase === 'preparing' || transferPhase === 'sending'
|
||||||
|
|
||||||
|
// 状态文本映射
|
||||||
|
const connectionStatusText = useMemo(() => {
|
||||||
|
const statusMap = {
|
||||||
|
initializing: t('settings.data.export_to_phone.lan.status.initializing'),
|
||||||
|
waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'),
|
||||||
|
connecting: t('settings.data.export_to_phone.lan.status.connecting'),
|
||||||
|
connected: t('settings.data.export_to_phone.lan.status.connected'),
|
||||||
|
disconnected: t('settings.data.export_to_phone.lan.status.disconnected'),
|
||||||
|
error: t('settings.data.export_to_phone.lan.status.error')
|
||||||
|
}
|
||||||
|
return statusMap[connectionPhase]
|
||||||
|
}, [connectionPhase, t])
|
||||||
|
|
||||||
|
const transferStatusText = useMemo(() => {
|
||||||
|
const statusMap = {
|
||||||
|
idle: '',
|
||||||
|
preparing: t('settings.data.export_to_phone.lan.status.preparing'),
|
||||||
|
sending: t('settings.data.export_to_phone.lan.status.sending'),
|
||||||
|
completed: t('settings.data.export_to_phone.lan.status.completed'),
|
||||||
|
error: t('settings.data.export_to_phone.lan.status.error')
|
||||||
|
}
|
||||||
|
return statusMap[transferPhase]
|
||||||
|
}, [transferPhase, t])
|
||||||
|
|
||||||
|
// 状态样式映射
|
||||||
|
const connectionStatusStyles = useMemo(() => {
|
||||||
|
const styleMap = {
|
||||||
|
initializing: {
|
||||||
|
bg: 'var(--color-background-mute)',
|
||||||
|
border: 'var(--color-border-mute)'
|
||||||
|
},
|
||||||
|
waiting_qr_scan: {
|
||||||
|
bg: 'var(--color-primary-mute)',
|
||||||
|
border: 'var(--color-primary-soft)'
|
||||||
|
},
|
||||||
|
connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' },
|
||||||
|
connected: {
|
||||||
|
bg: 'var(--color-status-success)',
|
||||||
|
border: 'var(--color-status-success)'
|
||||||
|
},
|
||||||
|
disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' },
|
||||||
|
error: { bg: 'var(--color-error)', border: 'var(--color-error)' }
|
||||||
|
}
|
||||||
|
return styleMap[connectionPhase]
|
||||||
|
}, [connectionPhase])
|
||||||
|
|
||||||
|
const initWebSocket = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setConnectionPhase('initializing')
|
||||||
|
await window.api.webSocket.start()
|
||||||
|
const { port, ip } = await window.api.webSocket.status()
|
||||||
|
|
||||||
|
if (ip && port) {
|
||||||
|
const candidatesData = await window.api.webSocket.getAllCandidates()
|
||||||
|
|
||||||
|
const optimizeConnectionInfo = () => {
|
||||||
|
const ipToNumber = (ip: string) => {
|
||||||
|
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressedData = [
|
||||||
|
'CSA',
|
||||||
|
ipToNumber(ip),
|
||||||
|
candidatesData.map((candidate: WebSocketCandidatesResponse) => ipToNumber(candidate.host)),
|
||||||
|
port, // 端口号
|
||||||
|
Date.now() % 86400000
|
||||||
|
]
|
||||||
|
|
||||||
|
return compressedData
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressedData = optimizeConnectionInfo()
|
||||||
|
const qrCodeValue = JSON.stringify(compressedData)
|
||||||
|
setQrCodeValue(qrCodeValue)
|
||||||
|
setConnectionPhase('waiting_qr_scan')
|
||||||
|
} else {
|
||||||
|
setError(t('settings.data.export_to_phone.lan.error.no_ip'))
|
||||||
|
setConnectionPhase('error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(
|
||||||
|
`${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}`
|
||||||
|
)
|
||||||
|
setConnectionPhase('error')
|
||||||
|
logger.error('Failed to initialize WebSocket:', error as Error)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => {
|
||||||
|
logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`)
|
||||||
|
if (data.connected) {
|
||||||
|
setConnectionPhase('connected')
|
||||||
|
setError(null)
|
||||||
|
} else {
|
||||||
|
setConnectionPhase('disconnected')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMessageReceived = useCallback((_event: any, data: any) => {
|
||||||
|
logger.info(`Received message from mobile: ${JSON.stringify(data)}`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSendProgress = useCallback(
|
||||||
|
(_event: any, data: { progress: number }) => {
|
||||||
|
const progress = data.progress
|
||||||
|
setSendProgress(progress)
|
||||||
|
|
||||||
|
if (transferPhase === 'preparing' && progress > 0) {
|
||||||
|
setTransferPhase('sending')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
setTransferPhase('completed')
|
||||||
|
// 启动 3 秒倒计时自动关闭
|
||||||
|
setAutoCloseCountdown(3)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transferPhase]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectZip = useCallback(async () => {
|
||||||
|
const result = await window.api.file.select()
|
||||||
|
if (result) {
|
||||||
|
setSelectedFolderPath(result[0].path)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSendZip = useCallback(async () => {
|
||||||
|
if (!selectedFolderPath) {
|
||||||
|
setError(t('settings.data.export_to_phone.lan.error.no_file'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransferPhase('preparing')
|
||||||
|
setError(null)
|
||||||
|
setSendProgress(0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Starting file transfer: ${selectedFolderPath}`)
|
||||||
|
await window.api.webSocket.sendFile(selectedFolderPath)
|
||||||
|
} catch (error) {
|
||||||
|
setError(
|
||||||
|
`${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}`
|
||||||
|
)
|
||||||
|
setTransferPhase('error')
|
||||||
|
logger.error('Failed to send file:', error as Error)
|
||||||
|
}
|
||||||
|
}, [selectedFolderPath, t])
|
||||||
|
|
||||||
|
// 尝试关闭弹窗 - 如果正在传输则显示确认
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (isSending) {
|
||||||
|
setShowCloseConfirm(true)
|
||||||
|
} else {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}, [isSending])
|
||||||
|
|
||||||
|
// 确认强制关闭
|
||||||
|
const handleForceClose = useCallback(() => {
|
||||||
|
logger.info('Force closing popup during transfer')
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 取消关闭确认
|
||||||
|
const handleCancelClose = useCallback(() => {
|
||||||
|
setShowCloseConfirm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 清理并关闭
|
||||||
|
const handleClose = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// 主动断开 WebSocket 连接
|
||||||
|
if (isConnected || connectionPhase !== 'disconnected') {
|
||||||
|
logger.info('Closing popup, stopping WebSocket')
|
||||||
|
await window.api.webSocket.stop()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to stop WebSocket on close:', error as Error)
|
||||||
|
}
|
||||||
|
resolve({})
|
||||||
|
}, [resolve, isConnected, connectionPhase])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initWebSocket()
|
||||||
|
|
||||||
|
const removeClientConnectedListener = window.electron.ipcRenderer.on(
|
||||||
|
'websocket-client-connected',
|
||||||
|
handleClientConnected
|
||||||
|
)
|
||||||
|
const removeMessageReceivedListener = window.electron.ipcRenderer.on(
|
||||||
|
'websocket-message-received',
|
||||||
|
handleMessageReceived
|
||||||
|
)
|
||||||
|
const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeClientConnectedListener()
|
||||||
|
removeMessageReceivedListener()
|
||||||
|
removeSendProgressListener()
|
||||||
|
window.api.webSocket.stop()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 自动关闭倒计时
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoCloseCountdown === null) return
|
||||||
|
|
||||||
|
if (autoCloseCountdown <= 0) {
|
||||||
|
logger.debug('Auto-closing popup after transfer completion')
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setAutoCloseCountdown(autoCloseCountdown - 1)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [autoCloseCountdown])
|
||||||
|
|
||||||
|
// 状态指示器组件
|
||||||
|
const StatusIndicator = useCallback(
|
||||||
|
() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: connectionStatusStyles.bg,
|
||||||
|
border: `1px solid ${connectionStatusStyles.border}`
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[connectionStatusStyles, connectionStatusText]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 二维码显示组件 - 使用显式条件渲染以避免类型不匹配
|
||||||
|
const QRCodeDisplay = useCallback(() => {
|
||||||
|
switch (connectionPhase) {
|
||||||
|
case 'waiting_qr_scan':
|
||||||
|
case 'disconnected':
|
||||||
|
return <ScanQRCode qrCodeValue={qrCodeValue} />
|
||||||
|
case 'initializing':
|
||||||
|
return <LoadingQRCode />
|
||||||
|
case 'connecting':
|
||||||
|
return <ConnectingAnimation />
|
||||||
|
case 'connected':
|
||||||
|
return <ConnectedDisplay />
|
||||||
|
case 'error':
|
||||||
|
return <ErrorQRCode error={error} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [connectionPhase, qrCodeValue, error])
|
||||||
|
|
||||||
|
// 传输进度组件
|
||||||
|
const TransferProgress = useCallback(() => {
|
||||||
|
if (!isSending && transferPhase !== 'completed') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingTop: '8px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
border: `1px solid var(--color-border)`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--color-background-mute)'
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--color-text)' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.transfer_progress')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{ color: transferPhase === 'completed' ? 'var(--color-status-success)' : 'var(--color-primary)' }}>
|
||||||
|
{transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={Math.round(sendProgress)}
|
||||||
|
size="md"
|
||||||
|
color={transferPhase === 'completed' ? 'success' : 'primary'}
|
||||||
|
showValueLabel={false}
|
||||||
|
aria-label="Send progress"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [isSending, transferPhase, sendProgress, t])
|
||||||
|
|
||||||
|
const AutoCloseCountdown = useCallback(() => {
|
||||||
|
if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingTop: '4px'
|
||||||
|
}}>
|
||||||
|
{t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [transferPhase, autoCloseCountdown, t])
|
||||||
|
|
||||||
|
// 错误显示组件
|
||||||
|
const ErrorDisplay = useCallback(() => {
|
||||||
|
if (!error || transferPhase !== 'error') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
border: `1px solid var(--color-error)`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--color-error)',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>❌ {error}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [error, transferPhase])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isDismissable={false}
|
||||||
|
isKeyboardDismissDisabled={false}
|
||||||
|
placement="center"
|
||||||
|
onClose={handleClose}>
|
||||||
|
<ModalContent>
|
||||||
|
{() => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<SettingRow>
|
||||||
|
<StatusIndicator />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow>
|
||||||
|
<div>{t('settings.data.export_to_phone.lan.content')}</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
|
||||||
|
<QRCodeDisplay />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
|
||||||
|
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}>
|
||||||
|
{t('settings.data.export_to_phone.lan.selectZip')}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}>
|
||||||
|
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingHelpText
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
|
||||||
|
</SettingHelpText>
|
||||||
|
|
||||||
|
<TransferProgress />
|
||||||
|
<AutoCloseCountdown />
|
||||||
|
<ErrorDisplay />
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
{showCloseConfirm && (
|
||||||
|
<ModalFooter>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--color-status-warning)',
|
||||||
|
border: '1px solid var(--color-status-warning)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>⚠️</span>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--color-text)', fontWeight: '500' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.confirm_close_title')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '13px', color: 'var(--color-text-2)', marginLeft: '28px' }}>
|
||||||
|
{t('settings.data.export_to_phone.lan.confirm_close_message')}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
|
||||||
|
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="danger" onPress={handleForceClose}>
|
||||||
|
{t('settings.data.export_to_phone.lan.force_close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopViewKey = 'ExportToPhoneLanPopup'
|
||||||
|
|
||||||
|
export default class ExportToPhoneLanPopup {
|
||||||
|
static topviewId = 0
|
||||||
|
static hide() {
|
||||||
|
TopView.hide(TopViewKey)
|
||||||
|
}
|
||||||
|
static show() {
|
||||||
|
return new Promise<any>((resolve) => {
|
||||||
|
TopView.show(
|
||||||
|
<PopupContainer
|
||||||
|
resolve={(v) => {
|
||||||
|
resolve(v)
|
||||||
|
TopView.hide(TopViewKey)
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
TopViewKey
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/renderer/src/components/RichEditor/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Attribute used to store the original source line number in markdown editors
|
||||||
|
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
|
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||||
|
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
|
||||||
import DragHandle from '@tiptap/extension-drag-handle-react'
|
import DragHandle from '@tiptap/extension-drag-handle-react'
|
||||||
import { EditorContent } from '@tiptap/react'
|
import { EditorContent } from '@tiptap/react'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
@@ -29,6 +30,156 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
|
|||||||
import { useRichEditor } from './useRichEditor'
|
import { useRichEditor } from './useRichEditor'
|
||||||
const logger = loggerService.withContext('RichEditor')
|
const logger = loggerService.withContext('RichEditor')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find element by line number with fallback strategies:
|
||||||
|
* 1. Exact line + content match
|
||||||
|
* 2. Exact line match
|
||||||
|
* 3. Closest line <= target
|
||||||
|
*/
|
||||||
|
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
|
||||||
|
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
|
||||||
|
if (allElements.length === 0) {
|
||||||
|
logger.warn('No elements with data-source-line attribute found')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const exactMatches = editorDom.querySelectorAll(
|
||||||
|
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
|
||||||
|
) as NodeListOf<HTMLElement>
|
||||||
|
|
||||||
|
// Strategy 1: Exact line + content match
|
||||||
|
if (exactMatches.length > 1 && lineContent) {
|
||||||
|
for (const match of Array.from(exactMatches)) {
|
||||||
|
if (match.textContent?.includes(lineContent)) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Exact line match
|
||||||
|
if (exactMatches.length > 0) {
|
||||||
|
return exactMatches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Closest line <= target
|
||||||
|
let closestElement: HTMLElement | null = null
|
||||||
|
let closestLine = 0
|
||||||
|
|
||||||
|
for (const el of allElements) {
|
||||||
|
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
|
||||||
|
if (sourceLine <= lineNumber && sourceLine > closestLine) {
|
||||||
|
closestLine = sourceLine
|
||||||
|
closestElement = el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestElement
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create fixed-position highlight overlay at element location
|
||||||
|
* with boundary detection to prevent overflow and toolbar overlap
|
||||||
|
*/
|
||||||
|
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
|
||||||
|
try {
|
||||||
|
// Remove previous overlay
|
||||||
|
const previousOverlay = document.body.querySelector('.highlight-overlay')
|
||||||
|
if (previousOverlay) {
|
||||||
|
previousOverlay.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorWrapper = container.closest('.rich-editor-wrapper')
|
||||||
|
|
||||||
|
// Create overlay at element position
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const overlay = document.createElement('div')
|
||||||
|
overlay.className = 'highlight-overlay animation-locate-highlight'
|
||||||
|
overlay.style.position = 'fixed'
|
||||||
|
overlay.style.left = `${rect.left}px`
|
||||||
|
overlay.style.top = `${rect.top}px`
|
||||||
|
overlay.style.width = `${rect.width}px`
|
||||||
|
overlay.style.height = `${rect.height}px`
|
||||||
|
overlay.style.pointerEvents = 'none'
|
||||||
|
overlay.style.zIndex = '9999'
|
||||||
|
overlay.style.borderRadius = '4px'
|
||||||
|
|
||||||
|
document.body.appendChild(overlay)
|
||||||
|
|
||||||
|
// Update overlay position and visibility on scroll
|
||||||
|
const updatePosition = () => {
|
||||||
|
const newRect = element.getBoundingClientRect()
|
||||||
|
const newContainerRect = container.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
overlay.style.left = `${newRect.left}px`
|
||||||
|
overlay.style.top = `${newRect.top}px`
|
||||||
|
overlay.style.width = `${newRect.width}px`
|
||||||
|
overlay.style.height = `${newRect.height}px`
|
||||||
|
|
||||||
|
// Get current toolbar bottom (it might change)
|
||||||
|
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
|
||||||
|
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
|
||||||
|
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
|
||||||
|
|
||||||
|
// Check if overlay is within visible bounds
|
||||||
|
const overlayTop = newRect.top
|
||||||
|
const overlayBottom = newRect.bottom
|
||||||
|
const visibleTop = currentToolbarBottom // Don't overlap toolbar
|
||||||
|
const visibleBottom = newContainerRect.bottom
|
||||||
|
|
||||||
|
// Hide overlay if any part is outside the visible container area
|
||||||
|
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
|
||||||
|
overlay.style.opacity = '0'
|
||||||
|
overlay.style.visibility = 'hidden'
|
||||||
|
} else {
|
||||||
|
overlay.style.opacity = '1'
|
||||||
|
overlay.style.visibility = 'visible'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('scroll', updatePosition)
|
||||||
|
|
||||||
|
// Auto-remove after animation
|
||||||
|
const handleAnimationEnd = () => {
|
||||||
|
overlay.remove()
|
||||||
|
container.removeEventListener('scroll', updatePosition)
|
||||||
|
overlay.removeEventListener('animationend', handleAnimationEnd)
|
||||||
|
}
|
||||||
|
overlay.addEventListener('animationend', handleAnimationEnd)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create highlight overlay:', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to element and show highlight after scroll completes
|
||||||
|
*/
|
||||||
|
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
|
||||||
|
|
||||||
|
let scrollTimeout: NodeJS.Timeout
|
||||||
|
const handleScroll = () => {
|
||||||
|
clearTimeout(scrollTimeout)
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
container.removeEventListener('scroll', handleScroll)
|
||||||
|
requestAnimationFrame(() => createHighlightOverlay(element, container))
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll)
|
||||||
|
|
||||||
|
// Fallback: if element already in view (no scroll happens)
|
||||||
|
setTimeout(() => {
|
||||||
|
const initialScrollTop = container.scrollTop
|
||||||
|
setTimeout(() => {
|
||||||
|
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
|
||||||
|
container.removeEventListener('scroll', handleScroll)
|
||||||
|
clearTimeout(scrollTimeout)
|
||||||
|
requestAnimationFrame(() => createHighlightOverlay(element, container))
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
const RichEditor = ({
|
const RichEditor = ({
|
||||||
ref,
|
ref,
|
||||||
initialContent = '',
|
initialContent = '',
|
||||||
@@ -372,6 +523,22 @@ const RichEditor = ({
|
|||||||
scrollContainerRef.current.scrollTop = value
|
scrollContainerRef.current.scrollTop = value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
|
||||||
|
if (!editor || !scrollContainerRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
if (options?.highlight) {
|
||||||
|
scrollAndHighlight(element, scrollContainerRef.current)
|
||||||
|
} else {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed in scrollToLine:', error as Error)
|
||||||
|
}
|
||||||
|
},
|
||||||
// Dynamic command management
|
// Dynamic command management
|
||||||
registerCommand,
|
registerCommand,
|
||||||
registerToolbarCommand,
|
registerToolbarCommand,
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface RichEditorRef {
|
|||||||
getScrollTop: () => number
|
getScrollTop: () => number
|
||||||
/** Set scrollTop of the editor scroll container */
|
/** Set scrollTop of the editor scroll container */
|
||||||
setScrollTop: (value: number) => void
|
setScrollTop: (value: number) => void
|
||||||
|
/** Scroll to specific line number in markdown */
|
||||||
|
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
|
||||||
// Dynamic command management
|
// Dynamic command management
|
||||||
/** Register a new command/toolbar item */
|
/** Register a new command/toolbar item */
|
||||||
registerCommand: (cmd: Command) => void
|
registerCommand: (cmd: Command) => void
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css'
|
|||||||
|
|
||||||
import { TableKit } from '@cherrystudio/extension-table-plus'
|
import { TableKit } from '@cherrystudio/extension-table-plus'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
|
||||||
import type { FormattingState } from '@renderer/components/RichEditor/types'
|
import type { FormattingState } from '@renderer/components/RichEditor/types'
|
||||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
markdownToPreviewText
|
markdownToPreviewText
|
||||||
} from '@renderer/utils/markdownConverter'
|
} from '@renderer/utils/markdownConverter'
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
|
import { Extension } from '@tiptap/core'
|
||||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||||
import { migrateMathStrings } from '@tiptap/extension-mathematics'
|
import { migrateMathStrings } from '@tiptap/extension-mathematics'
|
||||||
import Mention from '@tiptap/extension-mention'
|
import Mention from '@tiptap/extension-mention'
|
||||||
@@ -36,6 +38,31 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
|
|||||||
|
|
||||||
const logger = loggerService.withContext('useRichEditor')
|
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 {
|
export interface UseRichEditorOptions {
|
||||||
/** Initial markdown content */
|
/** Initial markdown content */
|
||||||
initialContent?: string
|
initialContent?: string
|
||||||
@@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
// TipTap editor extensions
|
// TipTap editor extensions
|
||||||
const extensions = useMemo(
|
const extensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
SourceLineAttribute,
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: {
|
heading: {
|
||||||
levels: [1, 2, 3, 4, 5, 6]
|
levels: [1, 2, 3, 4, 5, 6]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-
|
|||||||
import { restoreFromS3 } from '@renderer/services/BackupService'
|
import { restoreFromS3 } from '@renderer/services/BackupService'
|
||||||
import type { S3Config } from '@renderer/types'
|
import type { S3Config } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { Button, Modal, Table, Tooltip } from 'antd'
|
import { Button, Modal, Space, Table, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -253,6 +253,26 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const footerContent = (
|
||||||
|
<Space align="center">
|
||||||
|
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||||
|
{t('settings.data.s3.manager.refresh')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
key="delete"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
disabled={selectedRowKeys.length === 0 || deleting}
|
||||||
|
loading={deleting}>
|
||||||
|
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
|
||||||
|
</Button>
|
||||||
|
<Button key="close" onClick={onClose}>
|
||||||
|
{t('settings.data.s3.manager.close')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t('settings.data.s3.manager.title')}
|
title={t('settings.data.s3.manager.title')}
|
||||||
@@ -261,23 +281,7 @@ export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S
|
|||||||
width={800}
|
width={800}
|
||||||
centered
|
centered
|
||||||
transitionName="animation-move-down"
|
transitionName="animation-move-down"
|
||||||
footer={[
|
footer={footerContent}>
|
||||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
|
||||||
{t('settings.data.s3.manager.refresh')}
|
|
||||||
</Button>,
|
|
||||||
<Button
|
|
||||||
key="delete"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={handleDeleteSelected}
|
|
||||||
disabled={selectedRowKeys.length === 0 || deleting}
|
|
||||||
loading={deleting}>
|
|
||||||
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
|
|
||||||
</Button>,
|
|
||||||
<Button key="close" onClick={onClose}>
|
|
||||||
{t('settings.data.s3.manager.close')}
|
|
||||||
</Button>
|
|
||||||
]}>
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="fileName"
|
rowKey="fileName"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
101
src/renderer/src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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
|
||||||
@@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
|
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
|
||||||
max-height: var(--navbar-height);
|
max-height: var(--navbar-height);
|
||||||
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
|
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
|
||||||
padding-left: ${({ $isFullScreen }) =>
|
padding-left: ${({ $isFullScreen }) =>
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
|
|||||||
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
|
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
|
||||||
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
|
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
|
||||||
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.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 KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
|
||||||
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
|
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
|
||||||
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
|
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
|
||||||
@@ -32,13 +30,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
|
|||||||
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
|
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
|
||||||
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
|
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
|
||||||
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?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 NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
|
||||||
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
|
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
|
||||||
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
|
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
|
||||||
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
|
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
|
||||||
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
|
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
|
||||||
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?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 ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
|
||||||
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
|
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
|
||||||
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
|
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
|
||||||
@@ -46,7 +44,6 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
|
|||||||
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
|
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
|
||||||
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
|
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
|
||||||
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?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 ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
|
||||||
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
|
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
|
||||||
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
|
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
|
||||||
@@ -145,14 +142,14 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
|||||||
{
|
{
|
||||||
id: 'dashscope',
|
id: 'dashscope',
|
||||||
name: i18n.t('minapps.qwen'),
|
name: i18n.t('minapps.qwen'),
|
||||||
url: 'https://tongyi.aliyun.com/qianwen/',
|
url: 'https://www.tongyi.com/',
|
||||||
logo: QwenModelLogo
|
logo: QwenModelLogo
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stepfun',
|
id: 'stepfun',
|
||||||
name: i18n.t('minapps.yuewen'),
|
name: i18n.t('minapps.stepfun'),
|
||||||
url: 'https://yuewen.cn/chats/new',
|
url: 'https://stepfun.com',
|
||||||
logo: YuewenAppLogo,
|
logo: StepfunAppLogo,
|
||||||
bodered: true
|
bodered: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -263,13 +260,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
|||||||
url: 'https://www.tiangong.cn/',
|
url: 'https://www.tiangong.cn/',
|
||||||
bodered: true
|
bodered: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'hugging-chat',
|
|
||||||
name: 'HuggingChat',
|
|
||||||
logo: HuggingChatLogo,
|
|
||||||
url: 'https://huggingface.co/chat/',
|
|
||||||
bodered: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'Felo',
|
id: 'Felo',
|
||||||
name: 'Felo',
|
name: 'Felo',
|
||||||
@@ -297,13 +287,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
|||||||
url: 'https://bot.n.cn/',
|
url: 'https://bot.n.cn/',
|
||||||
bodered: true
|
bodered: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'nm-search',
|
|
||||||
name: i18n.t('minapps.nami-ai-search'),
|
|
||||||
logo: NamiAiSearchLogo,
|
|
||||||
url: 'https://www.n.cn/',
|
|
||||||
bodered: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'thinkany',
|
id: 'thinkany',
|
||||||
name: 'ThinkAny',
|
name: 'ThinkAny',
|
||||||
@@ -314,13 +297,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
|||||||
padding: 5
|
padding: 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'hika',
|
|
||||||
name: 'Hika',
|
|
||||||
logo: HikaLogo,
|
|
||||||
url: 'https://hika.fyi/',
|
|
||||||
bodered: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'github-copilot',
|
id: 'github-copilot',
|
||||||
name: 'GitHub Copilot',
|
name: 'GitHub Copilot',
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
// Default quick assistant model
|
// Default quick assistant model
|
||||||
glm45FlashModel
|
glm45FlashModel
|
||||||
],
|
],
|
||||||
// cherryin: [],
|
cherryin: [],
|
||||||
vertexai: [],
|
vertexai: [],
|
||||||
|
sophnet: [],
|
||||||
'302ai': [
|
'302ai': [
|
||||||
{
|
{
|
||||||
id: 'deepseek-chat',
|
id: 'deepseek-chat',
|
||||||
@@ -430,6 +431,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
anthropic: [
|
anthropic: [
|
||||||
|
{
|
||||||
|
id: 'claude-haiku-4-5-20251001',
|
||||||
|
provider: 'anthropic',
|
||||||
|
name: 'Claude Haiku 4.5',
|
||||||
|
group: 'Claude 4.5'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-sonnet-4-5-20250929',
|
id: 'claude-sonnet-4-5-20250929',
|
||||||
provider: 'anthropic',
|
provider: 'anthropic',
|
||||||
|
|||||||
@@ -10,20 +10,22 @@ import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
|||||||
import { isEmbeddingModel, isRerankModel } from './embedding'
|
import { isEmbeddingModel, isRerankModel } from './embedding'
|
||||||
import { isGPT5SeriesModel } from './utils'
|
import { isGPT5SeriesModel } from './utils'
|
||||||
import { isTextToImageModel } from './vision'
|
import { isTextToImageModel } from './vision'
|
||||||
import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
|
import { GEMINI_FLASH_MODEL_REGEX, isOpenAIDeepResearchModel } from './websearch'
|
||||||
|
|
||||||
// Reasoning models
|
// Reasoning models
|
||||||
export const REASONING_REGEX =
|
export const REASONING_REGEX =
|
||||||
/^(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
|
/^(?!.*-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
|
||||||
|
|
||||||
// 模型类型到支持的reasoning_effort的映射表
|
// 模型类型到支持的reasoning_effort的映射表
|
||||||
// TODO: refactor this. too many identical options
|
// TODO: refactor this. too many identical options
|
||||||
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||||
default: ['low', 'medium', 'high'] as const,
|
default: ['low', 'medium', 'high'] as const,
|
||||||
o: ['low', 'medium', 'high'] as const,
|
o: ['low', 'medium', 'high'] as const,
|
||||||
|
openai_deep_research: ['medium'] as const,
|
||||||
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
||||||
gpt5_codex: ['low', 'medium', 'high'] as const,
|
gpt5_codex: ['low', 'medium', 'high'] as const,
|
||||||
grok: ['low', 'high'] as const,
|
grok: ['low', 'high'] as const,
|
||||||
|
grok4_fast: ['auto'] as const,
|
||||||
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
||||||
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||||
qwen: ['low', 'medium', 'high'] as const,
|
qwen: ['low', 'medium', 'high'] as const,
|
||||||
@@ -40,9 +42,11 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
|||||||
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||||
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
||||||
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
||||||
|
openai_deep_research: MODEL_SUPPORTED_REASONING_EFFORT.openai_deep_research,
|
||||||
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
||||||
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
|
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
|
||||||
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
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: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
||||||
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
||||||
@@ -58,6 +62,9 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
|||||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||||
let thinkingModelType: ThinkingModelType = 'default'
|
let thinkingModelType: ThinkingModelType = 'default'
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
|
if (isOpenAIDeepResearchModel(model)) {
|
||||||
|
return 'openai_deep_research'
|
||||||
|
}
|
||||||
if (isGPT5SeriesModel(model)) {
|
if (isGPT5SeriesModel(model)) {
|
||||||
if (modelId.includes('codex')) {
|
if (modelId.includes('codex')) {
|
||||||
thinkingModelType = 'gpt5_codex'
|
thinkingModelType = 'gpt5_codex'
|
||||||
@@ -66,6 +73,8 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
|
|||||||
}
|
}
|
||||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||||
thinkingModelType = 'o'
|
thinkingModelType = 'o'
|
||||||
|
} else if (isGrok4FastReasoningModel(model)) {
|
||||||
|
thinkingModelType = 'grok4_fast'
|
||||||
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||||
thinkingModelType = 'gemini'
|
thinkingModelType = 'gemini'
|
||||||
@@ -142,19 +151,46 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
|
const providerId = model.provider.toLowerCase()
|
||||||
if (modelId.includes('grok-3-mini')) {
|
if (modelId.includes('grok-3-mini')) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
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 {
|
export function isGrokReasoningModel(model?: Model): boolean {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
|
if (
|
||||||
|
isSupportedReasoningEffortGrokModel(model) ||
|
||||||
|
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
|
||||||
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +301,11 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const modelId = getLowerBaseModelName(model.id, '/')
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
return modelId.startsWith('qwen3') && modelId.includes('thinking')
|
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
|
||||||
|
return (
|
||||||
|
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
|
||||||
|
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doubao 支持思考模式的模型正则
|
// Doubao 支持思考模式的模型正则
|
||||||
@@ -291,6 +331,12 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
|
|||||||
return DOUBAO_THINKING_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_MODEL_REGEX.test(model.name)
|
return DOUBAO_THINKING_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_MODEL_REGEX.test(model.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isClaude45ReasoningModel(model: Model): boolean {
|
||||||
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
|
const regex = /claude-(sonnet|opus|haiku)-4(-|.)5(?:-[\w-]+)?$/i
|
||||||
|
return regex.test(modelId)
|
||||||
|
}
|
||||||
|
|
||||||
export function isClaudeReasoningModel(model?: Model): boolean {
|
export function isClaudeReasoningModel(model?: Model): boolean {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return false
|
return false
|
||||||
@@ -300,7 +346,8 @@ export function isClaudeReasoningModel(model?: Model): boolean {
|
|||||||
modelId.includes('claude-3-7-sonnet') ||
|
modelId.includes('claude-3-7-sonnet') ||
|
||||||
modelId.includes('claude-3.7-sonnet') ||
|
modelId.includes('claude-3.7-sonnet') ||
|
||||||
modelId.includes('claude-sonnet-4') ||
|
modelId.includes('claude-sonnet-4') ||
|
||||||
modelId.includes('claude-opus-4')
|
modelId.includes('claude-opus-4') ||
|
||||||
|
modelId.includes('claude-haiku-4')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +376,10 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modelId = getLowerBaseModelName(model.id, '/')
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
|
return (
|
||||||
|
isSupportedReasoningEffortPerplexityModel(model) ||
|
||||||
|
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
|
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
|
||||||
@@ -443,6 +493,8 @@ 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 同步变更
|
// 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-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
|
||||||
'qwen3-30b-a3b-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-07-14$': { min: 0, max: 38_912 },
|
||||||
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
|
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
|
||||||
'qwen3-1\\.7b$': { min: 0, max: 30_720 },
|
'qwen3-1\\.7b$': { min: 0, max: 30_720 },
|
||||||
@@ -453,8 +505,9 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
|||||||
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
|
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
|
||||||
|
|
||||||
// Claude models
|
// Claude models
|
||||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
|
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 },
|
||||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
|
'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 },
|
||||||
|
'claude-opus-4-1.*$': { min: 1024, max: 32_000 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
|
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const visionAllowedModels = [
|
|||||||
'gemini-(flash|pro|flash-lite)-latest',
|
'gemini-(flash|pro|flash-lite)-latest',
|
||||||
'gemini-exp',
|
'gemini-exp',
|
||||||
'claude-3',
|
'claude-3',
|
||||||
|
'claude-haiku-4',
|
||||||
'claude-sonnet-4',
|
'claude-sonnet-4',
|
||||||
'claude-opus-4',
|
'claude-opus-4',
|
||||||
'vision',
|
'vision',
|
||||||
@@ -24,7 +25,7 @@ const visionAllowedModels = [
|
|||||||
'qwen2.5-vl',
|
'qwen2.5-vl',
|
||||||
'qwen3-vl',
|
'qwen3-vl',
|
||||||
'qwen2.5-omni',
|
'qwen2.5-omni',
|
||||||
'qwen3-omni',
|
'qwen3-omni(?:-[\\w-]+)?',
|
||||||
'qvq',
|
'qvq',
|
||||||
'internvl2',
|
'internvl2',
|
||||||
'grok-vision-beta',
|
'grok-vision-beta',
|
||||||
@@ -82,14 +83,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
|
|||||||
'grok-2-image(?:-[\\w-]+)?',
|
'grok-2-image(?:-[\\w-]+)?',
|
||||||
'qwen-image-edit',
|
'qwen-image-edit',
|
||||||
'gpt-image-1',
|
'gpt-image-1',
|
||||||
'gemini-2.5-flash-image-preview',
|
'gemini-2.5-flash-image',
|
||||||
'gemini-2.0-flash-preview-image-generation'
|
'gemini-2.0-flash-preview-image-generation'
|
||||||
]
|
]
|
||||||
|
|
||||||
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
|
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
|
||||||
|
|
||||||
// Models that should auto-enable image generation button when selected
|
// Models that should auto-enable image generation button when selected
|
||||||
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
|
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
|
||||||
|
|
||||||
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
|
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
|
||||||
'o3',
|
'o3',
|
||||||
@@ -107,7 +108,7 @@ export const GENERATE_IMAGE_MODELS = [
|
|||||||
'gemini-2.0-flash-exp',
|
'gemini-2.0-flash-exp',
|
||||||
'gemini-2.0-flash-exp-image-generation',
|
'gemini-2.0-flash-exp-image-generation',
|
||||||
'gemini-2.0-flash-preview-image-generation',
|
'gemini-2.0-flash-preview-image-generation',
|
||||||
'gemini-2.5-flash-image-preview',
|
'gemini-2.5-flash-image',
|
||||||
...DEDICATED_IMAGE_MODELS
|
...DEDICATED_IMAGE_MODELS
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { isAnthropicModel } from './utils'
|
|||||||
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
|
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
|
||||||
|
|
||||||
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||||
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
|
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`,
|
||||||
'i'
|
'i'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +26,22 @@ export const PERPLEXITY_SEARCH_MODELS = [
|
|||||||
'sonar-deep-research'
|
'sonar-deep-research'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const OPENAI_DEEP_RESEARCH_MODEL_REGEX = /deep[-_]?research/
|
||||||
|
|
||||||
|
export function isOpenAIDeepResearchModel(model?: Model): boolean {
|
||||||
|
if (!model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerId = model.provider
|
||||||
|
if (providerId !== 'openai' && providerId !== 'openai-chat') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
|
return OPENAI_DEEP_RESEARCH_MODEL_REGEX.test(modelId)
|
||||||
|
}
|
||||||
|
|
||||||
export function isWebSearchModel(model: Model): boolean {
|
export function isWebSearchModel(model: Model): boolean {
|
||||||
if (
|
if (
|
||||||
!model ||
|
!model ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BuiltinOcrProvider,
|
BuiltinOcrProvider,
|
||||||
BuiltinOcrProviderId,
|
BuiltinOcrProviderId,
|
||||||
|
OcrOvProvider,
|
||||||
OcrPpocrProvider,
|
OcrPpocrProvider,
|
||||||
OcrProviderCapability,
|
OcrProviderCapability,
|
||||||
OcrSystemProvider,
|
OcrSystemProvider,
|
||||||
@@ -50,10 +51,23 @@ const ppocrOcr: OcrPpocrProvider = {
|
|||||||
}
|
}
|
||||||
} as const
|
} 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 = {
|
export const BUILTIN_OCR_PROVIDERS_MAP = {
|
||||||
tesseract,
|
tesseract,
|
||||||
system: systemOcr,
|
system: systemOcr,
|
||||||
paddleocr: ppocrOcr
|
paddleocr: ppocrOcr,
|
||||||
|
ovocr: ovOcr
|
||||||
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
|
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
|
||||||
|
|
||||||
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
|
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import Ph8ProviderLogo from '@renderer/assets/images/providers/ph8.png'
|
|||||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||||
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
|
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
|
||||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||||
|
import SophnetProviderLogo from '@renderer/assets/images/providers/sophnet.svg'
|
||||||
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||||
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
||||||
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
||||||
@@ -80,16 +81,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
|
||||||
// cherryin: {
|
cherryin: {
|
||||||
// id: 'cherryin',
|
id: 'cherryin',
|
||||||
// name: 'CherryIN',
|
name: 'CherryIN',
|
||||||
// type: 'openai',
|
type: 'openai',
|
||||||
// apiKey: '',
|
apiKey: '',
|
||||||
// apiHost: 'https://open.cherryin.ai',
|
apiHost: 'https://open.cherryin.net',
|
||||||
// models: [],
|
models: [],
|
||||||
// isSystem: true,
|
isSystem: true,
|
||||||
// enabled: true
|
enabled: true
|
||||||
// },
|
},
|
||||||
silicon: {
|
silicon: {
|
||||||
id: 'silicon',
|
id: 'silicon',
|
||||||
name: 'Silicon',
|
name: 'Silicon',
|
||||||
@@ -240,6 +241,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
|
sophnet: {
|
||||||
|
id: 'sophnet',
|
||||||
|
name: 'SophNet',
|
||||||
|
type: 'openai',
|
||||||
|
apiKey: '',
|
||||||
|
apiHost: 'https://www.sophnet.com/api/open-apis/v1/',
|
||||||
|
models: [],
|
||||||
|
isSystem: true,
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
ppio: {
|
ppio: {
|
||||||
id: 'ppio',
|
id: 'ppio',
|
||||||
name: 'PPIO',
|
name: 'PPIO',
|
||||||
@@ -283,7 +294,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
'new-api': {
|
'new-api': {
|
||||||
id: 'new-api',
|
id: 'new-api',
|
||||||
name: 'New API',
|
name: 'New API',
|
||||||
type: 'openai',
|
type: 'new-api',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
apiHost: 'http://localhost:3000',
|
apiHost: 'http://localhost:3000',
|
||||||
models: SYSTEM_MODELS['new-api'],
|
models: SYSTEM_MODELS['new-api'],
|
||||||
@@ -708,7 +719,8 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
|||||||
'aws-bedrock': AwsProviderLogo,
|
'aws-bedrock': AwsProviderLogo,
|
||||||
poe: 'poe', // use svg icon component
|
poe: 'poe', // use svg icon component
|
||||||
aionly: AiOnlyProviderLogo,
|
aionly: AiOnlyProviderLogo,
|
||||||
longcat: LongCatProviderLogo
|
longcat: LongCatProviderLogo,
|
||||||
|
sophnet: SophnetProviderLogo
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function getProviderLogo(providerId: string) {
|
export function getProviderLogo(providerId: string) {
|
||||||
@@ -732,17 +744,17 @@ type ProviderUrls = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||||
// cherryin: {
|
cherryin: {
|
||||||
// api: {
|
api: {
|
||||||
// url: 'https://open.cherryin.ai'
|
url: 'https://open.cherryin.net'
|
||||||
// },
|
},
|
||||||
// websites: {
|
websites: {
|
||||||
// official: 'https://open.cherryin.ai',
|
official: 'https://open.cherryin.ai',
|
||||||
// apiKey: 'https://open.cherryin.ai/console/token',
|
apiKey: 'https://open.cherryin.ai/console/token',
|
||||||
// docs: 'https://open.cherryin.ai',
|
docs: 'https://open.cherryin.ai',
|
||||||
// models: 'https://open.cherryin.ai/pricing'
|
models: 'https://open.cherryin.ai/pricing'
|
||||||
// }
|
}
|
||||||
// },
|
},
|
||||||
ph8: {
|
ph8: {
|
||||||
api: {
|
api: {
|
||||||
url: 'https://ph8.co'
|
url: 'https://ph8.co'
|
||||||
@@ -787,6 +799,17 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
|||||||
models: 'https://ai.burncloud.com/pricing'
|
models: 'https://ai.burncloud.com/pricing'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
sophnet: {
|
||||||
|
api: {
|
||||||
|
url: 'https://www.sophnet.com/api/open-apis/v1/'
|
||||||
|
},
|
||||||
|
websites: {
|
||||||
|
official: 'https://sophnet.com',
|
||||||
|
apiKey: 'https://sophnet.com/#/project/key',
|
||||||
|
docs: 'https://sophnet.com/docs/component/introduce.html',
|
||||||
|
models: 'https://sophnet.com/#/model/list'
|
||||||
|
}
|
||||||
|
},
|
||||||
ppio: {
|
ppio: {
|
||||||
api: {
|
api: {
|
||||||
url: 'https://api.ppinfra.com/v3/openai'
|
url: 'https://api.ppinfra.com/v3/openai'
|
||||||
@@ -1422,5 +1445,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const isNewApiProvider = (provider: Provider) => {
|
export const isNewApiProvider = (provider: Provider) => {
|
||||||
return ['new-api', 'cherryin'].includes(provider.id)
|
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,14 +76,31 @@ export function useAppInit() {
|
|||||||
}, [avatar, dispatch])
|
}, [avatar, dispatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
const { isPackaged } = await window.api.getAppInfo()
|
||||||
|
|
||||||
|
if (!isPackaged || !autoCheckUpdate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { updateInfo } = await window.api.checkForUpdate()
|
||||||
|
dispatch(setUpdateState({ info: updateInfo }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check with delay
|
||||||
runAsyncFunction(async () => {
|
runAsyncFunction(async () => {
|
||||||
const { isPackaged } = await window.api.getAppInfo()
|
const { isPackaged } = await window.api.getAppInfo()
|
||||||
if (isPackaged && autoCheckUpdate) {
|
if (isPackaged && autoCheckUpdate) {
|
||||||
await delay(2)
|
await delay(2)
|
||||||
const { updateInfo } = await window.api.checkForUpdate()
|
await checkForUpdates()
|
||||||
dispatch(setUpdateState({ info: updateInfo }))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set up 4-hour interval check
|
||||||
|
const FOUR_HOURS = 4 * 60 * 60 * 1000
|
||||||
|
const intervalId = setInterval(checkForUpdates, FOUR_HOURS)
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId)
|
||||||
}, [dispatch, autoCheckUpdate])
|
}, [dispatch, autoCheckUpdate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
saveEdit()
|
saveEdit()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
|
|||||||
const deleteKnowledgeBase = (baseId: string) => {
|
const deleteKnowledgeBase = (baseId: string) => {
|
||||||
const base = bases.find((b) => b.id === baseId)
|
const base = bases.find((b) => b.id === baseId)
|
||||||
if (!base) return
|
if (!base) return
|
||||||
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
|
dispatch(deleteBase({ baseId }))
|
||||||
|
|
||||||
// remove assistant knowledge_base
|
// remove assistant knowledge_base
|
||||||
const _assistants = assistants.map((assistant) => {
|
const _assistants = assistants.map((assistant) => {
|
||||||
|
|||||||
57
src/renderer/src/hooks/useMCPServerTrust.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import ProtocolInstallWarningContent from '@renderer/pages/settings/MCPSettings/ProtocolInstallWarning'
|
||||||
|
import {
|
||||||
|
ensureServerTrusted as ensureServerTrustedCore,
|
||||||
|
getCommandPreview
|
||||||
|
} from '@renderer/pages/settings/MCPSettings/utils'
|
||||||
|
import { MCPServer } from '@renderer/types'
|
||||||
|
import { modalConfirm } from '@renderer/utils'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { useMCPServers } from './useMCPServers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for handling MCP server trust verification
|
||||||
|
* Binds UI (modal dialog) to the core trust verification logic
|
||||||
|
*/
|
||||||
|
export const useMCPServerTrust = () => {
|
||||||
|
const { updateMCPServer } = useMCPServers()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request user confirmation to trust a server
|
||||||
|
* Shows a warning modal with server command preview
|
||||||
|
*/
|
||||||
|
const requestConfirm = useCallback(
|
||||||
|
async (server: MCPServer): Promise<boolean> => {
|
||||||
|
const commandPreview = getCommandPreview(server)
|
||||||
|
return modalConfirm({
|
||||||
|
title: t('settings.mcp.protocolInstallWarning.title'),
|
||||||
|
content: (
|
||||||
|
<ProtocolInstallWarningContent
|
||||||
|
message={t('settings.mcp.protocolInstallWarning.message')}
|
||||||
|
commandLabel={t('settings.mcp.protocolInstallWarning.command')}
|
||||||
|
commandPreview={commandPreview}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
okText: t('settings.mcp.protocolInstallWarning.run'),
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
okButtonProps: { danger: true }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures a server is trusted before proceeding
|
||||||
|
* Combines core logic with UI confirmation
|
||||||
|
*/
|
||||||
|
const ensureServerTrusted = useCallback(
|
||||||
|
async (server: MCPServer): Promise<MCPServer | null> => {
|
||||||
|
return ensureServerTrustedCore(server, requestConfirm, updateMCPServer)
|
||||||
|
},
|
||||||
|
[requestConfirm, updateMCPServer]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { ensureServerTrusted }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import IntelLogo from '@renderer/assets/images/providers/intel.png'
|
||||||
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
|
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
|
||||||
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
|
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
|
||||||
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
|
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
|
||||||
@@ -83,6 +84,8 @@ export const useOcrProviders = () => {
|
|||||||
return <MonitorIcon size={size} />
|
return <MonitorIcon size={size} />
|
||||||
case 'paddleocr':
|
case 'paddleocr':
|
||||||
return <Avatar size={size} src={PaddleocrLogo} />
|
return <Avatar size={size} src={PaddleocrLogo} />
|
||||||
|
case 'ovocr':
|
||||||
|
return <Avatar size={size} src={IntelLogo} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <FileQuestionMarkIcon size={size} />
|
return <FileQuestionMarkIcon size={size} />
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ const providerKeyMap = {
|
|||||||
zhinao: 'provider.zhinao',
|
zhinao: 'provider.zhinao',
|
||||||
zhipu: 'provider.zhipu',
|
zhipu: 'provider.zhipu',
|
||||||
poe: 'provider.poe',
|
poe: 'provider.poe',
|
||||||
aionly: 'provider.aionly'
|
aionly: 'provider.aionly',
|
||||||
|
sophnet: 'provider.sophnet'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,7 +223,7 @@ const paintingsImageSizeOptionsKeyMap = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const getPaintingsImageSizeOptionsLabel = (key: string): string => {
|
export const getPaintingsImageSizeOptionsLabel = (key: string): string => {
|
||||||
return getLabel(paintingsImageSizeOptionsKeyMap, key)
|
return paintingsImageSizeOptionsKeyMap[key] ? getLabel(paintingsImageSizeOptionsKeyMap, key) : key
|
||||||
}
|
}
|
||||||
|
|
||||||
const paintingsQualityOptionsKeyMap = {
|
const paintingsQualityOptionsKeyMap = {
|
||||||
@@ -322,7 +323,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
|
|||||||
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
|
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
|
||||||
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
|
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
|
||||||
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
|
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
|
||||||
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
|
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
|
||||||
|
[BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
||||||
@@ -332,11 +334,13 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
|
|||||||
const builtinOcrProviderKeyMap = {
|
const builtinOcrProviderKeyMap = {
|
||||||
system: 'ocr.builtin.system',
|
system: 'ocr.builtin.system',
|
||||||
tesseract: '',
|
tesseract: '',
|
||||||
paddleocr: ''
|
paddleocr: '',
|
||||||
|
ovocr: ''
|
||||||
} as const satisfies Record<BuiltinOcrProviderId, string>
|
} as const satisfies Record<BuiltinOcrProviderId, string>
|
||||||
|
|
||||||
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
|
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
|
||||||
if (key === 'tesseract') return 'Tesseract'
|
if (key === 'tesseract') return 'Tesseract'
|
||||||
else if (key == 'paddleocr') return 'PaddleOCR'
|
else if (key == 'paddleocr') return 'PaddleOCR'
|
||||||
|
else if (key == 'ovocr') return 'Intel OV(NPU) OCR'
|
||||||
else return getLabel(builtinOcrProviderKeyMap, key)
|
else return getLabel(builtinOcrProviderKeyMap, key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
|
"completed": "Completed",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1728,6 +1729,14 @@
|
|||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
|
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
|
||||||
"save": "Save to Notes",
|
"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": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
@@ -1812,6 +1821,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "Cannot delete built-in provider",
|
"cannot_remove_builtin": "Cannot delete built-in provider",
|
||||||
"existing": "The provider already exists",
|
"existing": "The provider already exists",
|
||||||
|
"get_providers": "Failed to get available providers",
|
||||||
"not_found": "OCR provider does not exist",
|
"not_found": "OCR provider does not exist",
|
||||||
"update_failed": "Failed to update configuration"
|
"update_failed": "Failed to update configuration"
|
||||||
},
|
},
|
||||||
@@ -1873,8 +1883,10 @@
|
|||||||
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
|
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
|
||||||
"install_code_102": "Only supports Windows",
|
"install_code_102": "Only supports Windows",
|
||||||
"install_code_103": "Download OVMS runtime failed",
|
"install_code_103": "Download OVMS runtime failed",
|
||||||
"install_code_104": "Uncompress OVMS runtime failed",
|
"install_code_104": "Failed to install OVMS runtime",
|
||||||
"install_code_105": "Clean OVMS runtime failed",
|
"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:",
|
"run": "Run OVMS failed:",
|
||||||
"stop": "Stop OVMS failed:"
|
"stop": "Stop OVMS failed:"
|
||||||
},
|
},
|
||||||
@@ -2125,6 +2137,7 @@
|
|||||||
"qiniu": "Qiniu AI",
|
"qiniu": "Qiniu AI",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "SiliconFlow",
|
"silicon": "SiliconFlow",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "StepFun",
|
"stepfun": "StepFun",
|
||||||
"tencent-cloud-ti": "Tencent Cloud TI",
|
"tencent-cloud-ti": "Tencent Cloud TI",
|
||||||
"together": "Together",
|
"together": "Together",
|
||||||
@@ -2571,15 +2584,14 @@
|
|||||||
},
|
},
|
||||||
"description": "A powerful AI assistant for producer",
|
"description": "A powerful AI assistant for producer",
|
||||||
"downloading": "Downloading...",
|
"downloading": "Downloading...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Enterprise"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Feedback",
|
"button": "Feedback",
|
||||||
"title": "Feedback"
|
"title": "Feedback"
|
||||||
},
|
},
|
||||||
"label": "About & Feedback",
|
"label": "About & Feedback",
|
||||||
"license": {
|
|
||||||
"button": "License",
|
|
||||||
"title": "License"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "Releases",
|
"button": "Releases",
|
||||||
"title": "Release Notes"
|
"title": "Release Notes"
|
||||||
@@ -2687,6 +2699,46 @@
|
|||||||
"title": "Export Menu Settings",
|
"title": "Export Menu Settings",
|
||||||
"yuque": "Export to Yuque"
|
"yuque": "Export to Yuque"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "Select backup file"
|
||||||
|
},
|
||||||
|
"content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "Confirm Close",
|
||||||
|
"connected": "Connected",
|
||||||
|
"connection_failed": "Connection failed",
|
||||||
|
"content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "Initialization failed",
|
||||||
|
"no_file": "No file selected",
|
||||||
|
"no_ip": "Unable to get IP address",
|
||||||
|
"send_failed": "Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "Force Close",
|
||||||
|
"generating_qr": "Generating QR code...",
|
||||||
|
"noZipSelected": "No compressed file selected",
|
||||||
|
"scan_qr": "Please scan QR code with your phone",
|
||||||
|
"selectZip": "Select a compressed file",
|
||||||
|
"sendZip": "Begin data recovery",
|
||||||
|
"status": {
|
||||||
|
"completed": "Transfer completed",
|
||||||
|
"connected": "Connected",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"error": "Connection error",
|
||||||
|
"initializing": "Initializing connection...",
|
||||||
|
"preparing": "Preparing transfer...",
|
||||||
|
"sending": "Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "LAN transmission",
|
||||||
|
"transfer_progress": "Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "Export to phone"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} hour",
|
"hour_interval_one": "{{count}} hour",
|
||||||
"hour_interval_other": "{{count}} hours",
|
"hour_interval_other": "{{count}} hours",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3397,7 @@
|
|||||||
"builtinServers": "Builtin Servers",
|
"builtinServers": "Builtin Servers",
|
||||||
"builtinServersDescriptions": {
|
"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",
|
"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",
|
"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",
|
"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.",
|
"filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.",
|
||||||
@@ -3432,6 +3485,12 @@
|
|||||||
"noPromptsAvailable": "No prompts available",
|
"noPromptsAvailable": "No prompts available",
|
||||||
"requiredField": "Required Field"
|
"requiredField": "Required Field"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "Startup command",
|
||||||
|
"message": "This MCP was installed from an external source via protocol. Running unknown tools may harm your computer.",
|
||||||
|
"run": "Run",
|
||||||
|
"title": "Run external MCP?"
|
||||||
|
},
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"providerPlaceholder": "Provider name",
|
"providerPlaceholder": "Provider name",
|
||||||
"providerUrl": "Provider URL",
|
"providerUrl": "Provider URL",
|
||||||
@@ -3844,7 +3903,7 @@
|
|||||||
"api_host": "API Host",
|
"api_host": "API Host",
|
||||||
"api_key": {
|
"api_key": {
|
||||||
"label": "API Key",
|
"label": "API Key",
|
||||||
"tip": "Multiple keys separated by commas or spaces"
|
"tip": "Use commas to separate multiple keys"
|
||||||
},
|
},
|
||||||
"api_version": "API Version",
|
"api_version": "API Version",
|
||||||
"aws-bedrock": {
|
"aws-bedrock": {
|
||||||
@@ -4412,6 +4471,7 @@
|
|||||||
"later": "Later",
|
"later": "Later",
|
||||||
"message": "New version {{version}} is ready, do you want to install it now?",
|
"message": "New version {{version}} is ready, do you want to install it now?",
|
||||||
"noReleaseNotes": "No release notes",
|
"noReleaseNotes": "No release notes",
|
||||||
|
"saveDataError": "Failed to save data, please try again.",
|
||||||
"title": "Update"
|
"title": "Update"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"collapse": "折叠",
|
"collapse": "折叠",
|
||||||
|
"completed": "完成",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"copied": "已复制",
|
"copied": "已复制",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "纳米AI搜索",
|
"nami-ai-search": "纳米AI搜索",
|
||||||
"qwen": "通义千问",
|
"qwen": "通义千问",
|
||||||
"sensechat": "商量",
|
"sensechat": "商量",
|
||||||
|
"stepfun": "阶跃AI",
|
||||||
"tencent-yuanbao": "腾讯元宝",
|
"tencent-yuanbao": "腾讯元宝",
|
||||||
"tiangong-ai": "天工AI",
|
"tiangong-ai": "天工AI",
|
||||||
"wanzhi": "万知",
|
"wanzhi": "万知",
|
||||||
"wenxin": "文心一言",
|
"wenxin": "文心一言",
|
||||||
"wps-copilot": "WPS灵犀",
|
"wps-copilot": "WPS灵犀",
|
||||||
"xiaoyi": "小艺",
|
"xiaoyi": "小艺",
|
||||||
"yuewen": "跃问",
|
|
||||||
"zhihu": "知乎直答"
|
"zhihu": "知乎直答"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1728,6 +1729,14 @@
|
|||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
|
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
|
||||||
"save": "保存到笔记",
|
"save": "保存到笔记",
|
||||||
|
"search": {
|
||||||
|
"both": "名称+内容",
|
||||||
|
"content": "内容",
|
||||||
|
"found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
|
||||||
|
"more_matches": "个匹配",
|
||||||
|
"searching": "搜索中...",
|
||||||
|
"show_less": "收起"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
"apply": "应用",
|
"apply": "应用",
|
||||||
@@ -1812,6 +1821,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "不能删除内置提供商",
|
"cannot_remove_builtin": "不能删除内置提供商",
|
||||||
"existing": "提供商已存在",
|
"existing": "提供商已存在",
|
||||||
|
"get_providers": "获取可用提供商失败",
|
||||||
"not_found": "OCR 提供商不存在",
|
"not_found": "OCR 提供商不存在",
|
||||||
"update_failed": "更新配置失败"
|
"update_failed": "更新配置失败"
|
||||||
},
|
},
|
||||||
@@ -1848,7 +1858,7 @@
|
|||||||
"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>",
|
"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": {
|
"download": {
|
||||||
"button": "下载",
|
"button": "下载",
|
||||||
"error": "选择失败",
|
"error": "下载失败",
|
||||||
"model_id": {
|
"model_id": {
|
||||||
"label": "模型 ID",
|
"label": "模型 ID",
|
||||||
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
|
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
|
||||||
@@ -1873,8 +1883,10 @@
|
|||||||
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
|
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
|
||||||
"install_code_102": "仅支持 Windows",
|
"install_code_102": "仅支持 Windows",
|
||||||
"install_code_103": "下载 OVMS runtime 失败",
|
"install_code_103": "下载 OVMS runtime 失败",
|
||||||
"install_code_104": "解压 OVMS runtime 失败",
|
"install_code_104": "安装 OVMS runtime 失败",
|
||||||
"install_code_105": "清理 OVMS runtime 失败",
|
"install_code_105": "创建 ovdnd.exe 失败",
|
||||||
|
"install_code_106": "创建 run.bat 失败",
|
||||||
|
"install_code_110": "清理旧 OVMS runtime 失败",
|
||||||
"run": "运行 OVMS 失败:",
|
"run": "运行 OVMS 失败:",
|
||||||
"stop": "停止 OVMS 失败:"
|
"stop": "停止 OVMS 失败:"
|
||||||
},
|
},
|
||||||
@@ -2125,6 +2137,7 @@
|
|||||||
"qiniu": "七牛云 AI 推理",
|
"qiniu": "七牛云 AI 推理",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "硅基流动",
|
"silicon": "硅基流动",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "阶跃星辰",
|
"stepfun": "阶跃星辰",
|
||||||
"tencent-cloud-ti": "腾讯云 TI",
|
"tencent-cloud-ti": "腾讯云 TI",
|
||||||
"together": "Together",
|
"together": "Together",
|
||||||
@@ -2571,15 +2584,14 @@
|
|||||||
},
|
},
|
||||||
"description": "一款为创造者而生的 AI 助手",
|
"description": "一款为创造者而生的 AI 助手",
|
||||||
"downloading": "正在下载更新...",
|
"downloading": "正在下载更新...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "企业版"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "反馈",
|
"button": "反馈",
|
||||||
"title": "意见反馈"
|
"title": "意见反馈"
|
||||||
},
|
},
|
||||||
"label": "关于我们",
|
"label": "关于我们",
|
||||||
"license": {
|
|
||||||
"button": "查看",
|
|
||||||
"title": "许可证"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "查看",
|
"button": "查看",
|
||||||
"title": "更新日志"
|
"title": "更新日志"
|
||||||
@@ -2687,6 +2699,46 @@
|
|||||||
"title": "导出菜单设置",
|
"title": "导出菜单设置",
|
||||||
"yuque": "导出到语雀"
|
"yuque": "导出到语雀"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "选择备份文件"
|
||||||
|
},
|
||||||
|
"content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "{{seconds}} 秒后自动关闭...",
|
||||||
|
"confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?",
|
||||||
|
"confirm_close_title": "确认关闭",
|
||||||
|
"connected": "连接成功",
|
||||||
|
"connection_failed": "连接失败",
|
||||||
|
"content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "初始化失败",
|
||||||
|
"no_file": "未选择文件",
|
||||||
|
"no_ip": "无法获取 IP 地址",
|
||||||
|
"send_failed": "发送文件失败"
|
||||||
|
},
|
||||||
|
"force_close": "强制关闭",
|
||||||
|
"generating_qr": "正在生成二维码...",
|
||||||
|
"noZipSelected": "未选择压缩文件",
|
||||||
|
"scan_qr": "请使用手机扫码连接",
|
||||||
|
"selectZip": "选择压缩文件",
|
||||||
|
"sendZip": "开始恢复数据",
|
||||||
|
"status": {
|
||||||
|
"completed": "传输完成",
|
||||||
|
"connected": "连接成功",
|
||||||
|
"connecting": "正在连接中...",
|
||||||
|
"disconnected": "连接已断开",
|
||||||
|
"error": "连接出错",
|
||||||
|
"initializing": "正在初始化连接...",
|
||||||
|
"preparing": "准备传输中...",
|
||||||
|
"sending": "传输中 {{progress}}%",
|
||||||
|
"waiting_qr_scan": "请扫描二维码连接"
|
||||||
|
},
|
||||||
|
"title": "局域网传输",
|
||||||
|
"transfer_progress": "传输进度"
|
||||||
|
},
|
||||||
|
"title": "导出至手机"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} 小时",
|
"hour_interval_one": "{{count}} 小时",
|
||||||
"hour_interval_other": "{{count}} 小时",
|
"hour_interval_other": "{{count}} 小时",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3397,7 @@
|
|||||||
"builtinServers": "内置服务器",
|
"builtinServers": "内置服务器",
|
||||||
"builtinServersDescriptions": {
|
"builtinServersDescriptions": {
|
||||||
"brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量",
|
"brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量",
|
||||||
|
"didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量",
|
||||||
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
|
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
|
||||||
"fetch": "用于获取 URL 网页内容的 MCP 服务器",
|
"fetch": "用于获取 URL 网页内容的 MCP 服务器",
|
||||||
"filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录",
|
"filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录",
|
||||||
@@ -3432,6 +3485,12 @@
|
|||||||
"noPromptsAvailable": "无可用提示",
|
"noPromptsAvailable": "无可用提示",
|
||||||
"requiredField": "必填字段"
|
"requiredField": "必填字段"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "启动命令",
|
||||||
|
"message": "该 MCP 是通过协议从外部来源安装的,运行来历不明的工具可能对您的计算机造成危害。",
|
||||||
|
"run": "运行",
|
||||||
|
"title": "运行外部 MCP?"
|
||||||
|
},
|
||||||
"provider": "提供者",
|
"provider": "提供者",
|
||||||
"providerPlaceholder": "提供者名称",
|
"providerPlaceholder": "提供者名称",
|
||||||
"providerUrl": "提供者网址",
|
"providerUrl": "提供者网址",
|
||||||
@@ -3844,7 +3903,7 @@
|
|||||||
"api_host": "API 地址",
|
"api_host": "API 地址",
|
||||||
"api_key": {
|
"api_key": {
|
||||||
"label": "API 密钥",
|
"label": "API 密钥",
|
||||||
"tip": "多个密钥使用逗号或空格分隔"
|
"tip": "多个密钥使用逗号分隔"
|
||||||
},
|
},
|
||||||
"api_version": "API 版本",
|
"api_version": "API 版本",
|
||||||
"aws-bedrock": {
|
"aws-bedrock": {
|
||||||
@@ -4412,6 +4471,7 @@
|
|||||||
"later": "稍后",
|
"later": "稍后",
|
||||||
"message": "发现新版本 {{version}},是否立即安装?",
|
"message": "发现新版本 {{version}},是否立即安装?",
|
||||||
"noReleaseNotes": "暂无更新日志",
|
"noReleaseNotes": "暂无更新日志",
|
||||||
|
"saveDataError": "保存数据失败,请重试",
|
||||||
"title": "更新提示"
|
"title": "更新提示"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"close": "關閉",
|
"close": "關閉",
|
||||||
"collapse": "折疊",
|
"collapse": "折疊",
|
||||||
|
"completed": "[to be translated]:Completed",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"copied": "已複製",
|
"copied": "已複製",
|
||||||
"copy": "複製",
|
"copy": "複製",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "納米AI搜索",
|
"nami-ai-search": "納米AI搜索",
|
||||||
"qwen": "通義千問",
|
"qwen": "通義千問",
|
||||||
"sensechat": "商量",
|
"sensechat": "商量",
|
||||||
|
"stepfun": "階躍AI",
|
||||||
"tencent-yuanbao": "騰訊元寶",
|
"tencent-yuanbao": "騰訊元寶",
|
||||||
"tiangong-ai": "天工AI",
|
"tiangong-ai": "天工AI",
|
||||||
"wanzhi": "萬知",
|
"wanzhi": "萬知",
|
||||||
"wenxin": "文心一言",
|
"wenxin": "文心一言",
|
||||||
"wps-copilot": "WPS靈犀",
|
"wps-copilot": "WPS靈犀",
|
||||||
"xiaoyi": "小藝",
|
"xiaoyi": "小藝",
|
||||||
"yuewen": "躍問",
|
|
||||||
"zhihu": "知乎直答"
|
"zhihu": "知乎直答"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1728,6 +1729,14 @@
|
|||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
|
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
|
||||||
"save": "儲存到筆記",
|
"save": "儲存到筆記",
|
||||||
|
"search": {
|
||||||
|
"both": "名稱+內容",
|
||||||
|
"content": "內容",
|
||||||
|
"found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})",
|
||||||
|
"more_matches": "個匹配",
|
||||||
|
"searching": "搜索中...",
|
||||||
|
"show_less": "收起"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"data": {
|
"data": {
|
||||||
"apply": "應用",
|
"apply": "應用",
|
||||||
@@ -1811,8 +1820,9 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "不能刪除內建提供者",
|
"cannot_remove_builtin": "不能刪除內建提供者",
|
||||||
"existing": "提供商已存在",
|
"existing": "提供者已存在",
|
||||||
"not_found": "OCR 提供商不存在",
|
"get_providers": "取得可用提供者失敗",
|
||||||
|
"not_found": "OCR 提供者不存在",
|
||||||
"update_failed": "更新配置失敗"
|
"update_failed": "更新配置失敗"
|
||||||
},
|
},
|
||||||
"unknown": "OCR過程發生錯誤"
|
"unknown": "OCR過程發生錯誤"
|
||||||
@@ -1848,7 +1858,7 @@
|
|||||||
"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>",
|
"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": {
|
"download": {
|
||||||
"button": "下載",
|
"button": "下載",
|
||||||
"error": "選擇失敗",
|
"error": "下載失敗",
|
||||||
"model_id": {
|
"model_id": {
|
||||||
"label": "模型 ID",
|
"label": "模型 ID",
|
||||||
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
|
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
|
||||||
@@ -1873,8 +1883,10 @@
|
|||||||
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
|
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
|
||||||
"install_code_102": "僅支援 Windows",
|
"install_code_102": "僅支援 Windows",
|
||||||
"install_code_103": "下載 OVMS runtime 失敗",
|
"install_code_103": "下載 OVMS runtime 失敗",
|
||||||
"install_code_104": "解壓 OVMS runtime 失敗",
|
"install_code_104": "安裝 OVMS runtime 失敗",
|
||||||
"install_code_105": "清理 OVMS runtime 失敗",
|
"install_code_105": "創建 ovdnd.exe 失敗",
|
||||||
|
"install_code_106": "創建 run.bat 失敗",
|
||||||
|
"install_code_110": "清理舊 OVMS runtime 失敗",
|
||||||
"run": "執行 OVMS 失敗:",
|
"run": "執行 OVMS 失敗:",
|
||||||
"stop": "停止 OVMS 失敗:"
|
"stop": "停止 OVMS 失敗:"
|
||||||
},
|
},
|
||||||
@@ -2125,6 +2137,7 @@
|
|||||||
"qiniu": "七牛雲 AI 推理",
|
"qiniu": "七牛雲 AI 推理",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "SiliconFlow",
|
"silicon": "SiliconFlow",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "StepFun",
|
"stepfun": "StepFun",
|
||||||
"tencent-cloud-ti": "騰訊雲 TI",
|
"tencent-cloud-ti": "騰訊雲 TI",
|
||||||
"together": "Together",
|
"together": "Together",
|
||||||
@@ -2571,15 +2584,14 @@
|
|||||||
},
|
},
|
||||||
"description": "一款為創作者而生的強大 AI 助手",
|
"description": "一款為創作者而生的強大 AI 助手",
|
||||||
"downloading": "正在下載...",
|
"downloading": "正在下載...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "企業版"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "回饋",
|
"button": "回饋",
|
||||||
"title": "回饋"
|
"title": "回饋"
|
||||||
},
|
},
|
||||||
"label": "關於與回饋",
|
"label": "關於與回饋",
|
||||||
"license": {
|
|
||||||
"button": "檢視",
|
|
||||||
"title": "授權"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "檢視",
|
"button": "檢視",
|
||||||
"title": "更新日誌"
|
"title": "更新日誌"
|
||||||
@@ -2687,6 +2699,46 @@
|
|||||||
"title": "匯出選單設定",
|
"title": "匯出選單設定",
|
||||||
"yuque": "匯出到語雀"
|
"yuque": "匯出到語雀"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "選擇備份檔案"
|
||||||
|
},
|
||||||
|
"content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connection_failed": "[to be translated]:Connection failed",
|
||||||
|
"content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "[to be translated]:Initialization failed",
|
||||||
|
"no_file": "[to be translated]:No file selected",
|
||||||
|
"no_ip": "[to be translated]:Unable to get IP address",
|
||||||
|
"send_failed": "[to be translated]:Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "[to be translated]:Force Close",
|
||||||
|
"generating_qr": "[to be translated]:Generating QR code...",
|
||||||
|
"noZipSelected": "未選取壓縮檔案",
|
||||||
|
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||||
|
"selectZip": "選擇壓縮檔案",
|
||||||
|
"sendZip": "開始恢復資料",
|
||||||
|
"status": {
|
||||||
|
"completed": "[to be translated]:Transfer completed",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connecting": "[to be translated]:Connecting...",
|
||||||
|
"disconnected": "[to be translated]:Disconnected",
|
||||||
|
"error": "[to be translated]:Connection error",
|
||||||
|
"initializing": "[to be translated]:Initializing connection...",
|
||||||
|
"preparing": "[to be translated]:Preparing transfer...",
|
||||||
|
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "區域網路傳輸",
|
||||||
|
"transfer_progress": "[to be translated]:Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "匯出手機"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} 小時",
|
"hour_interval_one": "{{count}} 小時",
|
||||||
"hour_interval_other": "{{count}} 小時",
|
"hour_interval_other": "{{count}} 小時",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3397,7 @@
|
|||||||
"builtinServers": "內置伺服器",
|
"builtinServers": "內置伺服器",
|
||||||
"builtinServersDescriptions": {
|
"builtinServersDescriptions": {
|
||||||
"brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數",
|
"brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數",
|
||||||
|
"didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數",
|
||||||
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
|
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
|
||||||
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
|
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
|
||||||
"filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄",
|
"filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄",
|
||||||
@@ -3432,6 +3485,12 @@
|
|||||||
"noPromptsAvailable": "無可用提示",
|
"noPromptsAvailable": "無可用提示",
|
||||||
"requiredField": "必填欄位"
|
"requiredField": "必填欄位"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "啟動命令",
|
||||||
|
"message": "此 MCP 透過協議從外部來源安裝,執行來源不明的工具可能會對您的電腦造成危害。",
|
||||||
|
"run": "執行",
|
||||||
|
"title": "執行外部 MCP?"
|
||||||
|
},
|
||||||
"provider": "提供者",
|
"provider": "提供者",
|
||||||
"providerPlaceholder": "提供者名稱",
|
"providerPlaceholder": "提供者名稱",
|
||||||
"providerUrl": "提供者網址",
|
"providerUrl": "提供者網址",
|
||||||
@@ -3844,7 +3903,7 @@
|
|||||||
"api_host": "API 主機地址",
|
"api_host": "API 主機地址",
|
||||||
"api_key": {
|
"api_key": {
|
||||||
"label": "API 金鑰",
|
"label": "API 金鑰",
|
||||||
"tip": "多個金鑰使用逗號或空格分隔"
|
"tip": "多個金鑰使用逗號分隔"
|
||||||
},
|
},
|
||||||
"api_version": "API 版本",
|
"api_version": "API 版本",
|
||||||
"aws-bedrock": {
|
"aws-bedrock": {
|
||||||
@@ -4412,6 +4471,7 @@
|
|||||||
"later": "稍後",
|
"later": "稍後",
|
||||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||||
"noReleaseNotes": "暫無更新日誌",
|
"noReleaseNotes": "暫無更新日誌",
|
||||||
|
"saveDataError": "保存數據失敗,請重試",
|
||||||
"title": "更新提示"
|
"title": "更新提示"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "Καθαρισμός",
|
"clear": "Καθαρισμός",
|
||||||
"close": "Κλείσιμο",
|
"close": "Κλείσιμο",
|
||||||
"collapse": "Σύμπτυξη",
|
"collapse": "Σύμπτυξη",
|
||||||
|
"completed": "[to be translated]:Completed",
|
||||||
"confirm": "Επιβεβαίωση",
|
"confirm": "Επιβεβαίωση",
|
||||||
"copied": "Αντιγράφηκε",
|
"copied": "Αντιγράφηκε",
|
||||||
"copy": "Αντιγραφή",
|
"copy": "Αντιγραφή",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1812,6 +1813,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
|
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
|
||||||
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
|
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
|
||||||
|
"get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων",
|
||||||
"not_found": "Ο πάροχος OCR δεν υπάρχει",
|
"not_found": "Ο πάροχος OCR δεν υπάρχει",
|
||||||
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
|
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
|
||||||
},
|
},
|
||||||
@@ -2119,12 +2121,13 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"ovms": "Intel OVMS",
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexity",
|
"perplexity": "Perplexity",
|
||||||
"ph8": "Πλατφόρμα Ανοιχτής Μεγάλης Μοντέλου PH8",
|
"ph8": "PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
"ppio": "PPIO Piao Yun",
|
"ppio": "PPIO Piao Yun",
|
||||||
"qiniu": "Qiniu AI",
|
"qiniu": "Qiniu AI",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "Σιδηρική Παρουσία",
|
"silicon": "Σιδηρική Παρουσία",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "Βήμα Ουράς",
|
"stepfun": "Βήμα Ουράς",
|
||||||
"tencent-cloud-ti": "Tencent Cloud TI",
|
"tencent-cloud-ti": "Tencent Cloud TI",
|
||||||
"together": "Together",
|
"together": "Together",
|
||||||
@@ -2571,15 +2574,14 @@
|
|||||||
},
|
},
|
||||||
"description": "Ένα AI ασιστάντα που έχει σχεδιαστεί για δημιουργούς",
|
"description": "Ένα AI ασιστάντα που έχει σχεδιαστεί για δημιουργούς",
|
||||||
"downloading": "Λήψη ενημερώσεων...",
|
"downloading": "Λήψη ενημερώσεων...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Επιχείρηση"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Σχόλια και Παρατηρήσεις",
|
"button": "Σχόλια και Παρατηρήσεις",
|
||||||
"title": "Αποστολή σχολίων"
|
"title": "Αποστολή σχολίων"
|
||||||
},
|
},
|
||||||
"label": "Περί μας",
|
"label": "Περί μας",
|
||||||
"license": {
|
|
||||||
"button": "Προβολή",
|
|
||||||
"title": "Licenses"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "Προβολή",
|
"button": "Προβολή",
|
||||||
"title": "Ημερολόγιο Ενημερώσεων"
|
"title": "Ημερολόγιο Ενημερώσεων"
|
||||||
@@ -2687,6 +2689,46 @@
|
|||||||
"title": "Εξαγωγή ρυθμίσεων μενού",
|
"title": "Εξαγωγή ρυθμίσεων μενού",
|
||||||
"yuque": "Εξαγωγή στο Yuque"
|
"yuque": "Εξαγωγή στο Yuque"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "[to be translated]:选择备份文件"
|
||||||
|
},
|
||||||
|
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connection_failed": "[to be translated]:Connection failed",
|
||||||
|
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "[to be translated]:Initialization failed",
|
||||||
|
"no_file": "[to be translated]:No file selected",
|
||||||
|
"no_ip": "[to be translated]:Unable to get IP address",
|
||||||
|
"send_failed": "[to be translated]:Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "[to be translated]:Force Close",
|
||||||
|
"generating_qr": "[to be translated]:Generating QR code...",
|
||||||
|
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||||
|
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||||
|
"selectZip": "[to be translated]:选择压缩文件",
|
||||||
|
"sendZip": "[to be translated]:开始恢复数据",
|
||||||
|
"status": {
|
||||||
|
"completed": "[to be translated]:Transfer completed",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connecting": "[to be translated]:Connecting...",
|
||||||
|
"disconnected": "[to be translated]:Disconnected",
|
||||||
|
"error": "[to be translated]:Connection error",
|
||||||
|
"initializing": "[to be translated]:Initializing connection...",
|
||||||
|
"preparing": "[to be translated]:Preparing transfer...",
|
||||||
|
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:局域网传输",
|
||||||
|
"transfer_progress": "[to be translated]:Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:导出至手机"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} ώρα",
|
"hour_interval_one": "{{count}} ώρα",
|
||||||
"hour_interval_other": "{{count}} ώρες",
|
"hour_interval_other": "{{count}} ώρες",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3387,7 @@
|
|||||||
"builtinServers": "Ενσωματωμένοι Διακομιστές",
|
"builtinServers": "Ενσωματωμένοι Διακομιστές",
|
||||||
"builtinServersDescriptions": {
|
"builtinServersDescriptions": {
|
||||||
"brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY",
|
"brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY",
|
||||||
|
"didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY",
|
||||||
"dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify",
|
"dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify",
|
||||||
"fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL",
|
"fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL",
|
||||||
"filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους",
|
"filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους",
|
||||||
@@ -3432,6 +3475,12 @@
|
|||||||
"noPromptsAvailable": "Δεν υπάρχουν διαθέσιμες υποδείξεις",
|
"noPromptsAvailable": "Δεν υπάρχουν διαθέσιμες υποδείξεις",
|
||||||
"requiredField": "Υποχρεωτικό πεδίο"
|
"requiredField": "Υποχρεωτικό πεδίο"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "Εντολή εκκίνησης",
|
||||||
|
"message": "Αυτό το MCP εγκαταστάθηκε από εξωτερική πηγή μέσω πρωτοκόλλου. Η εκτέλεση άγνωστων εργαλείων ενδέχεται να βλάψει τον υπολογιστή σας.",
|
||||||
|
"run": "Τρέξε",
|
||||||
|
"title": "Εκτέλεση εξωτερικού MCP;"
|
||||||
|
},
|
||||||
"provider": "Πάροχος",
|
"provider": "Πάροχος",
|
||||||
"providerPlaceholder": "Όνομα παρόχου",
|
"providerPlaceholder": "Όνομα παρόχου",
|
||||||
"providerUrl": "URL Παρόχου",
|
"providerUrl": "URL Παρόχου",
|
||||||
@@ -4412,6 +4461,7 @@
|
|||||||
"later": "Μετά",
|
"later": "Μετά",
|
||||||
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
||||||
"noReleaseNotes": "Χωρίς σημειώσεις",
|
"noReleaseNotes": "Χωρίς σημειώσεις",
|
||||||
|
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
|
||||||
"title": "Ενημέρωση"
|
"title": "Ενημέρωση"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "Limpiar",
|
"clear": "Limpiar",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"collapse": "Colapsar",
|
"collapse": "Colapsar",
|
||||||
|
"completed": "[to be translated]:Completed",
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"copied": "Copiado",
|
"copied": "Copiado",
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1812,6 +1813,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
|
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
|
||||||
"existing": "El proveedor ya existe",
|
"existing": "El proveedor ya existe",
|
||||||
|
"get_providers": "Error al obtener proveedores disponibles",
|
||||||
"not_found": "El proveedor de OCR no existe",
|
"not_found": "El proveedor de OCR no existe",
|
||||||
"update_failed": "Actualización de la configuración fallida"
|
"update_failed": "Actualización de la configuración fallida"
|
||||||
},
|
},
|
||||||
@@ -2119,12 +2121,13 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"ovms": "Intel OVMS",
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplejidad",
|
"perplexity": "Perplejidad",
|
||||||
"ph8": "Plataforma Abierta de Grandes Modelos PH8",
|
"ph8": "PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
"ppio": "PPIO Cloud Piao",
|
"ppio": "PPIO Cloud Piao",
|
||||||
"qiniu": "Qiniu AI",
|
"qiniu": "Qiniu AI",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "Silicio Fluido",
|
"silicon": "Silicio Fluido",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "Función Salto",
|
"stepfun": "Función Salto",
|
||||||
"tencent-cloud-ti": "Tencent Nube TI",
|
"tencent-cloud-ti": "Tencent Nube TI",
|
||||||
"together": "Juntos",
|
"together": "Juntos",
|
||||||
@@ -2571,15 +2574,14 @@
|
|||||||
},
|
},
|
||||||
"description": "Una asistente de IA creada para los creadores",
|
"description": "Una asistente de IA creada para los creadores",
|
||||||
"downloading": "Descargando actualización...",
|
"downloading": "Descargando actualización...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Empresa"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Enviar feedback",
|
"button": "Enviar feedback",
|
||||||
"title": "Enviar comentarios"
|
"title": "Enviar comentarios"
|
||||||
},
|
},
|
||||||
"label": "Acerca de nosotros",
|
"label": "Acerca de nosotros",
|
||||||
"license": {
|
|
||||||
"button": "Ver",
|
|
||||||
"title": "Licencia"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "Ver",
|
"button": "Ver",
|
||||||
"title": "Registro de cambios"
|
"title": "Registro de cambios"
|
||||||
@@ -2687,6 +2689,46 @@
|
|||||||
"title": "Exportar configuración del menú",
|
"title": "Exportar configuración del menú",
|
||||||
"yuque": "Exportar a Yuque"
|
"yuque": "Exportar a Yuque"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "[to be translated]:选择备份文件"
|
||||||
|
},
|
||||||
|
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connection_failed": "[to be translated]:Connection failed",
|
||||||
|
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "[to be translated]:Initialization failed",
|
||||||
|
"no_file": "[to be translated]:No file selected",
|
||||||
|
"no_ip": "[to be translated]:Unable to get IP address",
|
||||||
|
"send_failed": "[to be translated]:Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "[to be translated]:Force Close",
|
||||||
|
"generating_qr": "[to be translated]:Generating QR code...",
|
||||||
|
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||||
|
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||||
|
"selectZip": "[to be translated]:选择压缩文件",
|
||||||
|
"sendZip": "[to be translated]:开始恢复数据",
|
||||||
|
"status": {
|
||||||
|
"completed": "[to be translated]:Transfer completed",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connecting": "[to be translated]:Connecting...",
|
||||||
|
"disconnected": "[to be translated]:Disconnected",
|
||||||
|
"error": "[to be translated]:Connection error",
|
||||||
|
"initializing": "[to be translated]:Initializing connection...",
|
||||||
|
"preparing": "[to be translated]:Preparing transfer...",
|
||||||
|
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:局域网传输",
|
||||||
|
"transfer_progress": "[to be translated]:Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:导出至手机"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} hora",
|
"hour_interval_one": "{{count}} hora",
|
||||||
"hour_interval_other": "{{count}} horas",
|
"hour_interval_other": "{{count}} horas",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3387,7 @@
|
|||||||
"builtinServers": "Servidores integrados",
|
"builtinServers": "Servidores integrados",
|
||||||
"builtinServersDescriptions": {
|
"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",
|
"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.",
|
"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",
|
"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",
|
"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",
|
||||||
@@ -3432,6 +3475,12 @@
|
|||||||
"noPromptsAvailable": "No hay indicaciones disponibles",
|
"noPromptsAvailable": "No hay indicaciones disponibles",
|
||||||
"requiredField": "Campo obligatorio"
|
"requiredField": "Campo obligatorio"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "Comando de inicio",
|
||||||
|
"message": "Este MCP fue instalado desde una fuente externa a través del protocolo. Ejecutar herramientas desconocidas puede dañar tu computadora.",
|
||||||
|
"run": "Correr",
|
||||||
|
"title": "¿Ejecutar MCP externo?"
|
||||||
|
},
|
||||||
"provider": "Proveedor",
|
"provider": "Proveedor",
|
||||||
"providerPlaceholder": "Nombre del proveedor",
|
"providerPlaceholder": "Nombre del proveedor",
|
||||||
"providerUrl": "URL del proveedor",
|
"providerUrl": "URL del proveedor",
|
||||||
@@ -4412,6 +4461,7 @@
|
|||||||
"later": "Más tarde",
|
"later": "Más tarde",
|
||||||
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
||||||
"noReleaseNotes": "Sin notas de la versión",
|
"noReleaseNotes": "Sin notas de la versión",
|
||||||
|
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
|
||||||
"title": "Actualización"
|
"title": "Actualización"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "Effacer",
|
"clear": "Effacer",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
"collapse": "Réduire",
|
"collapse": "Réduire",
|
||||||
|
"completed": "[to be translated]:Completed",
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
"copied": "Copié",
|
"copied": "Copié",
|
||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1812,6 +1813,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
|
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
|
||||||
"existing": "Le fournisseur existe déjà",
|
"existing": "Le fournisseur existe déjà",
|
||||||
|
"get_providers": "Échec de l'obtention des fournisseurs disponibles",
|
||||||
"not_found": "Le fournisseur OCR n'existe pas",
|
"not_found": "Le fournisseur OCR n'existe pas",
|
||||||
"update_failed": "Échec de la mise à jour de la configuration"
|
"update_failed": "Échec de la mise à jour de la configuration"
|
||||||
},
|
},
|
||||||
@@ -2119,12 +2121,13 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"ovms": "Intel OVMS",
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexité",
|
"perplexity": "Perplexité",
|
||||||
"ph8": "Plateforme ouverte de grands modèles PH8",
|
"ph8": "PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
"ppio": "PPIO Cloud Piou",
|
"ppio": "PPIO Cloud Piou",
|
||||||
"qiniu": "Qiniu AI",
|
"qiniu": "Qiniu AI",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "Silicium Fluide",
|
"silicon": "Silicium Fluide",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "Échelon Étoile",
|
"stepfun": "Échelon Étoile",
|
||||||
"tencent-cloud-ti": "Tencent Cloud TI",
|
"tencent-cloud-ti": "Tencent Cloud TI",
|
||||||
"together": "Ensemble",
|
"together": "Ensemble",
|
||||||
@@ -2571,15 +2574,14 @@
|
|||||||
},
|
},
|
||||||
"description": "Un assistant IA conçu pour les créateurs",
|
"description": "Un assistant IA conçu pour les créateurs",
|
||||||
"downloading": "Téléchargement de la mise à jour en cours...",
|
"downloading": "Téléchargement de la mise à jour en cours...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Entreprise"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Faire un retour",
|
"button": "Faire un retour",
|
||||||
"title": "Retour d'information"
|
"title": "Retour d'information"
|
||||||
},
|
},
|
||||||
"label": "À propos de nous",
|
"label": "À propos de nous",
|
||||||
"license": {
|
|
||||||
"button": "Afficher",
|
|
||||||
"title": "Licence"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "Afficher",
|
"button": "Afficher",
|
||||||
"title": "Journal des mises à jour"
|
"title": "Journal des mises à jour"
|
||||||
@@ -2687,6 +2689,46 @@
|
|||||||
"title": "Exporter les paramètres du menu",
|
"title": "Exporter les paramètres du menu",
|
||||||
"yuque": "Exporter vers Yuque"
|
"yuque": "Exporter vers Yuque"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "[to be translated]:选择备份文件"
|
||||||
|
},
|
||||||
|
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connection_failed": "[to be translated]:Connection failed",
|
||||||
|
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "[to be translated]:Initialization failed",
|
||||||
|
"no_file": "[to be translated]:No file selected",
|
||||||
|
"no_ip": "[to be translated]:Unable to get IP address",
|
||||||
|
"send_failed": "[to be translated]:Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "[to be translated]:Force Close",
|
||||||
|
"generating_qr": "[to be translated]:Generating QR code...",
|
||||||
|
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||||
|
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||||
|
"selectZip": "[to be translated]:选择压缩文件",
|
||||||
|
"sendZip": "[to be translated]:开始恢复数据",
|
||||||
|
"status": {
|
||||||
|
"completed": "[to be translated]:Transfer completed",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connecting": "[to be translated]:Connecting...",
|
||||||
|
"disconnected": "[to be translated]:Disconnected",
|
||||||
|
"error": "[to be translated]:Connection error",
|
||||||
|
"initializing": "[to be translated]:Initializing connection...",
|
||||||
|
"preparing": "[to be translated]:Preparing transfer...",
|
||||||
|
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:局域网传输",
|
||||||
|
"transfer_progress": "[to be translated]:Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:导出至手机"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} heure",
|
"hour_interval_one": "{{count}} heure",
|
||||||
"hour_interval_other": "{{count}} heures",
|
"hour_interval_other": "{{count}} heures",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3387,7 @@
|
|||||||
"builtinServers": "Serveurs intégrés",
|
"builtinServers": "Serveurs intégrés",
|
||||||
"builtinServersDescriptions": {
|
"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",
|
"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",
|
"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",
|
"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.",
|
"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.",
|
||||||
@@ -3432,6 +3475,12 @@
|
|||||||
"noPromptsAvailable": "Aucune invite disponible",
|
"noPromptsAvailable": "Aucune invite disponible",
|
||||||
"requiredField": "Champ obligatoire"
|
"requiredField": "Champ obligatoire"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "Commande de démarrage",
|
||||||
|
"message": "Ce MCP a été installé depuis une source externe via le protocole. L'exécution d'outils inconnus peut endommager votre ordinateur.",
|
||||||
|
"run": "Courir",
|
||||||
|
"title": "Exécuter un MCP externe ?"
|
||||||
|
},
|
||||||
"provider": "Поставщик",
|
"provider": "Поставщик",
|
||||||
"providerPlaceholder": "Название поставщика",
|
"providerPlaceholder": "Название поставщика",
|
||||||
"providerUrl": "Адрес поставщика",
|
"providerUrl": "Адрес поставщика",
|
||||||
@@ -4412,6 +4461,7 @@
|
|||||||
"later": "Plus tard",
|
"later": "Plus tard",
|
||||||
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
||||||
"noReleaseNotes": "Aucune note de version",
|
"noReleaseNotes": "Aucune note de version",
|
||||||
|
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
|
||||||
"title": "Mise à jour"
|
"title": "Mise à jour"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "クリア",
|
"clear": "クリア",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"collapse": "折りたたむ",
|
"collapse": "折りたたむ",
|
||||||
|
"completed": "[to be translated]:Completed",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"copied": "コピーされました",
|
"copied": "コピーされました",
|
||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "通義千問",
|
"qwen": "通義千問",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "騰訊元宝",
|
"tencent-yuanbao": "騰訊元宝",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "万知",
|
"wanzhi": "万知",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "小藝",
|
"xiaoyi": "小藝",
|
||||||
"yuewen": "躍問",
|
|
||||||
"zhihu": "知乎直答"
|
"zhihu": "知乎直答"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1812,6 +1813,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
|
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
|
||||||
"existing": "プロバイダーはすでに存在します",
|
"existing": "プロバイダーはすでに存在します",
|
||||||
|
"get_providers": "利用可能なプロバイダーの取得に失敗しました",
|
||||||
"not_found": "OCRプロバイダーが存在しません",
|
"not_found": "OCRプロバイダーが存在しません",
|
||||||
"update_failed": "更新構成に失敗しました"
|
"update_failed": "更新構成に失敗しました"
|
||||||
},
|
},
|
||||||
@@ -2125,6 +2127,7 @@
|
|||||||
"qiniu": "七牛云 AI 推理",
|
"qiniu": "七牛云 AI 推理",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "SiliconFlow",
|
"silicon": "SiliconFlow",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "StepFun",
|
"stepfun": "StepFun",
|
||||||
"tencent-cloud-ti": "Tencent Cloud TI",
|
"tencent-cloud-ti": "Tencent Cloud TI",
|
||||||
"together": "Together",
|
"together": "Together",
|
||||||
@@ -2571,15 +2574,14 @@
|
|||||||
},
|
},
|
||||||
"description": "クリエイターのための強力なAIアシスタント",
|
"description": "クリエイターのための強力なAIアシスタント",
|
||||||
"downloading": "ダウンロード中...",
|
"downloading": "ダウンロード中...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "エンタープライズ"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "フィードバック",
|
"button": "フィードバック",
|
||||||
"title": "フィードバック"
|
"title": "フィードバック"
|
||||||
},
|
},
|
||||||
"label": "について",
|
"label": "について",
|
||||||
"license": {
|
|
||||||
"button": "ライセンス",
|
|
||||||
"title": "ライセンス"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "リリース",
|
"button": "リリース",
|
||||||
"title": "リリースノート"
|
"title": "リリースノート"
|
||||||
@@ -2687,6 +2689,46 @@
|
|||||||
"title": "エクスポートメニュー設定",
|
"title": "エクスポートメニュー設定",
|
||||||
"yuque": "語雀にエクスポート"
|
"yuque": "語雀にエクスポート"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "[to be translated]:选择备份文件"
|
||||||
|
},
|
||||||
|
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connection_failed": "[to be translated]:Connection failed",
|
||||||
|
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "[to be translated]:Initialization failed",
|
||||||
|
"no_file": "[to be translated]:No file selected",
|
||||||
|
"no_ip": "[to be translated]:Unable to get IP address",
|
||||||
|
"send_failed": "[to be translated]:Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "[to be translated]:Force Close",
|
||||||
|
"generating_qr": "[to be translated]:Generating QR code...",
|
||||||
|
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||||
|
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||||
|
"selectZip": "[to be translated]:选择压缩文件",
|
||||||
|
"sendZip": "[to be translated]:开始恢复数据",
|
||||||
|
"status": {
|
||||||
|
"completed": "[to be translated]:Transfer completed",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connecting": "[to be translated]:Connecting...",
|
||||||
|
"disconnected": "[to be translated]:Disconnected",
|
||||||
|
"error": "[to be translated]:Connection error",
|
||||||
|
"initializing": "[to be translated]:Initializing connection...",
|
||||||
|
"preparing": "[to be translated]:Preparing transfer...",
|
||||||
|
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:局域网传输",
|
||||||
|
"transfer_progress": "[to be translated]:Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:导出至手机"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} 時間",
|
"hour_interval_one": "{{count}} 時間",
|
||||||
"hour_interval_other": "{{count}} 時間",
|
"hour_interval_other": "{{count}} 時間",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3387,7 @@
|
|||||||
"builtinServers": "組み込みサーバー",
|
"builtinServers": "組み込みサーバー",
|
||||||
"builtinServersDescriptions": {
|
"builtinServersDescriptions": {
|
||||||
"brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です",
|
"brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です",
|
||||||
|
"didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です",
|
||||||
"dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。",
|
"dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。",
|
||||||
"fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー",
|
"fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー",
|
||||||
"filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です",
|
"filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です",
|
||||||
@@ -3432,6 +3475,12 @@
|
|||||||
"noPromptsAvailable": "利用可能なプロンプトはありません",
|
"noPromptsAvailable": "利用可能なプロンプトはありません",
|
||||||
"requiredField": "必須フィールド"
|
"requiredField": "必須フィールド"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "起動コマンド",
|
||||||
|
"message": "このMCPは外部ソースからプロトコル経由でインストールされました。不明なツールを実行すると、コンピューターに危害を及ぼす可能性があります。",
|
||||||
|
"run": "走る",
|
||||||
|
"title": "外部のMCPを実行しますか?"
|
||||||
|
},
|
||||||
"provider": "プロバイダー",
|
"provider": "プロバイダー",
|
||||||
"providerPlaceholder": "プロバイダー名",
|
"providerPlaceholder": "プロバイダー名",
|
||||||
"providerUrl": "プロバイダーURL",
|
"providerUrl": "プロバイダーURL",
|
||||||
@@ -4412,6 +4461,7 @@
|
|||||||
"later": "後で",
|
"later": "後で",
|
||||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||||
"noReleaseNotes": "暫無更新日誌",
|
"noReleaseNotes": "暫無更新日誌",
|
||||||
|
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
|
||||||
"title": "更新"
|
"title": "更新"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "Limpar",
|
"clear": "Limpar",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
"collapse": "Recolher",
|
"collapse": "Recolher",
|
||||||
|
"completed": "[to be translated]:Completed",
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"copied": "Copiado",
|
"copied": "Copiado",
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Yuanbao",
|
"tencent-yuanbao": "Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1812,6 +1813,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
|
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
|
||||||
"existing": "O provedor já existe",
|
"existing": "O provedor já existe",
|
||||||
|
"get_providers": "Falha ao obter provedores disponíveis",
|
||||||
"not_found": "O provedor OCR não existe",
|
"not_found": "O provedor OCR não existe",
|
||||||
"update_failed": "Falha ao atualizar a configuração"
|
"update_failed": "Falha ao atualizar a configuração"
|
||||||
},
|
},
|
||||||
@@ -2119,12 +2121,13 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"ovms": "Intel OVMS",
|
"ovms": "Intel OVMS",
|
||||||
"perplexity": "Perplexidade",
|
"perplexity": "Perplexidade",
|
||||||
"ph8": "Plataforma Aberta de Grandes Modelos PH8",
|
"ph8": "PH8",
|
||||||
"poe": "Poe",
|
"poe": "Poe",
|
||||||
"ppio": "PPIO Nuvem Piao",
|
"ppio": "PPIO Nuvem Piao",
|
||||||
"qiniu": "Qiniu AI",
|
"qiniu": "Qiniu AI",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "Silício em Fluxo",
|
"silicon": "Silício em Fluxo",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "Função de Passo Estelar",
|
"stepfun": "Função de Passo Estelar",
|
||||||
"tencent-cloud-ti": "Nuvem TI da Tencent",
|
"tencent-cloud-ti": "Nuvem TI da Tencent",
|
||||||
"together": "Juntos",
|
"together": "Juntos",
|
||||||
@@ -2571,15 +2574,14 @@
|
|||||||
},
|
},
|
||||||
"description": "Um assistente de IA criado para criadores",
|
"description": "Um assistente de IA criado para criadores",
|
||||||
"downloading": "Baixando atualizações...",
|
"downloading": "Baixando atualizações...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Empresa"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Feedback",
|
"button": "Feedback",
|
||||||
"title": "Enviar feedback"
|
"title": "Enviar feedback"
|
||||||
},
|
},
|
||||||
"label": "Sobre Nós",
|
"label": "Sobre Nós",
|
||||||
"license": {
|
|
||||||
"button": "Ver",
|
|
||||||
"title": "Licença"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "Ver",
|
"button": "Ver",
|
||||||
"title": "Registro de alterações"
|
"title": "Registro de alterações"
|
||||||
@@ -2687,6 +2689,46 @@
|
|||||||
"title": "Exportar Configurações do Menu",
|
"title": "Exportar Configurações do Menu",
|
||||||
"yuque": "Exportar para Yuque"
|
"yuque": "Exportar para Yuque"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "[to be translated]:选择备份文件"
|
||||||
|
},
|
||||||
|
"content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connection_failed": "[to be translated]:Connection failed",
|
||||||
|
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "[to be translated]:Initialization failed",
|
||||||
|
"no_file": "[to be translated]:No file selected",
|
||||||
|
"no_ip": "[to be translated]:Unable to get IP address",
|
||||||
|
"send_failed": "[to be translated]:Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "[to be translated]:Force Close",
|
||||||
|
"generating_qr": "[to be translated]:Generating QR code...",
|
||||||
|
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||||
|
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||||
|
"selectZip": "[to be translated]:选择压缩文件",
|
||||||
|
"sendZip": "[to be translated]:开始恢复数据",
|
||||||
|
"status": {
|
||||||
|
"completed": "[to be translated]:Transfer completed",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connecting": "[to be translated]:Connecting...",
|
||||||
|
"disconnected": "[to be translated]:Disconnected",
|
||||||
|
"error": "[to be translated]:Connection error",
|
||||||
|
"initializing": "[to be translated]:Initializing connection...",
|
||||||
|
"preparing": "[to be translated]:Preparing transfer...",
|
||||||
|
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:局域网传输",
|
||||||
|
"transfer_progress": "[to be translated]:Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:导出至手机"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} hora",
|
"hour_interval_one": "{{count}} hora",
|
||||||
"hour_interval_other": "{{count}} horas",
|
"hour_interval_other": "{{count}} horas",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3387,7 @@
|
|||||||
"builtinServers": "Servidores integrados",
|
"builtinServers": "Servidores integrados",
|
||||||
"builtinServersDescriptions": {
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
||||||
@@ -3432,6 +3475,12 @@
|
|||||||
"noPromptsAvailable": "Nenhuma dica disponível",
|
"noPromptsAvailable": "Nenhuma dica disponível",
|
||||||
"requiredField": "Campo obrigatório"
|
"requiredField": "Campo obrigatório"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "Comando de inicialização",
|
||||||
|
"message": "Este MCP foi instalado a partir de uma fonte externa via protocolo. Executar ferramentas desconhecidas pode prejudicar seu computador.",
|
||||||
|
"run": "Correr",
|
||||||
|
"title": "Executar MCP externo?"
|
||||||
|
},
|
||||||
"provider": "Fornecedor",
|
"provider": "Fornecedor",
|
||||||
"providerPlaceholder": "Nome do Fornecedor",
|
"providerPlaceholder": "Nome do Fornecedor",
|
||||||
"providerUrl": "URL do Fornecedor",
|
"providerUrl": "URL do Fornecedor",
|
||||||
@@ -4412,6 +4461,7 @@
|
|||||||
"later": "Mais tarde",
|
"later": "Mais tarde",
|
||||||
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
||||||
"noReleaseNotes": "Sem notas de versão",
|
"noReleaseNotes": "Sem notas de versão",
|
||||||
|
"saveDataError": "Falha ao salvar os dados, tente novamente",
|
||||||
"title": "Atualização"
|
"title": "Atualização"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -757,6 +757,7 @@
|
|||||||
"clear": "Очистить",
|
"clear": "Очистить",
|
||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
|
"completed": "[to be translated]:Completed",
|
||||||
"confirm": "Подтверждение",
|
"confirm": "Подтверждение",
|
||||||
"copied": "Скопировано",
|
"copied": "Скопировано",
|
||||||
"copy": "Копировать",
|
"copy": "Копировать",
|
||||||
@@ -1583,13 +1584,13 @@
|
|||||||
"nami-ai-search": "Nami AI Search",
|
"nami-ai-search": "Nami AI Search",
|
||||||
"qwen": "Qwen",
|
"qwen": "Qwen",
|
||||||
"sensechat": "SenseChat",
|
"sensechat": "SenseChat",
|
||||||
|
"stepfun": "Stepfun",
|
||||||
"tencent-yuanbao": "Tencent Yuanbao",
|
"tencent-yuanbao": "Tencent Yuanbao",
|
||||||
"tiangong-ai": "Skywork",
|
"tiangong-ai": "Skywork",
|
||||||
"wanzhi": "Wanzhi",
|
"wanzhi": "Wanzhi",
|
||||||
"wenxin": "ERNIE",
|
"wenxin": "ERNIE",
|
||||||
"wps-copilot": "WPS Copilot",
|
"wps-copilot": "WPS Copilot",
|
||||||
"xiaoyi": "Xiaoyi",
|
"xiaoyi": "Xiaoyi",
|
||||||
"yuewen": "Yuewen",
|
|
||||||
"zhihu": "Zhihu"
|
"zhihu": "Zhihu"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
@@ -1812,6 +1813,7 @@
|
|||||||
"provider": {
|
"provider": {
|
||||||
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
|
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
|
||||||
"existing": "Поставщик уже существует",
|
"existing": "Поставщик уже существует",
|
||||||
|
"get_providers": "Не удалось получить доступных поставщиков",
|
||||||
"not_found": "Поставщик OCR отсутствует",
|
"not_found": "Поставщик OCR отсутствует",
|
||||||
"update_failed": "Обновление конфигурации не удалось"
|
"update_failed": "Обновление конфигурации не удалось"
|
||||||
},
|
},
|
||||||
@@ -2125,6 +2127,7 @@
|
|||||||
"qiniu": "Qiniu AI",
|
"qiniu": "Qiniu AI",
|
||||||
"qwenlm": "QwenLM",
|
"qwenlm": "QwenLM",
|
||||||
"silicon": "SiliconFlow",
|
"silicon": "SiliconFlow",
|
||||||
|
"sophnet": "SophNet",
|
||||||
"stepfun": "StepFun",
|
"stepfun": "StepFun",
|
||||||
"tencent-cloud-ti": "Tencent Cloud TI",
|
"tencent-cloud-ti": "Tencent Cloud TI",
|
||||||
"together": "Together",
|
"together": "Together",
|
||||||
@@ -2571,15 +2574,14 @@
|
|||||||
},
|
},
|
||||||
"description": "Мощный AI-ассистент для созидания",
|
"description": "Мощный AI-ассистент для созидания",
|
||||||
"downloading": "Загрузка...",
|
"downloading": "Загрузка...",
|
||||||
|
"enterprise": {
|
||||||
|
"title": "Предприятие"
|
||||||
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"button": "Обратная связь",
|
"button": "Обратная связь",
|
||||||
"title": "Обратная связь"
|
"title": "Обратная связь"
|
||||||
},
|
},
|
||||||
"label": "О программе и обратная связь",
|
"label": "О программе и обратная связь",
|
||||||
"license": {
|
|
||||||
"button": "Лицензия",
|
|
||||||
"title": "Лицензия"
|
|
||||||
},
|
|
||||||
"releases": {
|
"releases": {
|
||||||
"button": "Релизы",
|
"button": "Релизы",
|
||||||
"title": "Заметки о релизах"
|
"title": "Заметки о релизах"
|
||||||
@@ -2687,6 +2689,46 @@
|
|||||||
"title": "Настройки меню экспорта",
|
"title": "Настройки меню экспорта",
|
||||||
"yuque": "Экспорт в Yuque"
|
"yuque": "Экспорт в Yuque"
|
||||||
},
|
},
|
||||||
|
"export_to_phone": {
|
||||||
|
"confirm": {
|
||||||
|
"button": "[to be translated]:选择备份文件"
|
||||||
|
},
|
||||||
|
"content": "Экспорт части данных, включая историю чатов и настройки. Обратите внимание, процесс резервного копирования может занять некоторое время, благодарим за ваше терпение.",
|
||||||
|
"lan": {
|
||||||
|
"auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...",
|
||||||
|
"confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
|
||||||
|
"confirm_close_title": "[to be translated]:Confirm Close",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connection_failed": "[to be translated]:Connection failed",
|
||||||
|
"content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
|
||||||
|
"error": {
|
||||||
|
"init_failed": "[to be translated]:Initialization failed",
|
||||||
|
"no_file": "[to be translated]:No file selected",
|
||||||
|
"no_ip": "[to be translated]:Unable to get IP address",
|
||||||
|
"send_failed": "[to be translated]:Failed to send file"
|
||||||
|
},
|
||||||
|
"force_close": "[to be translated]:Force Close",
|
||||||
|
"generating_qr": "[to be translated]:Generating QR code...",
|
||||||
|
"noZipSelected": "[to be translated]:未选择压缩文件",
|
||||||
|
"scan_qr": "[to be translated]:Please scan QR code with your phone",
|
||||||
|
"selectZip": "[to be translated]:选择压缩文件",
|
||||||
|
"sendZip": "[to be translated]:开始恢复数据",
|
||||||
|
"status": {
|
||||||
|
"completed": "[to be translated]:Transfer completed",
|
||||||
|
"connected": "[to be translated]:Connected",
|
||||||
|
"connecting": "[to be translated]:Connecting...",
|
||||||
|
"disconnected": "[to be translated]:Disconnected",
|
||||||
|
"error": "[to be translated]:Connection error",
|
||||||
|
"initializing": "[to be translated]:Initializing connection...",
|
||||||
|
"preparing": "[to be translated]:Preparing transfer...",
|
||||||
|
"sending": "[to be translated]:Transferring {{progress}}%",
|
||||||
|
"waiting_qr_scan": "[to be translated]:Please scan QR code to connect"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:局域网传输",
|
||||||
|
"transfer_progress": "[to be translated]:Transfer progress"
|
||||||
|
},
|
||||||
|
"title": "[to be translated]:导出至手机"
|
||||||
|
},
|
||||||
"hour_interval_one": "{{count}} час",
|
"hour_interval_one": "{{count}} час",
|
||||||
"hour_interval_other": "{{count}} часов",
|
"hour_interval_other": "{{count}} часов",
|
||||||
"joplin": {
|
"joplin": {
|
||||||
@@ -3345,6 +3387,7 @@
|
|||||||
"builtinServers": "Встроенные серверы",
|
"builtinServers": "Встроенные серверы",
|
||||||
"builtinServersDescriptions": {
|
"builtinServersDescriptions": {
|
||||||
"brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY",
|
"brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY",
|
||||||
|
"didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY",
|
||||||
"dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify",
|
"dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify",
|
||||||
"fetch": "MCP-сервер для получения содержимого веб-страниц по URL",
|
"fetch": "MCP-сервер для получения содержимого веб-страниц по URL",
|
||||||
"filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ",
|
"filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ",
|
||||||
@@ -3432,6 +3475,12 @@
|
|||||||
"noPromptsAvailable": "Нет доступных подсказок",
|
"noPromptsAvailable": "Нет доступных подсказок",
|
||||||
"requiredField": "Обязательное поле"
|
"requiredField": "Обязательное поле"
|
||||||
},
|
},
|
||||||
|
"protocolInstallWarning": {
|
||||||
|
"command": "Команда запуска",
|
||||||
|
"message": "Этот MCP был установлен из внешнего источника через протокол. Запуск неизвестных инструментов может повредить ваш компьютер.",
|
||||||
|
"run": "Беги",
|
||||||
|
"title": "Запускать внешний MCP?"
|
||||||
|
},
|
||||||
"provider": "Провайдер",
|
"provider": "Провайдер",
|
||||||
"providerPlaceholder": "Имя провайдера",
|
"providerPlaceholder": "Имя провайдера",
|
||||||
"providerUrl": "URL провайдера",
|
"providerUrl": "URL провайдера",
|
||||||
@@ -4412,6 +4461,7 @@
|
|||||||
"later": "Позже",
|
"later": "Позже",
|
||||||
"message": "Новая версия {{version}} готова, установить сейчас?",
|
"message": "Новая версия {{version}} готова, установить сейчас?",
|
||||||
"noReleaseNotes": "Нет заметок об обновлении",
|
"noReleaseNotes": "Нет заметок об обновлении",
|
||||||
|
"saveDataError": "Ошибка сохранения данных, повторите попытку",
|
||||||
"title": "Обновление"
|
"title": "Обновление"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
|||||||
@@ -383,7 +383,9 @@ const InputbarTools = ({
|
|||||||
key: 'url_context',
|
key: 'url_context',
|
||||||
label: t('chat.input.url_context'),
|
label: t('chat.input.url_context'),
|
||||||
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
||||||
condition: isGeminiModel(model) && isSupportUrlContextProvider(getProviderByModel(model))
|
condition:
|
||||||
|
isGeminiModel(model) &&
|
||||||
|
(isSupportUrlContextProvider(getProviderByModel(model)) || model.endpoint_type === 'gemini')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'knowledge_base',
|
key: 'knowledge_base',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@heroui/button'
|
import { Button } from '@heroui/button'
|
||||||
import CodeViewer from '@renderer/components/CodeViewer'
|
import CodeViewer from '@renderer/components/CodeViewer'
|
||||||
|
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
|
import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
|
||||||
import { getProviderById } from '@renderer/services/ProviderService'
|
import { getProviderById } from '@renderer/services/ProviderService'
|
||||||
@@ -35,7 +36,7 @@ import {
|
|||||||
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
|
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
|
||||||
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
|
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
|
||||||
import { Alert as AntdAlert, Modal } from 'antd'
|
import { Alert as AntdAlert, Modal } from 'antd'
|
||||||
import React, { useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@@ -305,14 +306,36 @@ const BuiltinError = ({ error }: { error: SerializedError }) => {
|
|||||||
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
|
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
|
||||||
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
|
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { highlightCode } = useCodeStyle()
|
||||||
|
const [highlightedString, setHighlightedString] = useState('')
|
||||||
const cause = error.cause
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<BuiltinError error={error} />
|
<BuiltinError error={error} />
|
||||||
{cause && (
|
{cause && (
|
||||||
<ErrorDetailItem>
|
<ErrorDetailItem>
|
||||||
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
|
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
|
||||||
<ErrorDetailValue>{error.cause}</ErrorDetailValue>
|
<ErrorDetailValue>
|
||||||
|
<div
|
||||||
|
className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightedString }}
|
||||||
|
/>
|
||||||
|
</ErrorDetailValue>
|
||||||
</ErrorDetailItem>
|
</ErrorDetailItem>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -337,30 +337,18 @@ const GroupContainer = styled.div`
|
|||||||
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
|
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
overflow-y: visible;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
&.horizontal {
|
&.horizontal {
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
|
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
|
||||||
overflow-y: hidden;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
&::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--color-scrollbar-thumb);
|
|
||||||
border-radius: var(--scrollbar-thumb-radius);
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-scrollbar-thumb-hover);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
&.fold,
|
&.fold,
|
||||||
&.vertical {
|
&.vertical {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
&.grid {
|
&.grid {
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(
|
||||||
@@ -368,15 +356,11 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
|
|||||||
minmax(0, 1fr)
|
minmax(0, 1fr)
|
||||||
);
|
);
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.multi-select-mode {
|
&.multi-select-mode {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
.grid {
|
.grid {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
@@ -402,7 +386,7 @@ interface MessageWrapperProps {
|
|||||||
const MessageWrapper = styled.div<MessageWrapperProps>`
|
const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||||
&.horizontal {
|
&.horizontal {
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
/* overflow-y: auto; */
|
overflow-y: auto;
|
||||||
.message {
|
.message {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
@@ -424,7 +408,7 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
|
|||||||
&.grid {
|
&.grid {
|
||||||
display: block;
|
display: block;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
overflow: hidden;
|
overflow-y: hidden;
|
||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { SyncOutlined } from '@ant-design/icons'
|
import { SyncOutlined } from '@ant-design/icons'
|
||||||
|
import { useDisclosure } from '@heroui/react'
|
||||||
|
import UpdateDialog from '@renderer/components/UpdateDialog'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
@@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => {
|
|||||||
const { update } = useRuntime()
|
const { update } = useRuntime()
|
||||||
const { autoCheckUpdate } = useSettings()
|
const { autoCheckUpdate } = useSettings()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
|
|
||||||
if (!update) {
|
if (!update) {
|
||||||
return null
|
return null
|
||||||
@@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => {
|
|||||||
<Container>
|
<Container>
|
||||||
<UpdateButton
|
<UpdateButton
|
||||||
className="nodrag"
|
className="nodrag"
|
||||||
onClick={() => window.api.showUpdateDialog()}
|
onClick={onOpen}
|
||||||
icon={<SyncOutlined />}
|
icon={<SyncOutlined />}
|
||||||
color="orange"
|
color="orange"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small">
|
size="small">
|
||||||
{t('button.update_available')}
|
{t('button.update_available')}
|
||||||
</UpdateButton>
|
</UpdateButton>
|
||||||
|
|
||||||
|
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
||||||
import MinimalToolbar from './components/MinimalToolbar'
|
import MinimalToolbar from './components/MinimalToolbar'
|
||||||
|
import WebviewSearch from './components/WebviewSearch'
|
||||||
|
|
||||||
const logger = loggerService.withContext('MinAppPage')
|
const logger = loggerService.withContext('MinAppPage')
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ const MinAppPage: FC = () => {
|
|||||||
onOpenDevTools={handleOpenDevTools}
|
onOpenDevTools={handleOpenDevTools}
|
||||||
/>
|
/>
|
||||||
</ToolbarWrapper>
|
</ToolbarWrapper>
|
||||||
|
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
|
||||||
{!isReady && (
|
{!isReady && (
|
||||||
<LoadingMask>
|
<LoadingMask>
|
||||||
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PushpinOutlined,
|
PushpinOutlined,
|
||||||
ReloadOutlined
|
ReloadOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
import { isDev } from '@renderer/config/constant'
|
import { isDev } from '@renderer/config/constant'
|
||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
@@ -17,11 +18,21 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
|||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { WebviewTag } from 'electron'
|
import { WebviewTag } from 'electron'
|
||||||
import { FC, useCallback, useState } from 'react'
|
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
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 {
|
interface Props {
|
||||||
app: MinAppType
|
app: MinAppType
|
||||||
webviewRef: React.RefObject<WebviewTag | null>
|
webviewRef: React.RefObject<WebviewTag | null>
|
||||||
@@ -42,27 +53,166 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
|
|||||||
const isPinned = pinned.some((item) => item.id === app.id)
|
const isPinned = pinned.some((item) => item.id === app.id)
|
||||||
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
|
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
|
// Update navigation state
|
||||||
const updateNavigationState = useCallback(() => {
|
const updateNavigationState = useCallback(() => {
|
||||||
if (webviewRef.current) {
|
if (webviewRef.current) {
|
||||||
setCanGoBack(webviewRef.current.canGoBack())
|
try {
|
||||||
setCanGoForward(webviewRef.current.canGoForward())
|
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)
|
||||||
}
|
}
|
||||||
}, [webviewRef])
|
}, [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
|
||||||
|
|
||||||
const handleGoBack = useCallback(() => {
|
const handleGoBack = useCallback(() => {
|
||||||
if (webviewRef.current && webviewRef.current.canGoBack()) {
|
if (webviewRef.current) {
|
||||||
webviewRef.current.goBack()
|
try {
|
||||||
updateNavigationState()
|
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' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [webviewRef, updateNavigationState])
|
}, [app.id, webviewRef, scheduleNavigationUpdate])
|
||||||
|
|
||||||
const handleGoForward = useCallback(() => {
|
const handleGoForward = useCallback(() => {
|
||||||
if (webviewRef.current && webviewRef.current.canGoForward()) {
|
if (webviewRef.current) {
|
||||||
webviewRef.current.goForward()
|
try {
|
||||||
updateNavigationState()
|
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' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [webviewRef, updateNavigationState])
|
}, [app.id, webviewRef, scheduleNavigationUpdate])
|
||||||
|
|
||||||
const handleMinimize = useCallback(() => {
|
const handleMinimize = useCallback(() => {
|
||||||
navigate('/apps')
|
navigate('/apps')
|
||||||
|
|||||||
377
src/renderer/src/pages/minapps/components/WebviewSearch.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
|
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
|
||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||||
import { HSpaceBetweenStack } from '@renderer/components/Layout'
|
import { HSpaceBetweenStack } from '@renderer/components/Layout'
|
||||||
import RichEditor from '@renderer/components/RichEditor'
|
import RichEditor from '@renderer/components/RichEditor'
|
||||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||||
@@ -20,11 +20,12 @@ interface NotesEditorProps {
|
|||||||
currentContent: string
|
currentContent: string
|
||||||
tokenCount: number
|
tokenCount: number
|
||||||
editorRef: RefObject<RichEditorRef | null>
|
editorRef: RefObject<RichEditorRef | null>
|
||||||
|
codeEditorRef: RefObject<CodeEditorHandles | null>
|
||||||
onMarkdownChange: (content: string) => void
|
onMarkdownChange: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotesEditor: FC<NotesEditorProps> = memo(
|
const NotesEditor: FC<NotesEditorProps> = memo(
|
||||||
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
|
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { settings } = useNotesSettings()
|
const { settings } = useNotesSettings()
|
||||||
@@ -59,6 +60,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
{tmpViewMode === 'source' ? (
|
{tmpViewMode === 'source' ? (
|
||||||
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
|
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
ref={codeEditorRef}
|
||||||
value={currentContent}
|
value={currentContent}
|
||||||
language="markdown"
|
language="markdown"
|
||||||
onChange={onMarkdownChange}
|
onChange={onMarkdownChange}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||||
|
import { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||||
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
|
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||||
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import {
|
import {
|
||||||
addDir,
|
addDir,
|
||||||
addNote,
|
addNote,
|
||||||
@@ -51,6 +53,7 @@ const logger = loggerService.withContext('NotesPage')
|
|||||||
|
|
||||||
const NotesPage: FC = () => {
|
const NotesPage: FC = () => {
|
||||||
const editorRef = useRef<RichEditorRef>(null)
|
const editorRef = useRef<RichEditorRef>(null)
|
||||||
|
const codeEditorRef = useRef<CodeEditorHandles>(null)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showWorkspace } = useShowWorkspace()
|
const { showWorkspace } = useShowWorkspace()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -76,6 +79,7 @@ const NotesPage: FC = () => {
|
|||||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||||
const isRenamingRef = useRef(false)
|
const isRenamingRef = useRef(false)
|
||||||
const isCreatingNoteRef = useRef(false)
|
const isCreatingNoteRef = useRef(false)
|
||||||
|
const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null)
|
||||||
|
|
||||||
const activeFilePathRef = useRef<string | undefined>(activeFilePath)
|
const activeFilePathRef = useRef<string | undefined>(activeFilePath)
|
||||||
const currentContentRef = useRef(currentContent)
|
const currentContentRef = useRef(currentContent)
|
||||||
@@ -366,6 +370,32 @@ const NotesPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentContent, activeFilePath])
|
}, [currentContent, activeFilePath])
|
||||||
|
|
||||||
|
// Execute pending scroll after file switch
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingScrollRef.current || !currentContent) return
|
||||||
|
|
||||||
|
const { lineNumber, lineContent } = pendingScrollRef.current
|
||||||
|
pendingScrollRef.current = null
|
||||||
|
|
||||||
|
// Wait for DOM to update before scrolling
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const codeEditor = codeEditorRef.current
|
||||||
|
const richEditor = editorRef.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (codeEditor?.scrollToLine) {
|
||||||
|
codeEditor.scrollToLine(lineNumber, { highlight: true })
|
||||||
|
} else if (richEditor?.scrollToLine) {
|
||||||
|
richEditor.scrollToLine(lineNumber, { highlight: true, lineContent })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to execute pending scroll:', error as Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [activeFilePath, currentContent])
|
||||||
|
|
||||||
// 切换文件时的清理工作
|
// 切换文件时的清理工作
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -385,21 +415,25 @@ const NotesPage: FC = () => {
|
|||||||
}, [activeFilePath])
|
}, [activeFilePath])
|
||||||
|
|
||||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||||
const getTargetFolderPath = useCallback(() => {
|
const getTargetFolderPath = useCallback(
|
||||||
if (selectedFolderId) {
|
(targetFolderId?: string) => {
|
||||||
const selectedNode = findNode(notesTree, selectedFolderId)
|
const folderId = targetFolderId || selectedFolderId
|
||||||
if (selectedNode && selectedNode.type === 'folder') {
|
if (folderId) {
|
||||||
return selectedNode.externalPath
|
const selectedNode = findNode(notesTree, folderId)
|
||||||
|
if (selectedNode && selectedNode.type === 'folder') {
|
||||||
|
return selectedNode.externalPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return notesPath // 默认返回根目录
|
||||||
return notesPath // 默认返回根目录
|
},
|
||||||
}, [selectedFolderId, notesTree, notesPath])
|
[selectedFolderId, notesTree, notesPath]
|
||||||
|
)
|
||||||
|
|
||||||
// 创建文件夹
|
// 创建文件夹
|
||||||
const handleCreateFolder = useCallback(
|
const handleCreateFolder = useCallback(
|
||||||
async (name: string) => {
|
async (name: string, targetFolderId?: string) => {
|
||||||
try {
|
try {
|
||||||
const targetPath = getTargetFolderPath()
|
const targetPath = getTargetFolderPath(targetFolderId)
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
throw new Error('No folder path selected')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
@@ -415,11 +449,11 @@ const NotesPage: FC = () => {
|
|||||||
|
|
||||||
// 创建笔记
|
// 创建笔记
|
||||||
const handleCreateNote = useCallback(
|
const handleCreateNote = useCallback(
|
||||||
async (name: string) => {
|
async (name: string, targetFolderId?: string) => {
|
||||||
try {
|
try {
|
||||||
isCreatingNoteRef.current = true
|
isCreatingNoteRef.current = true
|
||||||
|
|
||||||
const targetPath = getTargetFolderPath()
|
const targetPath = getTargetFolderPath(targetFolderId)
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
throw new Error('No folder path selected')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
@@ -751,6 +785,53 @@ const NotesPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentContent, settings.defaultEditMode])
|
}, [currentContent, settings.defaultEditMode])
|
||||||
|
|
||||||
|
// Listen for external requests to locate a specific line in a note
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLocateNoteLine = ({
|
||||||
|
noteId,
|
||||||
|
lineNumber,
|
||||||
|
lineContent
|
||||||
|
}: {
|
||||||
|
noteId: string
|
||||||
|
lineNumber: number
|
||||||
|
lineContent?: string
|
||||||
|
}) => {
|
||||||
|
const targetNode = findNode(notesTree, noteId)
|
||||||
|
|
||||||
|
if (!targetNode || targetNode.type !== 'file') {
|
||||||
|
logger.warn('Target note not found or not a file', { noteId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsSwitchFile = targetNode.externalPath !== activeFilePath
|
||||||
|
|
||||||
|
if (needsSwitchFile) {
|
||||||
|
// switch to target note first then scroll to line
|
||||||
|
pendingScrollRef.current = { lineNumber, lineContent }
|
||||||
|
dispatch(setActiveFilePath(targetNode.externalPath))
|
||||||
|
invalidateFileContent(targetNode.externalPath)
|
||||||
|
} else {
|
||||||
|
const richEditor = editorRef.current
|
||||||
|
const codeEditor = codeEditorRef.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (codeEditor?.scrollToLine) {
|
||||||
|
codeEditor.scrollToLine(lineNumber, { highlight: true })
|
||||||
|
} else if (richEditor?.scrollToLine) {
|
||||||
|
richEditor.scrollToLine(lineNumber, { highlight: true, lineContent })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to scroll to line:', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = EventEmitter.on(EVENT_NAMES.LOCATE_NOTE_LINE, handleLocateNoteLine)
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [activeNode?.id, activeFilePath, notesTree, dispatch, invalidateFileContent])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container id="notes-page">
|
<Container id="notes-page">
|
||||||
<Navbar>
|
<Navbar>
|
||||||
@@ -796,6 +877,7 @@ const NotesPage: FC = () => {
|
|||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
onMarkdownChange={handleMarkdownChange}
|
onMarkdownChange={handleMarkdownChange}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
|
codeEditorRef={codeEditorRef}
|
||||||
/>
|
/>
|
||||||
</EditorWrapper>
|
</EditorWrapper>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import HighlightText from '@renderer/components/HighlightText'
|
||||||
import { DeleteIcon } from '@renderer/components/Icons'
|
import { DeleteIcon } from '@renderer/components/Icons'
|
||||||
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
@@ -7,6 +8,8 @@ import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
|||||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||||
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||||
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
||||||
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
|
import { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService'
|
||||||
import { RootState, useAppSelector } from '@renderer/store'
|
import { RootState, useAppSelector } from '@renderer/store'
|
||||||
import { selectSortType } from '@renderer/store/note'
|
import { selectSortType } from '@renderer/store/note'
|
||||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
@@ -23,19 +26,23 @@ import {
|
|||||||
FileSearch,
|
FileSearch,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
StarOff,
|
StarOff,
|
||||||
UploadIcon
|
UploadIcon,
|
||||||
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import { useFullTextSearch } from './hooks/useFullTextSearch'
|
||||||
|
|
||||||
interface NotesSidebarProps {
|
interface NotesSidebarProps {
|
||||||
onCreateFolder: (name: string, parentId?: string) => void
|
onCreateFolder: (name: string, targetFolderId?: string) => void
|
||||||
onCreateNote: (name: string, parentId?: string) => void
|
onCreateNote: (name: string, targetFolderId?: string) => void
|
||||||
onSelectNode: (node: NotesTreeNode) => void
|
onSelectNode: (node: NotesTreeNode) => void
|
||||||
onDeleteNode: (nodeId: string) => void
|
onDeleteNode: (nodeId: string) => void
|
||||||
onRenameNode: (nodeId: string, newName: string) => void
|
onRenameNode: (nodeId: string, newName: string) => void
|
||||||
@@ -51,7 +58,7 @@ interface NotesSidebarProps {
|
|||||||
const logger = loggerService.withContext('NotesSidebar')
|
const logger = loggerService.withContext('NotesSidebar')
|
||||||
|
|
||||||
interface TreeNodeProps {
|
interface TreeNodeProps {
|
||||||
node: NotesTreeNode
|
node: NotesTreeNode | SearchResult
|
||||||
depth: number
|
depth: number
|
||||||
selectedFolderId?: string | null
|
selectedFolderId?: string | null
|
||||||
activeNodeId?: string
|
activeNodeId?: string
|
||||||
@@ -71,6 +78,10 @@ interface TreeNodeProps {
|
|||||||
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
|
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||||
onDragEnd: () => void
|
onDragEnd: () => void
|
||||||
renderChildren?: boolean // 控制是否渲染子节点
|
renderChildren?: boolean // 控制是否渲染子节点
|
||||||
|
searchKeyword?: string // 搜索关键词,用于高亮
|
||||||
|
showMatches?: boolean // 是否显示匹配预览
|
||||||
|
openDropdownKey: string | null
|
||||||
|
onDropdownOpenChange: (key: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNode = memo<TreeNodeProps>(
|
const TreeNode = memo<TreeNodeProps>(
|
||||||
@@ -94,9 +105,31 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
onDragLeave,
|
onDragLeave,
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
renderChildren = true
|
renderChildren = true,
|
||||||
|
searchKeyword = '',
|
||||||
|
showMatches = false,
|
||||||
|
openDropdownKey,
|
||||||
|
onDropdownOpenChange
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [showAllMatches, setShowAllMatches] = useState(false)
|
||||||
|
|
||||||
|
// 检查是否是搜索结果
|
||||||
|
const searchResult = 'matchType' in node ? (node as SearchResult) : null
|
||||||
|
const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0
|
||||||
|
|
||||||
|
// 处理匹配项点击
|
||||||
|
const handleMatchClick = useCallback(
|
||||||
|
(match: SearchMatch) => {
|
||||||
|
// 发送定位事件
|
||||||
|
EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, {
|
||||||
|
noteId: node.id,
|
||||||
|
lineNumber: match.lineNumber,
|
||||||
|
lineContent: match.lineContent
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[node]
|
||||||
|
)
|
||||||
|
|
||||||
const isActive = selectedFolderId
|
const isActive = selectedFolderId
|
||||||
? node.type === 'folder' && node.id === selectedFolderId
|
? node.type === 'folder' && node.id === selectedFolderId
|
||||||
@@ -117,10 +150,45 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = useMemo(() => {
|
||||||
|
if (!searchKeyword) {
|
||||||
|
return node.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = node.name ?? ''
|
||||||
|
if (!name) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = searchKeyword
|
||||||
|
const nameLower = name.toLowerCase()
|
||||||
|
const keywordLower = keyword.toLowerCase()
|
||||||
|
const matchStart = nameLower.indexOf(keywordLower)
|
||||||
|
|
||||||
|
if (matchStart === -1) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchEnd = matchStart + keyword.length
|
||||||
|
const beforeMatch = Math.min(2, matchStart)
|
||||||
|
const contextStart = matchStart - beforeMatch
|
||||||
|
const contextLength = 50
|
||||||
|
const contextEnd = Math.min(name.length, matchEnd + contextLength)
|
||||||
|
|
||||||
|
const prefix = contextStart > 0 ? '...' : ''
|
||||||
|
const suffix = contextEnd < name.length ? '...' : ''
|
||||||
|
|
||||||
|
return prefix + name.substring(contextStart, contextEnd) + suffix
|
||||||
|
}, [node.name, searchKeyword])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id}>
|
<div key={node.id}>
|
||||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
<Dropdown
|
||||||
<div>
|
menu={{ items: getMenuItems(node) }}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
open={openDropdownKey === node.id}
|
||||||
|
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
|
||||||
|
<div onContextMenu={(e) => e.stopPropagation()}>
|
||||||
<TreeNodeContainer
|
<TreeNodeContainer
|
||||||
active={isActive}
|
active={isActive}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
@@ -174,13 +242,55 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName>
|
<NodeNameContainer>
|
||||||
|
<NodeName className={getNodeNameClassName()}>
|
||||||
|
{searchKeyword ? <HighlightText text={displayName} keyword={searchKeyword} /> : node.name}
|
||||||
|
</NodeName>
|
||||||
|
{searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && (
|
||||||
|
<MatchBadge matchType={searchResult.matchType}>
|
||||||
|
{searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')}
|
||||||
|
</MatchBadge>
|
||||||
|
)}
|
||||||
|
</NodeNameContainer>
|
||||||
)}
|
)}
|
||||||
</TreeNodeContent>
|
</TreeNodeContent>
|
||||||
</TreeNodeContainer>
|
</TreeNodeContainer>
|
||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
|
{showMatches && hasMatches && (
|
||||||
|
<SearchMatchesContainer depth={depth}>
|
||||||
|
{(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => (
|
||||||
|
<MatchItem key={idx} onClick={() => handleMatchClick(match)}>
|
||||||
|
<MatchLineNumber>{match.lineNumber}</MatchLineNumber>
|
||||||
|
<MatchContext>
|
||||||
|
<HighlightText text={match.context} keyword={searchKeyword} />
|
||||||
|
</MatchContext>
|
||||||
|
</MatchItem>
|
||||||
|
))}
|
||||||
|
{searchResult!.matches!.length > 3 && (
|
||||||
|
<MoreMatches
|
||||||
|
depth={depth}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowAllMatches(!showAllMatches)
|
||||||
|
}}>
|
||||||
|
{showAllMatches ? (
|
||||||
|
<>
|
||||||
|
<ChevronDown size={12} style={{ marginRight: 4 }} />
|
||||||
|
{t('notes.search.show_less')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronRight size={12} style={{ marginRight: 4 }} />+{searchResult!.matches!.length - 3}{' '}
|
||||||
|
{t('notes.search.more_matches')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MoreMatches>
|
||||||
|
)}
|
||||||
|
</SearchMatchesContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
|
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
|
||||||
<div>
|
<div>
|
||||||
{node.children!.map((child) => (
|
{node.children!.map((child) => (
|
||||||
@@ -206,6 +316,8 @@ const TreeNode = memo<TreeNodeProps>(
|
|||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
renderChildren={renderChildren}
|
renderChildren={renderChildren}
|
||||||
|
openDropdownKey={openDropdownKey}
|
||||||
|
onDropdownOpenChange={onDropdownOpenChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -244,8 +356,34 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const [isShowSearch, setIsShowSearch] = useState(false)
|
const [isShowSearch, setIsShowSearch] = useState(false)
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
||||||
|
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
|
||||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||||
const scrollbarRef = useRef<any>(null)
|
const scrollbarRef = useRef<any>(null)
|
||||||
|
const notesTreeRef = useRef<NotesTreeNode[]>(notesTree)
|
||||||
|
const trimmedSearchKeyword = useMemo(() => searchKeyword.trim(), [searchKeyword])
|
||||||
|
const hasSearchKeyword = trimmedSearchKeyword.length > 0
|
||||||
|
|
||||||
|
// 全文搜索配置
|
||||||
|
const searchOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
debounceMs: 300,
|
||||||
|
maxResults: 100,
|
||||||
|
contextLength: 50,
|
||||||
|
caseSensitive: false,
|
||||||
|
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
enabled: isShowSearch
|
||||||
|
}),
|
||||||
|
[isShowSearch]
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
cancel,
|
||||||
|
reset,
|
||||||
|
isSearching,
|
||||||
|
results: searchResults,
|
||||||
|
stats: searchStats
|
||||||
|
} = useFullTextSearch(searchOptions)
|
||||||
|
|
||||||
const inPlaceEdit = useInPlaceEdit({
|
const inPlaceEdit = useInPlaceEdit({
|
||||||
onSave: (newName: string) => {
|
onSave: (newName: string) => {
|
||||||
@@ -486,6 +624,25 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
setIsShowSearch(!isShowSearch)
|
setIsShowSearch(!isShowSearch)
|
||||||
}, [isShowSearch])
|
}, [isShowSearch])
|
||||||
|
|
||||||
|
// 同步 notesTree 到 ref
|
||||||
|
useEffect(() => {
|
||||||
|
notesTreeRef.current = notesTree
|
||||||
|
}, [notesTree])
|
||||||
|
|
||||||
|
// 触发全文搜索
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isShowSearch) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSearchKeyword) {
|
||||||
|
search(notesTreeRef.current, trimmedSearchKeyword)
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}, [isShowSearch, hasSearchKeyword, trimmedSearchKeyword, search, reset])
|
||||||
|
|
||||||
// Flatten tree nodes for virtualization and filtering
|
// Flatten tree nodes for virtualization and filtering
|
||||||
const flattenedNodes = useMemo(() => {
|
const flattenedNodes = useMemo(() => {
|
||||||
const flattenForVirtualization = (
|
const flattenForVirtualization = (
|
||||||
@@ -509,11 +666,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
let result: NotesTreeNode[] = []
|
let result: NotesTreeNode[] = []
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (isShowSearch && searchKeyword) {
|
if (isShowStarred) {
|
||||||
if (node.type === 'file' && node.name.toLowerCase().includes(searchKeyword.toLowerCase())) {
|
|
||||||
result.push(node)
|
|
||||||
}
|
|
||||||
} else if (isShowStarred) {
|
|
||||||
if (node.type === 'file' && node.isStarred) {
|
if (node.type === 'file' && node.isStarred) {
|
||||||
result.push(node)
|
result.push(node)
|
||||||
}
|
}
|
||||||
@@ -525,7 +678,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isShowStarred || isShowSearch) {
|
if (isShowSearch) {
|
||||||
|
if (hasSearchKeyword) {
|
||||||
|
return searchResults.map((result) => ({ node: result, depth: 0 }))
|
||||||
|
}
|
||||||
|
return [] // 搜索关键词为空
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShowStarred) {
|
||||||
// For filtered views, return flat list without virtualization for simplicity
|
// For filtered views, return flat list without virtualization for simplicity
|
||||||
const filteredNodes = flattenForFiltering(notesTree)
|
const filteredNodes = flattenForFiltering(notesTree)
|
||||||
return filteredNodes.map((node) => ({ node, depth: 0 }))
|
return filteredNodes.map((node) => ({ node, depth: 0 }))
|
||||||
@@ -533,7 +693,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
// For normal tree view, use hierarchical flattening for virtualization
|
// For normal tree view, use hierarchical flattening for virtualization
|
||||||
return flattenForVirtualization(notesTree)
|
return flattenForVirtualization(notesTree)
|
||||||
}, [notesTree, isShowStarred, isShowSearch, searchKeyword])
|
}, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults])
|
||||||
|
|
||||||
// Use virtualization only for normal tree view with many items
|
// Use virtualization only for normal tree view with many items
|
||||||
const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100
|
const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100
|
||||||
@@ -571,6 +731,28 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
baseMenuItems.push(
|
||||||
|
{
|
||||||
|
label: t('notes.new_note'),
|
||||||
|
key: 'new_note',
|
||||||
|
icon: <FilePlus size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
onCreateNote(t('notes.untitled_note'), node.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.new_folder'),
|
||||||
|
key: 'new_folder',
|
||||||
|
icon: <Folder size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
onCreateFolder(t('notes.untitled_folder'), node.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'divider' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
baseMenuItems.push(
|
baseMenuItems.push(
|
||||||
{
|
{
|
||||||
label: t('notes.rename'),
|
label: t('notes.rename'),
|
||||||
@@ -674,7 +856,9 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
handleDeleteNode,
|
handleDeleteNode,
|
||||||
renamingNodeIds,
|
renamingNodeIds,
|
||||||
handleAutoRename,
|
handleAutoRename,
|
||||||
exportMenuOptions
|
exportMenuOptions,
|
||||||
|
onCreateNote,
|
||||||
|
onCreateFolder
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -755,6 +939,23 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
fileInput.click()
|
fileInput.click()
|
||||||
}, [onUploadFiles])
|
}, [onUploadFiles])
|
||||||
|
|
||||||
|
const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('notes.new_note'),
|
||||||
|
key: 'new_note',
|
||||||
|
icon: <FilePlus size={14} />,
|
||||||
|
onClick: handleCreateNote
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.new_folder'),
|
||||||
|
key: 'new_folder',
|
||||||
|
icon: <Folder size={14} />,
|
||||||
|
onClick: handleCreateFolder
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, [t, handleCreateNote, handleCreateFolder])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContainer
|
<SidebarContainer
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
@@ -783,32 +984,111 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NotesTreeContainer>
|
<NotesTreeContainer>
|
||||||
|
{isShowSearch && isSearching && (
|
||||||
|
<SearchStatusBar>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span>{t('notes.search.searching')}</span>
|
||||||
|
<CancelButton onClick={cancel} title={t('common.cancel')}>
|
||||||
|
<X size={14} />
|
||||||
|
</CancelButton>
|
||||||
|
</SearchStatusBar>
|
||||||
|
)}
|
||||||
|
{isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && (
|
||||||
|
<SearchStatusBar>
|
||||||
|
<span>
|
||||||
|
{t('notes.search.found_results', {
|
||||||
|
count: searchStats.total,
|
||||||
|
nameCount: searchStats.fileNameMatches,
|
||||||
|
contentCount: searchStats.contentMatches + searchStats.bothMatches
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</SearchStatusBar>
|
||||||
|
)}
|
||||||
{shouldUseVirtualization ? (
|
{shouldUseVirtualization ? (
|
||||||
<VirtualizedTreeContainer ref={parentRef}>
|
<Dropdown
|
||||||
<div
|
menu={{ items: getEmptyAreaMenuItems() }}
|
||||||
style={{
|
trigger={['contextMenu']}
|
||||||
height: `${virtualizer.getTotalSize()}px`,
|
open={openDropdownKey === 'empty-area'}
|
||||||
width: '100%',
|
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
|
||||||
position: 'relative'
|
<VirtualizedTreeContainer ref={parentRef}>
|
||||||
}}>
|
<div
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
style={{
|
||||||
const { node, depth } = flattenedNodes[virtualItem.index]
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
return (
|
width: '100%',
|
||||||
<div
|
position: 'relative'
|
||||||
key={virtualItem.key}
|
}}>
|
||||||
data-index={virtualItem.index}
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
ref={virtualizer.measureElement}
|
const { node, depth } = flattenedNodes[virtualItem.index]
|
||||||
style={{
|
return (
|
||||||
position: 'absolute',
|
<div
|
||||||
top: 0,
|
key={virtualItem.key}
|
||||||
left: 0,
|
data-index={virtualItem.index}
|
||||||
width: '100%',
|
ref={virtualizer.measureElement}
|
||||||
transform: `translateY(${virtualItem.start}px)`
|
style={{
|
||||||
}}>
|
position: 'absolute',
|
||||||
<div style={{ padding: '0 8px' }}>
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${virtualItem.start}px)`
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '0 8px' }}>
|
||||||
|
<TreeNode
|
||||||
|
node={node}
|
||||||
|
depth={depth}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
activeNodeId={activeNode?.id}
|
||||||
|
editingNodeId={editingNodeId}
|
||||||
|
renamingNodeIds={renamingNodeIds}
|
||||||
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
|
draggedNodeId={draggedNodeId}
|
||||||
|
dragOverNodeId={dragOverNodeId}
|
||||||
|
dragPosition={dragPosition}
|
||||||
|
inPlaceEdit={inPlaceEdit}
|
||||||
|
getMenuItems={getMenuItems}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleExpanded={onToggleExpanded}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
renderChildren={false}
|
||||||
|
openDropdownKey={openDropdownKey}
|
||||||
|
onDropdownOpenChange={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!isShowStarred && !isShowSearch && (
|
||||||
|
<DropHintNode>
|
||||||
|
<TreeNodeContainer active={false} depth={0}>
|
||||||
|
<TreeNodeContent>
|
||||||
|
<NodeIcon>
|
||||||
|
<FilePlus size={16} />
|
||||||
|
</NodeIcon>
|
||||||
|
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||||
|
</TreeNodeContent>
|
||||||
|
</TreeNodeContainer>
|
||||||
|
</DropHintNode>
|
||||||
|
)}
|
||||||
|
</VirtualizedTreeContainer>
|
||||||
|
</Dropdown>
|
||||||
|
) : (
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: getEmptyAreaMenuItems() }}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
open={openDropdownKey === 'empty-area'}
|
||||||
|
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
|
||||||
|
<StyledScrollbar ref={scrollbarRef}>
|
||||||
|
<TreeContent>
|
||||||
|
{isShowStarred || isShowSearch
|
||||||
|
? filteredTree.map((node) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
node={node}
|
node={node}
|
||||||
depth={depth}
|
depth={0}
|
||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
activeNodeId={activeNode?.id}
|
activeNodeId={activeNode?.id}
|
||||||
editingNodeId={editingNodeId}
|
editingNodeId={editingNodeId}
|
||||||
@@ -826,92 +1106,53 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
renderChildren={false}
|
openDropdownKey={openDropdownKey}
|
||||||
|
onDropdownOpenChange={setOpenDropdownKey}
|
||||||
|
searchKeyword={isShowSearch ? trimmedSearchKeyword : ''}
|
||||||
|
showMatches={isShowSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
))
|
||||||
</div>
|
: notesTree.map((node) => (
|
||||||
)
|
<TreeNode
|
||||||
})}
|
key={node.id}
|
||||||
</div>
|
node={node}
|
||||||
{!isShowStarred && !isShowSearch && (
|
depth={0}
|
||||||
<DropHintNode>
|
selectedFolderId={selectedFolderId}
|
||||||
<TreeNodeContainer active={false} depth={0}>
|
activeNodeId={activeNode?.id}
|
||||||
<TreeNodeContent>
|
editingNodeId={editingNodeId}
|
||||||
<NodeIcon>
|
renamingNodeIds={renamingNodeIds}
|
||||||
<FilePlus size={16} />
|
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||||
</NodeIcon>
|
draggedNodeId={draggedNodeId}
|
||||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
dragOverNodeId={dragOverNodeId}
|
||||||
</TreeNodeContent>
|
dragPosition={dragPosition}
|
||||||
</TreeNodeContainer>
|
inPlaceEdit={inPlaceEdit}
|
||||||
</DropHintNode>
|
getMenuItems={getMenuItems}
|
||||||
)}
|
onSelectNode={onSelectNode}
|
||||||
</VirtualizedTreeContainer>
|
onToggleExpanded={onToggleExpanded}
|
||||||
) : (
|
onDragStart={handleDragStart}
|
||||||
<StyledScrollbar ref={scrollbarRef}>
|
onDragOver={handleDragOver}
|
||||||
<TreeContent>
|
onDragLeave={handleDragLeave}
|
||||||
{isShowStarred || isShowSearch
|
onDrop={handleDrop}
|
||||||
? filteredTree.map((node) => (
|
onDragEnd={handleDragEnd}
|
||||||
<TreeNode
|
openDropdownKey={openDropdownKey}
|
||||||
key={node.id}
|
onDropdownOpenChange={setOpenDropdownKey}
|
||||||
node={node}
|
/>
|
||||||
depth={0}
|
))}
|
||||||
selectedFolderId={selectedFolderId}
|
{!isShowStarred && !isShowSearch && (
|
||||||
activeNodeId={activeNode?.id}
|
<DropHintNode>
|
||||||
editingNodeId={editingNodeId}
|
<TreeNodeContainer active={false} depth={0}>
|
||||||
renamingNodeIds={renamingNodeIds}
|
<TreeNodeContent>
|
||||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
<NodeIcon>
|
||||||
draggedNodeId={draggedNodeId}
|
<FilePlus size={16} />
|
||||||
dragOverNodeId={dragOverNodeId}
|
</NodeIcon>
|
||||||
dragPosition={dragPosition}
|
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||||
inPlaceEdit={inPlaceEdit}
|
</TreeNodeContent>
|
||||||
getMenuItems={getMenuItems}
|
</TreeNodeContainer>
|
||||||
onSelectNode={onSelectNode}
|
</DropHintNode>
|
||||||
onToggleExpanded={onToggleExpanded}
|
)}
|
||||||
onDragStart={handleDragStart}
|
</TreeContent>
|
||||||
onDragOver={handleDragOver}
|
</StyledScrollbar>
|
||||||
onDragLeave={handleDragLeave}
|
</Dropdown>
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: notesTree.map((node) => (
|
|
||||||
<TreeNode
|
|
||||||
key={node.id}
|
|
||||||
node={node}
|
|
||||||
depth={0}
|
|
||||||
selectedFolderId={selectedFolderId}
|
|
||||||
activeNodeId={activeNode?.id}
|
|
||||||
editingNodeId={editingNodeId}
|
|
||||||
renamingNodeIds={renamingNodeIds}
|
|
||||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
|
||||||
draggedNodeId={draggedNodeId}
|
|
||||||
dragOverNodeId={dragOverNodeId}
|
|
||||||
dragPosition={dragPosition}
|
|
||||||
inPlaceEdit={inPlaceEdit}
|
|
||||||
getMenuItems={getMenuItems}
|
|
||||||
onSelectNode={onSelectNode}
|
|
||||||
onToggleExpanded={onToggleExpanded}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!isShowStarred && !isShowSearch && (
|
|
||||||
<DropHintNode>
|
|
||||||
<TreeNodeContainer active={false} depth={0}>
|
|
||||||
<TreeNodeContent>
|
|
||||||
<NodeIcon>
|
|
||||||
<FilePlus size={16} />
|
|
||||||
</NodeIcon>
|
|
||||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
|
||||||
</TreeNodeContent>
|
|
||||||
</TreeNodeContainer>
|
|
||||||
</DropHintNode>
|
|
||||||
)}
|
|
||||||
</TreeContent>
|
|
||||||
</StyledScrollbar>
|
|
||||||
)}
|
)}
|
||||||
</NotesTreeContainer>
|
</NotesTreeContainer>
|
||||||
|
|
||||||
@@ -1151,4 +1392,148 @@ const DropHintText = styled.div`
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 搜索相关样式
|
||||||
|
const SearchStatusBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
border-bottom: 0.5px solid var(--color-border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const CancelButton = styled.button`
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--color-active);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const NodeNameContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const MatchBadge = styled.span<{ matchType: string }>`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: ${(props) =>
|
||||||
|
props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'};
|
||||||
|
color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')};
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SearchMatchesContainer = styled.div<{ depth: number }>`
|
||||||
|
margin-left: ${(props) => props.depth * 16 + 40}px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 2px solid var(--color-primary-soft);
|
||||||
|
`
|
||||||
|
|
||||||
|
const MatchItem = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
margin-right: -6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const MatchLineNumber = styled.span`
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-family: monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const MatchContext = styled.div`
|
||||||
|
color: var(--color-text-2);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
`
|
||||||
|
|
||||||
|
const MoreMatches = styled.div<{ depth: number }>`
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
margin-right: -6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export default memo(NotesSidebar)
|
export default memo(NotesSidebar)
|
||||||
|
|||||||
159
src/renderer/src/pages/notes/hooks/useFullTextSearch.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { searchAllFiles, SearchOptions, SearchResult } from '@renderer/services/NotesSearchService'
|
||||||
|
import { NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface UseFullTextSearchOptions extends SearchOptions {
|
||||||
|
debounceMs?: number
|
||||||
|
maxResults?: number
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFullTextSearchReturn {
|
||||||
|
search: (nodes: NotesTreeNode[], keyword: string) => void
|
||||||
|
cancel: () => void
|
||||||
|
reset: () => void
|
||||||
|
isSearching: boolean
|
||||||
|
results: SearchResult[]
|
||||||
|
stats: {
|
||||||
|
total: number
|
||||||
|
fileNameMatches: number
|
||||||
|
contentMatches: number
|
||||||
|
bothMatches: number
|
||||||
|
}
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text search hook for notes
|
||||||
|
*/
|
||||||
|
export function useFullTextSearch(options: UseFullTextSearchOptions = {}): UseFullTextSearchReturn {
|
||||||
|
const { debounceMs = 300, maxResults = 100, enabled = true, ...searchOptions } = options
|
||||||
|
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
total: 0,
|
||||||
|
fileNameMatches: 0,
|
||||||
|
contentMatches: 0,
|
||||||
|
bothMatches: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Store options in refs to avoid reference changes
|
||||||
|
const searchOptionsRef = useRef(searchOptions)
|
||||||
|
const maxResultsRef = useRef(maxResults)
|
||||||
|
const enabledRef = useRef(enabled)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchOptionsRef.current = searchOptions
|
||||||
|
maxResultsRef.current = maxResults
|
||||||
|
enabledRef.current = enabled
|
||||||
|
}, [searchOptions, maxResults, enabled])
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort()
|
||||||
|
abortControllerRef.current = null
|
||||||
|
}
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
debounceTimerRef.current = null
|
||||||
|
}
|
||||||
|
setIsSearching(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
cancel()
|
||||||
|
setResults([])
|
||||||
|
setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 })
|
||||||
|
setError(null)
|
||||||
|
}, [cancel])
|
||||||
|
|
||||||
|
const performSearch = useCallback(
|
||||||
|
async (nodes: NotesTreeNode[], keyword: string) => {
|
||||||
|
if (!enabledRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
setResults([])
|
||||||
|
setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
abortControllerRef.current = abortController
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResults = await searchAllFiles(
|
||||||
|
nodes,
|
||||||
|
keyword.trim(),
|
||||||
|
searchOptionsRef.current,
|
||||||
|
abortController.signal
|
||||||
|
)
|
||||||
|
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedResults = searchResults.slice(0, maxResultsRef.current)
|
||||||
|
|
||||||
|
const newStats = {
|
||||||
|
total: limitedResults.length,
|
||||||
|
fileNameMatches: limitedResults.filter((r) => r.matchType === 'filename').length,
|
||||||
|
contentMatches: limitedResults.filter((r) => r.matchType === 'content').length,
|
||||||
|
bothMatches: limitedResults.filter((r) => r.matchType === 'both').length
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(limitedResults)
|
||||||
|
setStats(newStats)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
|
setError(err)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancel]
|
||||||
|
)
|
||||||
|
|
||||||
|
const search = useCallback(
|
||||||
|
(nodes: NotesTreeNode[], keyword: string) => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
performSearch(nodes, keyword)
|
||||||
|
}, debounceMs)
|
||||||
|
},
|
||||||
|
[performSearch, debounceMs]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}, [cancel])
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
cancel,
|
||||||
|
reset,
|
||||||
|
isSearching,
|
||||||
|
results,
|
||||||
|
stats,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
|
|||||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { translateText } from '@renderer/services/TranslateService'
|
import { translateText } from '@renderer/services/TranslateService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
@@ -35,6 +34,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
|||||||
import { SettingHelpLink, SettingTitle } from '../settings'
|
import { SettingHelpLink, SettingTitle } from '../settings'
|
||||||
import Artboard from './components/Artboard'
|
import Artboard from './components/Artboard'
|
||||||
import PaintingsList from './components/PaintingsList'
|
import PaintingsList from './components/PaintingsList'
|
||||||
|
import ProviderSelect from './components/ProviderSelect'
|
||||||
import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig'
|
import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig'
|
||||||
import { checkProviderEnabled } from './utils'
|
import { checkProviderEnabled } from './utils'
|
||||||
|
|
||||||
@@ -76,20 +76,6 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const providers = useAllProviders()
|
const providers = useAllProviders()
|
||||||
const providerOptions = Options.map((option) => {
|
|
||||||
const provider = providers.find((p) => p.id === option)
|
|
||||||
if (provider) {
|
|
||||||
return {
|
|
||||||
label: getProviderLabel(provider.id),
|
|
||||||
value: provider.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: 'Unknown Provider',
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { generating } = useRuntime()
|
const { generating } = useRuntime()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -849,17 +835,12 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
/>
|
/>
|
||||||
</SettingHelpLink>
|
</SettingHelpLink>
|
||||||
</ProviderTitleContainer>
|
</ProviderTitleContainer>
|
||||||
|
<ProviderSelect
|
||||||
<Select value={providerOptions[1].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
|
provider={aihubmixProvider}
|
||||||
{providerOptions.map((provider) => (
|
options={Options}
|
||||||
<Select.Option value={provider.value} key={provider.value}>
|
onChange={handleProviderChange}
|
||||||
<SelectOptionContainer>
|
className={'mb-4'}
|
||||||
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
|
/>
|
||||||
{provider.label}
|
|
||||||
</SelectOptionContainer>
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 使用JSON配置渲染设置项 */}
|
{/* 使用JSON配置渲染设置项 */}
|
||||||
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
|
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
|
||||||
@@ -1034,12 +1015,6 @@ const ModeSegmentedContainer = styled.div`
|
|||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const SelectOptionContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`
|
|
||||||
|
|
||||||
// 添加新的样式组件
|
// 添加新的样式组件
|
||||||
const ProviderTitleContainer = styled.div`
|
const ProviderTitleContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { getProviderLogo } from '@renderer/config/providers'
|
|||||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setGenerating } from '@renderer/store/runtime'
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
@@ -29,6 +28,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
|
|||||||
import Artboard from './components/Artboard'
|
import Artboard from './components/Artboard'
|
||||||
import ImageUploader from './components/ImageUploader'
|
import ImageUploader from './components/ImageUploader'
|
||||||
import PaintingsList from './components/PaintingsList'
|
import PaintingsList from './components/PaintingsList'
|
||||||
|
import ProviderSelect from './components/ProviderSelect'
|
||||||
import {
|
import {
|
||||||
COURSE_URL,
|
COURSE_URL,
|
||||||
DEFAULT_PAINTING,
|
DEFAULT_PAINTING,
|
||||||
@@ -46,20 +46,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING)
|
const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const providers = useAllProviders()
|
const providers = useAllProviders()
|
||||||
const providerOptions = Options.map((option) => {
|
|
||||||
const provider = providers.find((p) => p.id === option)
|
|
||||||
if (provider) {
|
|
||||||
return {
|
|
||||||
label: getProviderLabel(provider.id),
|
|
||||||
value: provider.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: 'Unknown Provider',
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')!
|
const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')!
|
||||||
|
|
||||||
@@ -785,9 +771,9 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Navbar>
|
<Navbar>
|
||||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
|
<NavbarCenter className="border-r-0">{t('paintings.title')}</NavbarCenter>
|
||||||
{isMac && (
|
{isMac && (
|
||||||
<NavbarRight style={{ justifyContent: 'flex-end' }}>
|
<NavbarRight className="justify-end">
|
||||||
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={createNewPainting}>
|
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={createNewPainting}>
|
||||||
{t('paintings.button.new.image')}
|
{t('paintings.button.new.image')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -797,7 +783,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
<ContentContainer id="content-container">
|
<ContentContainer id="content-container">
|
||||||
<LeftContainer>
|
<LeftContainer>
|
||||||
<ProviderTitleContainer>
|
<ProviderTitleContainer>
|
||||||
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
|
<SettingTitle className="mb-1">{t('common.provider')}</SettingTitle>
|
||||||
<div>
|
<div>
|
||||||
<SettingHelpLink target="_blank" href={COURSE_URL}>
|
<SettingHelpLink target="_blank" href={COURSE_URL}>
|
||||||
{t('paintings.paint_course')}
|
{t('paintings.paint_course')}
|
||||||
@@ -805,28 +791,19 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
<SettingHelpLink target="_blank" href={TOP_UP_URL}>
|
<SettingHelpLink target="_blank" href={TOP_UP_URL}>
|
||||||
{t('paintings.top_up')}
|
{t('paintings.top_up')}
|
||||||
</SettingHelpLink>
|
</SettingHelpLink>
|
||||||
<ProviderLogo
|
<ProviderLogo shape="square" src={getProviderLogo(dmxapiProvider.id)} size={16} className="ml-1" />
|
||||||
shape="square"
|
|
||||||
src={getProviderLogo(dmxapiProvider.id)}
|
|
||||||
size={16}
|
|
||||||
style={{ marginLeft: 5 }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ProviderTitleContainer>
|
</ProviderTitleContainer>
|
||||||
<Select value={providerOptions[3].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
|
<ProviderSelect
|
||||||
{providerOptions.map((provider) => (
|
provider={dmxapiProvider}
|
||||||
<Select.Option value={provider.value} key={provider.value}>
|
options={Options}
|
||||||
<SelectOptionContainer>
|
onChange={handleProviderChange}
|
||||||
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
|
className="mb-4"
|
||||||
{provider.label}
|
/>
|
||||||
</SelectOptionContainer>
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{painting.generationMode &&
|
{painting.generationMode &&
|
||||||
[generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && (
|
[generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && (
|
||||||
<>
|
<>
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>参考图</SettingTitle>
|
<SettingTitle className="mt-4 mb-1">参考图</SettingTitle>
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
fileMap={fileMap}
|
fileMap={fileMap}
|
||||||
maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3}
|
maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3}
|
||||||
@@ -838,13 +815,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
<SettingTitle className="mt-4 mb-1">
|
||||||
{t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice>
|
{t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice>
|
||||||
</SettingTitle>
|
</SettingTitle>
|
||||||
<Select
|
<Select
|
||||||
value={painting.model}
|
value={painting.model}
|
||||||
onChange={onSelectModel}
|
onChange={onSelectModel}
|
||||||
style={{ width: '100%' }}
|
className="w-full"
|
||||||
loading={isLoadingModels}
|
loading={isLoadingModels}
|
||||||
placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}>
|
placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}>
|
||||||
{Object.entries(modelOptions).map(([provider, models]) => {
|
{Object.entries(modelOptions).map(([provider, models]) => {
|
||||||
@@ -861,11 +838,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
})}
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
|
<SettingTitle className="mt-4 mb-1">{t('paintings.image.size')}</SettingTitle>
|
||||||
<Select
|
<Select
|
||||||
value={isCustomSize ? 'custom' : painting.image_size}
|
value={isCustomSize ? 'custom' : painting.image_size}
|
||||||
onChange={(value) => onSelectImageSize(value)}
|
onChange={(value) => onSelectImageSize(value)}
|
||||||
style={{ width: '100%' }}>
|
className="w-full">
|
||||||
{(() => {
|
{(() => {
|
||||||
const currentModel = allModels.find((m) => m.id === painting.model)
|
const currentModel = allModels.find((m) => m.id === painting.model)
|
||||||
const modelImageSizes = currentModel?.image_sizes || []
|
const modelImageSizes = currentModel?.image_sizes || []
|
||||||
@@ -874,7 +851,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
return modelImageSizes.map((size) => {
|
return modelImageSizes.map((size) => {
|
||||||
return (
|
return (
|
||||||
<Select.Option key={size.value} value={size.value}>
|
<Select.Option key={size.value} value={size.value}>
|
||||||
<HStack style={{ alignItems: 'center', gap: 8 }}>
|
<HStack className="items-center gap-2">
|
||||||
<span>{size.label}</span>
|
<span>{size.label}</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
@@ -884,7 +861,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
{/* 检查当前模型是否支持自定义尺寸 */}
|
{/* 检查当前模型是否支持自定义尺寸 */}
|
||||||
{allModels.find((m) => m.id === painting.model)?.is_custom_size && (
|
{allModels.find((m) => m.id === painting.model)?.is_custom_size && (
|
||||||
<Select.Option value="custom" key="custom">
|
<Select.Option value="custom" key="custom">
|
||||||
<HStack style={{ alignItems: 'center', gap: 8 }}>
|
<HStack className="items-center gap-2">
|
||||||
<span>{t('paintings.custom_size')}</span>
|
<span>{t('paintings.custom_size')}</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Select.Option>
|
</Select.Option>
|
||||||
@@ -893,7 +870,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
|
|
||||||
{/* 自定义尺寸输入框 */}
|
{/* 自定义尺寸输入框 */}
|
||||||
{isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && (
|
{isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<div className="mt-2.5">
|
||||||
<HStack style={{ gap: 8, alignItems: 'center' }}>
|
<HStack style={{ gap: 8, alignItems: 'center' }}>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
placeholder="W"
|
placeholder="W"
|
||||||
@@ -921,7 +898,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
|
|
||||||
{painting.generationMode === generationModeType.GENERATION && (
|
{painting.generationMode === generationModeType.GENERATION && (
|
||||||
<>
|
<>
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
<SettingTitle className="mt-4 mb-1">
|
||||||
{t('paintings.seed')}
|
{t('paintings.seed')}
|
||||||
<Tooltip title={t('paintings.seed_desc_tip')}>
|
<Tooltip title={t('paintings.seed_desc_tip')}>
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
@@ -941,7 +918,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.style_type')}</SettingTitle>
|
<SettingTitle className="mt-4 mb-1">{t('paintings.style_type')}</SettingTitle>
|
||||||
<SliderContainer>
|
<SliderContainer>
|
||||||
<RadioTextBox>
|
<RadioTextBox>
|
||||||
{STYLE_TYPE_OPTIONS.map((ele) => (
|
{STYLE_TYPE_OPTIONS.map((ele) => (
|
||||||
@@ -955,7 +932,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
</RadioTextBox>
|
</RadioTextBox>
|
||||||
</SliderContainer>
|
</SliderContainer>
|
||||||
|
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
<SettingTitle className="mt-4 mb-1">
|
||||||
{t('paintings.auto_create_paint')}
|
{t('paintings.auto_create_paint')}
|
||||||
<Tooltip title={t('paintings.auto_create_paint_tip')}>
|
<Tooltip title={t('paintings.auto_create_paint_tip')}>
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
@@ -1032,11 +1009,6 @@ const ProviderTitleContainer = styled.div`
|
|||||||
const ProviderLogo = styled(Avatar)`
|
const ProviderLogo = styled(Avatar)`
|
||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
`
|
`
|
||||||
const SelectOptionContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const ContentContainer = styled.div`
|
const ContentContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
|
|||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import TranslateButton from '@renderer/components/TranslateButton'
|
import TranslateButton from '@renderer/components/TranslateButton'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
import { getProviderLogo } from '@renderer/config/providers'
|
import { getProviderLogo, isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers'
|
||||||
import { LanguagesEnum } from '@renderer/config/translate'
|
import { LanguagesEnum } from '@renderer/config/translate'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||||
@@ -17,8 +17,7 @@ import {
|
|||||||
getPaintingsBackgroundOptionsLabel,
|
getPaintingsBackgroundOptionsLabel,
|
||||||
getPaintingsImageSizeOptionsLabel,
|
getPaintingsImageSizeOptionsLabel,
|
||||||
getPaintingsModerationOptionsLabel,
|
getPaintingsModerationOptionsLabel,
|
||||||
getPaintingsQualityOptionsLabel,
|
getPaintingsQualityOptionsLabel
|
||||||
getProviderLabel
|
|
||||||
} from '@renderer/i18n/label'
|
} from '@renderer/i18n/label'
|
||||||
import PaintingsList from '@renderer/pages/paintings/components/PaintingsList'
|
import PaintingsList from '@renderer/pages/paintings/components/PaintingsList'
|
||||||
import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig'
|
import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig'
|
||||||
@@ -40,6 +39,7 @@ import styled from 'styled-components'
|
|||||||
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
||||||
import { SettingHelpLink, SettingTitle } from '../settings'
|
import { SettingHelpLink, SettingTitle } from '../settings'
|
||||||
import Artboard from './components/Artboard'
|
import Artboard from './components/Artboard'
|
||||||
|
import ProviderSelect from './components/ProviderSelect'
|
||||||
import { checkProviderEnabled } from './utils'
|
import { checkProviderEnabled } from './utils'
|
||||||
|
|
||||||
const logger = loggerService.withContext('NewApiPage')
|
const logger = loggerService.withContext('NewApiPage')
|
||||||
@@ -55,8 +55,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
}
|
}
|
||||||
}, [openai_image_generate, openai_image_edit])
|
}, [openai_image_generate, openai_image_edit])
|
||||||
|
|
||||||
const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode])
|
// moved below after newApiProvider is defined
|
||||||
const [painting, setPainting] = useState<PaintingAction>(filteredPaintings[0] || DEFAULT_PAINTING)
|
|
||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||||
@@ -67,27 +66,22 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const providers = useAllProviders()
|
const providers = useAllProviders()
|
||||||
const providerOptions = Options.map((option) => {
|
const location = useLocation()
|
||||||
const provider = providers.find((p) => p.id === option)
|
const routeName = location.pathname.split('/').pop() || 'new-api'
|
||||||
if (provider) {
|
const newApiProviders = providers.filter((p) => isNewApiProvider(p))
|
||||||
return {
|
|
||||||
label: getProviderLabel(provider.id),
|
|
||||||
value: provider.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: 'Unknown Provider',
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { generating } = useRuntime()
|
const { generating } = useRuntime()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
|
||||||
const { autoTranslateWithSpace } = useSettings()
|
const { autoTranslateWithSpace } = useSettings()
|
||||||
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
|
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
|
||||||
const newApiProvider = providers.find((p) => p.id === 'new-api')!
|
const newApiProvider = newApiProviders.find((p) => p.id === routeName) || newApiProviders[0]
|
||||||
|
|
||||||
|
const filteredPaintings = useMemo(
|
||||||
|
() => (newApiPaintings[mode] || []).filter((p) => p.providerId === newApiProvider.id),
|
||||||
|
[newApiPaintings, mode, newApiProvider.id]
|
||||||
|
)
|
||||||
|
const [painting, setPainting] = useState<PaintingAction>({ ...DEFAULT_PAINTING, providerId: newApiProvider.id })
|
||||||
|
|
||||||
const modeOptions = [
|
const modeOptions = [
|
||||||
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' },
|
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' },
|
||||||
@@ -102,7 +96,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
}, [editImageFiles])
|
}, [editImageFiles])
|
||||||
|
|
||||||
const updatePaintingState = (updates: Partial<PaintingAction>) => {
|
const updatePaintingState = (updates: Partial<PaintingAction>) => {
|
||||||
const updatedPainting = { ...painting, ...updates }
|
const updatedPainting = { ...painting, providerId: newApiProvider.id, ...updates }
|
||||||
setPainting(updatedPainting)
|
setPainting(updatedPainting)
|
||||||
updatePainting(mode, updatedPainting)
|
updatePainting(mode, updatedPainting)
|
||||||
}
|
}
|
||||||
@@ -138,9 +132,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
return {
|
return {
|
||||||
...DEFAULT_PAINTING,
|
...DEFAULT_PAINTING,
|
||||||
model: painting.model || modelOptions[0]?.value || '',
|
model: painting.model || modelOptions[0]?.value || '',
|
||||||
id: uuid()
|
id: uuid(),
|
||||||
|
providerId: newApiProvider.id
|
||||||
}
|
}
|
||||||
}, [modelOptions, painting.model])
|
}, [modelOptions, painting.model, newApiProvider.id])
|
||||||
|
|
||||||
const selectedModelConfig = useMemo(
|
const selectedModelConfig = useMemo(
|
||||||
() => MODELS.find((m) => m.name === painting.model) || MODELS[0],
|
() => MODELS.find((m) => m.name === painting.model) || MODELS[0],
|
||||||
@@ -444,11 +439,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
// 处理模式切换
|
// 处理模式切换
|
||||||
const handleModeChange = (value: string) => {
|
const handleModeChange = (value: string) => {
|
||||||
setMode(value as keyof PaintingsState)
|
setMode(value as keyof PaintingsState)
|
||||||
if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) {
|
const list = (newApiPaintings[value as keyof PaintingsState] || []).filter(
|
||||||
setPainting(newApiPaintings[value as keyof PaintingsState][0])
|
(p) => p.providerId === newApiProvider.id
|
||||||
} else {
|
)
|
||||||
setPainting(DEFAULT_PAINTING)
|
setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: newApiProvider.id })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染配置项的函数
|
// 渲染配置项的函数
|
||||||
@@ -473,8 +467,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const newPainting = getNewPainting()
|
const newPainting = getNewPainting()
|
||||||
addPainting(mode, newPainting)
|
addPainting(mode, newPainting)
|
||||||
setPainting(newPainting)
|
setPainting(newPainting)
|
||||||
|
} else {
|
||||||
|
setPainting(filteredPaintings[0])
|
||||||
}
|
}
|
||||||
}, [filteredPaintings, mode, addPainting, painting, getNewPainting])
|
}, [filteredPaintings, mode, addPainting, getNewPainting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = spaceClickTimer.current
|
const timer = spaceClickTimer.current
|
||||||
@@ -485,6 +481,13 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// if painting.model is not set, set it to the first model in modelOptions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!painting.model && modelOptions.length > 0) {
|
||||||
|
updatePaintingState({ model: modelOptions[0].value })
|
||||||
|
}
|
||||||
|
}, [modelOptions, painting.model, updatePaintingState])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Navbar>
|
<Navbar>
|
||||||
@@ -501,7 +504,9 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
<LeftContainer>
|
<LeftContainer>
|
||||||
<ProviderTitleContainer>
|
<ProviderTitleContainer>
|
||||||
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
|
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
|
||||||
<SettingHelpLink target="_blank" href={'https://docs.newapi.pro/apps/cherry-studio/'}>
|
<SettingHelpLink
|
||||||
|
target="_blank"
|
||||||
|
href={PROVIDER_URLS[newApiProvider.id]?.websites?.docs || 'https://docs.newapi.pro/apps/cherry-studio/'}>
|
||||||
{t('paintings.learn_more')}
|
{t('paintings.learn_more')}
|
||||||
<ProviderLogo
|
<ProviderLogo
|
||||||
shape="square"
|
shape="square"
|
||||||
@@ -512,19 +517,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
</SettingHelpLink>
|
</SettingHelpLink>
|
||||||
</ProviderTitleContainer>
|
</ProviderTitleContainer>
|
||||||
|
|
||||||
<Select
|
<ProviderSelect provider={newApiProvider} options={Options} onChange={handleProviderChange} />
|
||||||
value={providerOptions.find((p) => p.value === 'new-api')?.value}
|
|
||||||
onChange={handleProviderChange}
|
|
||||||
style={{ width: '100%' }}>
|
|
||||||
{providerOptions.map((provider) => (
|
|
||||||
<Select.Option value={provider.value} key={provider.value}>
|
|
||||||
<SelectOptionContainer>
|
|
||||||
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
|
|
||||||
{provider.label}
|
|
||||||
</SelectOptionContainer>
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */}
|
{/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */}
|
||||||
{modelOptions.length === 0 && (
|
{modelOptions.length === 0 && (
|
||||||
@@ -792,20 +785,12 @@ const ProviderLogo = styled(Avatar)`
|
|||||||
border: 0.5px solid var(--color-border);
|
border: 0.5px solid var(--color-border);
|
||||||
`
|
`
|
||||||
|
|
||||||
// 添加新的样式组件
|
|
||||||
const ModeSegmentedContainer = styled.div`
|
const ModeSegmentedContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const SelectOptionContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`
|
|
||||||
|
|
||||||
// 添加新的样式组件
|
|
||||||
const ProviderTitleContainer = styled.div`
|
const ProviderTitleContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { isNewApiProvider } from '@renderer/config/providers'
|
||||||
|
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setDefaultPaintingProvider } from '@renderer/store/settings'
|
import { setDefaultPaintingProvider } from '@renderer/store/settings'
|
||||||
import { PaintingProvider } from '@renderer/types'
|
import { updateTab } from '@renderer/store/tabs'
|
||||||
import { FC, useEffect } from 'react'
|
import type { PaintingProvider, SystemProviderId } from '@renderer/types'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
import { Route, Routes, useParams } from 'react-router-dom'
|
import { Route, Routes, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import AihubmixPage from './AihubmixPage'
|
import AihubmixPage from './AihubmixPage'
|
||||||
@@ -14,29 +18,37 @@ import ZhipuPage from './ZhipuPage'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('PaintingsRoutePage')
|
const logger = loggerService.withContext('PaintingsRoutePage')
|
||||||
|
|
||||||
const Options = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux', 'new-api']
|
const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux']
|
||||||
|
|
||||||
const PaintingsRoutePage: FC = () => {
|
const PaintingsRoutePage: FC = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const provider = params['*']
|
const provider = params['*']
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const providers = useAllProviders()
|
||||||
|
|
||||||
|
const Options = useMemo(() => [...BASE_OPTIONS, ...providers.filter(isNewApiProvider).map((p) => p.id)], [providers])
|
||||||
|
const newApiProviders = useMemo(() => providers.filter(isNewApiProvider), [providers])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.debug(`defaultPaintingProvider: ${provider}`)
|
logger.debug(`defaultPaintingProvider: ${provider}`)
|
||||||
if (provider && Options.includes(provider)) {
|
if (provider && Options.includes(provider)) {
|
||||||
dispatch(setDefaultPaintingProvider(provider as PaintingProvider))
|
dispatch(setDefaultPaintingProvider(provider as PaintingProvider))
|
||||||
|
dispatch(updateTab({ id: 'paintings', updates: { path: `/paintings/${provider}` } }))
|
||||||
}
|
}
|
||||||
}, [provider, dispatch])
|
}, [provider, dispatch, Options])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="*" element={<ZhipuPage Options={Options} />} />
|
<Route path="*" element={<NewApiPage Options={Options} />} />
|
||||||
<Route path="/zhipu" element={<ZhipuPage Options={Options} />} />
|
<Route path="/zhipu" element={<ZhipuPage Options={Options} />} />
|
||||||
<Route path="/aihubmix" element={<AihubmixPage Options={Options} />} />
|
<Route path="/aihubmix" element={<AihubmixPage Options={Options} />} />
|
||||||
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
|
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
|
||||||
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
|
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
|
||||||
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
|
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
|
||||||
<Route path="/new-api" element={<NewApiPage Options={Options} />} />
|
{/* new-api family providers are mounted dynamically below */}
|
||||||
|
{newApiProviders.map((p) => (
|
||||||
|
<Route key={p.id} path={`/${p.id}`} element={<NewApiPage Options={Options} />} />
|
||||||
|
))}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
|
|||||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
|
||||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { translateText } from '@renderer/services/TranslateService'
|
import { translateText } from '@renderer/services/TranslateService'
|
||||||
@@ -39,6 +38,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
|||||||
import { SettingTitle } from '../settings'
|
import { SettingTitle } from '../settings'
|
||||||
import Artboard from './components/Artboard'
|
import Artboard from './components/Artboard'
|
||||||
import PaintingsList from './components/PaintingsList'
|
import PaintingsList from './components/PaintingsList'
|
||||||
|
import ProviderSelect from './components/ProviderSelect'
|
||||||
import { checkProviderEnabled } from './utils'
|
import { checkProviderEnabled } from './utils'
|
||||||
|
|
||||||
export const TEXT_TO_IMAGES_MODELS = [
|
export const TEXT_TO_IMAGES_MODELS = [
|
||||||
@@ -114,22 +114,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const [painting, setPainting] = useState<Painting>(siliconflow_paintings[0] || DEFAULT_PAINTING)
|
const [painting, setPainting] = useState<Painting>(siliconflow_paintings[0] || DEFAULT_PAINTING)
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const providers = useAllProviders()
|
const providers = useAllProviders()
|
||||||
const providerOptions = Options.map((option) => {
|
|
||||||
const provider = providers.find((p) => p.id === option)
|
|
||||||
if (provider) {
|
|
||||||
return {
|
|
||||||
label: getProviderLabel(provider.id),
|
|
||||||
value: provider.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: 'Unknown Provider',
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const siliconflowProvider = providers.find((p) => p.id === 'silicon')
|
const siliconFlowProvider = providers.find((p) => p.id === 'silicon')!
|
||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -169,7 +155,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGenerate = async () => {
|
const onGenerate = async () => {
|
||||||
await checkProviderEnabled(siliconflowProvider!, t)
|
await checkProviderEnabled(siliconFlowProvider!, t)
|
||||||
|
|
||||||
if (painting.files.length > 0) {
|
if (painting.files.length > 0) {
|
||||||
const confirmed = await window.modal.confirm({
|
const confirmed = await window.modal.confirm({
|
||||||
@@ -388,8 +374,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
<ContentContainer id="content-container">
|
<ContentContainer id="content-container">
|
||||||
<LeftContainer>
|
<LeftContainer>
|
||||||
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
|
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
|
||||||
<Select value={providerOptions[2].value} onChange={handleProviderChange} options={providerOptions} />
|
<ProviderSelect provider={siliconFlowProvider} options={Options} onChange={handleProviderChange} />
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
|
<SettingTitle className="mt-4 mb-1">{t('common.model')}</SettingTitle>
|
||||||
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
|
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
|
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
|
|||||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { translateText } from '@renderer/services/TranslateService'
|
import { translateText } from '@renderer/services/TranslateService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
@@ -31,6 +30,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
|
|||||||
import Artboard from './components/Artboard'
|
import Artboard from './components/Artboard'
|
||||||
import { DynamicFormRender } from './components/DynamicFormRender'
|
import { DynamicFormRender } from './components/DynamicFormRender'
|
||||||
import PaintingsList from './components/PaintingsList'
|
import PaintingsList from './components/PaintingsList'
|
||||||
|
import ProviderSelect from './components/ProviderSelect'
|
||||||
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
|
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
|
||||||
import { checkProviderEnabled } from './utils'
|
import { checkProviderEnabled } from './utils'
|
||||||
import TokenFluxService from './utils/TokenFluxService'
|
import TokenFluxService from './utils/TokenFluxService'
|
||||||
@@ -55,21 +55,6 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() }
|
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() }
|
||||||
)
|
)
|
||||||
|
|
||||||
const providerOptions = Options.map((option) => {
|
|
||||||
const provider = providers.find((p) => p.id === option)
|
|
||||||
if (provider) {
|
|
||||||
return {
|
|
||||||
label: getProviderLabel(provider.id),
|
|
||||||
value: provider.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: 'Unknown Provider',
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { generating } = useRuntime()
|
const { generating } = useRuntime()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -387,19 +372,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
</SettingHelpLink>
|
</SettingHelpLink>
|
||||||
</ProviderTitleContainer>
|
</ProviderTitleContainer>
|
||||||
|
|
||||||
<Select
|
<ProviderSelect provider={tokenfluxProvider} options={Options} onChange={handleProviderChange} />
|
||||||
value={providerOptions.find((p) => p.value === 'tokenflux')?.value}
|
|
||||||
onChange={handleProviderChange}
|
|
||||||
style={{ width: '100%' }}>
|
|
||||||
{providerOptions.map((provider) => (
|
|
||||||
<Select.Option value={provider.value} key={provider.value}>
|
|
||||||
<SelectOptionContainer>
|
|
||||||
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
|
|
||||||
{provider.label}
|
|
||||||
</SelectOptionContainer>
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Model & Pricing Section */}
|
{/* Model & Pricing Section */}
|
||||||
<SectionTitle
|
<SectionTitle
|
||||||
@@ -775,11 +748,4 @@ const ProviderTitleContainer = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const SelectOptionContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default TokenFluxPage
|
export default TokenFluxPage
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { getProviderLogo } from '@renderer/config/providers'
|
|||||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setGenerating } from '@renderer/store/runtime'
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
@@ -24,6 +23,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
|||||||
import { SettingHelpLink, SettingTitle } from '../settings'
|
import { SettingHelpLink, SettingTitle } from '../settings'
|
||||||
import Artboard from './components/Artboard'
|
import Artboard from './components/Artboard'
|
||||||
import PaintingsList from './components/PaintingsList'
|
import PaintingsList from './components/PaintingsList'
|
||||||
|
import ProviderSelect from './components/ProviderSelect'
|
||||||
import {
|
import {
|
||||||
COURSE_URL,
|
COURSE_URL,
|
||||||
DEFAULT_PAINTING,
|
DEFAULT_PAINTING,
|
||||||
@@ -50,21 +50,6 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [painting?.id]) // 只在painting的id改变时执行,避免无限循环
|
}, [painting?.id]) // 只在painting的id改变时执行,避免无限循环
|
||||||
|
|
||||||
const providerOptions = Options.map((option) => {
|
|
||||||
const provider = providers.find((p) => p.id === option)
|
|
||||||
if (provider) {
|
|
||||||
return {
|
|
||||||
label: getProviderLabel(provider.id),
|
|
||||||
value: provider.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: 'Unknown Provider',
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const zhipuProvider = providers.find((p) => p.id === 'zhipu')!
|
const zhipuProvider = providers.find((p) => p.id === 'zhipu')!
|
||||||
|
|
||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||||
@@ -370,16 +355,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ProviderTitleContainer>
|
</ProviderTitleContainer>
|
||||||
<Select value={providerOptions[0].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
|
<ProviderSelect provider={zhipuProvider} options={Options} onChange={handleProviderChange} className="mb-4" />
|
||||||
{providerOptions.map((provider) => (
|
|
||||||
<Select.Option value={provider.value} key={provider.value}>
|
|
||||||
<SelectOptionContainer>
|
|
||||||
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
|
|
||||||
{provider.label}
|
|
||||||
</SelectOptionContainer>
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
|
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
|
||||||
<Select
|
<Select
|
||||||
@@ -563,12 +539,6 @@ const ProviderTitleContainer = styled.div`
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const SelectOptionContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const ProviderLogo = styled(Avatar)`
|
const ProviderLogo = styled(Avatar)`
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Select, SelectItem } from '@heroui/react'
|
||||||
|
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
|
||||||
|
import { getProviderLogo } from '@renderer/config/providers'
|
||||||
|
import ImageStorage from '@renderer/services/ImageStorage'
|
||||||
|
import { getProviderNameById } from '@renderer/services/ProviderService'
|
||||||
|
import { Provider } from '@types'
|
||||||
|
import React, { FC, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type ProviderSelectProps = {
|
||||||
|
provider: Provider
|
||||||
|
options: string[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
style?: React.CSSProperties
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProviderSelect: FC<ProviderSelectProps> = ({ provider, options, onChange, style, className }) => {
|
||||||
|
const [customLogos, setCustomLogos] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLogos = async () => {
|
||||||
|
const logos: Record<string, string> = {}
|
||||||
|
for (const providerId of options) {
|
||||||
|
try {
|
||||||
|
const logoData = await ImageStorage.get(`provider-${providerId}`)
|
||||||
|
if (logoData) {
|
||||||
|
logos[providerId] = logoData
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors for providers without custom logos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCustomLogos(logos)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLogos()
|
||||||
|
}, [options])
|
||||||
|
|
||||||
|
const getProviderLogoSrc = (providerId: string) => {
|
||||||
|
const systemLogo = getProviderLogo(providerId)
|
||||||
|
if (systemLogo) {
|
||||||
|
return systemLogo
|
||||||
|
}
|
||||||
|
return customLogos[providerId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOptions = options.map((option) => {
|
||||||
|
return {
|
||||||
|
label: getProviderNameById(option),
|
||||||
|
value: option
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
selectedKeys={[provider.id]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selectedKey = Array.from(keys)[0] as string
|
||||||
|
onChange(selectedKey)
|
||||||
|
}}
|
||||||
|
style={style}
|
||||||
|
className={`w-full ${className || ''}`}
|
||||||
|
renderValue={(items) => {
|
||||||
|
return items.map((item) => (
|
||||||
|
<div key={item.key} className="flex items-center gap-2">
|
||||||
|
<div className="flex h-4 w-4 items-center justify-center">
|
||||||
|
<ProviderAvatarPrimitive
|
||||||
|
providerId={item.key as string}
|
||||||
|
providerName={item.textValue || ''}
|
||||||
|
logoSrc={getProviderLogoSrc(item.key as string)}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{item.textValue}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}}>
|
||||||
|
{providerOptions.map((providerOption) => (
|
||||||
|
<SelectItem
|
||||||
|
key={providerOption.value}
|
||||||
|
textValue={providerOption.label}
|
||||||
|
startContent={
|
||||||
|
<div className="flex h-4 w-4 items-center justify-center">
|
||||||
|
<ProviderAvatarPrimitive
|
||||||
|
providerId={providerOption.value}
|
||||||
|
providerName={providerOption.label}
|
||||||
|
logoSrc={getProviderLogoSrc(providerOption.value)}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{providerOption.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProviderSelect
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
import { GithubOutlined } from '@ant-design/icons'
|
import { GithubOutlined } from '@ant-design/icons'
|
||||||
|
import { useDisclosure } from '@heroui/react'
|
||||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import UpdateDialog from '@renderer/components/UpdateDialog'
|
||||||
import { APP_NAME, AppLogo } from '@renderer/config/env'
|
import { APP_NAME, AppLogo } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
import { handleSaveData, useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setUpdateState } from '@renderer/store/runtime'
|
import { setUpdateState } from '@renderer/store/runtime'
|
||||||
import { ThemeMode } from '@renderer/types'
|
import { ThemeMode } from '@renderer/types'
|
||||||
import { runAsyncFunction } from '@renderer/utils'
|
import { runAsyncFunction } from '@renderer/utils'
|
||||||
import { UpgradeChannel } from '@shared/config/constant'
|
import { UpgradeChannel } from '@shared/config/constant'
|
||||||
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
|
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
|
||||||
|
import { UpdateInfo } from 'builder-util-runtime'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
|
import { Bug, Building2, Github, Globe, Mail, Rss } from 'lucide-react'
|
||||||
import { BadgeQuestionMark } from 'lucide-react'
|
import { BadgeQuestionMark } from 'lucide-react'
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -27,6 +30,8 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl
|
|||||||
const AboutSettings: FC = () => {
|
const AboutSettings: FC = () => {
|
||||||
const [version, setVersion] = useState('')
|
const [version, setVersion] = useState('')
|
||||||
const [isPortable, setIsPortable] = useState(false)
|
const [isPortable, setIsPortable] = useState(false)
|
||||||
|
const [updateDialogInfo, setUpdateDialogInfo] = useState<UpdateInfo | null>(null)
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings()
|
const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -41,8 +46,9 @@ const AboutSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (update.downloaded) {
|
if (update.downloaded) {
|
||||||
await handleSaveData()
|
// Open update dialog directly in renderer
|
||||||
window.api.showUpdateDialog()
|
setUpdateDialogInfo(update.info || null)
|
||||||
|
onOpen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +83,8 @@ const AboutSettings: FC = () => {
|
|||||||
await window.api.devTools.toggle()
|
await window.api.devTools.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showLicense = async () => {
|
const showEnterprise = async () => {
|
||||||
const { appPath } = await window.api.getAppInfo()
|
onOpenWebsite('https://cherry-ai.com/enterprise')
|
||||||
openSmartMinapp({
|
|
||||||
id: 'cherrystudio-license',
|
|
||||||
name: t('settings.about.license.title'),
|
|
||||||
url: `file://${appPath}/resources/cherry-studio/license.html`,
|
|
||||||
logo: AppLogo
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showReleases = async () => {
|
const showReleases = async () => {
|
||||||
@@ -309,7 +309,7 @@ const AboutSettings: FC = () => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>
|
<SettingRowTitle>
|
||||||
<GithubOutlined size={18} />
|
<Github size={18} />
|
||||||
{t('settings.about.feedback.title')}
|
{t('settings.about.feedback.title')}
|
||||||
</SettingRowTitle>
|
</SettingRowTitle>
|
||||||
<Button onClick={() => onOpenWebsite('https://github.com/CherryHQ/cherry-studio/issues/new/choose')}>
|
<Button onClick={() => onOpenWebsite('https://github.com/CherryHQ/cherry-studio/issues/new/choose')}>
|
||||||
@@ -319,10 +319,10 @@ const AboutSettings: FC = () => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>
|
<SettingRowTitle>
|
||||||
<FileCheck size={18} />
|
<Building2 size={18} />
|
||||||
{t('settings.about.license.title')}
|
{t('settings.about.enterprise.title')}
|
||||||
</SettingRowTitle>
|
</SettingRowTitle>
|
||||||
<Button onClick={showLicense}>{t('settings.about.license.button')}</Button>
|
<Button onClick={showEnterprise}>{t('settings.about.website.button')}</Button>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
@@ -341,6 +341,9 @@ const AboutSettings: FC = () => {
|
|||||||
<Button onClick={debug}>{t('settings.about.debug.open')}</Button>
|
<Button onClick={debug}>{t('settings.about.debug.open')}</Button>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
|
|
||||||
|
{/* Update Dialog */}
|
||||||
|
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
|||||||
const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt')
|
const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt')
|
||||||
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
|
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
|
||||||
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
|
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
|
||||||
const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? true)
|
const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? false)
|
||||||
const [customParameters, setCustomParameters] = useState<AssistantSettingCustomParameters[]>(
|
const [customParameters, setCustomParameters] = useState<AssistantSettingCustomParameters[]>(
|
||||||
assistant?.settings?.customParameters ?? []
|
assistant?.settings?.customParameters ?? []
|
||||||
)
|
)
|
||||||
@@ -164,7 +164,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
|||||||
setMaxTokens(0)
|
setMaxTokens(0)
|
||||||
setStreamOutput(true)
|
setStreamOutput(true)
|
||||||
setTopP(1)
|
setTopP(1)
|
||||||
setEnableTopP(true)
|
setEnableTopP(false)
|
||||||
setCustomParameters([])
|
setCustomParameters([])
|
||||||
setToolUseMode('prompt')
|
setToolUseMode('prompt')
|
||||||
updateAssistantSettings({
|
updateAssistantSettings({
|
||||||
@@ -175,7 +175,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
|||||||
maxTokens: 0,
|
maxTokens: 0,
|
||||||
streamOutput: true,
|
streamOutput: true,
|
||||||
topP: 1,
|
topP: 1,
|
||||||
enableTopP: true,
|
enableTopP: false,
|
||||||
customParameters: [],
|
customParameters: [],
|
||||||
toolUseMode: 'prompt'
|
toolUseMode: 'prompt'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import {
|
|||||||
CloudSyncOutlined,
|
CloudSyncOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
|
WifiOutlined,
|
||||||
YuqueOutlined
|
YuqueOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { Button } from '@heroui/button'
|
||||||
|
import { Switch } from '@heroui/switch'
|
||||||
import DividerWithText from '@renderer/components/DividerWithText'
|
import DividerWithText from '@renderer/components/DividerWithText'
|
||||||
import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons'
|
import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import ListItem from '@renderer/components/ListItem'
|
import ListItem from '@renderer/components/ListItem'
|
||||||
import BackupPopup from '@renderer/components/Popups/BackupPopup'
|
import BackupPopup from '@renderer/components/Popups/BackupPopup'
|
||||||
|
import ExportToPhoneLanPopup from '@renderer/components/Popups/ExportToPhoneLanPopup'
|
||||||
import RestorePopup from '@renderer/components/Popups/RestorePopup'
|
import RestorePopup from '@renderer/components/Popups/RestorePopup'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles'
|
import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles'
|
||||||
@@ -20,7 +24,7 @@ import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/setting
|
|||||||
import { AppInfo } from '@renderer/types'
|
import { AppInfo } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { occupiedDirs } from '@shared/config/constant'
|
import { occupiedDirs } from '@shared/config/constant'
|
||||||
import { Button, Progress, Switch, Typography } from 'antd'
|
import { Progress, Typography } from 'antd'
|
||||||
import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon, Sparkle } from 'lucide-react'
|
import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon, Sparkle } from 'lucide-react'
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -290,12 +294,16 @@ const DataSettings: FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
|
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={shouldCopyData}
|
defaultSelected={shouldCopyData}
|
||||||
onChange={(checked) => {
|
onValueChange={(checked) => {
|
||||||
shouldCopyData = checked
|
shouldCopyData = checked
|
||||||
}}
|
}}
|
||||||
style={{ marginRight: '8px' }}
|
size="sm">
|
||||||
/>
|
<span style={{ fontWeight: 'normal', fontSize: '14px' }}>
|
||||||
|
{t('settings.data.app_data.copy_data_option')}
|
||||||
|
</span>
|
||||||
|
</Switch>
|
||||||
|
|
||||||
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
|
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
|
||||||
{t('settings.data.app_data.copy_data_option')}
|
{t('settings.data.app_data.copy_data_option')}
|
||||||
</MigrationPathLabel>
|
</MigrationPathLabel>
|
||||||
@@ -605,10 +613,10 @@ const DataSettings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||||
<HStack gap="5px" justifyContent="space-between">
|
<HStack gap="5px" justifyContent="space-between">
|
||||||
<Button onClick={BackupPopup.show} icon={<SaveIcon size={14} />}>
|
<Button variant="ghost" size="sm" onPress={BackupPopup.show} startContent={<SaveIcon size={14} />}>
|
||||||
{t('settings.general.backup.button')}
|
{t('settings.general.backup.button')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={RestorePopup.show} icon={<FolderOpen size={14} />}>
|
<Button variant="ghost" size="sm" onPress={RestorePopup.show} startContent={<FolderOpen size={14} />}>
|
||||||
{t('settings.general.restore.button')}
|
{t('settings.general.restore.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -616,11 +624,24 @@ const DataSettings: FC = () => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
|
||||||
<Switch checked={skipBackupFile} onChange={onSkipBackupFilesChange} />
|
<Switch isSelected={skipBackupFile} onValueChange={onSkipBackupFilesChange} size="sm" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>{t('settings.data.export_to_phone.title')}</SettingRowTitle>
|
||||||
|
<HStack gap="5px" justifyContent="space-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onPress={ExportToPhoneLanPopup.show}
|
||||||
|
startContent={<WifiOutlined />}>
|
||||||
|
{t('settings.data.export_to_phone.lan.title')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</SettingRow>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
<SettingGroup theme={theme}>
|
<SettingGroup theme={theme}>
|
||||||
<SettingTitle>{t('settings.data.data.title')}</SettingTitle>
|
<SettingTitle>{t('settings.data.data.title')}</SettingTitle>
|
||||||
@@ -635,7 +656,9 @@ const DataSettings: FC = () => {
|
|||||||
</PathText>
|
</PathText>
|
||||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
|
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
|
||||||
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
||||||
<Button onClick={handleSelectAppDataPath}>{t('settings.data.app_data.select')}</Button>
|
<Button variant="ghost" size="sm" onClick={handleSelectAppDataPath}>
|
||||||
|
{t('settings.data.app_data.select')}
|
||||||
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</PathRow>
|
</PathRow>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -648,7 +671,7 @@ const DataSettings: FC = () => {
|
|||||||
</PathText>
|
</PathText>
|
||||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} />
|
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} />
|
||||||
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
||||||
<Button onClick={() => handleOpenPath(appInfo?.logsPath)}>
|
<Button variant="ghost" size="sm" onClick={() => handleOpenPath(appInfo?.logsPath)}>
|
||||||
{t('settings.data.app_logs.button')}
|
{t('settings.data.app_logs.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -658,7 +681,9 @@ const DataSettings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.data.app_knowledge.label')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.data.app_knowledge.label')}</SettingRowTitle>
|
||||||
<HStack alignItems="center" gap="5px">
|
<HStack alignItems="center" gap="5px">
|
||||||
<Button onClick={handleRemoveAllFiles}>{t('settings.data.app_knowledge.button.delete')}</Button>
|
<Button variant="ghost" size="sm" onClick={handleRemoveAllFiles}>
|
||||||
|
{t('settings.data.app_knowledge.button.delete')}
|
||||||
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
@@ -668,14 +693,16 @@ const DataSettings: FC = () => {
|
|||||||
{cacheSize && <CacheText>({cacheSize}MB)</CacheText>}
|
{cacheSize && <CacheText>({cacheSize}MB)</CacheText>}
|
||||||
</SettingRowTitle>
|
</SettingRowTitle>
|
||||||
<HStack gap="5px">
|
<HStack gap="5px">
|
||||||
<Button onClick={handleClearCache}>{t('settings.data.clear_cache.button')}</Button>
|
<Button variant="ghost" size="sm" onClick={handleClearCache}>
|
||||||
|
{t('settings.data.clear_cache.button')}
|
||||||
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
|
||||||
<HStack gap="5px">
|
<HStack gap="5px">
|
||||||
<Button onClick={reset} danger>
|
<Button variant="ghost" size="sm" onPress={reset} color="danger">
|
||||||
{t('settings.general.reset.title')}
|
{t('settings.general.reset.title')}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { Alert, Skeleton } from '@heroui/react'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
|
import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
|
||||||
import { isMac, isWin } from '@renderer/config/constant'
|
import { isMac, isWin } from '@renderer/config/constant'
|
||||||
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
|
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
|
||||||
import { BuiltinOcrProviderIds, ImageOcrProvider, isImageOcrProvider, OcrProvider } from '@renderer/types'
|
import { BuiltinOcrProviderIds, ImageOcrProvider, isImageOcrProvider, OcrProvider } from '@renderer/types'
|
||||||
|
import { getErrorMessage } from '@renderer/utils'
|
||||||
import { Select } from 'antd'
|
import { Select } from 'antd'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useSWRImmutable from 'swr/immutable'
|
||||||
|
|
||||||
import { SettingRow, SettingRowTitle } from '..'
|
import { SettingRow, SettingRowTitle } from '..'
|
||||||
|
|
||||||
@@ -18,10 +21,16 @@ type Props = {
|
|||||||
const OcrImageSettings = ({ setProvider }: Props) => {
|
const OcrImageSettings = ({ setProvider }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders()
|
const { providers, imageProvider, getOcrProviderName, setImageProviderId } = useOcrProviders()
|
||||||
|
const fetcher = useCallback(() => {
|
||||||
|
return window.api.ocr.listProviders()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { data: validProviders, isLoading, error } = useSWRImmutable('ocr/providers', fetcher)
|
||||||
|
|
||||||
const imageProviders = providers.filter((p) => isImageOcrProvider(p))
|
const imageProviders = providers.filter((p) => isImageOcrProvider(p))
|
||||||
|
|
||||||
// 挂载时更新外部状态
|
// 挂载时更新外部状态
|
||||||
|
// FIXME: Just keep the imageProvider always valid, so we don't need update it in this component.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProvider(imageProvider)
|
setProvider(imageProvider)
|
||||||
}, [imageProvider, setProvider])
|
}, [imageProvider, setProvider])
|
||||||
@@ -40,12 +49,17 @@ const OcrImageSettings = ({ setProvider }: Props) => {
|
|||||||
|
|
||||||
const platformSupport = isMac || isWin
|
const platformSupport = isMac || isWin
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
|
if (!validProviders) return []
|
||||||
const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system
|
const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system
|
||||||
return imageProviders.filter(platformFilter).map((p) => ({
|
const validFilter = (p: ImageOcrProvider) => validProviders.includes(p.id)
|
||||||
value: p.id,
|
return imageProviders
|
||||||
label: getOcrProviderName(p)
|
.filter(platformFilter)
|
||||||
}))
|
.filter(validFilter)
|
||||||
}, [getOcrProviderName, imageProviders, platformSupport])
|
.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: getOcrProviderName(p)
|
||||||
|
}))
|
||||||
|
}, [getOcrProviderName, imageProviders, platformSupport, validProviders])
|
||||||
|
|
||||||
const isSystem = imageProvider.id === BuiltinOcrProviderIds.system
|
const isSystem = imageProvider.id === BuiltinOcrProviderIds.system
|
||||||
|
|
||||||
@@ -55,12 +69,23 @@ const OcrImageSettings = ({ setProvider }: Props) => {
|
|||||||
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
{!platformSupport && isSystem && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
|
{!platformSupport && isSystem && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
|
||||||
<Select
|
<Skeleton isLoaded={!isLoading}>
|
||||||
value={imageProvider.id}
|
{!error && (
|
||||||
style={{ width: '200px' }}
|
<Select
|
||||||
onChange={(id: string) => setImageProvider(id)}
|
value={imageProvider.id}
|
||||||
options={options}
|
style={{ width: '200px' }}
|
||||||
/>
|
onChange={(id: string) => setImageProvider(id)}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
color="danger"
|
||||||
|
title={t('ocr.error.provider.get_providers')}
|
||||||
|
description={getErrorMessage(error)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Skeleton>
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
|
||||||
|
import { BuiltinOcrProviderIds, isOcrOVProvider } from '@renderer/types'
|
||||||
|
import { Flex, Tag } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { SettingRow, SettingRowTitle } from '..'
|
||||||
|
|
||||||
|
export const OcrOVSettings = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { provider } = useOcrProvider(BuiltinOcrProviderIds.ovocr)
|
||||||
|
|
||||||
|
if (!isOcrOVProvider(provider)) {
|
||||||
|
throw new Error('Not OV OCR provider.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitle>
|
||||||
|
<Flex align="center" gap={4}>
|
||||||
|
{t('settings.tool.ocr.common.langs')}
|
||||||
|
</Flex>
|
||||||
|
</SettingRowTitle>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<Tag>🇬🇧 {t('languages.english')}</Tag>
|
||||||
|
<Tag>🇨🇳 {t('languages.chinese')}</Tag>
|
||||||
|
<Tag>🇭🇰 {t('languages.chinese-traditional')}</Tag>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Divider, Flex } from 'antd'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { SettingGroup, SettingTitle } from '..'
|
import { SettingGroup, SettingTitle } from '..'
|
||||||
|
import { OcrOVSettings } from './OcrOVSettings'
|
||||||
import { OcrPpocrSettings } from './OcrPpocrSettings'
|
import { OcrPpocrSettings } from './OcrPpocrSettings'
|
||||||
import { OcrSystemSettings } from './OcrSystemSettings'
|
import { OcrSystemSettings } from './OcrSystemSettings'
|
||||||
import { OcrTesseractSettings } from './OcrTesseractSettings'
|
import { OcrTesseractSettings } from './OcrTesseractSettings'
|
||||||
@@ -35,6 +36,8 @@ const OcrProviderSettings = ({ provider }: Props) => {
|
|||||||
return <OcrSystemSettings />
|
return <OcrSystemSettings />
|
||||||
case 'paddleocr':
|
case 'paddleocr':
|
||||||
return <OcrPpocrSettings />
|
return <OcrPpocrSettings />
|
||||||
|
case 'ovocr':
|
||||||
|
return <OcrOVSettings />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ const GeneralSettings: FC = () => {
|
|||||||
{ value: 'pt', label: 'Português', flag: '🇵🇹' },
|
{ value: 'pt', label: 'Português', flag: '🇵🇹' },
|
||||||
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
|
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
|
||||||
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
|
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
|
||||||
{ value: 'pl', label: 'Polski', flag: '🇵🇱' }
|
{ value: 'pl', label: 'Polski', flag: '🇵🇱' },
|
||||||
|
{ value: 'el', label: 'Ελληνικά', flag: '🇬🇷' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleSpellCheckLanguagesChange = (selectedLanguages: string[]) => {
|
const handleSpellCheckLanguagesChange = (selectedLanguages: string[]) => {
|
||||||
|
|||||||