Compare commits

..

1 Commits

Author SHA1 Message Date
SongSong be94216f4d feat: add built-in DiDi MCP server integration
- Add DiDi MCP server implementation with ride-hailing services
- Support map search, price estimation, order management, and driver tracking
- Add multilingual translations for DiDi MCP server descriptions
- Available only in mainland China, requires DIDI_API_KEY environment variable
2025-09-30 23:59:11 +08:00
130 changed files with 949 additions and 4420 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
- name: 📦 Setting Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Delete merged branch
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
github.rest.git.deleteRef({
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
ref: main
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 20
@@ -1,8 +1,8 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index b957cb824faa79cf01ba3a504f221870bd8e306a..4d71d30f655775d61537d9d8b73f6e17d41fa67e 100644
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -452,7 +452,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
+4 -36
View File
@@ -125,39 +125,7 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.6.5
Features:
- Add Claude Haiku 4.5 model support
- Add Mistral provider configuration
- Add Intel OpenVINO (NPU) OCR provider
- Add notes full-text search with highlighting
- Add built-in DiDi MCP server (China only)
- Support NewAPI as generic provider
Bug Fixes:
- Fix webview search (Cmd/Ctrl+F) functionality
- Fix API key rotation for each request
- Fix translate auto copy functionality
- Fix message layout and overflow display
<!--LANG:zh-CN-->
v1.6.5 版本更新
新增功能:
- 新增 Claude Haiku 4.5 模型支持
- 新增 Mistral 提供商配置
- 新增 Intel OpenVINO (NPU) OCR 提取功能
- 新增笔记全文搜索和高亮显示
- 新增内置滴滴 MCP 服务器(仅限中国)
- 支持 NewAPI 作为通用提供商
问题修复:
- 修复 webview 搜索(Cmd/Ctrl+F)功能
- 修复 API 密钥轮换机制
- 修复翻译自动复制功能
- 修复消息布局和溢出显示
<!--LANG:END-->
Optimized note-taking feature, now able to quickly rename by modifying the title
Fixed issue where CherryAI free model could not be used
Fixed issue where VertexAI proxy address could not be called normally
Fixed issue where built-in tools from service providers could not be called normally
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.6.5",
"version": "1.6.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -238,7 +238,7 @@
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"electron": "37.6.0",
"electron": "37.4.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-store": "^8.2.0",
@@ -325,7 +325,6 @@
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swr": "^2.3.6",
"tailwindcss": "^4.1.13",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
@@ -370,7 +369,7 @@
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch"
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {
+2 -3
View File
@@ -5,8 +5,8 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
@@ -53,7 +53,6 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
// Open
Open_Path = 'open:path',
@@ -230,6 +229,7 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -331,7 +331,6 @@ export enum IpcChannel {
// OCR
OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_AddModel = 'ovms:add-model',
-9
View File
@@ -22,12 +22,3 @@ export type MCPProgressEvent = {
callId: string
progress: number // 0-1 range
}
export type WebviewKeyEvent = {
webviewId: number
key: string
control: boolean
meta: boolean
shift: boolean
alt: boolean
}
+74 -160
View File
@@ -5,171 +5,105 @@ const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_RELEASE_BASE_URL =
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip'
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
const OVMS_PKG_NAME = 'ovms250911.zip'
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
/**
* 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
* Downloads and extracts the OVMS binary for the specified platform
*/
/**
* Clean old OVMS installation if it exists
*/
function cleanOldOvmsInstallation() {
console.log('Cleaning the existing OVMS installation...')
async function downloadOvmsBinary() {
// Create output directory structure - OVMS goes into its own subdirectory
const csDir = path.join(os.homedir(), '.cherrystudio')
// Ensure directories exist
fs.mkdirSync(csDir, { recursive: true })
const csOvmsDir = path.join(csDir, 'ovms')
// Delete existing OVMS directory if it exists
if (fs.existsSync(csOvmsDir)) {
try {
fs.rmSync(csOvmsDir, { recursive: true })
} catch (error) {
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
return 110
}
fs.rmSync(csOvmsDir, { recursive: true })
}
return 0
}
/**
* Install OVMS Base package
*/
async function installOvmsBase() {
// Download the base package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
try {
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
// Try each URL until one succeeds
let downloadSuccess = false
let lastError = null
// 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)
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
try {
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(downloadUrl, tempFilename)
// If we get here, download was successful
downloadSuccess = true
console.log(`Successfully downloaded from: ${downloadUrl}`)
break
} catch (error) {
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
lastError = error
// Clean up failed download file if it exists
if (fs.existsSync(tempFilename)) {
try {
fs.unlinkSync(tempFilename)
} catch (cleanupError) {
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
}
}
// Continue to next URL if this one failed
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
console.log(`Trying next URL...`)
}
}
}
// Check if any download succeeded
if (!downloadSuccess) {
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
return 103
}
// unzip the base package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
fs.mkdirSync(csOvmsDir, { recursive: true })
try {
console.log(`Extracting to ${csDir}...`)
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
console.log(`Extracting OVMS to ${csDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS: ${error.message}`)
fs.unlinkSync(tempFilename)
if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
// Check if ovmsDir is empty and remove it if so
try {
const ovmsDir = path.join(csDir, 'ovms')
const files = fs.readdirSync(ovmsDir)
if (files.length === 0) {
fs.rmSync(ovmsDir, { recursive: true })
console.log(`Removed empty directory: ${ovmsDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
return 105
}
return 104
}
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
}
@@ -224,27 +158,7 @@ async function installOvms() {
return 102
}
// Clean old installation if it exists
const cleanupCode = cleanOldOvmsInstallation()
if (cleanupCode !== 0) {
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
return cleanupCode
}
const installBaseCode = await installOvmsBase()
if (installBaseCode !== 0) {
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
cleanOldOvmsInstallation()
return installBaseCode
}
const installExtraCode = await installOvmsExtra()
if (installExtraCode !== 0) {
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
return installExtraCode
}
return 0
return await downloadOvmsBinary()
}
// Run the installation
-2
View File
@@ -28,7 +28,6 @@ import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import process from 'node:process'
import { apiServerService } from './services/ApiServerService'
import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
@@ -107,7 +106,6 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
+2 -2
View File
@@ -132,7 +132,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -754,6 +754,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
@@ -843,7 +844,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider)
)
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
+5 -3
View File
@@ -12,7 +12,10 @@
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema
} from '@modelcontextprotocol/sdk/types.js'
const logger = loggerService.withContext('DiDiMCPServer')
@@ -86,8 +89,7 @@ export class DiDiMcpServer {
},
reason: {
type: 'string',
description:
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
description: 'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
}
},
required: ['order_id']
+65 -4
View File
@@ -1,15 +1,17 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, net } from 'electron'
import { app, BrowserWindow, dialog, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
@@ -24,6 +26,7 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -63,6 +66,7 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
@@ -243,9 +247,37 @@ export default class AppUpdater {
}
}
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
}
/**
@@ -317,9 +349,38 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}
+3 -9
View File
@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
import { getAllFiles } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
@@ -147,16 +147,11 @@ class KnowledgeService {
}
}
private getDbPath = (id: string): string => {
// 消除网络搜索requestI d中的特殊字符
return path.join(this.storageDir, sanitizeFilename(id, '_'))
}
/**
* Delete knowledge base file
*/
private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = this.getDbPath(id)
const dbPath = path.join(this.storageDir, id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -249,8 +244,7 @@ class KnowledgeService {
dimensions
})
try {
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)
+1 -65
View File
@@ -1,5 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
import { session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
@@ -37,66 +36,3 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
}
})
}
const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (contents.getType?.() !== 'webview') {
return
}
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
if (!input) {
return
}
const key = input.key?.toLowerCase()
if (!key) {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
if (!isFindShortcut && !isEscape && !isEnter) {
return
}
const host = contents.hostWebContents
if (!host || host.isDestroyed()) {
return
}
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
if (isFindShortcut) {
event.preventDefault()
}
// Send the hotkey event to the renderer
// The renderer will decide whether to preventDefault for Escape and Enter
// based on whether the search bar is visible
host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id,
key,
control: Boolean(input.control),
meta: Boolean(input.meta),
shift: Boolean(input.shift),
alt: Boolean(input.alt)
})
}
contents.on('before-input-event', handleBeforeInput)
contents.once('destroyed', () => {
contents.removeListener('before-input-event', handleBeforeInput)
})
}
export function initWebviewHotkeys() {
webContents.getAllWebContents().forEach((contents) => {
if (contents.isDestroyed()) return
attachKeyboardHandler(contents)
})
app.on('web-contents-created', (_, contents) => {
attachKeyboardHandler(contents)
})
}
@@ -274,4 +274,46 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})
-7
View File
@@ -2,7 +2,6 @@ import { loggerService } from '@logger'
import { isLinux } from '@main/constant'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { ovOcrService } from './builtin/OvOcrService'
import { ppocrService } from './builtin/PpocrService'
import { systemOcrService } from './builtin/SystemOcrService'
import { tesseractService } from './builtin/TesseractService'
@@ -23,10 +22,6 @@ export class OcrService {
this.registry.delete(providerId)
}
public listProviderIds(): string[] {
return Array.from(this.registry.keys())
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id)
if (!handler) {
@@ -44,5 +39,3 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))
@@ -1,128 +0,0 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
import { exec } from 'child_process'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { promisify } from 'util'
import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('OvOcrService')
const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
export class OvOcrService extends OcrBaseService {
constructor() {
super()
}
public isAvailable(): boolean {
return (
isWin &&
os.cpus()[0].model.toLowerCase().includes('intel') &&
os.cpus()[0].model.toLowerCase().includes('ultra') &&
fs.existsSync(PATH_BAT_FILE)
)
}
private getOvOcrPath(): string {
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
}
private getImgDir(): string {
return path.join(this.getOvOcrPath(), 'img')
}
private getOutputDir(): string {
return path.join(this.getOvOcrPath(), 'output')
}
private async clearDirectory(dirPath: string): Promise<void> {
if (fs.existsSync(dirPath)) {
const files = await fs.promises.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) {
await this.clearDirectory(filePath)
await fs.promises.rmdir(filePath)
} else {
await fs.promises.unlink(filePath)
}
}
} else {
// If the directory does not exist, create it
await fs.promises.mkdir(dirPath, { recursive: true })
}
}
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
const imgDir = this.getImgDir()
const targetFilePath = path.join(imgDir, targetFileName)
await fs.promises.copyFile(sourceFilePath, targetFilePath)
}
private async runOcrBatch(): Promise<void> {
const ovOcrPath = this.getOvOcrPath()
try {
// Execute run.bat in the ov-ocr directory
await execAsync(`"${PATH_BAT_FILE}"`, {
cwd: ovOcrPath,
timeout: 60000 // 60 second timeout
})
} catch (error) {
logger.error(`Error running ovocr batch: ${error}`)
throw new Error(`Failed to run OCR batch: ${error}`)
}
}
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
try {
// 1. Clear img directory and output directory
await this.clearDirectory(this.getImgDir())
await this.clearDirectory(this.getOutputDir())
// 2. Copy file to img directory
const fileName = path.basename(filePath)
await this.copyFileToImgDir(filePath, fileName)
logger.info(`File copied to img directory: ${fileName}`)
// 3. Run run.bat
logger.info('Running OV OCR batch process...')
await this.runOcrBatch()
// 4. Check that output/[basename].txt file exists
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
if (!fs.existsSync(outputFilePath)) {
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
}
// 5. Read output/[basename].txt file content
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
// 6. Return result
return { text: ocrText }
} catch (error) {
logger.error(`Error during OV OCR process: ${error}`)
throw error
}
}
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
if (isImageFileMetadata(file)) {
return this.ocrImage(file.path, options)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const ovOcrService = new OvOcrService()
+5 -15
View File
@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
@@ -51,7 +51,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -221,7 +221,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
add: ({
base,
item,
@@ -388,16 +388,7 @@ const api = {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
@@ -474,8 +465,7 @@ const api = {
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
},
cherryai: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
@@ -6,10 +6,8 @@
import { loggerService } from '@logger'
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage } from '@renderer/utils/error'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
import type { TextStreamPart, ToolSet } from 'ai'
import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -24,8 +22,6 @@ export class AiSdkToChunkAdapter {
private accumulate: boolean | undefined
private isFirstChunk = true
private enableWebSearch: boolean = false
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
constructor(
private onChunk: (chunk: Chunk) => void,
@@ -38,17 +34,6 @@ export class AiSdkToChunkAdapter {
this.enableWebSearch = enableWebSearch || false
}
private markFirstTokenIfNeeded() {
if (this.firstTokenTimestamp === null && this.responseStartTimestamp !== null) {
this.firstTokenTimestamp = Date.now()
}
}
private resetTimingState() {
this.responseStartTimestamp = null
this.firstTokenTimestamp = null
}
/**
* 处理 AI SDK 流结果
* @param aiSdkResult AI SDK 的流结果对象
@@ -76,8 +61,6 @@ export class AiSdkToChunkAdapter {
webSearchResults: [],
reasoningId: ''
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset link converter state at the start of stream
this.isFirstChunk = true
@@ -90,7 +73,6 @@ export class AiSdkToChunkAdapter {
if (this.enableWebSearch) {
const remainingText = flushLinkConverterBuffer()
if (remainingText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: remainingText
@@ -105,7 +87,6 @@ export class AiSdkToChunkAdapter {
}
} finally {
reader.releaseLock()
this.resetTimingState()
}
}
@@ -156,7 +137,6 @@ export class AiSdkToChunkAdapter {
// Only emit chunk if there's text to send
if (finalText) {
this.markFirstTokenIfNeeded()
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: this.accumulate ? final.text : finalText
@@ -181,9 +161,6 @@ export class AiSdkToChunkAdapter {
break
case 'reasoning-delta':
final.reasoningContent += chunk.text || ''
if (chunk.text) {
this.markFirstTokenIfNeeded()
}
this.onChunk({
type: ChunkType.THINKING_DELTA,
text: final.reasoningContent || ''
@@ -283,37 +260,44 @@ export class AiSdkToChunkAdapter {
break
}
case 'finish': {
const usage = {
completion_tokens: chunk.totalUsage?.outputTokens || 0,
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
total_tokens: chunk.totalUsage?.totalTokens || 0
}
const metrics = this.buildMetrics(chunk.totalUsage)
const baseResponse = {
text: final.text || '',
reasoning_content: final.reasoningContent || ''
}
case 'finish':
this.onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
}
})
this.onChunk({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
text: final.text || '',
reasoning_content: final.reasoningContent || '',
usage: {
completion_tokens: chunk.totalUsage.outputTokens || 0,
prompt_tokens: chunk.totalUsage.inputTokens || 0,
total_tokens: chunk.totalUsage.totalTokens || 0
},
metrics: chunk.totalUsage
? {
completion_tokens: chunk.totalUsage.outputTokens || 0,
time_completion_millsec: 0
}
: undefined
}
})
this.resetTimingState()
break
}
// === 源和文件相关事件 ===
case 'source':
@@ -342,48 +326,13 @@ export class AiSdkToChunkAdapter {
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error:
chunk.error instanceof AISDKError
? chunk.error
: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
error: chunk.error as Record<string, any>
})
break
default:
}
}
private buildMetrics(totalUsage?: {
inputTokens?: number | null
outputTokens?: number | null
totalTokens?: number | null
}) {
if (!totalUsage) {
return undefined
}
const completionTokens = totalUsage.outputTokens ?? 0
const now = Date.now()
const start = this.responseStartTimestamp ?? now
const firstToken = this.firstTokenTimestamp
const timeFirstToken = Math.max(firstToken != null ? firstToken - start : 0, 0)
const baseForCompletion = firstToken ?? start
let timeCompletion = Math.max(now - baseForCompletion, 0)
if (timeCompletion === 0 && completionTokens > 0) {
timeCompletion = 1
}
return {
completion_tokens: completionTokens,
time_first_token_millsec: timeFirstToken,
time_completion_millsec: timeCompletion
}
}
}
export default AiSdkToChunkAdapter
+4 -2
View File
@@ -83,8 +83,10 @@ export default class ModernAiProvider {
throw new Error('Model is required for completions. Please use constructor with model parameter.')
}
// 每次请求时重新生成配置以确保API key轮换生效
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
// 确保配置存在
if (!this.config) {
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
}
// 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config)
@@ -70,19 +70,13 @@ export abstract class BaseApiClient<
{
public provider: Provider
protected host: string
protected apiKey: string
protected sdkInstance?: TSdkInstance
constructor(provider: Provider) {
this.provider = provider
this.host = this.getBaseURL()
}
/**
* Get the current API key with rotation support
* This getter ensures API keys rotate on each access when multiple keys are configured
*/
protected get apiKey(): string {
return this.getApiKey()
this.apiKey = this.getApiKey()
}
/**
@@ -23,7 +23,6 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
@@ -167,7 +166,7 @@ export async function buildStreamTextParams(
params.tools = tools
}
if (assistant.prompt) {
params.system = await replacePromptVariables(assistant.prompt, model.name)
params.system = assistant.prompt
}
logger.debug('params', params)
return {
@@ -62,14 +62,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider)
}
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider)
}
@@ -47,14 +47,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createPerplexity',
supportsImageGeneration: false,
aliases: ['perplexity']
},
{
id: 'mistral',
name: 'Mistral',
import: () => import('@ai-sdk/mistral'),
creatorFunctionName: 'createMistral',
supportsImageGeneration: false,
aliases: ['mistral']
}
] as const
+1 -30
View File
@@ -6,7 +6,6 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -53,12 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
// Don't disable reasoning for models that require it
if (
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -106,7 +100,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// reasoningEffort有效的情况
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(provider)) {
switch (provider.id) {
@@ -149,16 +142,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
// Grok 4 Fast doesn't support effort levels, always use enabled: true
if (isGrok4FastReasoningModel(model)) {
return {
reasoning: {
enabled: true // Ignore effort level, just enable reasoning
}
}
}
// Other OpenRouter models that support effort levels
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -429,13 +412,6 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
/**
* Get XAI-specific reasoning parameters
* This function should only be called for XAI provider models
* @param assistant - The assistant configuration
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
@@ -443,11 +419,6 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort) {
return {}
}
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -326,7 +326,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
* CodeViewer
* toolbar title
*/
min-width: 35ch;
min-width: 45ch;
.code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger'
import { Extension, keymap } from '@uiw/react-codemirror'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils'
@@ -203,80 +203,3 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
})
}, [onHeightChange])
}
interface UseScrollToLineOptions {
highlight?: boolean
}
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
const domAtPos = view.domAtPos(position)
let node: Node | null = domAtPos.node
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement
}
while (node) {
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
return node
}
node = node.parentElement
}
return null
}, [])
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
if (previousHighlight) {
previousHighlight.classList.remove('animation-locate-highlight')
}
element.classList.add('animation-locate-highlight')
const handleAnimationEnd = () => {
element.classList.remove('animation-locate-highlight')
element.removeEventListener('animationend', handleAnimationEnd)
}
element.addEventListener('animationend', handleAnimationEnd)
}, [])
return useCallback(
(lineNumber: number, options?: UseScrollToLineOptions) => {
const view = editorViewRef.current
if (!view) return
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
const lineElement = findLineElement(view, targetLine.from)
if (lineElement) {
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
if (options?.highlight) {
requestAnimationFrame(() => highlightLine(view, lineElement))
}
return
}
view.dispatch({
effects: EditorView.scrollIntoView(targetLine.from, {
y: 'start'
})
})
if (!options?.highlight) {
return
}
setTimeout(() => {
const fallbackElement = findLineElement(view, targetLine.from)
if (fallbackElement) {
highlightLine(view, fallbackElement)
}
}, 200)
},
[editorViewRef, findLineElement, highlightLine]
)
}
@@ -5,14 +5,13 @@ import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
export interface CodeEditorHandles {
save?: () => void
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
}
export interface CodeEditorProps {
@@ -76,15 +75,10 @@ export interface CodeEditorProps {
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor view is editable.
* Whether the editor is editable.
* @default true
*/
editable?: boolean
/**
* Set the editor state to read only but keep some user interactions, e.g., keymaps.
* @default false
*/
readOnly?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
@@ -120,7 +114,6 @@ const CodeEditor = ({
style,
className,
editable = true,
readOnly = false,
expanded = true,
wrapped = true
}: CodeEditorProps) => {
@@ -182,11 +175,8 @@ const CodeEditor = ({
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
const scrollToLine = useScrollToLine(editorViewRef)
useImperativeHandle(ref, () => ({
save: handleSave,
scrollToLine
save: handleSave
}))
return (
@@ -199,7 +189,6 @@ const CodeEditor = ({
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
editable={editable}
readOnly={readOnly}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}
@@ -1,48 +0,0 @@
import { FC, memo, useMemo } from 'react'
interface HighlightTextProps {
text: string
keyword: string
caseSensitive?: boolean
className?: string
}
/**
* Text highlighting component that marks keyword matches
*/
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
const highlightedText = useMemo(() => {
if (!keyword || !text) {
return <span>{text}</span>
}
// Escape regex special characters
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(`(${escapedKeyword})`, flags)
// Split text by keyword matches
const parts = text.split(regex)
return (
<>
{parts.map((part, index) => {
// Check if part matches keyword
const isMatch = regex.test(part)
regex.lastIndex = 0 // Reset regex state
if (isMatch) {
return <mark key={index}>{part}</mark>
}
return <span key={index}>{part}</span>
})}
</>
)
}, [text, keyword, caseSensitive])
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
return <span className={combinedClassName}>{highlightedText}</span>
}
export default memo(HighlightText)
@@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
@@ -18,10 +17,6 @@ export interface HorizontalScrollContainerProps {
dependencies?: readonly unknown[]
scrollDistance?: number
className?: string
classNames?: {
container?: string
content?: string
}
gap?: string
expandable?: boolean
}
@@ -31,7 +26,6 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
dependencies = [],
scrollDistance = 200,
className,
classNames,
gap = '8px',
expandable = false
}) => {
@@ -101,16 +95,11 @@ const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
return (
<Container
className={cn(className, classNames?.container)}
className={className}
$expandable={expandable}
$disableHoverButton={isScrolledToEnd}
onClick={expandable ? handleContainerClick : undefined}>
<ScrollContent
ref={scrollRef}
$gap={gap}
$isExpanded={isExpanded}
$expandable={expandable}
className={cn(classNames?.content)}>
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
{children}
</ScrollContent>
{canScroll && !isExpanded && !isScrolledToEnd && (
@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
{!isReady && (
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
<EmptyView>
<Avatar
src={currentAppInfo?.logo}
size={80}
@@ -25,7 +25,7 @@ const WebviewContainer = memo(
onNavigateCallback: (appid: string, url: string) => void
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings()
const { enableSpellCheck } = useSettings()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@@ -76,8 +76,6 @@ const WebviewContainer = memo(
const webviewId = webviewRef.current?.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
// Set link opening behavior for this webview
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
}
@@ -106,22 +104,6 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
// Update webview settings when they change
useEffect(() => {
if (!webviewRef.current) return
try {
const webviewId = webviewRef.current.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView may not be ready yet, settings will be applied in dom-ready event
logger.debug(`WebView ${appid} not ready for settings update`)
}
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
const WebviewStyle: React.CSSProperties = {
width: '100%',
height: '100%',
@@ -38,7 +38,6 @@ interface PopupContainerProps {
message?: Message
messages?: Message[]
topic?: Topic
rawContent?: string
}
// 转换文件信息数组为树形结构
@@ -141,8 +140,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
resolve,
message,
messages,
topic,
rawContent
topic
}) => {
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
const [state, setState] = useState({
@@ -231,9 +229,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
return
}
let markdown = ''
if (rawContent) {
markdown = rawContent
} else if (topic) {
if (topic) {
markdown = await topicToMarkdown(topic, exportReasoning)
} else if (messages && messages.length > 0) {
markdown = messagesToMarkdown(messages, exportReasoning)
@@ -303,6 +299,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
}
}
}
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
@@ -413,11 +410,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({
</Option>
</Select>
</Form.Item>
{!rawContent && (
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
)}
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
</Form>
</Modal>
)
@@ -9,7 +9,6 @@ interface ObsidianExportOptions {
topic?: Topic
message?: Message
messages?: Message[]
rawContent?: string
}
export default class ObsidianExportPopup {
@@ -25,7 +24,6 @@ export default class ObsidianExportPopup {
topic={options.topic}
message={options.message}
messages={options.messages}
rawContent={options.rawContent}
obsidianTags={''}
open={true}
resolve={(v) => {
@@ -55,15 +55,12 @@ const PopupContainer: React.FC<Props> = ({ text, title, extension, resolve }) =>
footer={null}>
{extension !== undefined ? (
<Editor
readOnly={true}
editable={false}
expanded={false}
height="100%"
style={{ height: '100%' }}
value={text}
language={extension}
options={{
keymap: true
}}
/>
) : (
<Text>{text}</Text>
@@ -1,2 +0,0 @@
// Attribute used to store the original source line number in markdown editors
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'
@@ -1,6 +1,5 @@
import { loggerService } from '@logger'
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent } from '@tiptap/react'
import { Tooltip } from 'antd'
@@ -30,156 +29,6 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
import { useRichEditor } from './useRichEditor'
const logger = loggerService.withContext('RichEditor')
/**
* Find element by line number with fallback strategies:
* 1. Exact line + content match
* 2. Exact line match
* 3. Closest line <= target
*/
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
if (allElements.length === 0) {
logger.warn('No elements with data-source-line attribute found')
return null
}
const exactMatches = editorDom.querySelectorAll(
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
) as NodeListOf<HTMLElement>
// Strategy 1: Exact line + content match
if (exactMatches.length > 1 && lineContent) {
for (const match of Array.from(exactMatches)) {
if (match.textContent?.includes(lineContent)) {
return match
}
}
}
// Strategy 2: Exact line match
if (exactMatches.length > 0) {
return exactMatches[0]
}
// Strategy 3: Closest line <= target
let closestElement: HTMLElement | null = null
let closestLine = 0
for (const el of allElements) {
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
if (sourceLine <= lineNumber && sourceLine > closestLine) {
closestLine = sourceLine
closestElement = el
}
}
return closestElement
}
/**
* Create fixed-position highlight overlay at element location
* with boundary detection to prevent overflow and toolbar overlap
*/
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
try {
// Remove previous overlay
const previousOverlay = document.body.querySelector('.highlight-overlay')
if (previousOverlay) {
previousOverlay.remove()
}
const editorWrapper = container.closest('.rich-editor-wrapper')
// Create overlay at element position
const rect = element.getBoundingClientRect()
const overlay = document.createElement('div')
overlay.className = 'highlight-overlay animation-locate-highlight'
overlay.style.position = 'fixed'
overlay.style.left = `${rect.left}px`
overlay.style.top = `${rect.top}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
overlay.style.pointerEvents = 'none'
overlay.style.zIndex = '9999'
overlay.style.borderRadius = '4px'
document.body.appendChild(overlay)
// Update overlay position and visibility on scroll
const updatePosition = () => {
const newRect = element.getBoundingClientRect()
const newContainerRect = container.getBoundingClientRect()
// Update position
overlay.style.left = `${newRect.left}px`
overlay.style.top = `${newRect.top}px`
overlay.style.width = `${newRect.width}px`
overlay.style.height = `${newRect.height}px`
// Get current toolbar bottom (it might change)
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
// Check if overlay is within visible bounds
const overlayTop = newRect.top
const overlayBottom = newRect.bottom
const visibleTop = currentToolbarBottom // Don't overlap toolbar
const visibleBottom = newContainerRect.bottom
// Hide overlay if any part is outside the visible container area
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
overlay.style.opacity = '0'
overlay.style.visibility = 'hidden'
} else {
overlay.style.opacity = '1'
overlay.style.visibility = 'visible'
}
}
container.addEventListener('scroll', updatePosition)
// Auto-remove after animation
const handleAnimationEnd = () => {
overlay.remove()
container.removeEventListener('scroll', updatePosition)
overlay.removeEventListener('animationend', handleAnimationEnd)
}
overlay.addEventListener('animationend', handleAnimationEnd)
} catch (error) {
logger.error('Failed to create highlight overlay:', error as Error)
}
}
/**
* Scroll to element and show highlight after scroll completes
*/
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
let scrollTimeout: NodeJS.Timeout
const handleScroll = () => {
clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(() => {
container.removeEventListener('scroll', handleScroll)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}, 150)
}
container.addEventListener('scroll', handleScroll)
// Fallback: if element already in view (no scroll happens)
setTimeout(() => {
const initialScrollTop = container.scrollTop
setTimeout(() => {
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
container.removeEventListener('scroll', handleScroll)
clearTimeout(scrollTimeout)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}
}, 200)
}, 50)
}
const RichEditor = ({
ref,
initialContent = '',
@@ -199,8 +48,7 @@ const RichEditor = ({
enableContentSearch = false,
isFullWidth = false,
fontFamily = 'default',
fontSize = 16,
enableSpellCheck = false
fontSize = 16
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
// Use the rich editor hook for complete editor management
@@ -223,7 +71,6 @@ const RichEditor = ({
onBlur,
placeholder,
editable,
enableSpellCheck,
scrollParent: () => scrollContainerRef.current,
onShowTableActionMenu: ({ position, actions }) => {
const iconMap: Record<string, React.ReactNode> = {
@@ -523,22 +370,6 @@ const RichEditor = ({
scrollContainerRef.current.scrollTop = value
}
},
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
if (!editor || !scrollContainerRef.current) return
try {
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
if (!element) return
if (options?.highlight) {
scrollAndHighlight(element, scrollContainerRef.current)
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
}
} catch (error) {
logger.error('Failed in scrollToLine:', error as Error)
}
},
// Dynamic command management
registerCommand,
registerToolbarCommand,
@@ -14,31 +14,6 @@ export const RichEditorWrapper = styled.div<{
border-radius: 6px;
background: var(--color-background);
overflow-y: hidden;
.ProseMirror table,
.tiptap table {
table-layout: auto !important;
}
.ProseMirror table th,
.ProseMirror table td,
.tiptap th,
.tiptap td {
white-space: normal !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
overflow: visible !important;
text-overflow: clip !important;
}
.ProseMirror table th > *,
.ProseMirror table td > *,
.tiptap td > *,
.tiptap th > * {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
}
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
@@ -46,7 +21,6 @@ export const RichEditorWrapper = styled.div<{
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
`
export const ToolbarWrapper = styled.div`
@@ -50,8 +50,6 @@ export interface RichEditorProps {
fontFamily?: 'default' | 'serif'
/** Font size in pixels */
fontSize?: number
/** Whether to enable spell check */
enableSpellCheck?: boolean
}
export interface ToolbarItem {
@@ -111,8 +109,6 @@ export interface RichEditorRef {
getScrollTop: () => number
/** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void
/** Scroll to specific line number in markdown */
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
// Dynamic command management
/** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void
@@ -2,7 +2,6 @@ import 'katex/dist/katex.min.css'
import { TableKit } from '@cherrystudio/extension-table-plus'
import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import {
@@ -12,7 +11,6 @@ import {
markdownToPreviewText
} from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { migrateMathStrings } from '@tiptap/extension-mathematics'
import Mention from '@tiptap/extension-mention'
@@ -38,31 +36,6 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
const logger = loggerService.withContext('useRichEditor')
// Create extension to preserve data-source-line attribute
const SourceLineAttribute = Extension.create({
name: 'sourceLineAttribute',
addGlobalAttributes() {
return [
{
types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'],
attributes: {
dataSourceLine: {
default: null,
parseHTML: (element) => {
const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR)
return value
},
renderHTML: (attributes) => {
if (!attributes.dataSourceLine) return {}
return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine }
}
}
}
}
]
}
})
export interface UseRichEditorOptions {
/** Initial markdown content */
initialContent?: string
@@ -84,8 +57,6 @@ export interface UseRichEditorOptions {
editable?: boolean
/** Whether to enable table of contents functionality */
enableTableOfContents?: boolean
/** Whether to enable spell check */
enableSpellCheck?: boolean
/** Show table action menu (row/column) with concrete actions and position */
onShowTableActionMenu?: (payload: {
type: 'row' | 'column'
@@ -155,7 +126,6 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
previewLength = 50,
placeholder = '',
editable = true,
enableSpellCheck = false,
onShowTableActionMenu,
scrollParent
} = options
@@ -223,7 +193,6 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions
const extensions = useMemo(
() => [
SourceLineAttribute,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6]
@@ -441,9 +410,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// Allow text selection even when not editable
style: editable
? ''
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;',
// Set spellcheck attribute on the contenteditable element
spellcheck: enableSpellCheck ? 'true' : 'false'
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;'
}
},
onUpdate: ({ editor }) => {
@@ -237,17 +237,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
onSortEnd={onSortEnd}
className="tabs-sortable"
renderItem={(tab) => (
<Tab
key={tab.id}
active={tab.id === activeTabId}
onClick={() => handleTabClick(tab)}
onAuxClick={(e) => {
if (e.button === 1 && tab.id !== 'home') {
e.preventDefault()
e.stopPropagation()
closeTab(tab.id)
}
}}>
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
@@ -1,101 +0,0 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store'
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
const logger = loggerService.withContext('UpdateDialog')
interface UpdateDialogProps {
isOpen: boolean
onClose: () => void
releaseInfo: UpdateInfo | null
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (isOpen && releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [isOpen, releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[85vh]',
header: 'border-b border-divider',
footer: 'border-t border-divider'
}}>
<ModalContent>
{(onModalClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
<p className="text-default-500 text-small">
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
</p>
</ModalHeader>
<ModalBody>
<ScrollShadow className="max-h-[450px]" hideScrollBar>
<div className="markdown rounded-lg bg-default-50 p-4">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</div>
</ScrollShadow>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
{t('update.later')}
</Button>
<Button
color="primary"
onPress={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
export default UpdateDialog
+29 -5
View File
@@ -22,6 +22,8 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
@@ -30,13 +32,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
@@ -44,6 +46,7 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
@@ -142,14 +145,14 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'dashscope',
name: i18n.t('minapps.qwen'),
url: 'https://www.tongyi.com/',
url: 'https://tongyi.aliyun.com/qianwen/',
logo: QwenModelLogo
},
{
id: 'stepfun',
name: i18n.t('minapps.stepfun'),
url: 'https://stepfun.com',
logo: StepfunAppLogo,
name: i18n.t('minapps.yuewen'),
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo,
bodered: true
},
{
@@ -260,6 +263,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://www.tiangong.cn/',
bodered: true
},
{
id: 'hugging-chat',
name: 'HuggingChat',
logo: HuggingChatLogo,
url: 'https://huggingface.co/chat/',
bodered: true
},
{
id: 'Felo',
name: 'Felo',
@@ -287,6 +297,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://bot.n.cn/',
bodered: true
},
{
id: 'nm-search',
name: i18n.t('minapps.nami-ai-search'),
logo: NamiAiSearchLogo,
url: 'https://www.n.cn/',
bodered: true
},
{
id: 'thinkany',
name: 'ThinkAny',
@@ -297,6 +314,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
padding: 5
}
},
{
id: 'hika',
name: 'Hika',
logo: HikaLogo,
url: 'https://hika.fyi/',
bodered: true
},
{
id: 'github-copilot',
name: 'GitHub Copilot',
+1 -19
View File
@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
cherryin: [],
// cherryin: [],
vertexai: [],
'302ai': [
{
@@ -430,18 +430,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
}
],
anthropic: [
{
id: 'claude-haiku-4-5-20251001',
provider: 'anthropic',
name: 'Claude Haiku 4.5',
group: 'Claude 4.5'
},
{
id: 'claude-sonnet-4-5-20250929',
provider: 'anthropic',
name: 'Claude Sonnet 4.5',
group: 'Claude 4.5'
},
{
id: 'claude-sonnet-4-20250514',
provider: 'anthropic',
@@ -710,12 +698,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
name: 'GLM-4.5-Flash',
group: 'GLM-4.5'
},
{
id: 'glm-4.6',
provider: 'zhipu',
name: 'GLM-4.6',
group: 'GLM-4.6'
},
{
id: 'glm-4.5',
provider: 'zhipu',
+10 -62
View File
@@ -14,7 +14,7 @@ import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
// Reasoning models
export const REASONING_REGEX =
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
@@ -24,7 +24,6 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
gpt5_codex: ['low', 'medium', 'high'] as const,
grok: ['low', 'high'] as const,
grok4_fast: ['auto'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
qwen: ['low', 'medium', 'high'] as const,
@@ -44,7 +43,6 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
@@ -68,8 +66,6 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
}
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isGrok4FastReasoningModel(model)) {
thinkingModelType = 'grok4_fast'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
thinkingModelType = 'gemini'
@@ -146,46 +142,19 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
}
const modelId = getLowerBaseModelName(model.id)
const providerId = model.provider.toLowerCase()
if (modelId.includes('grok-3-mini')) {
return true
}
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
return true
}
return false
}
/**
* Checks if the model is Grok 4 Fast reasoning version
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
*
* Note: XAI official uses different model IDs for reasoning vs non-reasoning
* Third-party providers like OpenRouter expose a single ID with reasoning parameters, while first-party providers require separate IDs. Only the OpenRouter variant supports toggling.
*
* @param model - The model to check
* @returns true if the model is a reasoning-enabled Grok 4 Fast model
*/
export function isGrok4FastReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('grok-4-fast') && !modelId.includes('non-reasoning')
}
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
if (
isSupportedReasoningEffortGrokModel(model) ||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
) {
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
return true
}
@@ -209,13 +178,9 @@ export function isGeminiReasoningModel(model?: Model): boolean {
return false
}
// Gemini 支持思考模式的模型正则
export const GEMINI_THINKING_MODEL_REGEX =
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
if (GEMINI_THINKING_MODEL_REGEX.test(modelId)) {
if (modelId.includes('gemini-2.5')) {
if (modelId.includes('image') || modelId.includes('tts')) {
return false
}
@@ -296,11 +261,7 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
return (
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
)
return modelId.startsWith('qwen3') && modelId.includes('thinking')
}
// Doubao 支持思考模式的模型正则
@@ -335,8 +296,7 @@ export function isClaudeReasoningModel(model?: Model): boolean {
modelId.includes('claude-3-7-sonnet') ||
modelId.includes('claude-3.7-sonnet') ||
modelId.includes('claude-sonnet-4') ||
modelId.includes('claude-opus-4') ||
modelId.includes('claude-haiku-4')
modelId.includes('claude-opus-4')
)
}
@@ -365,10 +325,7 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
return (
isSupportedReasoningEffortPerplexityModel(model) ||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
)
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
}
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
@@ -378,20 +335,14 @@ export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean
export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
return ['glm-4.5', 'glm-4.6'].some((id) => modelId.includes(id))
return modelId.includes('glm-4.5')
}
export const isDeepSeekHybridInferenceModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
// until the end of the string.
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
// TODO: move to utils and add test cases
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
}
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
@@ -482,8 +433,6 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-vl-235b-a22b-thinking$': { min: 0, max: 81_920 },
'qwen3-vl-30b-a3b-thinking$': { min: 0, max: 81_920 },
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
'qwen3-1\\.7b$': { min: 0, max: 30_720 },
@@ -494,9 +443,8 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
// Claude models
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 },
'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 },
'claude-opus-4-1.*$': { min: 1024, max: 32_000 }
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
+3 -7
View File
@@ -12,10 +12,8 @@ const visionAllowedModels = [
'gemini-1\\.5',
'gemini-2\\.0',
'gemini-2\\.5',
'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp',
'claude-3',
'claude-haiku-4',
'claude-sonnet-4',
'claude-opus-4',
'vision',
@@ -23,9 +21,7 @@ const visionAllowedModels = [
'qwen-vl',
'qwen2-vl',
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni(?:-[\\w-]+)?',
'qvq',
'internvl2',
'grok-vision-beta',
@@ -83,14 +79,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
'gemini-2.0-flash-preview-image-generation'
]
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
// Models that should auto-enable image generation button when selected
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
@@ -108,7 +104,7 @@ export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
...DEDICATED_IMAGE_MODELS
]
+3 -6
View File
@@ -7,16 +7,13 @@ import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`,
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
'i'
)
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$')
export const GEMINI_SEARCH_REGEX = new RegExp(
'gemini-(?:2.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
'i'
)
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
export const PERPLEXITY_SEARCH_MODELS = [
'sonar-pro',
+1 -15
View File
@@ -1,7 +1,6 @@
import {
BuiltinOcrProvider,
BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider,
OcrProviderCapability,
OcrSystemProvider,
@@ -51,23 +50,10 @@ const ppocrOcr: OcrPpocrProvider = {
}
} as const
const ovOcr: OcrOvProvider = {
id: 'ovocr',
name: 'Intel OV(NPU) OCR',
config: {
langs: isWin ? ['en-us', 'zh-cn'] : undefined
},
capabilities: {
image: true
// pdf: true
}
} as const satisfies OcrOvProvider
export const BUILTIN_OCR_PROVIDERS_MAP = {
tesseract,
system: systemOcr,
paddleocr: ppocrOcr,
ovocr: ovOcr
paddleocr: ppocrOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
+23 -23
View File
@@ -80,16 +80,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
}
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.net',
models: [],
isSystem: true,
enabled: true
},
// cherryin: {
// id: 'cherryin',
// name: 'CherryIN',
// type: 'openai',
// apiKey: '',
// apiHost: 'https://open.cherryin.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
silicon: {
id: 'silicon',
name: 'Silicon',
@@ -283,7 +283,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': {
id: 'new-api',
name: 'New API',
type: 'new-api',
type: 'openai',
apiKey: '',
apiHost: 'http://localhost:3000',
models: SYSTEM_MODELS['new-api'],
@@ -732,17 +732,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
cherryin: {
api: {
url: 'https://open.cherryin.net'
},
websites: {
official: 'https://open.cherryin.ai',
apiKey: 'https://open.cherryin.ai/console/token',
docs: 'https://open.cherryin.ai',
models: 'https://open.cherryin.ai/pricing'
}
},
// cherryin: {
// api: {
// url: 'https://open.cherryin.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
ph8: {
api: {
url: 'https://ph8.co'
@@ -1422,5 +1422,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
}
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
return ['new-api', 'cherryin'].includes(provider.id)
}
+1 -1
View File
@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
const deleteKnowledgeBase = (baseId: string) => {
const base = bases.find((b) => b.id === baseId)
if (!base) return
dispatch(deleteBase({ baseId }))
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
// remove assistant knowledge_base
const _assistants = assistants.map((assistant) => {
@@ -1,5 +1,4 @@
import { loggerService } from '@logger'
import IntelLogo from '@renderer/assets/images/providers/intel.png'
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
@@ -84,8 +83,6 @@ export const useOcrProviders = () => {
return <MonitorIcon size={size} />
case 'paddleocr':
return <Avatar size={size} src={PaddleocrLogo} />
case 'ovocr':
return <Avatar size={size} src={IntelLogo} />
}
}
return <FileQuestionMarkIcon size={size} />
-11
View File
@@ -48,17 +48,6 @@ export function useActiveTopic(assistantId: string, topic?: Topic) {
}
}, [activeTopic?.id, assistant])
useEffect(() => {
if (!assistant?.topics?.length || !activeTopic) {
return
}
const latestTopic = assistant.topics.find((item) => item.id === activeTopic.id)
if (latestTopic && latestTopic !== activeTopic) {
setActiveTopic(latestTopic)
}
}, [assistant?.topics, activeTopic])
return { activeTopic, setActiveTopic }
}
+1 -3
View File
@@ -333,13 +333,11 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
const builtinOcrProviderKeyMap = {
system: 'ocr.builtin.system',
tesseract: '',
paddleocr: '',
ovocr: ''
paddleocr: ''
} as const satisfies Record<BuiltinOcrProviderId, string>
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
if (key === 'tesseract') return 'Tesseract'
else if (key == 'paddleocr') return 'PaddleOCR'
else if (key == 'ovocr') return 'Intel OV(NPU) OCR'
else return getLabel(builtinOcrProviderKeyMap, key)
}
+4 -24
View File
@@ -1583,13 +1583,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1697,6 @@
"provider_settings": "Go to provider settings"
},
"notes": {
"auto_rename": {
"empty_note": "Note is empty, cannot generate name",
"failed": "Failed to generate note name",
"label": "Generate Note Name",
"success": "Note name generated successfully"
},
"characters": "Characters",
"collapse": "Collapse",
"content_placeholder": "Please enter the note content...",
@@ -1728,14 +1722,6 @@
"rename": "Rename",
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
"save": "Save to Notes",
"search": {
"both": "Name+Content",
"content": "Content",
"found_results": "Found {{count}} results (Name: {{nameCount}}, Content: {{contentCount}})",
"more_matches": "more matches",
"searching": "Searching...",
"show_less": "Show less"
},
"settings": {
"data": {
"apply": "Apply",
@@ -1792,8 +1778,6 @@
"sort_updated_asc": "Update time (oldest first)",
"sort_updated_desc": "Update time (newest first)",
"sort_z2a": "File name (Z-A)",
"spell_check": "Spell Check",
"spell_check_tooltip": "Enable/Disable spell check",
"star": "Favorite note",
"starred_notes": "Collected notes",
"title": "Notes",
@@ -1820,7 +1804,6 @@
"provider": {
"cannot_remove_builtin": "Cannot delete built-in provider",
"existing": "The provider already exists",
"get_providers": "Failed to get available providers",
"not_found": "OCR provider does not exist",
"update_failed": "Failed to update configuration"
},
@@ -1882,10 +1865,8 @@
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
"install_code_102": "Only supports Windows",
"install_code_103": "Download OVMS runtime failed",
"install_code_104": "Failed to install OVMS runtime",
"install_code_105": "Failed to create ovdnd.exe",
"install_code_106": "Failed to create run.bat",
"install_code_110": "Failed to clean old OVMS runtime",
"install_code_104": "Uncompress OVMS runtime failed",
"install_code_105": "Clean OVMS runtime failed",
"run": "Run OVMS failed:",
"stop": "Stop OVMS failed:"
},
@@ -3856,7 +3837,7 @@
"api_host": "API Host",
"api_key": {
"label": "API Key",
"tip": "Use commas to separate multiple keys"
"tip": "Multiple keys separated by commas or spaces"
},
"api_version": "API Version",
"aws-bedrock": {
@@ -4424,7 +4405,6 @@
"later": "Later",
"message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"warning": {
+5 -25
View File
@@ -1583,13 +1583,13 @@
"nami-ai-search": "纳米AI搜索",
"qwen": "通义千问",
"sensechat": "商量",
"stepfun": "阶跃AI",
"tencent-yuanbao": "腾讯元宝",
"tiangong-ai": "天工AI",
"wanzhi": "万知",
"wenxin": "文心一言",
"wps-copilot": "WPS灵犀",
"xiaoyi": "小艺",
"yuewen": "跃问",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -1697,12 +1697,6 @@
"provider_settings": "跳转到服务商设置界面"
},
"notes": {
"auto_rename": {
"empty_note": "笔记为空,无法生成名称",
"failed": "生成笔记名称失败",
"label": "生成笔记名称",
"success": "笔记名称生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "请输入笔记内容...",
@@ -1728,14 +1722,6 @@
"rename": "重命名",
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
"save": "保存到笔记",
"search": {
"both": "名称+内容",
"content": "内容",
"found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "个匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": {
"data": {
"apply": "应用",
@@ -1792,8 +1778,6 @@
"sort_updated_asc": "更新时间(从旧到新)",
"sort_updated_desc": "更新时间(从新到旧)",
"sort_z2a": "文件名(Z-A",
"spell_check": "拼写检查",
"spell_check_tooltip": "启用/禁用拼写检查",
"star": "收藏笔记",
"starred_notes": "收藏的笔记",
"title": "笔记",
@@ -1820,7 +1804,6 @@
"provider": {
"cannot_remove_builtin": "不能删除内置提供商",
"existing": "提供商已存在",
"get_providers": "获取可用提供商失败",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失败"
},
@@ -1857,7 +1840,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>",
"download": {
"button": "下载",
"error": "下载失败",
"error": "选择失败",
"model_id": {
"label": "模型 ID",
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
@@ -1882,10 +1865,8 @@
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "仅支持 Windows",
"install_code_103": "下载 OVMS runtime 失败",
"install_code_104": "安装 OVMS runtime 失败",
"install_code_105": "创建 ovdnd.exe 失败",
"install_code_106": "创建 run.bat 失败",
"install_code_110": "清理旧 OVMS runtime 失败",
"install_code_104": "解压 OVMS runtime 失败",
"install_code_105": "清理 OVMS runtime 失败",
"run": "运行 OVMS 失败:",
"stop": "停止 OVMS 失败:"
},
@@ -3856,7 +3837,7 @@
"api_host": "API 地址",
"api_key": {
"label": "API 密钥",
"tip": "多个密钥使用逗号分隔"
"tip": "多个密钥使用逗号或空格分隔"
},
"api_version": "API 版本",
"aws-bedrock": {
@@ -4424,7 +4405,6 @@
"later": "稍后",
"message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"warning": {
+7 -27
View File
@@ -1583,13 +1583,13 @@
"nami-ai-search": "納米AI搜索",
"qwen": "通義千問",
"sensechat": "商量",
"stepfun": "階躍AI",
"tencent-yuanbao": "騰訊元寶",
"tiangong-ai": "天工AI",
"wanzhi": "萬知",
"wenxin": "文心一言",
"wps-copilot": "WPS靈犀",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -1697,12 +1697,6 @@
"provider_settings": "跳轉到服務商設置界面"
},
"notes": {
"auto_rename": {
"empty_note": "筆記為空,無法生成名稱",
"failed": "生成筆記名稱失敗",
"label": "生成筆記名稱",
"success": "筆記名稱生成成功"
},
"characters": "字符",
"collapse": "收起",
"content_placeholder": "請輸入筆記內容...",
@@ -1728,14 +1722,6 @@
"rename": "重命名",
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
"save": "儲存到筆記",
"search": {
"both": "名稱+內容",
"content": "內容",
"found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})",
"more_matches": "個匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": {
"data": {
"apply": "應用",
@@ -1792,8 +1778,6 @@
"sort_updated_asc": "更新時間(從舊到新)",
"sort_updated_desc": "更新時間(從新到舊)",
"sort_z2a": "文件名(Z-A",
"spell_check": "拼寫檢查",
"spell_check_tooltip": "啟用/禁用拼寫檢查",
"star": "收藏筆記",
"starred_notes": "收藏的筆記",
"title": "筆記",
@@ -1819,9 +1803,8 @@
"error": {
"provider": {
"cannot_remove_builtin": "不能刪除內建提供者",
"existing": "提供已存在",
"get_providers": "取得可用提供者失敗",
"not_found": "OCR 提供者不存在",
"existing": "提供已存在",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失敗"
},
"unknown": "OCR過程發生錯誤"
@@ -1857,7 +1840,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>",
"download": {
"button": "下載",
"error": "下載失敗",
"error": "選擇失敗",
"model_id": {
"label": "模型 ID",
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
@@ -1882,10 +1865,8 @@
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "僅支援 Windows",
"install_code_103": "下載 OVMS runtime 失敗",
"install_code_104": "安裝 OVMS runtime 失敗",
"install_code_105": "創建 ovdnd.exe 失敗",
"install_code_106": "創建 run.bat 失敗",
"install_code_110": "清理舊 OVMS runtime 失敗",
"install_code_104": "解壓 OVMS runtime 失敗",
"install_code_105": "清理 OVMS runtime 失敗",
"run": "執行 OVMS 失敗:",
"stop": "停止 OVMS 失敗:"
},
@@ -3856,7 +3837,7 @@
"api_host": "API 主機地址",
"api_key": {
"label": "API 金鑰",
"tip": "多個金鑰使用逗號分隔"
"tip": "多個金鑰使用逗號或空格分隔"
},
"api_version": "API 版本",
"aws-bedrock": {
@@ -4424,7 +4405,6 @@
"later": "稍後",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"warning": {
+1 -12
View File
@@ -334,7 +334,6 @@
"new_topic": "Νέο θέμα {{Command}}",
"pause": "Παύση",
"placeholder": "Εισάγετε μήνυμα εδώ...",
"placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή",
"send": "Αποστολή",
"settings": "Ρυθμίσεις",
"thinking": {
@@ -1583,13 +1582,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1696,6 @@
"provider_settings": "Μετάβαση στις ρυθμίσεις παρόχου"
},
"notes": {
"auto_rename": {
"empty_note": "Το σημείωμα είναι κενό, δεν μπορεί να δημιουργηθεί όνομα",
"failed": "Αποτυχία δημιουργίας ονόματος σημείωσης",
"label": "Δημιουργία ονόματος σημείωσης",
"success": "Η δημιουργία του ονόματος σημειώσεων ολοκληρώθηκε με επιτυχία"
},
"characters": "χαρακτήρας",
"collapse": "σύμπτυξη",
"content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...",
@@ -1784,8 +1777,6 @@
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
"sort_z2a": "όνομα αρχείου (Z-A)",
"spell_check": "Έλεγχος ορθογραφίας",
"spell_check_tooltip": "Ενεργοποίηση/Απενεργοποίηση ελέγχου ορθογραφίας",
"star": "Αγαπημένες σημειώσεις",
"starred_notes": "Σημειώσεις συλλογής",
"title": "σημειώσεις",
@@ -1812,7 +1803,6 @@
"provider": {
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
"get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων",
"not_found": "Ο πάροχος OCR δεν υπάρχει",
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
},
@@ -4414,7 +4404,6 @@
"later": "Μετά",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"warning": {
+1 -12
View File
@@ -334,7 +334,6 @@
"new_topic": "Nuevo tema {{Command}}",
"pause": "Pausar",
"placeholder": "Escribe aquí tu mensaje...",
"placeholder_without_triggers": "Escriba un mensaje aquí y presione {{key}} para enviar",
"send": "Enviar",
"settings": "Configuración",
"thinking": {
@@ -1583,13 +1582,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1696,6 @@
"provider_settings": "Ir a la configuración del proveedor"
},
"notes": {
"auto_rename": {
"empty_note": "La nota está vacía, no se puede generar un nombre",
"failed": "Error al generar el nombre de la nota",
"label": "Generar nombre de nota",
"success": "Se ha generado correctamente el nombre de la nota"
},
"characters": "carácter",
"collapse": "ocultar",
"content_placeholder": "Introduzca el contenido de la nota...",
@@ -1784,8 +1777,6 @@
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
"sort_z2a": "Nombre de archivo (Z-A)",
"spell_check": "comprobación ortográfica",
"spell_check_tooltip": "Habilitar/deshabilitar revisión ortográfica",
"star": "Notas guardadas",
"starred_notes": "notas guardadas",
"title": "notas",
@@ -1812,7 +1803,6 @@
"provider": {
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
"existing": "El proveedor ya existe",
"get_providers": "Error al obtener proveedores disponibles",
"not_found": "El proveedor de OCR no existe",
"update_failed": "Actualización de la configuración fallida"
},
@@ -4414,7 +4404,6 @@
"later": "Más tarde",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"warning": {
+1 -12
View File
@@ -334,7 +334,6 @@
"new_topic": "Nouveau sujet {{Command}}",
"pause": "Pause",
"placeholder": "Entrez votre message ici...",
"placeholder_without_triggers": "Entrez votre message ici, appuyez sur {{key}} pour envoyer",
"send": "Envoyer",
"settings": "Paramètres",
"thinking": {
@@ -1583,13 +1582,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1696,6 @@
"provider_settings": "Aller aux paramètres du fournisseur"
},
"notes": {
"auto_rename": {
"empty_note": "La note est vide, impossible de générer un nom",
"failed": "Échec de la génération du nom de note",
"label": "Générer un nom de note",
"success": "La génération du nom de note a réussi"
},
"characters": "caractère",
"collapse": "réduire",
"content_placeholder": "Veuillez saisir le contenu de la note...",
@@ -1784,8 +1777,6 @@
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
"sort_z2a": "Nom de fichier (Z-A)",
"spell_check": "Vérification orthographique",
"spell_check_tooltip": "Activer/Désactiver la vérification orthographique",
"star": "Notes enregistrées",
"starred_notes": "notes de collection",
"title": "notes",
@@ -1812,7 +1803,6 @@
"provider": {
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
"existing": "Le fournisseur existe déjà",
"get_providers": "Échec de l'obtention des fournisseurs disponibles",
"not_found": "Le fournisseur OCR n'existe pas",
"update_failed": "Échec de la mise à jour de la configuration"
},
@@ -4414,7 +4404,6 @@
"later": "Plus tard",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"warning": {
+1 -12
View File
@@ -334,7 +334,6 @@
"new_topic": "新しいトピック {{Command}}",
"pause": "一時停止",
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください",
"send": "送信",
"settings": "設定",
"thinking": {
@@ -1583,13 +1582,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "通義千問",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "騰訊元宝",
"tiangong-ai": "Skywork",
"wanzhi": "万知",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -1697,12 +1696,6 @@
"provider_settings": "プロバイダー設定に移動"
},
"notes": {
"auto_rename": {
"empty_note": "ノートが空です。名前を生成できません。",
"failed": "ノート名の生成に失敗しました",
"label": "ノート名の生成",
"success": "ノート名の生成に成功しました"
},
"characters": "文字",
"collapse": "閉じる",
"content_placeholder": "メモの内容を入力してください...",
@@ -1784,8 +1777,6 @@
"sort_updated_asc": "更新日時(古い順)",
"sort_updated_desc": "更新日時(新しい順)",
"sort_z2a": "ファイル名(Z-A",
"spell_check": "スペルチェック",
"spell_check_tooltip": "スペルチェックの有効/無効",
"star": "お気に入りのノート",
"starred_notes": "収集したノート",
"title": "ノート",
@@ -1812,7 +1803,6 @@
"provider": {
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
"existing": "プロバイダーはすでに存在します",
"get_providers": "利用可能なプロバイダーの取得に失敗しました",
"not_found": "OCRプロバイダーが存在しません",
"update_failed": "更新構成に失敗しました"
},
@@ -4414,7 +4404,6 @@
"later": "後で",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"warning": {
+1 -12
View File
@@ -334,7 +334,6 @@
"new_topic": "Novo tópico {{Command}}",
"pause": "Pausar",
"placeholder": "Digite sua mensagem aqui...",
"placeholder_without_triggers": "Digite a mensagem aqui, pressione {{key}} para enviar",
"send": "Enviar",
"settings": "Configurações",
"thinking": {
@@ -1583,13 +1582,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1696,6 @@
"provider_settings": "Ir para as configurações do provedor"
},
"notes": {
"auto_rename": {
"empty_note": "A nota está vazia, não é possível gerar um nome",
"failed": "Falha ao gerar o nome da nota",
"label": "Gerar nome da nota",
"success": "Nome da nota gerado com sucesso"
},
"characters": "caractere",
"collapse": "[minimizar]",
"content_placeholder": "Introduza o conteúdo da nota...",
@@ -1784,8 +1777,6 @@
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
"sort_z2a": "Nome do arquivo (Z-A)",
"spell_check": "verificação ortográfica",
"spell_check_tooltip": "Ativar/Desativar verificação ortográfica",
"star": "Notas favoritas",
"starred_notes": "notas salvas",
"title": "nota",
@@ -1812,7 +1803,6 @@
"provider": {
"cannot_remove_builtin": "Não é possível excluir o provedor integrado",
"existing": "O provedor já existe",
"get_providers": "Falha ao obter provedores disponíveis",
"not_found": "O provedor OCR não existe",
"update_failed": "Falha ao atualizar a configuração"
},
@@ -4414,7 +4404,6 @@
"later": "Mais tarde",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"warning": {
+1 -12
View File
@@ -334,7 +334,6 @@
"new_topic": "Новый топик {{Command}}",
"pause": "Остановить",
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"placeholder_without_triggers": "Введите сообщение здесь, нажмите {{key}}, чтобы отправить",
"send": "Отправить",
"settings": "Настройки",
"thinking": {
@@ -1583,13 +1582,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Tencent Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -1697,12 +1696,6 @@
"provider_settings": "Перейти к настройкам поставщика"
},
"notes": {
"auto_rename": {
"empty_note": "Заметки пусты, имя невозможно сгенерировать",
"failed": "Создание названия заметки не удалось",
"label": "Создать название заметки",
"success": "Имя заметки успешно создано"
},
"characters": "Символы",
"collapse": "Свернуть",
"content_placeholder": "Введите содержимое заметки...",
@@ -1784,8 +1777,6 @@
"sort_updated_asc": "Время обновления (от старого к новому)",
"sort_updated_desc": "Время обновления (от нового к старому)",
"sort_z2a": "Имя файла (Я-А)",
"spell_check": "Проверка орфографии",
"spell_check_tooltip": "Включить/отключить проверку орфографии",
"star": "Избранные заметки",
"starred_notes": "Сохраненные заметки",
"title": "заметки",
@@ -1812,7 +1803,6 @@
"provider": {
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
"existing": "Поставщик уже существует",
"get_providers": "Не удалось получить доступных поставщиков",
"not_found": "Поставщик OCR отсутствует",
"update_failed": "Обновление конфигурации не удалось"
},
@@ -4414,7 +4404,6 @@
"later": "Позже",
"message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"warning": {
@@ -1,6 +1,5 @@
import { Button } from '@heroui/button'
import CodeViewer from '@renderer/components/CodeViewer'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
import { getProviderById } from '@renderer/services/ProviderService'
@@ -36,7 +35,7 @@ import {
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
import { Alert as AntdAlert, Modal } from 'antd'
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
@@ -306,36 +305,14 @@ const BuiltinError = ({ error }: { error: SerializedError }) => {
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
const { t } = useTranslation()
const { highlightCode } = useCodeStyle()
const [highlightedString, setHighlightedString] = useState('')
const cause = error.cause
useEffect(() => {
const highlight = async () => {
try {
const result = await highlightCode(JSON.stringify(JSON.parse(cause || '{}'), null, 2), 'json')
setHighlightedString(result)
} catch {
setHighlightedString(cause || '')
}
}
const timer = setTimeout(highlight, 0)
return () => clearTimeout(timer)
}, [highlightCode, cause])
return (
<>
<BuiltinError error={error} />
{cause && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
<ErrorDetailValue>
<div
className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: highlightedString }}
/>
</ErrorDetailValue>
<ErrorDetailValue>{error.cause}</ErrorDetailValue>
</ErrorDetailItem>
)}
</>
@@ -1,6 +1,4 @@
import { cn } from '@heroui/react'
import { loggerService } from '@logger'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useAssistant } from '@renderer/hooks/useAssistant'
@@ -227,28 +225,20 @@ const MessageItem: FC<Props> = ({
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter">
<HorizontalScrollContainer
classNames={{
content: cn(
'flex-1 items-center justify-between',
isLastMessage && messageStyle === 'plain' ? 'flex-row-reverse' : 'flex-row'
)
}}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
</HorizontalScrollContainer>
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
onUpdateUseful={onUpdateUseful}
/>
</MessageFooter>
)}
</>
@@ -292,8 +282,10 @@ const MessageContentContainer = styled(Scrollbar)`
overflow-y: auto;
`
const MessageFooter = styled.div`
const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plain' | 'bubble' }>`
display: flex;
flex-direction: ${({ $isLastMessage, $messageStyle }) =>
$isLastMessage && $messageStyle === 'plain' ? 'row-reverse' : 'row'};
align-items: center;
justify-content: space-between;
gap: 10px;
@@ -339,7 +339,6 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
display: grid;
overflow-y: visible;
gap: 16px;
&.horizontal {
padding-bottom: 4px;
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
@@ -406,7 +405,6 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
}
}
&.grid {
display: block;
height: 300px;
overflow-y: hidden;
border: 0.5px solid var(--color-border);
@@ -525,11 +525,6 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onContextMenu={() => setTargetTopic(topic)}
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
onDoubleClick={() => {
if (editingTopicId === topic.id && topicEdit.isEditing) return
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}
style={{
borderRadius,
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
@@ -546,7 +541,13 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onClick={(e) => e.stopPropagation()}
/>
) : (
<TopicName className={getTopicNameClassName()} title={topicName}>
<TopicName
className={getTopicNameClassName()}
title={topicName}
onDoubleClick={() => {
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}>
{topicName}
</TopicName>
)}
@@ -570,8 +571,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
} else {
handleDeleteClick(topic.id, e)
}
}}
onDoubleClick={(e) => e.stopPropagation()}>
}}>
{deletingTopicId === topic.id ? (
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (
@@ -386,7 +386,6 @@ const Container = styled.div`
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
&:hover {
background-color: var(--color-list-item-hover);
@@ -1,6 +1,4 @@
import { SyncOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Button } from 'antd'
@@ -12,7 +10,6 @@ const UpdateAppButton: FC = () => {
const { update } = useRuntime()
const { autoCheckUpdate } = useSettings()
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
if (!update) {
return null
@@ -26,15 +23,13 @@ const UpdateAppButton: FC = () => {
<Container>
<UpdateButton
className="nodrag"
onClick={onOpen}
onClick={() => window.api.showUpdateDialog()}
icon={<SyncOutlined />}
color="orange"
variant="outlined"
size="small">
{t('button.update_available')}
</UpdateButton>
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
</Container>
)
}
@@ -14,7 +14,6 @@ import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar'
import WebviewSearch from './components/WebviewSearch'
const logger = loggerService.withContext('MinAppPage')
@@ -185,7 +184,6 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools}
/>
</ToolbarWrapper>
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
{!isReady && (
<LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
@@ -8,7 +8,6 @@ import {
PushpinOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { loggerService } from '@logger'
import { isDev } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useMinapps } from '@renderer/hooks/useMinapps'
@@ -18,21 +17,11 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
const logger = loggerService.withContext('MinimalToolbar')
// Constants for timing delays
const WEBVIEW_CHECK_INITIAL_MS = 100 // Initial check interval
const WEBVIEW_CHECK_MAX_MS = 1000 // Maximum check interval (1 second)
const WEBVIEW_CHECK_MULTIPLIER = 2 // Exponential backoff multiplier
const WEBVIEW_CHECK_MAX_ATTEMPTS = 30 // Stop after ~30 seconds total
const NAVIGATION_UPDATE_DELAY_MS = 50
const NAVIGATION_COMPLETE_DELAY_MS = 100
interface Props {
app: MinAppType
webviewRef: React.RefObject<WebviewTag | null>
@@ -53,166 +42,27 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
const isPinned = pinned.some((item) => item.id === app.id)
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
// Ref to track navigation update timeout
const navigationUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Update navigation state
const updateNavigationState = useCallback(() => {
if (webviewRef.current) {
try {
setCanGoBack(webviewRef.current.canGoBack())
setCanGoForward(webviewRef.current.canGoForward())
} catch (error) {
logger.debug('WebView not ready for navigation state update', { appId: app.id })
setCanGoBack(false)
setCanGoForward(false)
}
} else {
setCanGoBack(false)
setCanGoForward(false)
setCanGoBack(webviewRef.current.canGoBack())
setCanGoForward(webviewRef.current.canGoForward())
}
}, [app.id, webviewRef])
// Schedule navigation state update with debouncing
const scheduleNavigationUpdate = useCallback(
(delay: number) => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
navigationUpdateTimeoutRef.current = setTimeout(() => {
updateNavigationState()
navigationUpdateTimeoutRef.current = null
}, delay)
},
[updateNavigationState]
)
// Cleanup navigation timeout on unmount
useEffect(() => {
return () => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
}
}, [])
// Monitor webviewRef changes and update navigation state
useEffect(() => {
let checkTimeout: NodeJS.Timeout | null = null
let navigationListener: (() => void) | null = null
let listenersAttached = false
let currentInterval = WEBVIEW_CHECK_INITIAL_MS
let attemptCount = 0
const attachListeners = () => {
if (webviewRef.current && !listenersAttached) {
// Update state immediately
updateNavigationState()
// Add navigation event listeners
const handleNavigation = () => {
scheduleNavigationUpdate(NAVIGATION_UPDATE_DELAY_MS)
}
webviewRef.current.addEventListener('did-navigate', handleNavigation)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigation)
listenersAttached = true
navigationListener = () => {
if (webviewRef.current) {
webviewRef.current.removeEventListener('did-navigate', handleNavigation)
webviewRef.current.removeEventListener('did-navigate-in-page', handleNavigation)
}
listenersAttached = false
}
if (checkTimeout) {
clearTimeout(checkTimeout)
checkTimeout = null
}
logger.debug('Navigation listeners attached', { appId: app.id, attempts: attemptCount })
return true
}
return false
}
const scheduleCheck = () => {
checkTimeout = setTimeout(() => {
// Use requestAnimationFrame to avoid blocking the main thread
requestAnimationFrame(() => {
attemptCount++
if (!attachListeners()) {
// Stop checking after max attempts to prevent infinite loops
if (attemptCount >= WEBVIEW_CHECK_MAX_ATTEMPTS) {
logger.warn('WebView attachment timeout', {
appId: app.id,
attempts: attemptCount,
totalTimeMs: currentInterval * attemptCount
})
return
}
// Exponential backoff: double the interval up to the maximum
currentInterval = Math.min(currentInterval * WEBVIEW_CHECK_MULTIPLIER, WEBVIEW_CHECK_MAX_MS)
// Log only on first few attempts or when interval changes significantly
if (attemptCount <= 3 || attemptCount % 10 === 0) {
logger.debug('WebView not ready, scheduling next check', {
appId: app.id,
nextCheckMs: currentInterval,
attempt: attemptCount
})
}
scheduleCheck()
}
})
}, currentInterval)
}
// Check for webview attachment
if (!webviewRef.current) {
scheduleCheck()
} else {
attachListeners()
}
// Cleanup
return () => {
if (checkTimeout) clearTimeout(checkTimeout)
if (navigationListener) navigationListener()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app.id, updateNavigationState, scheduleNavigationUpdate]) // webviewRef excluded as it's a ref object
}, [webviewRef])
const handleGoBack = useCallback(() => {
if (webviewRef.current) {
try {
if (webviewRef.current.canGoBack()) {
webviewRef.current.goBack()
// Delay update to ensure navigation completes
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
}
} catch (error) {
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goBack' })
}
if (webviewRef.current && webviewRef.current.canGoBack()) {
webviewRef.current.goBack()
updateNavigationState()
}
}, [app.id, webviewRef, scheduleNavigationUpdate])
}, [webviewRef, updateNavigationState])
const handleGoForward = useCallback(() => {
if (webviewRef.current) {
try {
if (webviewRef.current.canGoForward()) {
webviewRef.current.goForward()
// Delay update to ensure navigation completes
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
}
} catch (error) {
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goForward' })
}
if (webviewRef.current && webviewRef.current.canGoForward()) {
webviewRef.current.goForward()
updateNavigationState()
}
}, [app.id, webviewRef, scheduleNavigationUpdate])
}, [webviewRef, updateNavigationState])
const handleMinimize = useCallback(() => {
navigate('/apps')
@@ -1,377 +0,0 @@
import { Button, Input } from '@heroui/react'
import { loggerService } from '@logger'
import type { WebviewTag } from 'electron'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type FoundInPageResult = Electron.FoundInPageResult
interface WebviewSearchProps {
webviewRef: React.RefObject<WebviewTag | null>
isWebviewReady: boolean
appId: string
}
const logger = loggerService.withContext('WebviewSearch')
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
const [query, setQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null)
const activeWebview = webviewRef.current ?? null
const focusInput = useCallback(() => {
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
focusFrameRef.current = window.requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}, [])
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
if (!options?.keepQuery) {
setQuery('')
}
setMatchCount(0)
setActiveIndex(0)
}, [])
const ensureWebviewReady = useCallback(
(candidate: WebviewTag | null) => {
if (!candidate) return null
try {
const webContentsId = candidate.getWebContentsId?.()
if (!webContentsId) {
logger.debug('WebviewSearch: missing webContentsId before action', { appId })
return null
}
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed before action', { appId, error })
return null
}
return candidate
},
[appId]
)
const stopFindOnWebview = useCallback(
(webview: WebviewTag | null) => {
const usable = ensureWebviewReady(webview)
if (!usable) return false
try {
usable.stopFindInPage('clearSelection')
return true
} catch (error) {
logger.debug('stopFindInPage failed', { appId, error })
return false
}
},
[appId, ensureWebviewReady]
)
const getUsableWebview = useCallback(() => {
const candidates = [webviewRef.current, attachedWebviewRef.current]
for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate)
if (usable) {
return usable
}
}
return null
}, [ensureWebviewReady, webviewRef])
const stopSearch = useCallback(() => {
const target = getUsableWebview()
if (!target) return
stopFindOnWebview(target)
}, [getUsableWebview, stopFindOnWebview])
const closeSearch = useCallback(() => {
setIsVisible(false)
stopSearch()
resetSearchState({ keepQuery: true })
}, [resetSearchState, stopSearch])
const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => {
const target = getUsableWebview()
if (!target) {
logger.debug('Skip performSearch: webview not attached')
return
}
if (!text) {
stopSearch()
resetSearchState({ keepQuery: true })
return
}
try {
target.findInPage(text, options)
} catch (error) {
logger.error('findInPage failed', { error })
window.toast?.error(t('common.error'))
}
},
[getUsableWebview, resetSearchState, stopSearch, t]
)
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
if (!event.result) return
const { activeMatchOrdinal, matches } = event.result
if (matches !== undefined) {
setMatchCount(matches)
}
if (activeMatchOrdinal !== undefined) {
setActiveIndex(activeMatchOrdinal)
}
}, [])
const openSearch = useCallback(() => {
if (!isWebviewReady) {
logger.debug('Skip openSearch: webview not ready')
return
}
setIsVisible(true)
focusInput()
}, [focusInput, isWebviewReady])
const goToNext = useCallback(() => {
if (!query) return
performSearch(query, { forward: true, findNext: true })
}, [performSearch, query])
const goToPrevious = useCallback(() => {
if (!query) return
performSearch(query, { forward: false, findNext: true })
}, [performSearch, query])
useEffect(() => {
attachedWebviewRef.current = activeWebview
if (!activeWebview) {
return
}
const handle = handleFoundInPage
activeWebview.addEventListener('found-in-page', handle)
return () => {
activeWebview.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === activeWebview) {
stopFindOnWebview(activeWebview)
attachedWebviewRef.current = null
}
}
}, [activeWebview, handleFoundInPage, stopFindOnWebview])
useEffect(() => {
if (!activeWebview) return
if (!isWebviewReady) return
const onFindShortcut = window.api?.webview?.onFindShortcut
if (!onFindShortcut) return
let webContentsId: number | undefined
try {
webContentsId = activeWebview.getWebContentsId?.()
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed', { appId, error })
return
}
if (!webContentsId) {
logger.warn('WebviewSearch: missing webContentsId', { appId })
return
}
const unsubscribe = onFindShortcut(({ webviewId, key, control, meta, shift }) => {
if (webviewId !== webContentsId) return
if ((control || meta) && key === 'f') {
openSearch()
return
}
if (!isVisible) return
if (key === 'escape') {
closeSearch()
return
}
if (key === 'enter') {
if (shift) {
goToPrevious()
} else {
goToNext()
}
}
})
return () => {
unsubscribe?.()
}
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch])
useEffect(() => {
if (!isVisible) return
focusInput()
}, [focusInput, isVisible])
useEffect(() => {
if (!isVisible) return
if (!query) {
performSearch('')
return
}
performSearch(query)
}, [activeWebview, isVisible, performSearch, query])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
openSearch()
return
}
if (!isVisible) return
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (event.shiftKey) {
goToPrevious()
} else {
goToNext()
}
}
}
window.addEventListener('keydown', handleKeydown, true)
return () => {
window.removeEventListener('keydown', handleKeydown, true)
}
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
useEffect(() => {
if (!isWebviewReady) {
setIsVisible(false)
resetSearchState()
stopSearch()
return
}
}, [isWebviewReady, resetSearchState, stopSearch])
useEffect(() => {
if (!appId) return
if (lastAppIdRef.current === appId) return
lastAppIdRef.current = appId
setIsVisible(false)
resetSearchState()
stopSearch()
}, [appId, resetSearchState, stopSearch])
useEffect(() => {
return () => {
stopSearch()
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}
}, [stopSearch])
if (!isVisible) {
return null
}
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
const disableNavigation = !query || matchCount === 0
return (
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
<Input
ref={inputRef}
autoFocus
value={query}
onValueChange={setQuery}
spellCheck={'false'}
placeholder={t('common.search')}
size="sm"
radius="sm"
variant="flat"
classNames={{
base: 'w-[240px]',
inputWrapper:
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
input: 'text-small focus:outline-none focus-visible:outline-none',
innerWrapper: 'gap-0'
}}
/>
<span
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
title={noResultTitle}
role="status"
aria-live="polite"
aria-atomic="true">
{matchLabel}
</span>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToPrevious}
isDisabled={disableNavigation}
aria-label="Previous match"
className="text-default-500 hover:text-default-900">
<ChevronUp size={16} />
</Button>
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToNext}
isDisabled={disableNavigation}
aria-label="Next match"
className="text-default-500 hover:text-default-900">
<ChevronDown size={16} />
</Button>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={closeSearch}
aria-label={t('common.close')}
className="text-default-500 hover:text-default-900">
<X size={16} />
</Button>
</div>
)
}
export default WebviewSearch
@@ -1,396 +0,0 @@
import type { WebviewKeyEvent } from '@shared/config/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { WebviewTag } from 'electron'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import WebviewSearch from '../WebviewSearch'
const translations: Record<string, string> = {
'common.close': 'Close',
'common.error': 'Error',
'common.no_results': 'No results',
'common.search': 'Search'
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => translations[key] ?? key
})
}))
const createWebviewMock = () => {
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
const findInPageMock = vi.fn()
const stopFindInPageMock = vi.fn()
const webview = {
addEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
if (!listeners.has(type)) {
listeners.set(type, new Set())
}
listeners.get(type)!.add(listener)
}
),
removeEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
listeners.get(type)?.delete(listener)
}
),
getWebContentsId: vi.fn(() => 1),
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
} as unknown as WebviewTag
const emit = (type: string, result?: Electron.FoundInPageResult) => {
listeners.get(type)?.forEach((listener) => {
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
event.result = result
listener(event)
})
}
return {
emit,
findInPageMock,
stopFindInPageMock,
webview
}
}
const openSearchOverlay = async () => {
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
}
const originalRAF = window.requestAnimationFrame
const originalCAF = window.cancelAnimationFrame
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
})
const cancelAnimationFrameMock = vi.fn()
beforeAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: requestAnimationFrameMock,
writable: true
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: cancelAnimationFrameMock,
writable: true
})
})
afterAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: originalRAF
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: originalCAF
})
})
describe('WebviewSearch', () => {
const toastMock = {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
addToast: vi.fn()
}
let removeFindShortcutListenerMock: ReturnType<typeof vi.fn>
let onFindShortcutMock: ReturnType<typeof vi.fn>
const invokeLatestShortcut = (payload: WebviewKeyEvent) => {
const handler = onFindShortcutMock.mock.calls.at(-1)?.[0] as ((args: WebviewKeyEvent) => void) | undefined
if (!handler) {
throw new Error('Shortcut handler not registered')
}
act(() => {
handler(payload)
})
}
beforeEach(() => {
removeFindShortcutListenerMock = vi.fn()
onFindShortcutMock = vi.fn(() => removeFindShortcutListenerMock)
Object.assign(window as any, {
api: {
webview: {
onFindShortcut: onFindShortcutMock
}
}
})
Object.assign(window, { toast: toastMock })
})
afterEach(() => {
vi.clearAllMocks()
Reflect.deleteProperty(window, 'api')
})
it('opens the search overlay with keyboard shortcut', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
await openSearchOverlay()
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
it('opens the search overlay when webview shortcut is forwarded', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
})
it('skips shortcut wiring when getWebContentsId throws', async () => {
const { webview } = createWebviewMock()
const error = new Error('not ready')
;(webview as any).getWebContentsId = vi.fn(() => {
throw error
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
expect(onFindShortcutMock).not.toHaveBeenCalled()
;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
})
it('does not call stopFindInPage when webview is not ready', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const error = new Error('loading')
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
stopFindInPageMock.mockImplementation(() => {
throw new Error('should not be called')
})
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount()
expect(stopFindInPageMock).not.toHaveBeenCalled()
})
it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
invokeLatestShortcut({ webviewId: 1, key: 'escape', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
})
it('performs searches and navigates between results', async () => {
const { emit, findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
await act(async () => {
emit('found-in-page', {
requestId: 1,
matches: 3,
activeMatchOrdinal: 1,
selectionArea: undefined as unknown as Electron.Rectangle,
finalUpdate: false
} as Electron.FoundInPageResult)
})
const nextButton = screen.getByRole('button', { name: 'Next match' })
await waitFor(() => {
expect(nextButton).not.toBeDisabled()
})
await user.click(nextButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
})
const previousButton = screen.getByRole('button', { name: 'Previous match' })
await user.click(previousButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('navigates results when enter is forwarded from the webview', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: true, findNext: true })
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: true, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('clears search state when appId changes', async () => {
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalled()
})
await act(async () => {
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
})
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
})
it('shows toast error when search fails', async () => {
const { findInPageMock, webview } = createWebviewMock()
findInPageMock.mockImplementation(() => {
throw new Error('findInPage failed')
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(toastMock.error).toHaveBeenCalledWith('Error')
})
})
it('stops search when component unmounts', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
stopFindInPageMock.mockClear()
unmount()
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
expect(removeFindShortcutListenerMock).toHaveBeenCalled()
})
it('ignores keyboard shortcut when webview is not ready', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
await act(async () => {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
})
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
expect(findInPageMock).not.toHaveBeenCalled()
})
})
+4 -27
View File
@@ -1,16 +1,11 @@
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import CodeEditor from '@renderer/components/CodeEditor'
import { HSpaceBetweenStack } from '@renderer/components/Layout'
import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import Selector from '@renderer/components/Selector'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setEnableSpellCheck } from '@renderer/store/settings'
import { EditorView } from '@renderer/types'
import { Empty, Tooltip } from 'antd'
import { SpellCheck } from 'lucide-react'
import { Empty } from 'antd'
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -20,16 +15,13 @@ interface NotesEditorProps {
currentContent: string
tokenCount: number
editorRef: RefObject<RichEditorRef | null>
codeEditorRef: RefObject<CodeEditorHandles | null>
onMarkdownChange: (content: string) => void
}
const NotesEditor: FC<NotesEditorProps> = memo(
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef }) => {
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const { settings } = useNotesSettings()
const { enableSpellCheck } = useSettings()
const currentViewMode = useMemo(() => {
if (settings.defaultViewMode === 'edit') {
return settings.defaultEditMode
@@ -60,7 +52,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
{tmpViewMode === 'source' ? (
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
<CodeEditor
ref={codeEditorRef}
value={currentContent}
language="markdown"
onChange={onMarkdownChange}
@@ -87,7 +78,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
isFullWidth={settings.isFullWidth}
fontFamily={settings.fontFamily}
fontSize={settings.fontSize}
enableSpellCheck={enableSpellCheck}
/>
)}
</RichEditorContainer>
@@ -102,21 +92,8 @@ const NotesEditor: FC<NotesEditorProps> = memo(
color: 'var(--color-text-3)',
display: 'flex',
alignItems: 'center',
gap: 12
gap: 8
}}>
{tmpViewMode === 'preview' && (
<Tooltip placement="top" title={t('notes.spell_check_tooltip')} mouseLeaveDelay={0} arrow>
<ActionIconButton
active={enableSpellCheck}
onClick={() => {
const newValue = !enableSpellCheck
dispatch(setEnableSpellCheck(newValue))
window.api.setEnableSpellCheck(newValue)
}}>
<SpellCheck size={18} />
</ActionIconButton>
</Tooltip>
)}
<Selector
value={tmpViewMode as EditorView}
onChange={(value: EditorView) => setTmpViewMode(value)}
+12 -94
View File
@@ -1,11 +1,9 @@
import { loggerService } from '@logger'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import {
addDir,
addNote,
@@ -53,7 +51,6 @@ const logger = loggerService.withContext('NotesPage')
const NotesPage: FC = () => {
const editorRef = useRef<RichEditorRef>(null)
const codeEditorRef = useRef<CodeEditorHandles>(null)
const { t } = useTranslation()
const { showWorkspace } = useShowWorkspace()
const dispatch = useAppDispatch()
@@ -79,7 +76,6 @@ const NotesPage: FC = () => {
const lastFilePathRef = useRef<string | undefined>(undefined)
const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false)
const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null)
const activeFilePathRef = useRef<string | undefined>(activeFilePath)
const currentContentRef = useRef(currentContent)
@@ -370,32 +366,6 @@ const NotesPage: FC = () => {
}
}, [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(() => {
return () => {
@@ -415,25 +385,21 @@ const NotesPage: FC = () => {
}, [activeFilePath])
// 获取目标文件夹路径(选中文件夹或根目录)
const getTargetFolderPath = useCallback(
(targetFolderId?: string) => {
const folderId = targetFolderId || selectedFolderId
if (folderId) {
const selectedNode = findNode(notesTree, folderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
const getTargetFolderPath = useCallback(() => {
if (selectedFolderId) {
const selectedNode = findNode(notesTree, selectedFolderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
return notesPath // 默认返回根目录
},
[selectedFolderId, notesTree, notesPath]
)
}
return notesPath // 默认返回根目录
}, [selectedFolderId, notesTree, notesPath])
// 创建文件夹
const handleCreateFolder = useCallback(
async (name: string, targetFolderId?: string) => {
async (name: string) => {
try {
const targetPath = getTargetFolderPath(targetFolderId)
const targetPath = getTargetFolderPath()
if (!targetPath) {
throw new Error('No folder path selected')
}
@@ -449,11 +415,11 @@ const NotesPage: FC = () => {
// 创建笔记
const handleCreateNote = useCallback(
async (name: string, targetFolderId?: string) => {
async (name: string) => {
try {
isCreatingNoteRef.current = true
const targetPath = getTargetFolderPath(targetFolderId)
const targetPath = getTargetFolderPath()
if (!targetPath) {
throw new Error('No folder path selected')
}
@@ -785,53 +751,6 @@ const NotesPage: FC = () => {
}
}, [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 (
<Container id="notes-page">
<Navbar>
@@ -877,7 +796,6 @@ const NotesPage: FC = () => {
tokenCount={tokenCount}
onMarkdownChange={handleMarkdownChange}
editorRef={editorRef}
codeEditorRef={codeEditorRef}
/>
</EditorWrapper>
</ContentContainer>
File diff suppressed because it is too large Load Diff
@@ -1,159 +0,0 @@
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,6 +14,7 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import { useAppDispatch } from '@renderer/store'
@@ -34,7 +35,6 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig'
import { checkProviderEnabled } from './utils'
@@ -76,6 +76,20 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation()
const { theme } = useTheme()
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 { generating } = useRuntime()
const navigate = useNavigate()
@@ -835,12 +849,17 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
/>
</SettingHelpLink>
</ProviderTitleContainer>
<ProviderSelect
provider={aihubmixProvider}
options={Options}
onChange={handleProviderChange}
className={'mb-4'}
/>
<Select value={providerOptions[1].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
{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>
{/* 使用JSON配置渲染设置项 */}
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
@@ -1015,6 +1034,12 @@ const ModeSegmentedContainer = styled.div`
padding-top: 24px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件
const ProviderTitleContainer = styled.div`
display: flex;
+50 -22
View File
@@ -8,6 +8,7 @@ import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
@@ -28,7 +29,6 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import ImageUploader from './components/ImageUploader'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import {
COURSE_URL,
DEFAULT_PAINTING,
@@ -46,6 +46,20 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING)
const { t } = useTranslation()
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')!
@@ -771,9 +785,9 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return (
<Container>
<Navbar>
<NavbarCenter className="border-r-0">{t('paintings.title')}</NavbarCenter>
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
{isMac && (
<NavbarRight className="justify-end">
<NavbarRight style={{ justifyContent: 'flex-end' }}>
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={createNewPainting}>
{t('paintings.button.new.image')}
</Button>
@@ -783,7 +797,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<ProviderTitleContainer>
<SettingTitle className="mb-1">{t('common.provider')}</SettingTitle>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<div>
<SettingHelpLink target="_blank" href={COURSE_URL}>
{t('paintings.paint_course')}
@@ -791,19 +805,28 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<SettingHelpLink target="_blank" href={TOP_UP_URL}>
{t('paintings.top_up')}
</SettingHelpLink>
<ProviderLogo shape="square" src={getProviderLogo(dmxapiProvider.id)} size={16} className="ml-1" />
<ProviderLogo
shape="square"
src={getProviderLogo(dmxapiProvider.id)}
size={16}
style={{ marginLeft: 5 }}
/>
</div>
</ProviderTitleContainer>
<ProviderSelect
provider={dmxapiProvider}
options={Options}
onChange={handleProviderChange}
className="mb-4"
/>
<Select value={providerOptions[3].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
{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>
{painting.generationMode &&
[generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && (
<>
<SettingTitle className="mt-4 mb-1"></SettingTitle>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}></SettingTitle>
<ImageUploader
fileMap={fileMap}
maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3}
@@ -815,13 +838,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</>
)}
<SettingTitle className="mt-4 mb-1">
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
{t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice>
</SettingTitle>
<Select
value={painting.model}
onChange={onSelectModel}
className="w-full"
style={{ width: '100%' }}
loading={isLoadingModels}
placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}>
{Object.entries(modelOptions).map(([provider, models]) => {
@@ -838,11 +861,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
})}
</Select>
<SettingTitle className="mt-4 mb-1">{t('paintings.image.size')}</SettingTitle>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
<Select
value={isCustomSize ? 'custom' : painting.image_size}
onChange={(value) => onSelectImageSize(value)}
className="w-full">
style={{ width: '100%' }}>
{(() => {
const currentModel = allModels.find((m) => m.id === painting.model)
const modelImageSizes = currentModel?.image_sizes || []
@@ -851,7 +874,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return modelImageSizes.map((size) => {
return (
<Select.Option key={size.value} value={size.value}>
<HStack className="items-center gap-2">
<HStack style={{ alignItems: 'center', gap: 8 }}>
<span>{size.label}</span>
</HStack>
</Select.Option>
@@ -861,7 +884,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{/* 检查当前模型是否支持自定义尺寸 */}
{allModels.find((m) => m.id === painting.model)?.is_custom_size && (
<Select.Option value="custom" key="custom">
<HStack className="items-center gap-2">
<HStack style={{ alignItems: 'center', gap: 8 }}>
<span>{t('paintings.custom_size')}</span>
</HStack>
</Select.Option>
@@ -870,7 +893,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{/* 自定义尺寸输入框 */}
{isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && (
<div className="mt-2.5">
<div style={{ marginTop: 10 }}>
<HStack style={{ gap: 8, alignItems: 'center' }}>
<InputNumber
placeholder="W"
@@ -898,7 +921,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{painting.generationMode === generationModeType.GENERATION && (
<>
<SettingTitle className="mt-4 mb-1">
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
{t('paintings.seed')}
<Tooltip title={t('paintings.seed_desc_tip')}>
<InfoIcon />
@@ -918,7 +941,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</>
)}
<SettingTitle className="mt-4 mb-1">{t('paintings.style_type')}</SettingTitle>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.style_type')}</SettingTitle>
<SliderContainer>
<RadioTextBox>
{STYLE_TYPE_OPTIONS.map((ele) => (
@@ -932,7 +955,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</RadioTextBox>
</SliderContainer>
<SettingTitle className="mt-4 mb-1">
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
{t('paintings.auto_create_paint')}
<Tooltip title={t('paintings.auto_create_paint_tip')}>
<InfoIcon />
@@ -1009,6 +1032,11 @@ const ProviderTitleContainer = styled.div`
const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ContentContainer = styled.div`
display: flex;
+52 -30
View File
@@ -6,7 +6,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { getProviderLogo, isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers'
import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
@@ -17,7 +17,8 @@ import {
getPaintingsBackgroundOptionsLabel,
getPaintingsImageSizeOptionsLabel,
getPaintingsModerationOptionsLabel,
getPaintingsQualityOptionsLabel
getPaintingsQualityOptionsLabel,
getProviderLabel
} from '@renderer/i18n/label'
import PaintingsList from '@renderer/pages/paintings/components/PaintingsList'
import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig'
@@ -39,7 +40,6 @@ import styled from 'styled-components'
import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils'
const logger = loggerService.withContext('NewApiPage')
@@ -55,7 +55,8 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}
}, [openai_image_generate, openai_image_edit])
// moved below after newApiProvider is defined
const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode])
const [painting, setPainting] = useState<PaintingAction>(filteredPaintings[0] || DEFAULT_PAINTING)
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
@@ -66,22 +67,27 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const providers = useAllProviders()
const location = useLocation()
const routeName = location.pathname.split('/').pop() || 'new-api'
const newApiProviders = providers.filter((p) => isNewApiProvider(p))
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 { generating } = useRuntime()
const navigate = useNavigate()
const location = useLocation()
const { autoTranslateWithSpace } = useSettings()
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
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 newApiProvider = providers.find((p) => p.id === 'new-api')!
const modeOptions = [
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' },
@@ -96,7 +102,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}, [editImageFiles])
const updatePaintingState = (updates: Partial<PaintingAction>) => {
const updatedPainting = { ...painting, providerId: newApiProvider.id, ...updates }
const updatedPainting = { ...painting, ...updates }
setPainting(updatedPainting)
updatePainting(mode, updatedPainting)
}
@@ -132,10 +138,9 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
return {
...DEFAULT_PAINTING,
model: painting.model || modelOptions[0]?.value || '',
id: uuid(),
providerId: newApiProvider.id
id: uuid()
}
}, [modelOptions, painting.model, newApiProvider.id])
}, [modelOptions, painting.model])
const selectedModelConfig = useMemo(
() => MODELS.find((m) => m.name === painting.model) || MODELS[0],
@@ -439,10 +444,11 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
// 处理模式切换
const handleModeChange = (value: string) => {
setMode(value as keyof PaintingsState)
const list = (newApiPaintings[value as keyof PaintingsState] || []).filter(
(p) => p.providerId === newApiProvider.id
)
setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: newApiProvider.id })
if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) {
setPainting(newApiPaintings[value as keyof PaintingsState][0])
} else {
setPainting(DEFAULT_PAINTING)
}
}
// 渲染配置项的函数
@@ -467,10 +473,8 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const newPainting = getNewPainting()
addPainting(mode, newPainting)
setPainting(newPainting)
} else {
setPainting(filteredPaintings[0])
}
}, [filteredPaintings, mode, addPainting, getNewPainting])
}, [filteredPaintings, mode, addPainting, painting, getNewPainting])
useEffect(() => {
const timer = spaceClickTimer.current
@@ -497,9 +501,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
<LeftContainer>
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink
target="_blank"
href={PROVIDER_URLS[newApiProvider.id]?.websites?.docs || 'https://docs.newapi.pro/apps/cherry-studio/'}>
<SettingHelpLink target="_blank" href={'https://docs.newapi.pro/apps/cherry-studio/'}>
{t('paintings.learn_more')}
<ProviderLogo
shape="square"
@@ -510,7 +512,19 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink>
</ProviderTitleContainer>
<ProviderSelect provider={newApiProvider} options={Options} onChange={handleProviderChange} />
<Select
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 模型时,提示用户先去新增 */}
{modelOptions.length === 0 && (
@@ -778,12 +792,20 @@ const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
// 添加新的样式组件
const ModeSegmentedContainer = styled.div`
display: flex;
justify-content: center;
padding-top: 24px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件
const ProviderTitleContainer = styled.div`
display: flex;
justify-content: space-between;
@@ -1,10 +1,8 @@
import { loggerService } from '@logger'
import { isNewApiProvider } from '@renderer/config/providers'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useAppDispatch } from '@renderer/store'
import { setDefaultPaintingProvider } from '@renderer/store/settings'
import { PaintingProvider, SystemProviderId } from '@renderer/types'
import { FC, useEffect, useMemo } from 'react'
import { PaintingProvider } from '@renderer/types'
import { FC, useEffect } from 'react'
import { Route, Routes, useParams } from 'react-router-dom'
import AihubmixPage from './AihubmixPage'
@@ -16,23 +14,19 @@ import ZhipuPage from './ZhipuPage'
const logger = loggerService.withContext('PaintingsRoutePage')
const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux']
const Options = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux', 'new-api']
const PaintingsRoutePage: FC = () => {
const params = useParams()
const provider = params['*']
const dispatch = useAppDispatch()
const providers = useAllProviders()
const Options = useMemo(() => {
return [...BASE_OPTIONS, ...providers.filter((p) => isNewApiProvider(p)).map((p) => p.id)]
}, [providers])
useEffect(() => {
logger.debug(`defaultPaintingProvider: ${provider}`)
if (provider && Options.includes(provider)) {
dispatch(setDefaultPaintingProvider(provider as PaintingProvider))
}
}, [provider, dispatch, Options])
}, [provider, dispatch])
return (
<Routes>
@@ -42,12 +36,7 @@ const PaintingsRoutePage: FC = () => {
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
{/* new-api family providers are mounted dynamically below */}
{providers
.filter((p) => isNewApiProvider(p))
.map((p) => (
<Route key={p.id} path={`/${p.id}`} element={<NewApiPage Options={Options} />} />
))}
<Route path="/new-api" element={<NewApiPage Options={Options} />} />
</Routes>
)
}
@@ -18,6 +18,7 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import { getProviderByModel } from '@renderer/services/AssistantService'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
@@ -38,7 +39,6 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils'
export const TEXT_TO_IMAGES_MODELS = [
@@ -114,8 +114,22 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<Painting>(siliconflow_paintings[0] || DEFAULT_PAINTING)
const { theme } = useTheme()
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 [isLoading, setIsLoading] = useState(false)
@@ -155,7 +169,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
}
const onGenerate = async () => {
await checkProviderEnabled(siliconFlowProvider!, t)
await checkProviderEnabled(siliconflowProvider!, t)
if (painting.files.length > 0) {
const confirmed = await window.modal.confirm({
@@ -374,8 +388,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<ProviderSelect provider={siliconFlowProvider} options={Options} onChange={handleProviderChange} />
<SettingTitle className="mt-4 mb-1">{t('common.model')}</SettingTitle>
<Select value={providerOptions[2].value} onChange={handleProviderChange} options={providerOptions} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
<Radio.Group
@@ -10,6 +10,7 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import { useAppDispatch } from '@renderer/store'
@@ -30,7 +31,6 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import { DynamicFormRender } from './components/DynamicFormRender'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
import { checkProviderEnabled } from './utils'
import TokenFluxService from './utils/TokenFluxService'
@@ -55,6 +55,21 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
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 { generating } = useRuntime()
const navigate = useNavigate()
@@ -372,7 +387,19 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink>
</ProviderTitleContainer>
<ProviderSelect provider={tokenfluxProvider} options={Options} onChange={handleProviderChange} />
<Select
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 */}
<SectionTitle
@@ -748,4 +775,11 @@ const ProviderTitleContainer = styled.div`
align-items: center;
margin-bottom: 5px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
export default TokenFluxPage
+32 -2
View File
@@ -8,6 +8,7 @@ import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
@@ -23,7 +24,6 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import {
COURSE_URL,
DEFAULT_PAINTING,
@@ -50,6 +50,21 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 [currentImageIndex, setCurrentImageIndex] = useState(0)
@@ -355,7 +370,16 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
/>
</div>
</ProviderTitleContainer>
<ProviderSelect provider={zhipuProvider} options={Options} onChange={handleProviderChange} className="mb-4" />
<Select value={providerOptions[0].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
{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>
<Select
@@ -539,6 +563,12 @@ const ProviderTitleContainer = styled.div`
margin-bottom: 10px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ProviderLogo = styled(Avatar)`
border-radius: 4px;
`
@@ -1,99 +0,0 @@
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,21 +1,18 @@
import { GithubOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { APP_NAME, AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { handleSaveData, useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { UpgradeChannel } from '@shared/config/constant'
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
import { UpdateInfo } from 'builder-util-runtime'
import { debounce } from 'lodash'
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
import { BadgeQuestionMark } from 'lucide-react'
@@ -30,8 +27,6 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl
const AboutSettings: FC = () => {
const [version, setVersion] = useState('')
const [isPortable, setIsPortable] = useState(false)
const [updateDialogInfo, setUpdateDialogInfo] = useState<UpdateInfo | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()
const { t } = useTranslation()
const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings()
const { theme } = useTheme()
@@ -46,9 +41,8 @@ const AboutSettings: FC = () => {
}
if (update.downloaded) {
// Open update dialog directly in renderer
setUpdateDialogInfo(update.info || null)
onOpen()
await handleSaveData()
window.api.showUpdateDialog()
return
}
@@ -347,9 +341,6 @@ const AboutSettings: FC = () => {
<Button onClick={debug}>{t('settings.about.debug.open')}</Button>
</SettingRow>
</SettingGroup>
{/* Update Dialog */}
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
</SettingContainer>
)
}
@@ -33,7 +33,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt')
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? false)
const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? true)
const [customParameters, setCustomParameters] = useState<AssistantSettingCustomParameters[]>(
assistant?.settings?.customParameters ?? []
)
@@ -164,7 +164,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
setMaxTokens(0)
setStreamOutput(true)
setTopP(1)
setEnableTopP(false)
setEnableTopP(true)
setCustomParameters([])
setToolUseMode('prompt')
updateAssistantSettings({
@@ -175,7 +175,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
maxTokens: 0,
streamOutput: true,
topP: 1,
enableTopP: false,
enableTopP: true,
customParameters: [],
toolUseMode: 'prompt'
})
@@ -1,14 +1,11 @@
import { Alert, Skeleton } from '@heroui/react'
import { loggerService } from '@logger'
import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
import { isMac, isWin } from '@renderer/config/constant'
import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
import { BuiltinOcrProviderIds, ImageOcrProvider, isImageOcrProvider, OcrProvider } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { Select } from 'antd'
import { useCallback, useEffect, useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
import { SettingRow, SettingRowTitle } from '..'
@@ -21,16 +18,10 @@ type Props = {
const OcrImageSettings = ({ setProvider }: Props) => {
const { t } = useTranslation()
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))
// 挂载时更新外部状态
// FIXME: Just keep the imageProvider always valid, so we don't need update it in this component.
useEffect(() => {
setProvider(imageProvider)
}, [imageProvider, setProvider])
@@ -49,17 +40,12 @@ const OcrImageSettings = ({ setProvider }: Props) => {
const platformSupport = isMac || isWin
const options = useMemo(() => {
if (!validProviders) return []
const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system
const validFilter = (p: ImageOcrProvider) => validProviders.includes(p.id)
return imageProviders
.filter(platformFilter)
.filter(validFilter)
.map((p) => ({
value: p.id,
label: getOcrProviderName(p)
}))
}, [getOcrProviderName, imageProviders, platformSupport, validProviders])
return imageProviders.filter(platformFilter).map((p) => ({
value: p.id,
label: getOcrProviderName(p)
}))
}, [getOcrProviderName, imageProviders, platformSupport])
const isSystem = imageProvider.id === BuiltinOcrProviderIds.system
@@ -69,23 +55,12 @@ const OcrImageSettings = ({ setProvider }: Props) => {
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{!platformSupport && isSystem && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
<Skeleton isLoaded={!isLoading}>
{!error && (
<Select
value={imageProvider.id}
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>
<Select
value={imageProvider.id}
style={{ width: '200px' }}
onChange={(id: string) => setImageProvider(id)}
options={options}
/>
</div>
</SettingRow>
</>
@@ -1,32 +0,0 @@
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,7 +8,6 @@ import { Divider, Flex } from 'antd'
import styled from 'styled-components'
import { SettingGroup, SettingTitle } from '..'
import { OcrOVSettings } from './OcrOVSettings'
import { OcrPpocrSettings } from './OcrPpocrSettings'
import { OcrSystemSettings } from './OcrSystemSettings'
import { OcrTesseractSettings } from './OcrTesseractSettings'
@@ -36,8 +35,6 @@ const OcrProviderSettings = ({ provider }: Props) => {
return <OcrSystemSettings />
case 'paddleocr':
return <OcrPpocrSettings />
case 'ovocr':
return <OcrOVSettings />
default:
return null
}

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