Compare commits

...

42 Commits

Author SHA1 Message Date
kangfenmao
0d86e16e28 chore: bump version to v1.6.5 2025-10-18 23:28:18 +08:00
kangfenmao
a8600a5426 feat: add CherryIN provider option to AddProviderPopup (#10803) 2025-10-18 23:20:39 +08:00
kangfenmao
e208d60c10 feat: add SWR library for data fetching (#10802) 2025-10-18 23:04:58 +08:00
SuYao
6968302961 feat: add Claude Haiku 4.5 model support and update related regex patterns (#10800)
* feat: add Claude Haiku 4.5 model support and update related regex patterns

* fix: update Claude model token limits for consistency
2025-10-18 23:04:58 +08:00
SuYao
dd65fa2f71 fix: handle AISDKError in chunk processing (#10801) 2025-10-18 23:04:58 +08:00
SuYao
679043f26b feat: add Mistral provider configuration to AI Providers (#10795) 2025-10-18 23:04:58 +08:00
Phantom
6a1fb9bc7e fix(message): adjust layout and overflow properties for better display (#10746)
* style(CodeBlockView): reduce min-width from 45ch to 35ch to fix layout issues

* style(messages): adjust overflow properties and clean up commented code

Remove commented overflow properties and adjust overflow behavior for better scroll handling in message containers

* style: remove commented overflow css properties
2025-10-18 23:04:58 +08:00
Kejiang Ma
07619c11e5 feat: new build-in OCR provider -> intel OV(NPU) OCR (#10737)
* new build-in ocr provider intel ov

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>
Signed-off-by: Kejiang Ma <kj.ma@intel.com>

* updated base on PR's commnets

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

* feat(OcrImageSettings): use swr to fetch available providers

Add loading state and error handling when fetching available OCR providers. Display an alert when provider loading fails, showing the error message. Also optimize provider filtering logic using useMemo.

* refactor(ocr): rename providers to listProviders for consistency

Update method name to better reflect its functionality and maintain naming consistency across the codebase

---------

Signed-off-by: Ma, Kejiang <kj.ma@intel.com>
Signed-off-by: Kejiang Ma <kj.ma@intel.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-18 23:04:58 +08:00
beyondkmp
b71ea99738 feat: add Greek language option to spell checker options (#10793)
feat: add Greek language option to GeneralSettings component

- Added support for Greek (Ελληνικά) language in the language selection dropdown of the GeneralSettings component.
2025-10-18 23:04:58 +08:00
beyondkmp
5a29e45b88 fix: prevent default behavior for Cmd/Ctrl+F in WebviewService (#10783)
fix: prevent default behavior for Cmd/Ctrl+F in WebviewService (#10800)

Updated the keyboard handler in WebviewService to always prevent the default action for the Cmd/Ctrl+F shortcut, ensuring it overrides the guest page's native find dialog. This change allows the renderer to manage the behavior of Escape and Enter keys based on the visibility of the search bar.
2025-10-18 23:04:50 +08:00
beyondkmp
45195bb57d fix: guard webview search against destroyed webviews (#10704)
* 🐛 fix: guard webview search against destroyed webviews

* delete code

* delete code
2025-10-18 23:04:50 +08:00
beyondkmp
bb003c071c fix: intercept webview keyboard shortcuts for search functionality (#10641)
* feat: intercept webview keyboard shortcuts for search functionality

Implemented keyboard shortcut interception in webview to enable search functionality (Ctrl/Cmd+F) and navigation (Enter/Escape) within mini app pages. Previously, these shortcuts were consumed by the webview content and not propagated to the host application.

Changes:
- Added Webview_SearchHotkey IPC channel for forwarding keyboard events
- Implemented before-input-event handler in WebviewService to intercept Ctrl/Cmd+F, Escape, and Enter
- Extended preload API with onFindShortcut callback for webview shortcut events
- Updated WebviewSearch component to handle shortcuts from both window and webview
- Added comprehensive test coverage for webview shortcut handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix lint

* refactor: improve webview hotkey initialization and error handling

Refactored webview keyboard shortcut handler for better code organization and reliability.

Changes:
- Extracted keyboard handler logic into reusable attachKeyboardHandler function
- Added initWebviewHotkeys() to initialize handlers for existing webviews on startup
- Integrated initialization in main app entry point
- Added explanatory comment for event.preventDefault() behavior
- Added warning log when webContentsId is unavailable in WebviewSearch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add WebviewKeyEvent type and update related components

- Introduced WebviewKeyEvent type to standardize keyboard event handling for webviews.
- Updated preload index to utilize the new WebviewKeyEvent type in the onFindShortcut callback.
- Refactored WebviewSearch component and its tests to accommodate the new type, enhancing type safety and clarity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix lint

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-18 22:51:34 +08:00
Kejiang Ma
80c0fca963 feat: update and download ovms to 2025.3 official release from offici… (#10603)
* feat: update and download ovms to 2025.3 official release from official site.

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

* fix UI text

Signed-off-by: Kejiang Ma <kj.ma@intel.com>

---------

Signed-off-by: Kejiang Ma <kj.ma@intel.com>
2025-10-18 22:50:15 +08:00
defi-failure
c5629da46d feat: notes full text search (#10640)
* feat: notes full text search initial commit

* fix: update highlight overlay when scroll

* fix: reset note search result properly

* refactor: extract scrollToLine logic from CodeEditor into a custom hook

* fix: hide match overlay when overlap

* fix: truncate line with ellipsis around search match for better visibility

* fix: unified note search match highlight style
2025-10-18 22:50:04 +08:00
SongSong
8b16170b16 feat: add built-in DiDi MCP server integration (#10318)
* 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

* fix: resolve code formatting issues in DiDi MCP server

fixes code formatting issues in the DiDi MCP server implementation to resolve CI format check failures.

---------

Co-authored-by: BillySong <billysongli@didiglobal.com>
2025-10-18 22:49:50 +08:00
亢奋猫
182321a1bd fix: update default enableTopP setting to false in AssistantModelSett… (#10754)
fix: update default enableTopP setting to false in AssistantModelSettings and DefaultAssistantSettings

- Changed default value of enableTopP from true to false in AssistantModelSettings and DefaultAssistantSettings components.
- Updated related logic to ensure consistent behavior across settings.
2025-10-18 22:49:38 +08:00
Pleasure1234
cacabfa56d fix: add array checks for knowledge and memories in citations (#10778)
Updated formatCitationsFromBlock to verify that 'knowledge' and 'memories' are arrays before accessing their length and mapping over them. This prevents potential runtime errors if these properties are not arrays.
2025-10-18 22:49:24 +08:00
Shemol
aaef458010 fix: preserve spaces in API keys; update i18n tips to use commas or newlines (#10751)
fix(api): preserve spaces in API keys; i18n: clarify tips

Tips now say "Use commas to separate multiple keys." Full-width commas are auto-normalized.
2025-10-18 22:49:16 +08:00
Pleasure1234
43dff80211 fix: ensure API key rotation for each request (#10776)
Updated ModernAiProvider to regenerate config on every request, ensuring API key rotation is effective. Refactored BaseApiClient to use an API key getter for dynamic key retrieval, supporting key rotation when multiple keys are configured.
2025-10-18 22:49:02 +08:00
kangfenmao
1dfae45a12 fix: update Aihubmix auth URL to use console domain 2025-10-18 22:48:48 +08:00
George·Dong
26c7dd9976 fix(minapps): can't open links in external broswer when using tab navigation (#10669)
* fix(minapps): can't open links in external broswer when using tab navigation

* fix(minapps): stabilize webview navigation and add logging

* fix(minapps): debounce nav updates and robust webview attach
2025-10-18 22:48:41 +08:00
Phantom
5ce9209334 fix(translate): auto copy failed (#10745)
* fix(translate): auto copy failed

Because translatedContent may be stale

* refactor(translate): improve copy functionality dependency handling

Update copy callback dependencies to include setCopied and ensure proper memoization
Fix onCopy and translateText dependencies to include copy function
2025-10-18 22:47:51 +08:00
Calcium-Ion
0502ff48f1 feat: support NewAPI as a generic provider type (#10696)
* feat: add support for New API providerType

* feat: support New API as a generic painting provider

* refactor: update styling in painting pages to use Tailwind classes

- Replaced inline styles with Tailwind CSS classes for margin adjustments in AihubmixPage, DmxapiPage, SiliconPage, TokenFluxPage, and ZhipuPage.
- Enhanced consistency and maintainability of the codebase by standardizing styling approach across components.
- Minor refactor in ProviderSelect component to support className prop for better styling flexibility.
2025-10-18 22:47:26 +08:00
SuYao
726b2570e2 Fix/aisdk error (#10563)
* Add syntax highlighting to AI SDK error cause display

- Parse and format error cause as JSON with syntax highlighting
- Use CodeStyleProvider context for consistent code styling
- Maintain plain text fallback for non-JSON content

* fix patch

* chore: yarn lock

* feat: provider-specific-error

* chore

* chore

* fix: handle JSON parsing errors in AiSdkErrorBase component

* fix: improve error message formatting in AiSdkToChunkAdapter

* fix: remove unused MarkdownContainer and update AiSdkErrorBase to use styled div
2025-10-18 22:31:20 +08:00
Chen Tao
a33e3971d9 fix: support gemini-2.5-image-flash (#10683) 2025-10-13 19:33:36 +08:00
defi-failure
98faa80835 chore: update SiliconFlow logo (#10684) 2025-10-13 19:33:36 +08:00
George·Dong
49deece835 feat(reasoning): add special handling for Grok 4 fast models & qwen3-omni/qwen3-vl (#10367)
* feat(reasoning): add special handling for Grok 4 fast models

* feat(models): add grok4_fast model and refine grok reasoning

* feat(reasoning): unify Grok reasoning handling and XAI params

* feat(models): Grok/qwen handling and XAI

* feat(models): recognize qwen3-vl thinking models and add sizes

* fix(reasoning): reasoning enabled for QwenAlwaysThink models

* feat(reasoning): enable reasoning for Grok 4 Fast models

* fix(reasoning): rename and correct Grok 4 Fast model checks

* fix: adjust Grok-4 Fast reasoning detection for OpenRouter

* fix(reasoning): exclude non-reasoning models from reasoning detection
2025-10-13 19:20:15 +08:00
Chen Tao
eccdd7643e fix: remove LRU for websearch rag (#10631) 2025-10-13 19:20:02 +08:00
kangfenmao
7c8894616c refactor: remove unused minapp configurations and logos
- Deleted references to HuggingChat, NamiAiSearch, and Hika from the minapps configuration.
- Cleaned up imports by removing associated logo imports for the removed apps.
2025-10-11 15:34:20 +08:00
kangfenmao
170632a199 chore: bump version to 1.6.4 and update release notes
- Updated version in package.json to 1.6.4.
- Revised release notes to reflect new features, bug fixes, and technical updates.
- Added new features including CherryIN provider, right-click context menu for notes, and search functionality in the mini app page.
- Fixed issues related to reasoning block insertion order, knowledge base deletion, and Qwen model URL configuration.
2025-10-11 14:21:06 +08:00
ABucket
cd5841cdd4 fix: Provider icons are not displayed after selecting SiliconFlow in the "images" page (#10620) 2025-10-11 14:21:06 +08:00
ABucket
763afc5ca2 fix: Quick Assistant fails to correctly inject variables in prompts (#10617) 2025-10-11 14:21:06 +08:00
ABucket
45f033ff4e fix: AI_TypeValidationError when calling Ling-1T model (#10622) 2025-10-11 14:21:06 +08:00
kangfenmao
f8fadcc73f fix: adjust overflow properties in MessageGroup component
- Changed overflow properties in the GridContainer styled component to improve layout handling. Overflow is now set to hidden for vertical alignment.
2025-10-11 14:01:32 +08:00
kangfenmao
a94e5dad5f feat: remove some minapp and update related configurations
- Introduced new app icon for Stepfun.
- Updated minapps configuration to include Stepfun with its logo and URL.
- Removed Yuewen app from configurations and translations.
- Updated translations for multiple languages to reflect the addition of Stepfun and removal of Yuewen.
- Incremented version in the store configuration and added migration logic for new provider integration.
2025-10-11 11:54:37 +08:00
kangfenmao
632fd4c567 chore: update @ai-sdk/google to version 2.0.17 and add corresponding patch 2025-10-11 11:43:29 +08:00
ABucket
401e17eb0e feat: allow right click to create note and folder (#10523)
* feat: allow right click to create note and folder

* fix: duplicate menu for notes or folder

* fix: create notes in folder when a folder is selected
2025-10-11 10:29:00 +08:00
beyondkmp
80fc118465 feat: support search in mini app page (#10609)
*  feat: add webview find-in-page overlay

* 🐛 fix: reset webview search on tab change

* fix clear search issue

* 🐛 fix: rebind webview search events

* 🐛 fix: disable spellcheck in search input

* fix spellcheck

* 🐛 fix: webview search can now reopen after closing

Fixed an issue where the search overlay couldn't be reopened after closing.
The openSearch callback was unnecessarily depending on webviewRef.current,
causing event listener rebinding issues. Removed the redundant webviewRef
check as isWebviewReady is sufficient to ensure webview readiness.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Payne Fu <payne@Paynes-Mac-mini.rcoffice.ringcentral.com>
Co-authored-by: Payne Fu <payne@Paynes-MBP.rcoffice.ringcentral.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-11 10:28:51 +08:00
Tristan Zhang
9a8d7640f5 fix: insert reasoning block before the content block (#10545)
fix: always insert reasoning block before the content block
2025-10-11 10:28:43 +08:00
Chen Tao
2b3f6d5640 fix: knowledge base not delete and websearch rag error (#10595)
* fix: knowledge base not  delete

* fix: websearch rag error

* chore: add comment
2025-10-11 10:28:29 +08:00
beyondkmp
a2d81e6204 feat: add updating dialog in render (#10569)
* feat: replace update dialog handling with quit and install functionality

* refactor: remove App_ShowUpdateDialog and implement App_QuitAndInstall in IpcChannel
* update ipc.ts to handle quit and install action
* modify AppUpdater to include quitAndInstall method
* adjust preload index to invoke new quit and install action
* enhance AboutSettings to manage update dialog state and trigger quit and install

* fix(AboutSettings): handle null update info in update dialog state management

* fix(UpdateDialog): improve error handling during update installation and enhance release notes processing

* fix(AppUpdater): remove redundant assignment of releaseInfo after update download

* fix(IpcChannel): remove UpdateDownloadedCancelled enum value

* format code

* fix(UpdateDialog): enhance installation process with loading state and error handling

* update i18n

* fix(i18n): Auto update translations for PR #10569

* feat(UpdateAppButton): integrate UpdateDialog and update button functionality for better user experience

* fix(UpdateDialog): update installation handler to support async operation and ensure modal closes after installation

* refactor(AppUpdater.test): remove deprecated formatReleaseNotes tests to streamline test suite

* refactor(update-dialog): simplify dialog close handling

Replace onOpenChange with onClose prop to directly handle dialog closing
Remove redundant handleClose function and simplify button onPress handler

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-10-11 10:27:52 +08:00
Tristan Zhang
b6107c5fb1 fix: change the url for qwen (#10584) 2025-10-11 10:27:42 +08:00
111 changed files with 4262 additions and 885 deletions

View File

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

View File

@@ -125,21 +125,39 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
What's New in v1.6.3
<!--LANG:en-->
What's New in v1.6.5
Features:
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
- Code Tools: Add GitHub Copilot CLI integration
- 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 migration for missing providers
- Fix forked topic retaining old name after rename
- Restore first token latency reporting in metrics
- Fix UI scrollbar and overflow issues
- 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-->
Technical Updates:
- Upgrade to Electron 37.6.0
- Update dependencies across packages

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.6.3",
"version": "1.6.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -325,6 +325,7 @@
"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",
@@ -369,7 +370,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.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
"@ai-sdk/google@npm:2.0.17": "patch:@ai-sdk/google@npm%3A2.0.17#~/.yarn/patches/@ai-sdk-google-npm-2.0.17-fd88491de4.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

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,6 +53,7 @@ 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',
@@ -229,7 +230,6 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
@@ -331,6 +331,7 @@ export enum IpcChannel {
// OCR
OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_AddModel = 'ovms:add-model',

View File

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

View File

@@ -5,105 +5,171 @@ const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries
const OVMS_PKG_NAME = 'ovms250911.zip'
const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
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'
/**
* Downloads and extracts the OVMS binary for the specified platform
* error code:
* 101: Unsupported CPU (not Intel Ultra)
* 102: Unsupported platform (not Windows)
* 103: Download failed
* 104: Installation failed
* 105: Failed to create ovdnd.exe
* 106: Failed to create run.bat
* 110: Cleanup of old installation failed
*/
async function downloadOvmsBinary() {
// Create output directory structure - OVMS goes into its own subdirectory
/**
* Clean old OVMS installation if it exists
*/
function cleanOldOvmsInstallation() {
console.log('Cleaning the existing OVMS installation...')
const csDir = path.join(os.homedir(), '.cherrystudio')
// Ensure directories exist
fs.mkdirSync(csDir, { recursive: true })
const csOvmsDir = path.join(csDir, 'ovms')
// Delete existing OVMS directory if it exists
if (fs.existsSync(csOvmsDir)) {
fs.rmSync(csOvmsDir, { recursive: true })
}
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
// Try each URL until one succeeds
let downloadSuccess = false
let lastError = null
for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
const downloadUrl = OVMS_RELEASE_BASE_URL[i]
console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
try {
console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(downloadUrl, tempFilename)
// If we get here, download was successful
downloadSuccess = true
console.log(`Successfully downloaded from: ${downloadUrl}`)
break
fs.rmSync(csOvmsDir, { recursive: true })
} 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...`)
}
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
return 110
}
}
// Check if any download succeeded
if (!downloadSuccess) {
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
return 0
}
/**
* Install OVMS Base package
*/
async function installOvmsBase() {
// Download the base package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip')
try {
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`)
} catch (error) {
console.error(`Download OVMS Base failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
try {
console.log(`Extracting to ${csDir}...`)
// unzip the base package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
fs.mkdirSync(csOvmsDir, { recursive: true })
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS to ${csDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csDir}`)
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS: ${error.message}`)
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
}
fs.unlinkSync(tempFilename)
return 104
}
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
// copy ovms.exe to ovdnd.exe
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
console.log('Copied ovms.exe to ovdnd.exe')
} catch (error) {
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
return 105
}
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
// del %USERPROFILE%\.cherrystudio\ovms_log.log
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
fs.appendFileSync(runBatPath, '\r\n')
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
fs.appendFileSync(
runBatPath,
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
)
console.log(`Created run.bat at: ${runBatPath}`)
} catch (error) {
console.error(`Error creating run.bat: ${error.message}`)
return 106
}
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
console.log(`Created config file: ${configJsonPath}`)
return 0
}
/**
* Install OVMS Extra package
*/
async function installOvmsExtra() {
// Download the extra package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
try {
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
} catch (error) {
console.error(`Download OVMS Extra failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
// unzip the extra package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS Extra: ${error.message}`)
fs.unlinkSync(tempFilename)
return 104
}
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
try {
const files = fs.readdirSync(patchDir)
files.forEach((file) => {
const srcPath = path.join(patchDir, file)
const destPath = path.join(csOvmsBinDir, file)
fs.copyFileSync(srcPath, destPath)
console.log(`Applied patch file: ${file}`)
})
} catch (error) {
console.error(`Error applying OVMS patch: ${error.message}`)
}
return 0
}
@@ -158,7 +224,27 @@ async function installOvms() {
return 102
}
return await downloadOvmsBinary()
// Clean old installation if it exists
const cleanupCode = cleanOldOvmsInstallation()
if (cleanupCode !== 0) {
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
return cleanupCode
}
const installBaseCode = await installOvmsBase()
if (installBaseCode !== 0) {
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
cleanOldOvmsInstallation()
return installBaseCode
}
const installExtraCode = await installOvmsExtra()
if (installExtraCode !== 0) {
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
return installExtraCode
}
return 0
}
// Run the installation

View File

@@ -28,6 +28,7 @@ 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')
@@ -106,6 +107,7 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')

View File

@@ -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_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -754,7 +754,6 @@ 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
@@ -844,6 +843,7 @@ 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) =>

View File

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

View File

@@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search'
import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
@@ -42,6 +43,10 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
case BuiltinMCPServerNames.didiMCP: {
const apiKey = envs.DIDI_API_KEY
return new DiDiMcpServer(apiKey).server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -1,17 +1,15 @@
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, BrowserWindow, dialog, net } from 'electron'
import { app, 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'
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -66,7 +63,6 @@ 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)
})
@@ -247,37 +243,9 @@ export default class AppUpdater {
}
}
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)
}
})
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
}
/**
@@ -349,38 +317,9 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@@ -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 } from '@main/utils/file'
import { getAllFiles, sanitizeFilename } 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,11 +147,16 @@ 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 = path.join(this.storageDir, id)
const dbPath = this.getDbPath(id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -244,7 +249,8 @@ class KnowledgeService {
dimensions
})
try {
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)

View File

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

View File

@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})

View File

@@ -2,6 +2,7 @@ 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'
@@ -22,6 +23,10 @@ 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) {
@@ -39,3 +44,5 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))

View File

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

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 } from '@shared/config/types'
import type { FileChangeEvent, WebviewKeyEvent } 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),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
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: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
add: ({
base,
item,
@@ -388,7 +388,16 @@ 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)
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
@@ -465,7 +474,8 @@ const api = {
},
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider),
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
},
cherryai: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>

View File

@@ -6,8 +6,10 @@
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 type { TextStreamPart, ToolSet } from 'ai'
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -340,7 +342,14 @@ export class AiSdkToChunkAdapter {
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error: chunk.error as Record<string, any>
error:
chunk.error instanceof AISDKError
? chunk.error
: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
})
break

View File

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

View File

@@ -70,13 +70,19 @@ 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()
this.apiKey = this.getApiKey()
}
/**
* Get the current API key with rotation support
* This getter ensures API keys rotate on each access when multiple keys are configured
*/
protected get apiKey(): string {
return this.getApiKey()
}
/**

View File

@@ -23,6 +23,7 @@ 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'
@@ -166,7 +167,7 @@ export async function buildStreamTextParams(
params.tools = tools
}
if (assistant.prompt) {
params.system = assistant.prompt
params.system = await replacePromptVariables(assistant.prompt, model.name)
}
logger.debug('params', params)
return {

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -52,7 +53,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
// Don't disable reasoning for models that require it
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
if (
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -100,6 +106,7 @@ 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) {
@@ -142,6 +149,16 @@ 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: {
@@ -412,6 +429,13 @@ 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 {}
@@ -419,6 +443,11 @@ 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: 3.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

View File

@@ -5,13 +5,14 @@ import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
export interface CodeEditorHandles {
save?: () => void
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
}
export interface CodeEditorProps {
@@ -181,8 +182,11 @@ const CodeEditor = ({
].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
const scrollToLine = useScrollToLine(editorViewRef)
useImperativeHandle(ref, () => ({
save: handleSave
save: handleSave,
scrollToLine
}))
return (

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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'
@@ -29,6 +30,156 @@ 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 = '',
@@ -372,6 +523,22 @@ const RichEditor = ({
scrollContainerRef.current.scrollTop = value
}
},
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
if (!editor || !scrollContainerRef.current) return
try {
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
if (!element) return
if (options?.highlight) {
scrollAndHighlight(element, scrollContainerRef.current)
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
}
} catch (error) {
logger.error('Failed in scrollToLine:', error as Error)
}
},
// Dynamic command management
registerCommand,
registerToolbarCommand,

View File

@@ -111,6 +111,8 @@ export interface RichEditorRef {
getScrollTop: () => number
/** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void
/** Scroll to specific line number in markdown */
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
// Dynamic command management
/** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void

View File

@@ -2,6 +2,7 @@ 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 {
@@ -11,6 +12,7 @@ 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'
@@ -36,6 +38,31 @@ 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
@@ -196,6 +223,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions
const extensions = useMemo(
() => [
SourceLineAttribute,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6]

View File

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

View File

@@ -22,8 +22,6 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
import 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'
@@ -32,13 +30,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
import 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'
@@ -46,7 +44,6 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import 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'
@@ -145,14 +142,14 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'dashscope',
name: i18n.t('minapps.qwen'),
url: 'https://tongyi.aliyun.com/qianwen/',
url: 'https://www.tongyi.com/',
logo: QwenModelLogo
},
{
id: 'stepfun',
name: i18n.t('minapps.yuewen'),
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo,
name: i18n.t('minapps.stepfun'),
url: 'https://stepfun.com',
logo: StepfunAppLogo,
bodered: true
},
{
@@ -263,13 +260,6 @@ 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',
@@ -297,13 +287,6 @@ 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',
@@ -314,13 +297,6 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
padding: 5
}
},
{
id: 'hika',
name: 'Hika',
logo: HikaLogo,
url: 'https://hika.fyi/',
bodered: true
},
{
id: 'github-copilot',
name: 'GitHub Copilot',

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
// cherryin: [],
cherryin: [],
vertexai: [],
'302ai': [
{
@@ -430,6 +430,12 @@ 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',

View File

@@ -14,7 +14,7 @@ import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
// Reasoning models
export const REASONING_REGEX =
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
@@ -24,6 +24,7 @@ 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,
@@ -43,6 +44,7 @@ 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,
@@ -66,6 +68,8 @@ 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'
@@ -142,19 +146,46 @@ 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')) {
if (
isSupportedReasoningEffortGrokModel(model) ||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
) {
return true
}
@@ -265,7 +296,11 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
return modelId.startsWith('qwen3') && modelId.includes('thinking')
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
return (
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
)
}
// Doubao 支持思考模式的模型正则
@@ -300,7 +335,8 @@ 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-opus-4') ||
modelId.includes('claude-haiku-4')
)
}
@@ -329,7 +365,10 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
return (
isSupportedReasoningEffortPerplexityModel(model) ||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
)
}
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
@@ -443,6 +482,8 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
'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 },
@@ -453,8 +494,9 @@ 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: 64000 },
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
'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 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {

View File

@@ -15,6 +15,7 @@ const visionAllowedModels = [
'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp',
'claude-3',
'claude-haiku-4',
'claude-sonnet-4',
'claude-opus-4',
'vision',
@@ -24,7 +25,7 @@ const visionAllowedModels = [
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni',
'qwen3-omni(?:-[\\w-]+)?',
'qvq',
'internvl2',
'grok-vision-beta',
@@ -82,14 +83,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image-preview',
'gemini-2.5-flash-image',
'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-preview', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
@@ -107,7 +108,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-preview',
'gemini-2.5-flash-image',
...DEDICATED_IMAGE_MODELS
]

View File

@@ -7,7 +7,7 @@ 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-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`,
'i'
)

View File

@@ -1,6 +1,7 @@
import {
BuiltinOcrProvider,
BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider,
OcrProviderCapability,
OcrSystemProvider,
@@ -50,10 +51,23 @@ 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
paddleocr: ppocrOcr,
ovocr: ovOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)

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.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.net',
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: 'openai',
type: 'new-api',
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.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
cherryin: {
api: {
url: 'https://open.cherryin.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'
}
},
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)
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
}

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, baseParams: getKnowledgeBaseParams(base) }))
dispatch(deleteBase({ baseId }))
// remove assistant knowledge_base
const _assistants = assistants.map((assistant) => {

View File

@@ -1,4 +1,5 @@
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'
@@ -83,6 +84,8 @@ export const useOcrProviders = () => {
return <MonitorIcon size={size} />
case 'paddleocr':
return <Avatar size={size} src={PaddleocrLogo} />
case 'ovocr':
return <Avatar size={size} src={IntelLogo} />
}
}
return <FileQuestionMarkIcon size={size} />

View File

@@ -322,7 +322,8 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
[BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp'
} as const
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
@@ -332,11 +333,13 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
const builtinOcrProviderKeyMap = {
system: 'ocr.builtin.system',
tesseract: '',
paddleocr: ''
paddleocr: '',
ovocr: ''
} as const satisfies Record<BuiltinOcrProviderId, string>
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
if (key === 'tesseract') return 'Tesseract'
else if (key == 'paddleocr') return 'PaddleOCR'
else if (key == 'ovocr') return 'Intel OV(NPU) OCR'
else return getLabel(builtinOcrProviderKeyMap, key)
}

View File

@@ -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": {
@@ -1728,6 +1728,14 @@
"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",
@@ -1812,6 +1820,7 @@
"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"
},
@@ -1873,8 +1882,10 @@
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
"install_code_102": "Only supports Windows",
"install_code_103": "Download OVMS runtime failed",
"install_code_104": "Uncompress OVMS runtime failed",
"install_code_105": "Clean OVMS runtime failed",
"install_code_104": "Failed to install OVMS runtime",
"install_code_105": "Failed to create ovdnd.exe",
"install_code_106": "Failed to create run.bat",
"install_code_110": "Failed to clean old OVMS runtime",
"run": "Run OVMS failed:",
"stop": "Stop OVMS failed:"
},
@@ -3345,6 +3356,7 @@
"builtinServers": "Builtin Servers",
"builtinServersDescriptions": {
"brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable",
"didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable",
"dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key",
"fetch": "MCP server for retrieving URL web content",
"filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.",
@@ -3844,7 +3856,7 @@
"api_host": "API Host",
"api_key": {
"label": "API Key",
"tip": "Multiple keys separated by commas or spaces"
"tip": "Use commas to separate multiple keys"
},
"api_version": "API Version",
"aws-bedrock": {
@@ -4412,6 +4424,7 @@
"later": "Later",
"message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"warning": {

View File

@@ -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": {
@@ -1728,6 +1728,14 @@
"rename": "重命名",
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
"save": "保存到笔记",
"search": {
"both": "名称+内容",
"content": "内容",
"found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "个匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": {
"data": {
"apply": "应用",
@@ -1812,6 +1820,7 @@
"provider": {
"cannot_remove_builtin": "不能删除内置提供商",
"existing": "提供商已存在",
"get_providers": "获取可用提供商失败",
"not_found": "OCR 提供商不存在",
"update_failed": "更新配置失败"
},
@@ -1848,7 +1857,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/ 开头",
@@ -1873,8 +1882,10 @@
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "仅支持 Windows",
"install_code_103": "下载 OVMS runtime 失败",
"install_code_104": "解压 OVMS runtime 失败",
"install_code_105": "清理 OVMS runtime 失败",
"install_code_104": "安装 OVMS runtime 失败",
"install_code_105": "创建 ovdnd.exe 失败",
"install_code_106": "创建 run.bat 失败",
"install_code_110": "清理旧 OVMS runtime 失败",
"run": "运行 OVMS 失败:",
"stop": "停止 OVMS 失败:"
},
@@ -3345,6 +3356,7 @@
"builtinServers": "内置服务器",
"builtinServersDescriptions": {
"brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量",
"didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量",
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
"fetch": "用于获取 URL 网页内容的 MCP 服务器",
"filesystem": "实现文件系统操作的模型上下文协议MCP的 Node.js 服务器。需要配置允许访问的目录",
@@ -3844,7 +3856,7 @@
"api_host": "API 地址",
"api_key": {
"label": "API 密钥",
"tip": "多个密钥使用逗号或空格分隔"
"tip": "多个密钥使用逗号分隔"
},
"api_version": "API 版本",
"aws-bedrock": {
@@ -4412,6 +4424,7 @@
"later": "稍后",
"message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"warning": {

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": {
@@ -1728,6 +1728,14 @@
"rename": "重命名",
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
"save": "儲存到筆記",
"search": {
"both": "名稱+內容",
"content": "內容",
"found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})",
"more_matches": "個匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": {
"data": {
"apply": "應用",
@@ -1811,8 +1819,9 @@
"error": {
"provider": {
"cannot_remove_builtin": "不能刪除內建提供者",
"existing": "提供已存在",
"not_found": "OCR 提供商不存在",
"existing": "提供已存在",
"get_providers": "取得可用提供者失敗",
"not_found": "OCR 提供者不存在",
"update_failed": "更新配置失敗"
},
"unknown": "OCR過程發生錯誤"
@@ -1848,7 +1857,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/ 開頭",
@@ -1873,8 +1882,10 @@
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "僅支援 Windows",
"install_code_103": "下載 OVMS runtime 失敗",
"install_code_104": "解壓 OVMS runtime 失敗",
"install_code_105": "清理 OVMS runtime 失敗",
"install_code_104": "安裝 OVMS runtime 失敗",
"install_code_105": "創建 ovdnd.exe 失敗",
"install_code_106": "創建 run.bat 失敗",
"install_code_110": "清理舊 OVMS runtime 失敗",
"run": "執行 OVMS 失敗:",
"stop": "停止 OVMS 失敗:"
},
@@ -3345,6 +3356,7 @@
"builtinServers": "內置伺服器",
"builtinServersDescriptions": {
"brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數",
"didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數",
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
"filesystem": "實現文件系統操作的模型上下文協議MCP的 Node.js 伺服器。需要配置允許訪問的目錄",
@@ -3844,7 +3856,7 @@
"api_host": "API 主機地址",
"api_key": {
"label": "API 金鑰",
"tip": "多個金鑰使用逗號或空格分隔"
"tip": "多個金鑰使用逗號分隔"
},
"api_version": "API 版本",
"aws-bedrock": {
@@ -4412,6 +4424,7 @@
"later": "稍後",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"warning": {

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": {
@@ -1812,6 +1812,7 @@
"provider": {
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
"get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων",
"not_found": "Ο πάροχος OCR δεν υπάρχει",
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
},
@@ -3345,6 +3346,7 @@
"builtinServers": "Ενσωματωμένοι Διακομιστές",
"builtinServersDescriptions": {
"brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY",
"didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY",
"dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify",
"fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL",
"filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους",
@@ -4412,6 +4414,7 @@
"later": "Μετά",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"warning": {

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": {
@@ -1812,6 +1812,7 @@
"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"
},
@@ -3345,6 +3346,7 @@
"builtinServers": "Servidores integrados",
"builtinServersDescriptions": {
"brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY",
"didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY",
"dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.",
"fetch": "Servidor MCP para obtener el contenido de la página web de una URL",
"filesystem": "Servidor Node.js que implementa el protocolo de contexto del modelo (MCP) para operaciones del sistema de archivos. Requiere configuración del directorio permitido para el acceso",
@@ -4412,6 +4414,7 @@
"later": "Más tarde",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"warning": {

View File

@@ -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": {
@@ -1812,6 +1812,7 @@
"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"
},
@@ -3345,6 +3346,7 @@
"builtinServers": "Serveurs intégrés",
"builtinServersDescriptions": {
"brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY",
"didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY",
"dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify",
"fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL",
"filesystem": "Serveur Node.js implémentant le protocole de contexte de modèle (MCP) pour les opérations de système de fichiers. Nécessite une configuration des répertoires autorisés à être accédés.",
@@ -4412,6 +4414,7 @@
"later": "Plus tard",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"warning": {

View File

@@ -1583,13 +1583,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": {
@@ -1812,6 +1812,7 @@
"provider": {
"cannot_remove_builtin": "組み込みプロバイダーは削除できません",
"existing": "プロバイダーはすでに存在します",
"get_providers": "利用可能なプロバイダーの取得に失敗しました",
"not_found": "OCRプロバイダーが存在しません",
"update_failed": "更新構成に失敗しました"
},
@@ -3345,6 +3346,7 @@
"builtinServers": "組み込みサーバー",
"builtinServersDescriptions": {
"brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です",
"didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です",
"dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。",
"fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー",
"filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコルMCP。アクセスを許可するディレクトリの設定が必要です",
@@ -4412,6 +4414,7 @@
"later": "後で",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"warning": {

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": {
@@ -1812,6 +1812,7 @@
"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"
},
@@ -3345,6 +3346,7 @@
"builtinServers": "Servidores integrados",
"builtinServersDescriptions": {
"brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY",
"didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY",
"dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify",
"fetch": "servidor MCP para obter o conteúdo da página web do URL",
"filesystem": "Servidor Node.js do protocolo de contexto de modelo (MCP) para implementar operações de sistema de ficheiros. Requer configuração do diretório permitido para acesso",
@@ -4412,6 +4414,7 @@
"later": "Mais tarde",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"warning": {

View File

@@ -1583,13 +1583,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": {
@@ -1812,6 +1812,7 @@
"provider": {
"cannot_remove_builtin": "Не удается удалить встроенного поставщика",
"existing": "Поставщик уже существует",
"get_providers": "Не удалось получить доступных поставщиков",
"not_found": "Поставщик OCR отсутствует",
"update_failed": "Обновление конфигурации не удалось"
},
@@ -3345,6 +3346,7 @@
"builtinServers": "Встроенные серверы",
"builtinServersDescriptions": {
"brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY",
"didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY",
"dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify",
"fetch": "MCP-сервер для получения содержимого веб-страниц по URL",
"filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ",
@@ -4412,6 +4414,7 @@
"later": "Позже",
"message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"warning": {

View File

@@ -1,5 +1,6 @@
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'
@@ -35,7 +36,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, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
@@ -305,14 +306,36 @@ 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>{error.cause}</ErrorDetailValue>
<ErrorDetailValue>
<div
className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: highlightedString }}
/>
</ErrorDetailValue>
</ErrorDetailItem>
)}
</>

View File

@@ -337,30 +337,18 @@ const GroupContainer = styled.div`
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
width: 100%;
display: grid;
overflow-y: visible;
gap: 16px;
&.horizontal {
padding-bottom: 4px;
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
overflow-y: hidden;
overflow-x: auto;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
border-radius: var(--scrollbar-thumb-radius);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-thumb-hover);
}
}
&.fold,
&.vertical {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 8px;
overflow-y: auto;
overflow-x: hidden;
}
&.grid {
grid-template-columns: repeat(
@@ -368,15 +356,11 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
minmax(0, 1fr)
);
grid-template-rows: auto;
overflow-y: auto;
overflow-x: hidden;
}
&.multi-select-mode {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
overflow-y: auto;
overflow-x: hidden;
.grid {
height: auto;
}
@@ -402,7 +386,7 @@ interface MessageWrapperProps {
const MessageWrapper = styled.div<MessageWrapperProps>`
&.horizontal {
padding: 1px;
/* overflow-y: auto; */
overflow-y: auto;
.message {
height: 100%;
border: 0.5px solid var(--color-border);
@@ -424,7 +408,7 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
&.grid {
display: block;
height: 300px;
overflow: hidden;
overflow-y: hidden;
border: 0.5px solid var(--color-border);
border-radius: 10px;
cursor: pointer;

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ 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'
@@ -17,11 +18,21 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useState } from 'react'
import { FC, useCallback, useEffect, useRef, 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>
@@ -42,27 +53,166 @@ 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) {
setCanGoBack(webviewRef.current.canGoBack())
setCanGoForward(webviewRef.current.canGoForward())
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)
}
}, [webviewRef])
}, [app.id, webviewRef])
// Schedule navigation state update with debouncing
const scheduleNavigationUpdate = useCallback(
(delay: number) => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
navigationUpdateTimeoutRef.current = setTimeout(() => {
updateNavigationState()
navigationUpdateTimeoutRef.current = null
}, delay)
},
[updateNavigationState]
)
// Cleanup navigation timeout on unmount
useEffect(() => {
return () => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
}
}, [])
// Monitor webviewRef changes and update navigation state
useEffect(() => {
let checkTimeout: NodeJS.Timeout | null = null
let navigationListener: (() => void) | null = null
let listenersAttached = false
let currentInterval = WEBVIEW_CHECK_INITIAL_MS
let attemptCount = 0
const attachListeners = () => {
if (webviewRef.current && !listenersAttached) {
// Update state immediately
updateNavigationState()
// Add navigation event listeners
const handleNavigation = () => {
scheduleNavigationUpdate(NAVIGATION_UPDATE_DELAY_MS)
}
webviewRef.current.addEventListener('did-navigate', handleNavigation)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigation)
listenersAttached = true
navigationListener = () => {
if (webviewRef.current) {
webviewRef.current.removeEventListener('did-navigate', handleNavigation)
webviewRef.current.removeEventListener('did-navigate-in-page', handleNavigation)
}
listenersAttached = false
}
if (checkTimeout) {
clearTimeout(checkTimeout)
checkTimeout = null
}
logger.debug('Navigation listeners attached', { appId: app.id, attempts: attemptCount })
return true
}
return false
}
const scheduleCheck = () => {
checkTimeout = setTimeout(() => {
// Use requestAnimationFrame to avoid blocking the main thread
requestAnimationFrame(() => {
attemptCount++
if (!attachListeners()) {
// Stop checking after max attempts to prevent infinite loops
if (attemptCount >= WEBVIEW_CHECK_MAX_ATTEMPTS) {
logger.warn('WebView attachment timeout', {
appId: app.id,
attempts: attemptCount,
totalTimeMs: currentInterval * attemptCount
})
return
}
// Exponential backoff: double the interval up to the maximum
currentInterval = Math.min(currentInterval * WEBVIEW_CHECK_MULTIPLIER, WEBVIEW_CHECK_MAX_MS)
// Log only on first few attempts or when interval changes significantly
if (attemptCount <= 3 || attemptCount % 10 === 0) {
logger.debug('WebView not ready, scheduling next check', {
appId: app.id,
nextCheckMs: currentInterval,
attempt: attemptCount
})
}
scheduleCheck()
}
})
}, currentInterval)
}
// Check for webview attachment
if (!webviewRef.current) {
scheduleCheck()
} else {
attachListeners()
}
// Cleanup
return () => {
if (checkTimeout) clearTimeout(checkTimeout)
if (navigationListener) navigationListener()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app.id, updateNavigationState, scheduleNavigationUpdate]) // webviewRef excluded as it's a ref object
const handleGoBack = useCallback(() => {
if (webviewRef.current && webviewRef.current.canGoBack()) {
webviewRef.current.goBack()
updateNavigationState()
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' })
}
}
}, [webviewRef, updateNavigationState])
}, [app.id, webviewRef, scheduleNavigationUpdate])
const handleGoForward = useCallback(() => {
if (webviewRef.current && webviewRef.current.canGoForward()) {
webviewRef.current.goForward()
updateNavigationState()
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' })
}
}
}, [webviewRef, updateNavigationState])
}, [app.id, webviewRef, scheduleNavigationUpdate])
const handleMinimize = useCallback(() => {
navigate('/apps')

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import ActionIconButton from '@renderer/components/Buttons/ActionIconButton'
import CodeEditor from '@renderer/components/CodeEditor'
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
import { HSpaceBetweenStack } from '@renderer/components/Layout'
import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
@@ -20,11 +20,12 @@ 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 }) => {
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef, codeEditorRef }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const { settings } = useNotesSettings()
@@ -59,6 +60,7 @@ const NotesEditor: FC<NotesEditorProps> = memo(
{tmpViewMode === 'source' ? (
<SourceEditorWrapper isFullWidth={settings.isFullWidth} fontSize={settings.fontSize}>
<CodeEditor
ref={codeEditorRef}
value={currentContent}
language="markdown"
onChange={onMarkdownChange}

View File

@@ -1,9 +1,11 @@
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,
@@ -51,6 +53,7 @@ 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()
@@ -76,6 +79,7 @@ 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)
@@ -366,6 +370,32 @@ 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 () => {
@@ -385,21 +415,25 @@ const NotesPage: FC = () => {
}, [activeFilePath])
// 获取目标文件夹路径(选中文件夹或根目录)
const getTargetFolderPath = useCallback(() => {
if (selectedFolderId) {
const selectedNode = findNode(notesTree, selectedFolderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
const getTargetFolderPath = useCallback(
(targetFolderId?: string) => {
const folderId = targetFolderId || selectedFolderId
if (folderId) {
const selectedNode = findNode(notesTree, folderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
}
}
return notesPath // 默认返回根目录
}, [selectedFolderId, notesTree, notesPath])
return notesPath // 默认返回根目录
},
[selectedFolderId, notesTree, notesPath]
)
// 创建文件夹
const handleCreateFolder = useCallback(
async (name: string) => {
async (name: string, targetFolderId?: string) => {
try {
const targetPath = getTargetFolderPath()
const targetPath = getTargetFolderPath(targetFolderId)
if (!targetPath) {
throw new Error('No folder path selected')
}
@@ -415,11 +449,11 @@ const NotesPage: FC = () => {
// 创建笔记
const handleCreateNote = useCallback(
async (name: string) => {
async (name: string, targetFolderId?: string) => {
try {
isCreatingNoteRef.current = true
const targetPath = getTargetFolderPath()
const targetPath = getTargetFolderPath(targetFolderId)
if (!targetPath) {
throw new Error('No folder path selected')
}
@@ -751,6 +785,53 @@ 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>
@@ -796,6 +877,7 @@ const NotesPage: FC = () => {
tokenCount={tokenCount}
onMarkdownChange={handleMarkdownChange}
editorRef={editorRef}
codeEditorRef={codeEditorRef}
/>
</EditorWrapper>
</ContentContainer>

View File

@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import HighlightText from '@renderer/components/HighlightText'
import { DeleteIcon } from '@renderer/components/Icons'
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
import Scrollbar from '@renderer/components/Scrollbar'
@@ -7,6 +8,8 @@ import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
import { fetchNoteSummary } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService'
import { RootState, useAppSelector } from '@renderer/store'
import { selectSortType } from '@renderer/store/note'
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
@@ -23,19 +26,23 @@ import {
FileSearch,
Folder,
FolderOpen,
Loader2,
Sparkles,
Star,
StarOff,
UploadIcon
UploadIcon,
X
} from 'lucide-react'
import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import { useFullTextSearch } from './hooks/useFullTextSearch'
interface NotesSidebarProps {
onCreateFolder: (name: string, parentId?: string) => void
onCreateNote: (name: string, parentId?: string) => void
onCreateFolder: (name: string, targetFolderId?: string) => void
onCreateNote: (name: string, targetFolderId?: string) => void
onSelectNode: (node: NotesTreeNode) => void
onDeleteNode: (nodeId: string) => void
onRenameNode: (nodeId: string, newName: string) => void
@@ -51,7 +58,7 @@ interface NotesSidebarProps {
const logger = loggerService.withContext('NotesSidebar')
interface TreeNodeProps {
node: NotesTreeNode
node: NotesTreeNode | SearchResult
depth: number
selectedFolderId?: string | null
activeNodeId?: string
@@ -71,6 +78,10 @@ interface TreeNodeProps {
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
onDragEnd: () => void
renderChildren?: boolean // 控制是否渲染子节点
searchKeyword?: string // 搜索关键词,用于高亮
showMatches?: boolean // 是否显示匹配预览
openDropdownKey: string | null
onDropdownOpenChange: (key: string | null) => void
}
const TreeNode = memo<TreeNodeProps>(
@@ -94,9 +105,31 @@ const TreeNode = memo<TreeNodeProps>(
onDragLeave,
onDrop,
onDragEnd,
renderChildren = true
renderChildren = true,
searchKeyword = '',
showMatches = false,
openDropdownKey,
onDropdownOpenChange
}) => {
const { t } = useTranslation()
const [showAllMatches, setShowAllMatches] = useState(false)
// 检查是否是搜索结果
const searchResult = 'matchType' in node ? (node as SearchResult) : null
const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0
// 处理匹配项点击
const handleMatchClick = useCallback(
(match: SearchMatch) => {
// 发送定位事件
EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, {
noteId: node.id,
lineNumber: match.lineNumber,
lineContent: match.lineContent
})
},
[node]
)
const isActive = selectedFolderId
? node.type === 'folder' && node.id === selectedFolderId
@@ -117,10 +150,45 @@ const TreeNode = memo<TreeNodeProps>(
return ''
}
const displayName = useMemo(() => {
if (!searchKeyword) {
return node.name
}
const name = node.name ?? ''
if (!name) {
return name
}
const keyword = searchKeyword
const nameLower = name.toLowerCase()
const keywordLower = keyword.toLowerCase()
const matchStart = nameLower.indexOf(keywordLower)
if (matchStart === -1) {
return name
}
const matchEnd = matchStart + keyword.length
const beforeMatch = Math.min(2, matchStart)
const contextStart = matchStart - beforeMatch
const contextLength = 50
const contextEnd = Math.min(name.length, matchEnd + contextLength)
const prefix = contextStart > 0 ? '...' : ''
const suffix = contextEnd < name.length ? '...' : ''
return prefix + name.substring(contextStart, contextEnd) + suffix
}, [node.name, searchKeyword])
return (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
<div>
<Dropdown
menu={{ items: getMenuItems(node) }}
trigger={['contextMenu']}
open={openDropdownKey === node.id}
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
<div onContextMenu={(e) => e.stopPropagation()}>
<TreeNodeContainer
active={isActive}
depth={depth}
@@ -174,13 +242,55 @@ const TreeNode = memo<TreeNodeProps>(
size="small"
/>
) : (
<NodeName className={getNodeNameClassName()}>{node.name}</NodeName>
<NodeNameContainer>
<NodeName className={getNodeNameClassName()}>
{searchKeyword ? <HighlightText text={displayName} keyword={searchKeyword} /> : node.name}
</NodeName>
{searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && (
<MatchBadge matchType={searchResult.matchType}>
{searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')}
</MatchBadge>
)}
</NodeNameContainer>
)}
</TreeNodeContent>
</TreeNodeContainer>
</div>
</Dropdown>
{showMatches && hasMatches && (
<SearchMatchesContainer depth={depth}>
{(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => (
<MatchItem key={idx} onClick={() => handleMatchClick(match)}>
<MatchLineNumber>{match.lineNumber}</MatchLineNumber>
<MatchContext>
<HighlightText text={match.context} keyword={searchKeyword} />
</MatchContext>
</MatchItem>
))}
{searchResult!.matches!.length > 3 && (
<MoreMatches
depth={depth}
onClick={(e) => {
e.stopPropagation()
setShowAllMatches(!showAllMatches)
}}>
{showAllMatches ? (
<>
<ChevronDown size={12} style={{ marginRight: 4 }} />
{t('notes.search.show_less')}
</>
) : (
<>
<ChevronRight size={12} style={{ marginRight: 4 }} />+{searchResult!.matches!.length - 3}{' '}
{t('notes.search.more_matches')}
</>
)}
</MoreMatches>
)}
</SearchMatchesContainer>
)}
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
<div>
{node.children!.map((child) => (
@@ -206,6 +316,8 @@ const TreeNode = memo<TreeNodeProps>(
onDrop={onDrop}
onDragEnd={onDragEnd}
renderChildren={renderChildren}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={onDropdownOpenChange}
/>
))}
</div>
@@ -244,8 +356,34 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const [isShowSearch, setIsShowSearch] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const scrollbarRef = useRef<any>(null)
const notesTreeRef = useRef<NotesTreeNode[]>(notesTree)
const trimmedSearchKeyword = useMemo(() => searchKeyword.trim(), [searchKeyword])
const hasSearchKeyword = trimmedSearchKeyword.length > 0
// 全文搜索配置
const searchOptions = useMemo(
() => ({
debounceMs: 300,
maxResults: 100,
contextLength: 50,
caseSensitive: false,
maxFileSize: 10 * 1024 * 1024, // 10MB
enabled: isShowSearch
}),
[isShowSearch]
)
const {
search,
cancel,
reset,
isSearching,
results: searchResults,
stats: searchStats
} = useFullTextSearch(searchOptions)
const inPlaceEdit = useInPlaceEdit({
onSave: (newName: string) => {
@@ -486,6 +624,25 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
setIsShowSearch(!isShowSearch)
}, [isShowSearch])
// 同步 notesTree 到 ref
useEffect(() => {
notesTreeRef.current = notesTree
}, [notesTree])
// 触发全文搜索
useEffect(() => {
if (!isShowSearch) {
reset()
return
}
if (hasSearchKeyword) {
search(notesTreeRef.current, trimmedSearchKeyword)
} else {
reset()
}
}, [isShowSearch, hasSearchKeyword, trimmedSearchKeyword, search, reset])
// Flatten tree nodes for virtualization and filtering
const flattenedNodes = useMemo(() => {
const flattenForVirtualization = (
@@ -509,11 +666,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
let result: NotesTreeNode[] = []
for (const node of nodes) {
if (isShowSearch && searchKeyword) {
if (node.type === 'file' && node.name.toLowerCase().includes(searchKeyword.toLowerCase())) {
result.push(node)
}
} else if (isShowStarred) {
if (isShowStarred) {
if (node.type === 'file' && node.isStarred) {
result.push(node)
}
@@ -525,7 +678,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
return result
}
if (isShowStarred || isShowSearch) {
if (isShowSearch) {
if (hasSearchKeyword) {
return searchResults.map((result) => ({ node: result, depth: 0 }))
}
return [] // 搜索关键词为空
}
if (isShowStarred) {
// For filtered views, return flat list without virtualization for simplicity
const filteredNodes = flattenForFiltering(notesTree)
return filteredNodes.map((node) => ({ node, depth: 0 }))
@@ -533,7 +693,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
// For normal tree view, use hierarchical flattening for virtualization
return flattenForVirtualization(notesTree)
}, [notesTree, isShowStarred, isShowSearch, searchKeyword])
}, [notesTree, isShowStarred, isShowSearch, hasSearchKeyword, searchResults])
// Use virtualization only for normal tree view with many items
const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100
@@ -571,6 +731,28 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
})
}
if (node.type === 'folder') {
baseMenuItems.push(
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: () => {
onCreateNote(t('notes.untitled_note'), node.id)
}
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: () => {
onCreateFolder(t('notes.untitled_folder'), node.id)
}
},
{ type: 'divider' }
)
}
baseMenuItems.push(
{
label: t('notes.rename'),
@@ -674,7 +856,9 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
handleDeleteNode,
renamingNodeIds,
handleAutoRename,
exportMenuOptions
exportMenuOptions,
onCreateNote,
onCreateFolder
]
)
@@ -755,6 +939,23 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
fileInput.click()
}, [onUploadFiles])
const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => {
return [
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: handleCreateNote
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: handleCreateFolder
}
]
}, [t, handleCreateNote, handleCreateFolder])
return (
<SidebarContainer
onDragOver={(e) => {
@@ -783,32 +984,111 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
/>
<NotesTreeContainer>
{isShowSearch && isSearching && (
<SearchStatusBar>
<Loader2 size={14} className="animate-spin" />
<span>{t('notes.search.searching')}</span>
<CancelButton onClick={cancel} title={t('common.cancel')}>
<X size={14} />
</CancelButton>
</SearchStatusBar>
)}
{isShowSearch && !isSearching && hasSearchKeyword && searchStats.total > 0 && (
<SearchStatusBar>
<span>
{t('notes.search.found_results', {
count: searchStats.total,
nameCount: searchStats.fileNameMatches,
contentCount: searchStats.contentMatches + searchStats.bothMatches
})}
</span>
</SearchStatusBar>
)}
{shouldUseVirtualization ? (
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<TreeNode
node={node}
depth={depth}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
</div>
</div>
)
})}
</div>
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</VirtualizedTreeContainer>
</Dropdown>
) : (
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={depth}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
@@ -826,92 +1106,53 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
searchKeyword={isShowSearch ? trimmedSearchKeyword : ''}
showMatches={isShowSearch}
/>
</div>
</div>
)
})}
</div>
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</VirtualizedTreeContainer>
) : (
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</TreeContent>
</StyledScrollbar>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</TreeContent>
</StyledScrollbar>
</Dropdown>
)}
</NotesTreeContainer>
@@ -1151,4 +1392,148 @@ const DropHintText = styled.div`
font-style: italic;
`
// 搜索相关样式
const SearchStatusBar = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--color-background-soft);
border-bottom: 0.5px solid var(--color-border);
font-size: 12px;
color: var(--color-text-2);
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`
const CancelButton = styled.button`
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background-color: transparent;
color: var(--color-text-3);
cursor: pointer;
border-radius: 3px;
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-mute);
color: var(--color-text);
}
&:active {
background-color: var(--color-active);
}
`
const NodeNameContainer = styled.div`
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
`
const MatchBadge = styled.span<{ matchType: string }>`
display: inline-flex;
align-items: center;
padding: 0 4px;
height: 16px;
font-size: 10px;
line-height: 1;
border-radius: 2px;
background-color: ${(props) =>
props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'};
color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')};
font-weight: 500;
flex-shrink: 0;
`
const SearchMatchesContainer = styled.div<{ depth: number }>`
margin-left: ${(props) => props.depth * 16 + 40}px;
margin-top: 4px;
margin-bottom: 8px;
padding: 6px 8px;
background-color: var(--color-background-mute);
border-radius: 4px;
border-left: 2px solid var(--color-primary-soft);
`
const MatchItem = styled.div`
display: flex;
gap: 8px;
margin-bottom: 4px;
font-size: 12px;
padding: 4px 6px;
margin-left: -6px;
margin-right: -6px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: var(--color-background-soft);
transform: translateX(2px);
}
&:active {
background-color: var(--color-active);
}
&:last-child {
margin-bottom: 0;
}
`
const MatchLineNumber = styled.span`
color: var(--color-text-3);
font-family: monospace;
flex-shrink: 0;
width: 30px;
`
const MatchContext = styled.div`
color: var(--color-text-2);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
`
const MoreMatches = styled.div<{ depth: number }>`
margin-top: 4px;
padding: 4px 6px;
margin-left: -6px;
margin-right: -6px;
font-size: 11px;
color: var(--color-text-3);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.15s ease;
&:hover {
color: var(--color-text-2);
background-color: var(--color-background-soft);
}
`
export default memo(NotesSidebar)

View File

@@ -0,0 +1,159 @@
import { searchAllFiles, SearchOptions, SearchResult } from '@renderer/services/NotesSearchService'
import { NotesTreeNode } from '@renderer/types/note'
import { useCallback, useEffect, useRef, useState } from 'react'
export interface UseFullTextSearchOptions extends SearchOptions {
debounceMs?: number
maxResults?: number
enabled?: boolean
}
export interface UseFullTextSearchReturn {
search: (nodes: NotesTreeNode[], keyword: string) => void
cancel: () => void
reset: () => void
isSearching: boolean
results: SearchResult[]
stats: {
total: number
fileNameMatches: number
contentMatches: number
bothMatches: number
}
error: Error | null
}
/**
* Full-text search hook for notes
*/
export function useFullTextSearch(options: UseFullTextSearchOptions = {}): UseFullTextSearchReturn {
const { debounceMs = 300, maxResults = 100, enabled = true, ...searchOptions } = options
const [isSearching, setIsSearching] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
const [error, setError] = useState<Error | null>(null)
const [stats, setStats] = useState({
total: 0,
fileNameMatches: 0,
contentMatches: 0,
bothMatches: 0
})
const abortControllerRef = useRef<AbortController | null>(null)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
// Store options in refs to avoid reference changes
const searchOptionsRef = useRef(searchOptions)
const maxResultsRef = useRef(maxResults)
const enabledRef = useRef(enabled)
useEffect(() => {
searchOptionsRef.current = searchOptions
maxResultsRef.current = maxResults
enabledRef.current = enabled
}, [searchOptions, maxResults, enabled])
const cancel = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
}
setIsSearching(false)
}, [])
const reset = useCallback(() => {
cancel()
setResults([])
setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 })
setError(null)
}, [cancel])
const performSearch = useCallback(
async (nodes: NotesTreeNode[], keyword: string) => {
if (!enabledRef.current) {
return
}
cancel()
if (!keyword) {
setResults([])
setStats({ total: 0, fileNameMatches: 0, contentMatches: 0, bothMatches: 0 })
return
}
setIsSearching(true)
setError(null)
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
const searchResults = await searchAllFiles(
nodes,
keyword.trim(),
searchOptionsRef.current,
abortController.signal
)
if (abortController.signal.aborted) {
return
}
const limitedResults = searchResults.slice(0, maxResultsRef.current)
const newStats = {
total: limitedResults.length,
fileNameMatches: limitedResults.filter((r) => r.matchType === 'filename').length,
contentMatches: limitedResults.filter((r) => r.matchType === 'content').length,
bothMatches: limitedResults.filter((r) => r.matchType === 'both').length
}
setResults(limitedResults)
setStats(newStats)
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err)
}
} finally {
if (!abortController.signal.aborted) {
setIsSearching(false)
}
}
},
[cancel]
)
const search = useCallback(
(nodes: NotesTreeNode[], keyword: string) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
debounceTimerRef.current = setTimeout(() => {
performSearch(nodes, keyword)
}, debounceMs)
},
[performSearch, debounceMs]
)
useEffect(() => {
return () => {
cancel()
}
}, [cancel])
return {
search,
cancel,
reset,
isSearching,
results,
stats,
error
}
}

View File

@@ -14,7 +14,6 @@ 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'
@@ -35,6 +34,7 @@ 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,20 +76,6 @@ 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()
@@ -849,17 +835,12 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
/>
</SettingHelpLink>
</ProviderTitleContainer>
<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>
<ProviderSelect
provider={aihubmixProvider}
options={Options}
onChange={handleProviderChange}
className={'mb-4'}
/>
{/* 使用JSON配置渲染设置项 */}
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
@@ -1034,12 +1015,6 @@ const ModeSegmentedContainer = styled.div`
padding-top: 24px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件
const ProviderTitleContainer = styled.div`
display: flex;

View File

@@ -8,7 +8,6 @@ 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'
@@ -29,6 +28,7 @@ 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,20 +46,6 @@ 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')!
@@ -785,9 +771,9 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
<NavbarCenter className="border-r-0">{t('paintings.title')}</NavbarCenter>
{isMac && (
<NavbarRight style={{ justifyContent: 'flex-end' }}>
<NavbarRight className="justify-end">
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={createNewPainting}>
{t('paintings.button.new.image')}
</Button>
@@ -797,7 +783,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingTitle className="mb-1">{t('common.provider')}</SettingTitle>
<div>
<SettingHelpLink target="_blank" href={COURSE_URL}>
{t('paintings.paint_course')}
@@ -805,28 +791,19 @@ 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}
style={{ marginLeft: 5 }}
/>
<ProviderLogo shape="square" src={getProviderLogo(dmxapiProvider.id)} size={16} className="ml-1" />
</div>
</ProviderTitleContainer>
<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>
<ProviderSelect
provider={dmxapiProvider}
options={Options}
onChange={handleProviderChange}
className="mb-4"
/>
{painting.generationMode &&
[generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && (
<>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}></SettingTitle>
<SettingTitle className="mt-4 mb-1"></SettingTitle>
<ImageUploader
fileMap={fileMap}
maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3}
@@ -838,13 +815,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</>
)}
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
<SettingTitle className="mt-4 mb-1">
{t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice>
</SettingTitle>
<Select
value={painting.model}
onChange={onSelectModel}
style={{ width: '100%' }}
className="w-full"
loading={isLoadingModels}
placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}>
{Object.entries(modelOptions).map(([provider, models]) => {
@@ -861,11 +838,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
})}
</Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
<SettingTitle className="mt-4 mb-1">{t('paintings.image.size')}</SettingTitle>
<Select
value={isCustomSize ? 'custom' : painting.image_size}
onChange={(value) => onSelectImageSize(value)}
style={{ width: '100%' }}>
className="w-full">
{(() => {
const currentModel = allModels.find((m) => m.id === painting.model)
const modelImageSizes = currentModel?.image_sizes || []
@@ -874,7 +851,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return modelImageSizes.map((size) => {
return (
<Select.Option key={size.value} value={size.value}>
<HStack style={{ alignItems: 'center', gap: 8 }}>
<HStack className="items-center gap-2">
<span>{size.label}</span>
</HStack>
</Select.Option>
@@ -884,7 +861,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{/* 检查当前模型是否支持自定义尺寸 */}
{allModels.find((m) => m.id === painting.model)?.is_custom_size && (
<Select.Option value="custom" key="custom">
<HStack style={{ alignItems: 'center', gap: 8 }}>
<HStack className="items-center gap-2">
<span>{t('paintings.custom_size')}</span>
</HStack>
</Select.Option>
@@ -893,7 +870,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{/* 自定义尺寸输入框 */}
{isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && (
<div style={{ marginTop: 10 }}>
<div className="mt-2.5">
<HStack style={{ gap: 8, alignItems: 'center' }}>
<InputNumber
placeholder="W"
@@ -921,7 +898,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{painting.generationMode === generationModeType.GENERATION && (
<>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
<SettingTitle className="mt-4 mb-1">
{t('paintings.seed')}
<Tooltip title={t('paintings.seed_desc_tip')}>
<InfoIcon />
@@ -941,7 +918,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</>
)}
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.style_type')}</SettingTitle>
<SettingTitle className="mt-4 mb-1">{t('paintings.style_type')}</SettingTitle>
<SliderContainer>
<RadioTextBox>
{STYLE_TYPE_OPTIONS.map((ele) => (
@@ -955,7 +932,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</RadioTextBox>
</SliderContainer>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
<SettingTitle className="mt-4 mb-1">
{t('paintings.auto_create_paint')}
<Tooltip title={t('paintings.auto_create_paint_tip')}>
<InfoIcon />
@@ -1032,11 +1009,6 @@ 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;

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 } from '@renderer/config/providers'
import { getProviderLogo, isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
@@ -17,8 +17,7 @@ import {
getPaintingsBackgroundOptionsLabel,
getPaintingsImageSizeOptionsLabel,
getPaintingsModerationOptionsLabel,
getPaintingsQualityOptionsLabel,
getProviderLabel
getPaintingsQualityOptionsLabel
} from '@renderer/i18n/label'
import PaintingsList from '@renderer/pages/paintings/components/PaintingsList'
import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig'
@@ -40,6 +39,7 @@ import styled from 'styled-components'
import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils'
const logger = loggerService.withContext('NewApiPage')
@@ -55,8 +55,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}
}, [openai_image_generate, openai_image_edit])
const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode])
const [painting, setPainting] = useState<PaintingAction>(filteredPaintings[0] || DEFAULT_PAINTING)
// moved below after newApiProvider is defined
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
@@ -67,27 +66,22 @@ const NewApiPage: 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 location = useLocation()
const routeName = location.pathname.split('/').pop() || 'new-api'
const newApiProviders = providers.filter((p) => isNewApiProvider(p))
const dispatch = useAppDispatch()
const { generating } = useRuntime()
const navigate = useNavigate()
const location = useLocation()
const { autoTranslateWithSpace } = useSettings()
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const newApiProvider = providers.find((p) => p.id === 'new-api')!
const newApiProvider = newApiProviders.find((p) => p.id === routeName) || newApiProviders[0]
const filteredPaintings = useMemo(
() => (newApiPaintings[mode] || []).filter((p) => p.providerId === newApiProvider.id),
[newApiPaintings, mode, newApiProvider.id]
)
const [painting, setPainting] = useState<PaintingAction>({ ...DEFAULT_PAINTING, providerId: newApiProvider.id })
const modeOptions = [
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' },
@@ -102,7 +96,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}, [editImageFiles])
const updatePaintingState = (updates: Partial<PaintingAction>) => {
const updatedPainting = { ...painting, ...updates }
const updatedPainting = { ...painting, providerId: newApiProvider.id, ...updates }
setPainting(updatedPainting)
updatePainting(mode, updatedPainting)
}
@@ -138,9 +132,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
return {
...DEFAULT_PAINTING,
model: painting.model || modelOptions[0]?.value || '',
id: uuid()
id: uuid(),
providerId: newApiProvider.id
}
}, [modelOptions, painting.model])
}, [modelOptions, painting.model, newApiProvider.id])
const selectedModelConfig = useMemo(
() => MODELS.find((m) => m.name === painting.model) || MODELS[0],
@@ -444,11 +439,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
// 处理模式切换
const handleModeChange = (value: string) => {
setMode(value as keyof PaintingsState)
if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) {
setPainting(newApiPaintings[value as keyof PaintingsState][0])
} else {
setPainting(DEFAULT_PAINTING)
}
const list = (newApiPaintings[value as keyof PaintingsState] || []).filter(
(p) => p.providerId === newApiProvider.id
)
setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: newApiProvider.id })
}
// 渲染配置项的函数
@@ -473,8 +467,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const newPainting = getNewPainting()
addPainting(mode, newPainting)
setPainting(newPainting)
} else {
setPainting(filteredPaintings[0])
}
}, [filteredPaintings, mode, addPainting, painting, getNewPainting])
}, [filteredPaintings, mode, addPainting, getNewPainting])
useEffect(() => {
const timer = spaceClickTimer.current
@@ -501,7 +497,9 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
<LeftContainer>
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink target="_blank" href={'https://docs.newapi.pro/apps/cherry-studio/'}>
<SettingHelpLink
target="_blank"
href={PROVIDER_URLS[newApiProvider.id]?.websites?.docs || 'https://docs.newapi.pro/apps/cherry-studio/'}>
{t('paintings.learn_more')}
<ProviderLogo
shape="square"
@@ -512,19 +510,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink>
</ProviderTitleContainer>
<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>
<ProviderSelect provider={newApiProvider} options={Options} onChange={handleProviderChange} />
{/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */}
{modelOptions.length === 0 && (
@@ -792,20 +778,12 @@ 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;

View File

@@ -1,8 +1,10 @@
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 } from '@renderer/types'
import { FC, useEffect } from 'react'
import { PaintingProvider, SystemProviderId } from '@renderer/types'
import { FC, useEffect, useMemo } from 'react'
import { Route, Routes, useParams } from 'react-router-dom'
import AihubmixPage from './AihubmixPage'
@@ -14,19 +16,23 @@ import ZhipuPage from './ZhipuPage'
const logger = loggerService.withContext('PaintingsRoutePage')
const Options = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux', 'new-api']
const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux']
const PaintingsRoutePage: FC = () => {
const 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])
}, [provider, dispatch, Options])
return (
<Routes>
@@ -36,7 +42,12 @@ const PaintingsRoutePage: FC = () => {
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
<Route path="/new-api" element={<NewApiPage Options={Options} />} />
{/* new-api family providers are mounted dynamically below */}
{providers
.filter((p) => isNewApiProvider(p))
.map((p) => (
<Route key={p.id} path={`/${p.id}`} element={<NewApiPage Options={Options} />} />
))}
</Routes>
)
}

View File

@@ -18,7 +18,6 @@ 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'
@@ -39,6 +38,7 @@ 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,22 +114,8 @@ 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)
@@ -169,7 +155,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({
@@ -388,8 +374,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<Select value={providerOptions[2].value} onChange={handleProviderChange} options={providerOptions} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<ProviderSelect provider={siliconFlowProvider} options={Options} onChange={handleProviderChange} />
<SettingTitle className="mt-4 mb-1">{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

View File

@@ -10,7 +10,6 @@ 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'
@@ -31,6 +30,7 @@ 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,21 +55,6 @@ 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()
@@ -387,19 +372,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink>
</ProviderTitleContainer>
<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>
<ProviderSelect provider={tokenfluxProvider} options={Options} onChange={handleProviderChange} />
{/* Model & Pricing Section */}
<SectionTitle
@@ -775,11 +748,4 @@ const ProviderTitleContainer = styled.div`
align-items: center;
margin-bottom: 5px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
export default TokenFluxPage

View File

@@ -8,7 +8,6 @@ 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'
@@ -24,6 +23,7 @@ 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,21 +50,6 @@ 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)
@@ -370,16 +355,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
/>
</div>
</ProviderTitleContainer>
<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>
<ProviderSelect provider={zhipuProvider} options={Options} onChange={handleProviderChange} className="mb-4" />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<Select
@@ -563,12 +539,6 @@ 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;
`

View File

@@ -0,0 +1,99 @@
import { Select, SelectItem } from '@heroui/react'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import { getProviderLogo } from '@renderer/config/providers'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderNameById } from '@renderer/services/ProviderService'
import { Provider } from '@types'
import React, { FC, useEffect, useState } from 'react'
type ProviderSelectProps = {
provider: Provider
options: string[]
onChange: (value: string) => void
style?: React.CSSProperties
className?: string
}
const ProviderSelect: FC<ProviderSelectProps> = ({ provider, options, onChange, style, className }) => {
const [customLogos, setCustomLogos] = useState<Record<string, string>>({})
useEffect(() => {
const loadLogos = async () => {
const logos: Record<string, string> = {}
for (const providerId of options) {
try {
const logoData = await ImageStorage.get(`provider-${providerId}`)
if (logoData) {
logos[providerId] = logoData
}
} catch (error) {
// Ignore errors for providers without custom logos
}
}
setCustomLogos(logos)
}
loadLogos()
}, [options])
const getProviderLogoSrc = (providerId: string) => {
const systemLogo = getProviderLogo(providerId)
if (systemLogo) {
return systemLogo
}
return customLogos[providerId]
}
const providerOptions = options.map((option) => {
return {
label: getProviderNameById(option),
value: option
}
})
return (
<Select
selectedKeys={[provider.id]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string
onChange(selectedKey)
}}
style={style}
className={`w-full ${className || ''}`}
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={item.key as string}
providerName={item.textValue || ''}
logoSrc={getProviderLogoSrc(item.key as string)}
size={16}
/>
</div>
<span>{item.textValue}</span>
</div>
))
}}>
{providerOptions.map((providerOption) => (
<SelectItem
key={providerOption.value}
textValue={providerOption.label}
startContent={
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={providerOption.value}
providerName={providerOption.label}
logoSrc={getProviderLogoSrc(providerOption.value)}
size={16}
/>
</div>
}>
{providerOption.label}
</SelectItem>
))}
</Select>
)
}
export default ProviderSelect

View File

@@ -1,18 +1,21 @@
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 { handleSaveData, useAppDispatch } from '@renderer/store'
import { 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'
@@ -27,6 +30,8 @@ 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()
@@ -41,8 +46,9 @@ const AboutSettings: FC = () => {
}
if (update.downloaded) {
await handleSaveData()
window.api.showUpdateDialog()
// Open update dialog directly in renderer
setUpdateDialogInfo(update.info || null)
onOpen()
return
}
@@ -341,6 +347,9 @@ const AboutSettings: FC = () => {
<Button onClick={debug}>{t('settings.about.debug.open')}</Button>
</SettingRow>
</SettingGroup>
{/* Update Dialog */}
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
</SettingContainer>
)
}

View File

@@ -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 ?? true)
const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? false)
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(true)
setEnableTopP(false)
setCustomParameters([])
setToolUseMode('prompt')
updateAssistantSettings({
@@ -175,7 +175,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
maxTokens: 0,
streamOutput: true,
topP: 1,
enableTopP: true,
enableTopP: false,
customParameters: [],
toolUseMode: 'prompt'
})

View File

@@ -1,11 +1,14 @@
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 { useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
import { SettingRow, SettingRowTitle } from '..'
@@ -18,10 +21,16 @@ 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])
@@ -40,12 +49,17 @@ const OcrImageSettings = ({ setProvider }: Props) => {
const platformSupport = isMac || isWin
const options = useMemo(() => {
if (!validProviders) return []
const platformFilter = platformSupport ? () => true : (p: ImageOcrProvider) => p.id !== BuiltinOcrProviderIds.system
return imageProviders.filter(platformFilter).map((p) => ({
value: p.id,
label: getOcrProviderName(p)
}))
}, [getOcrProviderName, imageProviders, platformSupport])
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])
const isSystem = imageProvider.id === BuiltinOcrProviderIds.system
@@ -55,12 +69,23 @@ 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')} />}
<Select
value={imageProvider.id}
style={{ width: '200px' }}
onChange={(id: string) => setImageProvider(id)}
options={options}
/>
<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>
</div>
</SettingRow>
</>

View File

@@ -0,0 +1,32 @@
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
import { BuiltinOcrProviderIds, isOcrOVProvider } from '@renderer/types'
import { Flex, Tag } from 'antd'
import { useTranslation } from 'react-i18next'
import { SettingRow, SettingRowTitle } from '..'
export const OcrOVSettings = () => {
const { t } = useTranslation()
const { provider } = useOcrProvider(BuiltinOcrProviderIds.ovocr)
if (!isOcrOVProvider(provider)) {
throw new Error('Not OV OCR provider.')
}
return (
<>
<SettingRow>
<SettingRowTitle>
<Flex align="center" gap={4}>
{t('settings.tool.ocr.common.langs')}
</Flex>
</SettingRowTitle>
<div style={{ display: 'flex', gap: '8px' }}>
<Tag>🇬🇧 {t('languages.english')}</Tag>
<Tag>🇨🇳 {t('languages.chinese')}</Tag>
<Tag>🇭🇰 {t('languages.chinese-traditional')}</Tag>
</div>
</SettingRow>
</>
)
}

View File

@@ -8,6 +8,7 @@ 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'
@@ -35,6 +36,8 @@ const OcrProviderSettings = ({ provider }: Props) => {
return <OcrSystemSettings />
case 'paddleocr':
return <OcrPpocrSettings />
case 'ovocr':
return <OcrOVSettings />
default:
return null
}

View File

@@ -147,7 +147,8 @@ const GeneralSettings: FC = () => {
{ value: 'pt', label: 'Português', flag: '🇵🇹' },
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
{ value: 'pl', label: 'Polski', flag: '🇵🇱' }
{ value: 'pl', label: 'Polski', flag: '🇵🇱' },
{ value: 'el', label: 'Ελληνικά', flag: '🇬🇷' }
]
const handleSpellCheckLanguagesChange = (selectedLanguages: string[]) => {

View File

@@ -24,7 +24,7 @@ const AssistantSettings: FC = () => {
const [enableMaxTokens, setEnableMaxTokens] = useState(defaultAssistant?.settings?.enableMaxTokens ?? false)
const [maxTokens, setMaxTokens] = useState(defaultAssistant?.settings?.maxTokens ?? 0)
const [topP, setTopP] = useState(defaultAssistant.settings?.topP ?? 1)
const [enableTopP, setEnableTopP] = useState(defaultAssistant.settings?.enableTopP ?? true)
const [enableTopP, setEnableTopP] = useState(defaultAssistant.settings?.enableTopP ?? false)
const [emoji, setEmoji] = useState(defaultAssistant.emoji || getLeadingEmoji(defaultAssistant.name) || '')
const [name, setName] = useState(
defaultAssistant.name.replace(getLeadingEmoji(defaultAssistant.name) || '', '').trim()
@@ -71,7 +71,7 @@ const AssistantSettings: FC = () => {
setEnableMaxTokens(false)
setMaxTokens(0)
setTopP(1)
setEnableTopP(true)
setEnableTopP(false)
updateDefaultAssistant({
...defaultAssistant,
settings: {
@@ -83,7 +83,7 @@ const AssistantSettings: FC = () => {
maxTokens: DEFAULT_MAX_TOKENS,
streamOutput: true,
topP: 1,
enableTopP: true
enableTopP: false
}
})
}

View File

@@ -252,7 +252,9 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
{ label: 'OpenAI-Response', value: 'openai-response' },
{ label: 'Gemini', value: 'gemini' },
{ label: 'Anthropic', value: 'anthropic' },
{ label: 'Azure OpenAI', value: 'azure-openai' }
{ label: 'Azure OpenAI', value: 'azure-openai' },
{ label: 'New API', value: 'new-api' },
{ label: 'CherryIN', value: 'new-api' }
]}
/>
</Form.Item>

View File

@@ -36,7 +36,9 @@ const OVMSSettings: FC = () => {
'102': t('ovms.failed.install_code_102'),
'103': t('ovms.failed.install_code_103'),
'104': t('ovms.failed.install_code_104'),
'105': t('ovms.failed.install_code_105')
'105': t('ovms.failed.install_code_105'),
'106': t('ovms.failed.install_code_106'),
'110': t('ovms.failed.install_code_110')
}
const match = error.message.match(/code (\d+)/)
const code = match ? match[1] : 'unknown'
@@ -135,7 +137,7 @@ const OVMSSettings: FC = () => {
type="primary"
onClick={runOvms}
loading={isRunningOvms}
disabled={isRunningOvms}
disabled={isRunningOvms || isInstallingOvms}
size="small">
{isRunningOvms ? t('ovms.action.starting') : t('ovms.action.run')}
</Button>

View File

@@ -134,15 +134,22 @@ const TranslatePage: FC = () => {
)
// 控制复制行为
const copy = useCallback(
async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
},
[setCopied]
)
const onCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(translatedContent)
setCopied(true)
await copy(translatedContent)
} catch (error) {
logger.error('Failed to copy text to clipboard:', error as Error)
window.toast.error(t('common.copy_failed'))
}
}, [setCopied, t, translatedContent])
}, [copy, t, translatedContent])
/**
* 翻译文本并保存历史记录,包含完整的异常处理,不会抛出异常
@@ -183,7 +190,7 @@ const TranslatePage: FC = () => {
setTimeoutTimer(
'auto-copy',
async () => {
await onCopy()
await copy(translated)
},
100
)
@@ -200,7 +207,7 @@ const TranslatePage: FC = () => {
window.toast.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e))
}
},
[autoCopy, dispatch, onCopy, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating]
[autoCopy, copy, dispatch, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating]
)
// 控制翻译按钮是否可用

View File

@@ -35,7 +35,7 @@ export const DEFAULT_ASSISTANT_SETTINGS: AssistantSettings = {
maxTokens: 0,
streamOutput: true,
topP: 1,
enableTopP: true,
enableTopP: false,
toolUseMode: 'prompt',
customParameters: []
}
@@ -169,7 +169,7 @@ export const getAssistantSettings = (assistant: Assistant): AssistantSettings =>
temperature: assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE,
enableTemperature: assistant?.settings?.enableTemperature ?? true,
topP: assistant?.settings?.topP ?? 1,
enableTopP: assistant?.settings?.enableTopP ?? true,
enableTopP: assistant?.settings?.enableTopP ?? false,
enableMaxTokens: assistant?.settings?.enableMaxTokens ?? false,
maxTokens: getAssistantMaxTokens(),
streamOutput: assistant?.settings?.streamOutput ?? true,

View File

@@ -24,6 +24,7 @@ export const EVENT_NAMES = {
COPY_TOPIC_IMAGE: 'COPY_TOPIC_IMAGE',
EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE',
LOCATE_MESSAGE: 'LOCATE_MESSAGE',
LOCATE_NOTE_LINE: 'LOCATE_NOTE_LINE',
ADD_NEW_TOPIC: 'ADD_NEW_TOPIC',
RESEND_MESSAGE: 'RESEND_MESSAGE',
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',

View File

@@ -0,0 +1,262 @@
import { loggerService } from '@logger'
import { NotesTreeNode } from '@renderer/types/note'
const logger = loggerService.withContext('NotesSearchService')
/**
* Search match result
*/
export interface SearchMatch {
lineNumber: number
lineContent: string
matchStart: number
matchEnd: number
context: string
}
/**
* Search result with match information
*/
export interface SearchResult extends NotesTreeNode {
matchType: 'filename' | 'content' | 'both'
matches?: SearchMatch[]
score: number
}
/**
* Search options
*/
export interface SearchOptions {
caseSensitive?: boolean
useRegex?: boolean
maxFileSize?: number
maxMatchesPerFile?: number
contextLength?: number
}
/**
* Escape regex special characters
*/
export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Calculate relevance score
* - Filename match has higher priority
* - More matches increase score
* - More recent updates increase score
*/
export function calculateRelevanceScore(node: NotesTreeNode, keyword: string, matches: SearchMatch[]): number {
let score = 0
// Exact filename match (highest weight)
if (node.name.toLowerCase() === keyword.toLowerCase()) {
score += 200
}
// Filename contains match (high weight)
else if (node.name.toLowerCase().includes(keyword.toLowerCase())) {
score += 100
}
// Content match count
score += Math.min(matches.length * 2, 50)
// Recent updates boost score
const daysSinceUpdate = (Date.now() - new Date(node.updatedAt).getTime()) / (1000 * 60 * 60 * 24)
score += Math.max(0, 10 - daysSinceUpdate)
return score
}
/**
* Search file content for keyword matches
*/
export async function searchFileContent(
node: NotesTreeNode,
keyword: string,
options: SearchOptions = {}
): Promise<SearchResult | null> {
const {
caseSensitive = false,
useRegex = false,
maxFileSize = 10 * 1024 * 1024, // 10MB
maxMatchesPerFile = 50,
contextLength = 50
} = options
try {
if (node.type !== 'file') {
return null
}
const content = await window.api.file.readExternal(node.externalPath)
if (!content) {
return null
}
if (content.length > maxFileSize) {
logger.warn(`File too large to search: ${node.externalPath} (${content.length} bytes)`)
return null
}
const flags = caseSensitive ? 'g' : 'gi'
const pattern = useRegex ? new RegExp(keyword, flags) : new RegExp(escapeRegex(keyword), flags)
const lines = content.split('\n')
const matches: SearchMatch[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
pattern.lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(line)) !== null) {
const matchStart = match.index
const matchEnd = matchStart + match[0].length
// Keep context short: only 2 chars before match, more after
const beforeMatch = Math.min(2, matchStart)
const contextStart = matchStart - beforeMatch
const contextEnd = Math.min(line.length, matchEnd + contextLength)
// Add ellipsis if context doesn't start at line beginning
const prefix = contextStart > 0 ? '...' : ''
const contextText = prefix + line.substring(contextStart, contextEnd)
matches.push({
lineNumber: i + 1,
lineContent: line,
matchStart: beforeMatch + prefix.length,
matchEnd: matchEnd - matchStart + beforeMatch + prefix.length,
context: contextText
})
if (matches.length >= maxMatchesPerFile) {
break
}
}
if (matches.length >= maxMatchesPerFile) {
break
}
}
if (matches.length === 0) {
return null
}
const score = calculateRelevanceScore(node, keyword, matches)
return {
...node,
matchType: 'content',
matches,
score
}
} catch (error) {
logger.error(`Failed to search file content for ${node.externalPath}:`, error as Error)
return null
}
}
/**
* Check if filename matches keyword
*/
export function matchFileName(node: NotesTreeNode, keyword: string, caseSensitive = false): boolean {
const name = caseSensitive ? node.name : node.name.toLowerCase()
const key = caseSensitive ? keyword : keyword.toLowerCase()
return name.includes(key)
}
/**
* Flatten tree to extract file nodes
*/
export function flattenTreeToFiles(nodes: NotesTreeNode[]): NotesTreeNode[] {
const result: NotesTreeNode[] = []
function traverse(nodes: NotesTreeNode[]) {
for (const node of nodes) {
if (node.type === 'file') {
result.push(node)
}
if (node.children && node.children.length > 0) {
traverse(node.children)
}
}
}
traverse(nodes)
return result
}
/**
* Search all files concurrently
*/
export async function searchAllFiles(
nodes: NotesTreeNode[],
keyword: string,
options: SearchOptions = {},
signal?: AbortSignal
): Promise<SearchResult[]> {
const startTime = performance.now()
const CONCURRENCY = 5
const results: SearchResult[] = []
const fileNodes = flattenTreeToFiles(nodes)
logger.debug(
`Starting full-text search: keyword="${keyword}", totalFiles=${fileNodes.length}, options=${JSON.stringify(options)}`
)
const queue = [...fileNodes]
const worker = async () => {
while (queue.length > 0) {
if (signal?.aborted) {
break
}
const node = queue.shift()
if (!node) break
const nameMatch = matchFileName(node, keyword, options.caseSensitive)
const contentResult = await searchFileContent(node, keyword, options)
if (nameMatch && contentResult) {
results.push({
...contentResult,
matchType: 'both',
score: contentResult.score + 100
})
} else if (nameMatch) {
results.push({
...node,
matchType: 'filename',
matches: [],
score: 100
})
} else if (contentResult) {
results.push(contentResult)
}
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, fileNodes.length) }, () => worker()))
const sortedResults = results.sort((a, b) => b.score - a.score)
const endTime = performance.now()
const duration = (endTime - startTime).toFixed(2)
logger.debug(
`Full-text search completed: keyword="${keyword}", duration=${duration}ms, ` +
`totalFiles=${fileNodes.length}, resultsFound=${sortedResults.length}, ` +
`filenameMatches=${sortedResults.filter((r) => r.matchType === 'filename').length}, ` +
`contentMatches=${sortedResults.filter((r) => r.matchType === 'content').length}, ` +
`bothMatches=${sortedResults.filter((r) => r.matchType === 'both').length}`
)
return sortedResults
}

View File

@@ -12,6 +12,15 @@ export function getProviderName(model?: Model) {
return getFancyProviderName(provider)
}
export function getProviderNameById(pid: string) {
const provider = getStoreProviders().find((p) => p.id === pid)
if (provider) {
return getFancyProviderName(provider)
} else {
return 'Unknown Provider'
}
}
export function getProviderByModel(model?: Model) {
const id = model?.provider
const provider = getStoreProviders().find((p) => p.id === id)

View File

@@ -15,14 +15,13 @@ import {
WebSearchProviderResult,
WebSearchStatus
} from '@renderer/types'
import { hasObjectKey, uuid } from '@renderer/utils'
import { hasObjectKey, removeSpecialCharactersForFileName, uuid } from '@renderer/utils'
import { addAbortController } from '@renderer/utils/abortController'
import { formatErrorMessage } from '@renderer/utils/error'
import { ExtractResults } from '@renderer/utils/extract'
import { fetchWebContents } from '@renderer/utils/fetch'
import { consolidateReferencesByUrl, selectReferences } from '@renderer/utils/websearch'
import dayjs from 'dayjs'
import { LRUCache } from 'lru-cache'
import { sliceByTokens } from 'tokenx'
import { getKnowledgeBaseParams } from './KnowledgeService'
@@ -32,7 +31,6 @@ const logger = loggerService.withContext('WebSearchService')
interface RequestState {
signal: AbortSignal | null
searchBase?: KnowledgeBase
isPaused: boolean
createdAt: number
}
@@ -49,16 +47,7 @@ class WebSearchService {
isPaused = false
// 管理不同请求的状态
private requestStates = new LRUCache<string, RequestState>({
max: 5, // 最多5个并发请求
ttl: 1000 * 60 * 2, // 2分钟过期
dispose: (requestState: RequestState, requestId: string) => {
if (!requestState.searchBase) return
window.api.knowledgeBase
.delete(getKnowledgeBaseParams(requestState.searchBase), requestState.searchBase.id)
.catch((error) => logger.warn(`Failed to cleanup search base for ${requestId}:`, error))
}
})
private requestStates = new Map<string, RequestState>()
/**
* 获取或创建单个请求的状态
@@ -209,32 +198,22 @@ class WebSearchService {
}
/**
* 确保搜索压缩知识库存在并配置正确
* 创建临时搜索知识库
*/
private async ensureSearchBase(
config: CompressionConfig,
documentCount: number,
requestId: string
): Promise<KnowledgeBase> {
// requestId: eg: openai-responses-openai/gpt-5-timestamp-uuid
const baseId = `websearch-compression-${requestId}`
const state = this.getRequestState(requestId)
// 如果已存在且配置未变,直接复用
if (state.searchBase && this.isConfigMatched(state.searchBase, config)) {
return state.searchBase
}
// 清理旧的知识库
if (state.searchBase) {
await window.api.knowledgeBase.delete(getKnowledgeBaseParams(state.searchBase), state.searchBase.id)
}
if (!config.embeddingModel) {
throw new Error('Embedding model is required for RAG compression')
}
// 创建新的知识库
state.searchBase = {
const searchBase: KnowledgeBase = {
id: baseId,
name: `WebSearch-RAG-${requestId}`,
model: config.embeddingModel,
@@ -247,25 +226,23 @@ class WebSearchService {
version: 1
}
// 更新LRU cache
this.requestStates.set(requestId, state)
// 创建知识库
const baseParams = getKnowledgeBaseParams(state.searchBase)
const baseParams = getKnowledgeBaseParams(searchBase)
await window.api.knowledgeBase.create(baseParams)
return state.searchBase
return searchBase
}
/**
* 检查配置是否匹配
* 清理临时搜索知识库
*/
private isConfigMatched(base: KnowledgeBase, config: CompressionConfig): boolean {
return (
base.model.id === config.embeddingModel?.id &&
base.rerankModel?.id === config.rerankModel?.id &&
base.dimensions === config.embeddingDimensions
)
private async cleanupSearchBase(searchBase: KnowledgeBase): Promise<void> {
try {
await window.api.knowledgeBase.delete(removeSpecialCharactersForFileName(searchBase.id))
logger.debug(`Cleaned up search base: ${searchBase.id}`)
} catch (error) {
logger.warn(`Failed to cleanup search base ${searchBase.id}:`, error as Error)
}
}
/**
@@ -332,45 +309,50 @@ class WebSearchService {
const searchBase = await this.ensureSearchBase(config, totalDocumentCount, requestId)
logger.debug('Search base for RAG compression: ', searchBase)
// 1. 清空知识库
const baseParams = getKnowledgeBaseParams(searchBase)
await window.api.knowledgeBase.reset(baseParams)
try {
// 1. 清空知识库
const baseParams = getKnowledgeBaseParams(searchBase)
await window.api.knowledgeBase.reset(baseParams)
logger.debug('Search base parameters for RAG compression: ', baseParams)
logger.debug('Search base parameters for RAG compression: ', baseParams)
// 2. 顺序添加所有搜索结果到知识库
// FIXME: 目前的知识库 add 不支持并发
for (const result of rawResults) {
const item: KnowledgeItem & { sourceUrl?: string } = {
id: uuid(),
type: 'note',
content: result.content,
sourceUrl: result.url, // 设置 sourceUrl 用于映射
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending'
// 2. 顺序添加所有搜索结果到知识库
// FIXME: 目前的知识库 add 不支持并发
for (const result of rawResults) {
const item: KnowledgeItem & { sourceUrl?: string } = {
id: uuid(),
type: 'note',
content: result.content,
sourceUrl: result.url, // 设置 sourceUrl 用于映射
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending'
}
await window.api.knowledgeBase.add({
base: getKnowledgeBaseParams(searchBase),
item
})
}
await window.api.knowledgeBase.add({
base: getKnowledgeBaseParams(searchBase),
item
// 3. 对知识库执行多问题搜索获取压缩结果
const references = await this.querySearchBase(questions, searchBase)
// 4. 使用 Round Robin 策略选择引用
const selectedReferences = selectReferences(rawResults, references, totalDocumentCount)
logger.verbose('With RAG, the number of search results:', {
raw: rawResults.length,
retrieved: references.length,
selected: selectedReferences.length
})
// 5. 按 sourceUrl 分组并合并同源片段
return consolidateReferencesByUrl(rawResults, selectedReferences)
} finally {
// 无论成功或失败都立即清理知识库
await this.cleanupSearchBase(searchBase)
}
// 3. 对知识库执行多问题搜索获取压缩结果
const references = await this.querySearchBase(questions, searchBase)
// 4. 使用 Round Robin 策略选择引用
const selectedReferences = selectReferences(rawResults, references, totalDocumentCount)
logger.verbose('With RAG, the number of search results:', {
raw: rawResults.length,
retrieved: references.length,
selected: selectedReferences.length
})
// 5. 按 sourceUrl 分组并合并同源片段
return consolidateReferencesByUrl(rawResults, selectedReferences)
}
/**
@@ -462,7 +444,9 @@ class WebSearchService {
// 处理 summarize
if (questions[0] === 'summarize' && links && links.length > 0) {
const contents = await fetchWebContents(links, undefined, undefined, { signal })
const contents = await fetchWebContents(links, undefined, undefined, {
signal
})
webSearchProvider.topicId &&
endSpan({
topicId: webSearchProvider.topicId,

View File

@@ -121,7 +121,8 @@ export class BlockManager {
newMessagesActions.upsertBlockReference({
messageId: this.deps.assistantMsgId,
blockId: newBlock.id,
status: newBlock.status
status: newBlock.status,
blockType: newBlock.type
})
)

View File

@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 159,
version: 162,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -1,14 +1,7 @@
import { loggerService } from '@logger'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import FileManager from '@renderer/services/FileManager'
import {
FileMetadata,
KnowledgeBase,
KnowledgeBaseParams,
KnowledgeItem,
PreprocessProvider,
ProcessingStatus
} from '@renderer/types'
import { FileMetadata, KnowledgeBase, KnowledgeItem, PreprocessProvider, ProcessingStatus } from '@renderer/types'
const logger = loggerService.withContext('Store:Knowledge')
@@ -28,13 +21,13 @@ const knowledgeSlice = createSlice({
state.bases.push(action.payload)
},
deleteBase(state, action: PayloadAction<{ baseId: string; baseParams: KnowledgeBaseParams }>) {
deleteBase(state, action: PayloadAction<{ baseId: string }>) {
const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) {
state.bases = state.bases.filter((b) => b.id !== action.payload.baseId)
const files = base.items.filter((item) => item.type === 'file')
FileManager.deleteFiles(files.map((item) => item.content) as FileMetadata[])
window.api.knowledgeBase.delete(action.payload.baseParams, action.payload.baseId)
window.api.knowledgeBase.delete(action.payload.baseId)
}
},

View File

@@ -144,6 +144,18 @@ export const builtinMCPServers: BuiltinMCPServer[] = [
type: 'inMemory',
isActive: false,
provider: 'CherryAI'
},
{
id: nanoid(),
name: '@cherry/didi-mcp',
reference: 'https://mcp.didichuxing.com/',
type: 'inMemory',
isActive: false,
env: {
DIDI_API_KEY: 'YOUR_DIDI_API_KEY'
},
shouldConfig: true,
provider: 'CherryAI'
}
] as const

View File

@@ -242,7 +242,7 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined
}
}
// 3. Handle Knowledge Base References
if (block.knowledge && block.knowledge.length > 0) {
if (block.knowledge && Array.isArray(block.knowledge) && block.knowledge.length > 0) {
formattedCitations.push(
...block.knowledge.map((result, index) => {
const filePattern = /\[(.*?)]\(http:\/\/file\/(.*?)\)/
@@ -270,7 +270,7 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined
)
}
if (block.memories && block.memories.length > 0) {
if (block.memories && Array.isArray(block.memories) && block.memories.length > 0) {
// 5. Handle Memory References
formattedCitations.push(
...block.memories.map((memory, index) => ({

View File

@@ -59,6 +59,7 @@ function removeMiniAppIconsFromState(state: RootState) {
function removeMiniAppFromState(state: RootState, id: string) {
if (state.minapps) {
state.minapps.pinned = state.minapps.pinned.filter((app) => app.id !== id)
state.minapps.enabled = state.minapps.enabled.filter((app) => app.id !== id)
state.minapps.disabled = state.minapps.disabled.filter((app) => app.id !== id)
}
@@ -2568,6 +2569,28 @@ const migrateConfig = {
logger.error('migrate 159 error', error as Error)
return state
}
},
'161': (state: RootState) => {
try {
removeMiniAppFromState(state, 'nm-search')
removeMiniAppFromState(state, 'hika')
removeMiniAppFromState(state, 'hugging-chat')
addProvider(state, 'cherryin')
state.llm.providers = moveProvider(state.llm.providers, 'cherryin', 1)
return state
} catch (error) {
logger.error('migrate 161 error', error as Error)
return state
}
},
'162': (state: RootState) => {
try {
addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.ovocr)
return state
} catch (error) {
logger.error('migrate 162 error', error as Error)
return state
}
}
}

View File

@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
import { createEntityAdapter, createSlice, EntityState, PayloadAction } from '@reduxjs/toolkit'
// Separate type-only imports from value imports
import type { Message } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
const logger = loggerService.withContext('newMessage')
@@ -50,6 +50,7 @@ interface UpsertBlockReferencePayload {
messageId: string
blockId: string
status?: MessageBlockStatus
blockType?: MessageBlockType
}
// Payload for removing a single message
@@ -217,7 +218,7 @@ export const messagesSlice = createSlice({
messagesAdapter.removeMany(state, messageIds)
},
upsertBlockReference(state, action: PayloadAction<UpsertBlockReferencePayload>) {
const { messageId, blockId, status } = action.payload
const { messageId, blockId, status, blockType } = action.payload
const messageToUpdate = state.entities[messageId]
if (!messageToUpdate) {
@@ -230,7 +231,11 @@ export const messagesSlice = createSlice({
// Update Block ID
const currentBlocks = messageToUpdate.blocks || []
if (!currentBlocks.includes(blockId)) {
changes.blocks = [...currentBlocks, blockId]
if (blockType === MessageBlockType.THINKING) {
changes.blocks = [blockId, ...currentBlocks]
} else {
changes.blocks = [...currentBlocks, blockId]
}
}
// Update Message Status based on Block Status

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