From c79ea7d5ad75f746f881257c4c26157337abd9de Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 3 Jul 2025 13:07:13 +0800 Subject: [PATCH 001/317] fix: cannot move data dir in linux (#7643) * fix: cannot move data dir in linux * delete verion info in path --------- Co-authored-by: beyondkmp --- src/main/ipc.ts | 13 ++++++++++++- src/main/utils/file.ts | 25 +++++++++++++++++-------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8c6810bcdc..87aad8d936 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,7 +2,7 @@ import fs from 'node:fs' import { arch } from 'node:os' import path from 'node:path' -import { isMac, isWin } from '@main/constant' +import { isLinux, isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import { UpgradeChannel } from '@shared/config/constant' @@ -306,6 +306,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // Relaunch app ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => { + // Fix for .AppImage + if (isLinux && process.env.APPIMAGE) { + log.info('Relaunching app with options:', process.env.APPIMAGE, options) + // On Linux, we need to use the APPIMAGE environment variable to relaunch + // https://github.com/electron-userland/electron-builder/issues/1727#issuecomment-769896927 + options = options || {} + options.execPath = process.env.APPIMAGE + options.args = options.args || [] + options.args.unshift('--appimage-extract-and-run') + } + app.relaunch(options) app.exit(0) }) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 177a28a90f..a85c5cf8ed 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { isPortable } from '@main/constant' +import { isLinux, isPortable } from '@main/constant' import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant' import { FileType, FileTypes } from '@types' import { app } from 'electron' @@ -59,6 +59,13 @@ function getAppDataPathFromConfig() { return null } + let executablePath = app.getPath('exe') + if (isLinux && process.env.APPIMAGE) { + // 如果是 AppImage 打包的应用,直接使用 APPIMAGE 环境变量 + // 这样可以确保获取到正确的可执行文件路径 + executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage') + } + let appDataPath = null // 兼容旧版本 if (config.appDataPath && typeof config.appDataPath === 'string') { @@ -67,7 +74,7 @@ function getAppDataPathFromConfig() { appDataPath && updateAppDataConfig(appDataPath) } else { appDataPath = config.appDataPath.find( - (item: { executablePath: string }) => item.executablePath === app.getPath('exe') + (item: { executablePath: string }) => item.executablePath === executablePath )?.dataPath } @@ -90,11 +97,13 @@ export function updateAppDataConfig(appDataPath: string) { // config.json // appDataPath: [{ executablePath: string, dataPath: string }] const configPath = path.join(getConfigDir(), 'config.json') + let executablePath = app.getPath('exe') + if (isLinux && process.env.APPIMAGE) { + executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage') + } + if (!fs.existsSync(configPath)) { - fs.writeFileSync( - configPath, - JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2) - ) + fs.writeFileSync(configPath, JSON.stringify({ appDataPath: [{ executablePath, dataPath: appDataPath }] }, null, 2)) return } @@ -104,13 +113,13 @@ export function updateAppDataConfig(appDataPath: string) { } const existingPath = config.appDataPath.find( - (item: { executablePath: string }) => item.executablePath === app.getPath('exe') + (item: { executablePath: string }) => item.executablePath === executablePath ) if (existingPath) { existingPath.dataPath = appDataPath } else { - config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath }) + config.appDataPath.push({ executablePath, dataPath: appDataPath }) } fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) From cd1ef46577832d13378415ced077f71583468931 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 3 Jul 2025 14:05:35 +0800 Subject: [PATCH 002/317] chore: remove dependency updates (#7743) --- .github/dependabot.yml | 83 ++++-------------------------------------- 1 file changed, 8 insertions(+), 75 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e2b17486db..5dfa6afc15 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,84 +1,17 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "monthly" - open-pull-requests-limit: 5 - target-branch: "main" - commit-message: - prefix: "chore" - include: "scope" - ignore: - - dependency-name: "*" - update-types: - - "version-update:semver-major" - - dependency-name: "@google/genai" - - dependency-name: "antd" - - dependency-name: "epub" - - dependency-name: "openai" - groups: - # CherryStudio 自定义包 - cherrystudio-packages: - patterns: - - "@cherrystudio/*" - - "@kangfenmao/*" - - "selection-hook" - - # 测试工具 - testing-tools: - patterns: - - "vitest" - - "@vitest/*" - - "playwright" - - "@playwright/*" - - "testing-library/*" - - "jest-styled-components" - - # Lint 工具 - lint-tools: - patterns: - - "eslint" - - "eslint-plugin-*" - - "@eslint/*" - - "@eslint-react/*" - - "@electron-toolkit/eslint-config-*" - - "prettier" - - "husky" - - "lint-staged" - - # Markdown - markdown: - patterns: - - "react-markdown" - - "rehype-katex" - - "rehype-mathjax" - - "rehype-raw" - - "remark-cjk-friendly" - - "remark-gfm" - - "remark-math" - - "remove-markdown" - - "markdown-it" - - "@shikijs/markdown-it" - - "shiki" - - "@uiw/codemirror-extensions-langs" - - "@uiw/codemirror-themes-all" - - "@uiw/react-codemirror" - - "fast-diff" - - "mermaid" - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" + interval: 'monthly' open-pull-requests-limit: 3 commit-message: - prefix: "ci" - include: "scope" + prefix: 'ci' + include: 'scope' groups: github-actions: patterns: - - "*" + - '*' update-types: - - "minor" - - "patch" + - 'minor' + - 'patch' From 2f016efc50b9138a563d6e695e2df9c7fe0360a9 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 3 Jul 2025 14:31:31 +0800 Subject: [PATCH 003/317] =?UTF-8?q?feat:=20SelectionAssistant=20macOS=20ve?= =?UTF-8?q?rsion=20/=20=E5=88=92=E8=AF=8D=E5=8A=A9=E6=89=8BmacOS=E7=89=88?= =?UTF-8?q?=20(#7561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(SelectionAssistant): add macOS support and process trust handling - Updated the selection assistant to support macOS, including new IPC channels for process trust verification. - Enhanced the SelectionService to check for accessibility permissions on macOS before starting the service. - Added user interface elements to guide macOS users in granting necessary permissions. - Updated localization files to reflect macOS support and provide relevant user instructions. - Refactored selection-related configurations to accommodate both Windows and macOS environments. * feat(SelectionService): update toolbar window settings for macOS and Windows - Set the toolbar window to be hidden in Mission Control and accept the first mouse click on macOS. - Adjusted visibility settings for the toolbar window to ensure it appears correctly on all workspaces, including full-screen mode. - Refactored the MacProcessTrustHintModal component to improve layout and styling of buttons in the modal footer. * feat(SelectionToolbar): enhance styling and layout of selection toolbar components * feat(SelectionService): enhance toolbar window settings and refactor position calculation * feat(SelectionToolbar): update button padding and add last button padding for improved layout * chore(dependencies): update selection-hook to version 1.0.2 and refine build file exclusions in electron-builder.yml * feat(SelectionService): center action window on screen when not following toolbar * fix(SelectionService): implement workaround to prevent other windows from bringing the app to front on macOS when action window is closed * fix(SelectionService): refine macOS workaround to prevent other windows from bringing the app to front when action window is closed; update selection-toolbar logo padding in styles * fix(SelectionService): implement macOS toolbar reload to clear hover status; optimize display retrieval logic * fix(SelectionService): update macOS toolbar hover status handling by sending mouseMove event instead of reloading the window * chore: update selection-hook dependency to version 1.0.3 in package.json and yarn.lock * fix(SelectionService): improve toolbar visibility handling on macOS and ensure focusability of other windows when hiding the toolbar --------- Co-authored-by: Teo --- electron-builder.yml | 4 +- package.json | 2 +- packages/shared/IpcChannel.ts | 3 + src/main/configs/SelectionConfig.ts | 11 +- src/main/ipc.ts | 14 +- src/main/services/SelectionService.ts | 256 +++++++++++++----- src/main/services/TrayService.ts | 4 +- src/preload/index.ts | 4 + .../src/assets/styles/selection-toolbar.scss | 39 ++- src/renderer/src/i18n/locales/en-us.json | 24 +- src/renderer/src/i18n/locales/ja-jp.json | 24 +- src/renderer/src/i18n/locales/ru-ru.json | 24 +- src/renderer/src/i18n/locales/zh-cn.json | 24 +- src/renderer/src/i18n/locales/zh-tw.json | 24 +- .../SelectionAssistantSettings.tsx | 66 ++++- .../components/MacProcessTrustHintModal.tsx | 68 +++++ .../SelectionActionSearchModal.tsx | 0 .../SelectionActionUserModal.tsx | 0 .../{ => components}/SelectionActionsList.tsx | 10 +- .../SelectionFilterListModal.tsx | 7 +- .../hooks/useSettingsActionsList.ts | 2 +- .../src/pages/settings/ShortcutSettings.tsx | 2 +- .../selection/action/SelectionActionApp.tsx | 12 +- .../selection/toolbar/SelectionToolbar.tsx | 24 +- yarn.lock | 20 +- 25 files changed, 533 insertions(+), 135 deletions(-) create mode 100644 src/renderer/src/pages/settings/SelectionAssistantSettings/components/MacProcessTrustHintModal.tsx rename src/renderer/src/pages/settings/SelectionAssistantSettings/{ => components}/SelectionActionSearchModal.tsx (100%) rename src/renderer/src/pages/settings/SelectionAssistantSettings/{ => components}/SelectionActionUserModal.tsx (100%) rename src/renderer/src/pages/settings/SelectionAssistantSettings/{ => components}/SelectionActionsList.tsx (91%) rename src/renderer/src/pages/settings/SelectionAssistantSettings/{ => components}/SelectionFilterListModal.tsx (89%) diff --git a/electron-builder.yml b/electron-builder.yml index c65f20ed32..1303a4a3c8 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -53,7 +53,9 @@ files: - '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}' - '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}' - '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds - - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files + - '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir + - '!node_modules/selection-hook/src' # we don't need source files + - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files asarUnpack: - resources/** - '**/*.{metal,exp,lib}' diff --git a/package.json b/package.json index 69bf4268c3..35a85bf162 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "node-stream-zip": "^1.15.0", "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", - "selection-hook": "^0.9.23", + "selection-hook": "^1.0.3", "turndown": "7.2.0" }, "devDependencies": { diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index daea5dad6e..8118065278 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -32,6 +32,9 @@ export enum IpcChannel { App_InstallUvBinary = 'app:install-uv-binary', App_InstallBunBinary = 'app:install-bun-binary', + App_MacIsProcessTrusted = 'app:mac-is-process-trusted', + App_MacRequestProcessTrust = 'app:mac-request-process-trust', + App_QuoteToMain = 'app:quote-to-main', Notification_Send = 'notification:send', diff --git a/src/main/configs/SelectionConfig.ts b/src/main/configs/SelectionConfig.ts index 59988ded74..31868a4708 100644 --- a/src/main/configs/SelectionConfig.ts +++ b/src/main/configs/SelectionConfig.ts @@ -1,6 +1,6 @@ interface IFilterList { WINDOWS: string[] - MAC?: string[] + MAC: string[] } interface IFinetunedList { @@ -45,14 +45,17 @@ export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = { 'sldworks.exe', // Remote Desktop 'mstsc.exe' - ] + ], + MAC: [] } export const SELECTION_FINETUNED_LIST: IFinetunedList = { EXCLUDE_CLIPBOARD_CURSOR_DETECT: { - WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe'] + WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe'], + MAC: [] }, INCLUDE_CLIPBOARD_DELAY_READ: { - WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe'] + WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe'], + MAC: [] } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 87aad8d936..0176a27525 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -8,7 +8,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Shortcut, ThemeMode } from '@types' -import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron' +import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron' import log from 'electron-log' import { Notification } from 'src/renderer/src/types/notification' @@ -158,6 +158,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } }) + //only for mac + if (isMac) { + ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => { + return systemPreferences.isTrustedAccessibilityClient(false) + }) + + //return is only the current state, not the new state + ipcMain.handle(IpcChannel.App_MacRequestProcessTrust, (): boolean => { + return systemPreferences.isTrustedAccessibilityClient(true) + }) + } + ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { configManager.set(key, value, isNotify) }) diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index eba97179bc..23578b75e0 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1,7 +1,7 @@ import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig' -import { isDev, isWin } from '@main/constant' +import { isDev, isMac, isWin } from '@main/constant' import { IpcChannel } from '@shared/IpcChannel' -import { BrowserWindow, ipcMain, screen } from 'electron' +import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron' import Logger from 'electron-log' import { join } from 'path' import type { @@ -16,9 +16,12 @@ import type { ActionItem } from '../../renderer/src/types/selectionTypes' import { ConfigKeys, configManager } from './ConfigManager' import storeSyncService from './StoreSyncService' +const isSupportedOS = isWin || isMac + let SelectionHook: SelectionHookConstructor | null = null try { - if (isWin) { + //since selection-hook v1.0.0, it supports macOS + if (isSupportedOS) { SelectionHook = require('selection-hook') } } catch (error) { @@ -118,7 +121,7 @@ export class SelectionService { } public static getInstance(): SelectionService | null { - if (!isWin) return null + if (!isSupportedOS) return null if (!SelectionService.instance) { SelectionService.instance = new SelectionService() @@ -213,6 +216,8 @@ export class SelectionService { blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST } + const predefinedBlacklist = isWin ? SELECTION_PREDEFINED_BLACKLIST.WINDOWS : SELECTION_PREDEFINED_BLACKLIST.MAC + let combinedList: string[] = list let combinedMode = mode @@ -221,7 +226,7 @@ export class SelectionService { switch (mode) { case 'blacklist': //combine the predefined blacklist with the user-defined blacklist - combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])] + combinedList = [...new Set([...list, ...predefinedBlacklist])] break case 'whitelist': combinedList = [...list] @@ -229,7 +234,7 @@ export class SelectionService { case 'default': default: //use the predefined blacklist as the default filter list - combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS] + combinedList = [...predefinedBlacklist] combinedMode = 'blacklist' break } @@ -243,14 +248,21 @@ export class SelectionService { private setHookFineTunedList() { if (!this.selectionHook) return + const excludeClipboardCursorDetectList = isWin + ? SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS + : SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.MAC + const includeClipboardDelayReadList = isWin + ? SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS + : SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.MAC + this.selectionHook.setFineTunedList( SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT, - SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS + excludeClipboardCursorDetectList ) this.selectionHook.setFineTunedList( SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ, - SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS + includeClipboardDelayReadList ) } @@ -259,11 +271,28 @@ export class SelectionService { * @returns {boolean} Success status of service start */ public start(): boolean { - if (!this.selectionHook || this.started) { - this.logError(new Error('SelectionService start(): instance is null or already started')) + if (!this.selectionHook) { + this.logError(new Error('SelectionService start(): instance is null')) return false } + if (this.started) { + this.logError(new Error('SelectionService start(): already started')) + return false + } + + //On macOS, we need to check if the process is trusted + if (isMac) { + if (!systemPreferences.isTrustedAccessibilityClient(false)) { + this.logError( + new Error( + 'SelectionSerice not started: process is not trusted on macOS, please turn on the Accessibility permission' + ) + ) + return false + } + } + try { //make sure the toolbar window is ready this.createToolbarWindow() @@ -306,6 +335,7 @@ export class SelectionService { if (!this.selectionHook) return false this.selectionHook.stop() + this.selectionHook.cleanup() //already remove all listeners //reset the listener states @@ -316,6 +346,7 @@ export class SelectionService { this.toolbarWindow.close() this.toolbarWindow = null } + this.closePreloadedActionWindows() this.started = false @@ -366,21 +397,29 @@ export class SelectionService { this.toolbarWindow = new BrowserWindow({ width: toolbarWidth, height: toolbarHeight, + show: false, frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, + autoHideMenuBar: true, resizable: false, minimizable: false, maximizable: false, + fullscreenable: false, // [macOS] must be false movable: true, - focusable: false, hasShadow: false, thickFrame: false, roundedCorners: true, backgroundMaterial: 'none', - type: 'toolbar', - show: false, + + // Platform specific settings + // [macOS] DO NOT set type to 'panel', it will not work because it conflicts with other settings + // [macOS] DO NOT set focusable to false, it will make other windows bring to front together + ...(isWin ? { type: 'toolbar', focusable: false } : {}), + hiddenInMissionControl: true, // [macOS only] + acceptFirstMouse: true, // [macOS only] + webPreferences: { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, @@ -392,7 +431,9 @@ export class SelectionService { // Hide when losing focus this.toolbarWindow.on('blur', () => { - this.hideToolbar() + if (this.toolbarWindow!.isVisible()) { + this.hideToolbar() + } }) // Clean up when closed @@ -406,6 +447,13 @@ export class SelectionService { // Add show/hide event listeners this.toolbarWindow.on('show', () => { this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true) + + // [macOS] force the toolbar window to be visible on current desktop + // but it will make docker icon flash. And we found that it's not necessary now. + // will remove after testing + // if (isMac) { + // this.toolbarWindow!.setVisibleOnAllWorkspaces(false) + // } }) this.toolbarWindow.on('hide', () => { @@ -460,11 +508,22 @@ export class SelectionService { //set the window to always on top (highest level) //should set every time the window is shown this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver') - this.toolbarWindow!.show() + + // [macOS] force the toolbar window to be visible on current desktop + // but it will make docker icon flash. And we found that it's not necessary now. + // will remove after testing + // if (isMac) { + // this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + // } + + // [macOS] MUST use `showInactive()` to prevent other windows bring to front together + // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` + this.toolbarWindow!.showInactive() /** - * In Windows 10, setOpacity(1) will make the window completely transparent - * It's a strange behavior, so we don't use it for compatibility + * [Windows] + * In Windows 10, setOpacity(1) will make the window completely transparent + * It's a strange behavior, so we don't use it for compatibility */ // this.toolbarWindow!.setOpacity(1) @@ -477,10 +536,52 @@ export class SelectionService { public hideToolbar(): void { if (!this.isToolbarAlive()) return - // this.toolbarWindow!.setOpacity(0) + this.stopHideByMouseKeyListener() + + // [Windows] just hide the toolbar window is enough + if (!isMac) { + this.toolbarWindow!.hide() + return + } + + /************************************************ + * [macOS] the following code is only for macOS + *************************************************/ + + // [macOS] a HACKY way + // make sure other windows do not bring to front when toolbar is hidden + // get all focusable windows and set them to not focusable + const focusableWindows: BrowserWindow[] = [] + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed() && window.isVisible()) { + if (window.isFocusable()) { + focusableWindows.push(window) + window.setFocusable(false) + } + } + } + this.toolbarWindow!.hide() - this.stopHideByMouseKeyListener() + // set them back to focusable after 50ms + setTimeout(() => { + for (const window of focusableWindows) { + if (!window.isDestroyed()) { + window.setFocusable(true) + } + } + }, 50) + + // [macOS] hacky way + // Because toolbar is not a FOCUSED window, so the hover status will remain when next time show + // so we just send mouseMove event to the toolbar window to make the hover status disappear + this.toolbarWindow!.webContents.sendInputEvent({ + type: 'mouseMove', + x: -1, + y: -1 + }) + + return } /** @@ -520,71 +621,71 @@ export class SelectionService { /** * Calculate optimal toolbar position based on selection context * Ensures toolbar stays within screen boundaries and follows selection direction - * @param point Reference point for positioning, must be INTEGER + * @param refPoint Reference point for positioning, must be INTEGER * @param orientation Preferred position relative to reference point * @returns Calculated screen coordinates for toolbar, INTEGER */ - private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point { + private calculateToolbarPosition(refPoint: Point, orientation: RelativeOrientation): Point { // Calculate initial position based on the specified anchor - let posX: number, posY: number + const posPoint: Point = { x: 0, y: 0 } const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() switch (orientation) { case 'topLeft': - posX = point.x - toolbarWidth - posY = point.y - toolbarHeight + posPoint.x = refPoint.x - toolbarWidth + posPoint.y = refPoint.y - toolbarHeight break case 'topRight': - posX = point.x - posY = point.y - toolbarHeight + posPoint.x = refPoint.x + posPoint.y = refPoint.y - toolbarHeight break case 'topMiddle': - posX = point.x - toolbarWidth / 2 - posY = point.y - toolbarHeight + posPoint.x = refPoint.x - toolbarWidth / 2 + posPoint.y = refPoint.y - toolbarHeight break case 'bottomLeft': - posX = point.x - toolbarWidth - posY = point.y + posPoint.x = refPoint.x - toolbarWidth + posPoint.y = refPoint.y break case 'bottomRight': - posX = point.x - posY = point.y + posPoint.x = refPoint.x + posPoint.y = refPoint.y break case 'bottomMiddle': - posX = point.x - toolbarWidth / 2 - posY = point.y + posPoint.x = refPoint.x - toolbarWidth / 2 + posPoint.y = refPoint.y break case 'middleLeft': - posX = point.x - toolbarWidth - posY = point.y - toolbarHeight / 2 + posPoint.x = refPoint.x - toolbarWidth + posPoint.y = refPoint.y - toolbarHeight / 2 break case 'middleRight': - posX = point.x - posY = point.y - toolbarHeight / 2 + posPoint.x = refPoint.x + posPoint.y = refPoint.y - toolbarHeight / 2 break case 'center': - posX = point.x - toolbarWidth / 2 - posY = point.y - toolbarHeight / 2 + posPoint.x = refPoint.x - toolbarWidth / 2 + posPoint.y = refPoint.y - toolbarHeight / 2 break default: // Default to 'topMiddle' if invalid position - posX = point.x - toolbarWidth / 2 - posY = point.y - toolbarHeight / 2 + posPoint.x = refPoint.x - toolbarWidth / 2 + posPoint.y = refPoint.y - toolbarHeight / 2 } //use original point to get the display - const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y }) + const display = screen.getDisplayNearestPoint(refPoint) // Ensure toolbar stays within screen boundaries - posX = Math.round( - Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth)) + posPoint.x = Math.round( + Math.max(display.workArea.x, Math.min(posPoint.x, display.workArea.x + display.workArea.width - toolbarWidth)) ) - posY = Math.round( - Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight)) + posPoint.y = Math.round( + Math.max(display.workArea.y, Math.min(posPoint.y, display.workArea.y + display.workArea.height - toolbarHeight)) ) - return { x: posX, y: posY } + return posPoint } private isSamePoint(point1: Point, point2: Point): boolean { @@ -773,8 +874,11 @@ export class SelectionService { } if (!isLogical) { + // [macOS] don't need to convert by screenToDipPoint + if (!isMac) { + refPoint = screen.screenToDipPoint(refPoint) + } //screenToDipPoint can be float, so we need to round it - refPoint = screen.screenToDipPoint(refPoint) refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } } @@ -832,8 +936,8 @@ export class SelectionService { return } - //data point is physical coordinates, convert to logical coordinates - const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y }) + //data point is physical coordinates, convert to logical coordinates(only for windows/linux) + const mousePoint = isMac ? { x: data.x, y: data.y } : screen.screenToDipPoint({ x: data.x, y: data.y }) const bounds = this.toolbarWindow!.getBounds() @@ -966,7 +1070,8 @@ export class SelectionService { frame: false, transparent: true, autoHideMenuBar: true, - titleBarStyle: 'hidden', + titleBarStyle: 'hidden', // [macOS] + trafficLightPosition: { x: 12, y: 9 }, // [macOS] hasShadow: false, thickFrame: false, show: false, @@ -1043,6 +1148,27 @@ export class SelectionService { if (!actionWindow.isDestroyed()) { actionWindow.destroy() } + + // [macOS] a HACKY way + // make sure other windows do not bring to front when action window is closed + if (isMac) { + const focusableWindows: BrowserWindow[] = [] + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed() && window.isVisible()) { + if (window.isFocusable()) { + focusableWindows.push(window) + window.setFocusable(false) + } + } + } + setTimeout(() => { + for (const window of focusableWindows) { + if (!window.isDestroyed()) { + window.setFocusable(true) + } + } + }, 50) + } }) //remember the action window size @@ -1088,22 +1214,26 @@ export class SelectionService { //center way if (!this.isFollowToolbar || !this.toolbarWindow) { - if (this.isRemeberWinSize) { - actionWindow.setBounds({ - width: actionWindowWidth, - height: actionWindowHeight - }) - } + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + const workArea = display.workArea + + const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2 + const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2 + + actionWindow.setBounds({ + width: actionWindowWidth, + height: actionWindowHeight, + x: Math.round(centerX), + y: Math.round(centerY) + }) actionWindow.show() - this.hideToolbar() return } //follow toolbar - const toolbarBounds = this.toolbarWindow!.getBounds() - const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y }) + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) const workArea = display.workArea const GAP = 6 // 6px gap from screen edges @@ -1214,7 +1344,7 @@ export class SelectionService { selectionService?.hideToolbar() }) - ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => { + ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string): boolean => { return selectionService?.writeToClipboard(text) ?? false }) @@ -1291,7 +1421,7 @@ export class SelectionService { * @returns {boolean} Success status of initialization */ export function initSelectionService(): boolean { - if (!isWin) return false + if (!isSupportedOS) return false configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => { //avoid closure diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts index 89c88bc0ae..205d7fdee9 100644 --- a/src/main/services/TrayService.ts +++ b/src/main/services/TrayService.ts @@ -84,10 +84,8 @@ export class TrayService { label: trayLocale.show_mini_window, click: () => windowService.showMiniWindow() }, - isWin && { + (isWin || isMac) && { label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'), - // type: 'checkbox', - // checked: selectionAssistantEnabled, click: () => { if (selectionService) { selectionService.toggleEnabled() diff --git a/src/preload/index.ts b/src/preload/index.ts index 8412e00bc3..beabfa1a27 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,6 +42,10 @@ const api = { openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), + mac: { + isProcessTrusted: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted), + requestProcessTrust: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust) + }, notification: { send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification) }, diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss index bfe329c696..23f0edfb34 100644 --- a/src/renderer/src/assets/styles/selection-toolbar.scss +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -18,25 +18,37 @@ html { --selection-toolbar-logo-display: flex; // values: flex | none --selection-toolbar-logo-size: 22px; // default: 22px - --selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px + --selection-toolbar-logo-border-width: 0.5px 0 0.5px 0.5px; // default: none + --selection-toolbar-logo-border-style: solid; // default: none + --selection-toolbar-logo-border-color: rgba(255, 255, 255, 0.2); + --selection-toolbar-logo-margin: 0; // default: 0 + --selection-toolbar-logo-padding: 0 6px 0 8px; // default: 0 4px 0 8px + --selection-toolbar-logo-background: transparent; // default: transparent // DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING - --selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px + --selection-toolbar-padding: 0; // default: 0 --selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px // ------------------------------------------------------------ - --selection-toolbar-border-radius: 6px; - --selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5); + --selection-toolbar-border-radius: 10px; + --selection-toolbar-border: none; --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3); --selection-toolbar-background: rgba(20, 20, 20, 0.95); // Buttons + --selection-toolbar-buttons-border-width: 0.5px 0.5px 0.5px 0; + --selection-toolbar-buttons-border-style: solid; + --selection-toolbar-buttons-border-color: rgba(255, 255, 255, 0.2); + --selection-toolbar-buttons-border-radius: 0 var(--selection-toolbar-border-radius) + var(--selection-toolbar-border-radius) 0; --selection-toolbar-button-icon-size: 16px; // default: 16px - --selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px - --selection-toolbar-button-margin: 0 2px; // default: 0 2px - --selection-toolbar-button-padding: 4px 6px; // default: 4px 6px - --selection-toolbar-button-border-radius: 4px; // default: 4px + --selection-toolbar-button-direction: row; // default: row | column + --selection-toolbar-button-text-margin: 0 0 0 0; // default: 0 0 0 0 + --selection-toolbar-button-margin: 0; // default: 0 + --selection-toolbar-button-padding: 0 8px; // default: 0 8px + --selection-toolbar-button-last-padding: 0 12px 0 8px; + --selection-toolbar-button-border-radius: 0; // default: 0 --selection-toolbar-button-border: none; // default: none --selection-toolbar-button-box-shadow: none; // default: none @@ -45,14 +57,19 @@ html { --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); --selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary); --selection-toolbar-button-bgcolor: transparent; // default: transparent - --selection-toolbar-button-bgcolor-hover: #222222; + --selection-toolbar-button-bgcolor-hover: #333333; } [theme-mode='light'] { - --selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5); - --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3); + --selection-toolbar-border: none; + --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.1); --selection-toolbar-background: rgba(245, 245, 245, 0.95); + // Buttons + --selection-toolbar-buttons-border-color: rgba(0, 0, 0, 0.08); + + --selection-toolbar-logo-border-color: rgba(0, 0, 0, 0.08); + --selection-toolbar-button-text-color: rgba(0, 0, 0, 1); --selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color); --selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary); diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 0a529756b2..4511878b84 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2034,14 +2034,29 @@ "experimental": "Experimental Features", "enable": { "title": "Enable", - "description": "Currently only supported on Windows systems" + "description": "Currently only supported on Windows & macOS", + "mac_process_trust_hint": { + "title": "Accessibility Permission", + "description": [ + "Selection Assistant requires Accessibility Permission to work properly.", + "Please click \"Go to Settings\" and click the \"Open System Settings\" button in the permission request popup that appears later. Then find \"Cherry Studio\" in the application list that appears later and turn on the permission switch.", + "After completing the settings, please reopen the selection assistant." + ], + "button": { + "open_accessibility_settings": "Open Accessibility Settings", + "go_to_settings": "Go to Settings" + } + } }, "toolbar": { "title": "Toolbar", "trigger_mode": { "title": "Trigger Mode", "description": "The way to trigger the selection assistant and show the toolbar", - "description_note": "Some applications do not support selecting text with the Ctrl key. If you have remapped the Ctrl key using tools like AHK, it may cause some applications to fail to select text.", + "description_note": { + "windows": "Some applications do not support selecting text with the Ctrl key. If you have remapped the Ctrl key using tools like AHK, it may cause some applications to fail to select text.", + "mac": "If you have remapped the ⌘ key using shortcuts or keyboard mapping tools, it may cause some applications to fail to select text." + }, "selected": "Selection", "selected_note": "Show toolbar immediately when text is selected", "ctrlkey": "Ctrl Key", @@ -2166,7 +2181,10 @@ }, "filter_modal": { "title": "Application Filter List", - "user_tips": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc." + "user_tips": { + "windows": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc.", + "mac": "Please enter the Bundle ID of the application, one per line, case insensitive, can be fuzzy matched. For example: com.google.Chrome, com.apple.mail, etc." + } } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 30f5f2fb0a..2082cf0c27 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2037,14 +2037,29 @@ "experimental": "実験的機能", "enable": { "title": "有効化", - "description": "現在Windowsのみ対応" + "description": "現在Windows & macOSのみ対応", + "mac_process_trust_hint": { + "title": "アクセシビリティー権限", + "description": [ + "テキスト選択ツールは、アクセシビリティー権限が必要です。", + "「設定に移動」をクリックし、後で表示される権限要求ポップアップで「システム設定を開く」ボタンをクリックします。その後、表示されるアプリケーションリストで「Cherry Studio」を見つけ、権限スイッチをオンにしてください。", + "設定が完了したら、テキスト選択ツールを再起動してください。" + ], + "button": { + "open_accessibility_settings": "アクセシビリティー設定を開く", + "go_to_settings": "設定に移動" + } + } }, "toolbar": { "title": "ツールバー", "trigger_mode": { "title": "単語の取り出し方", "description": "テキスト選択後、取詞ツールバーを表示する方法", - "description_note": "一部のアプリケーションでは、Ctrl キーでテキストを選択できません。AHK などのツールを使用して Ctrl キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。", + "description_note": { + "windows": "一部のアプリケーションでは、Ctrl キーでテキストを選択できません。AHK などのツールを使用して Ctrl キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。", + "mac": "一部のアプリケーションでは、⌘ キーでテキストを選択できません。ショートカットキーまたはキーボードマッピングツールを使用して ⌘ キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。" + }, "selected": "選択時", "selected_note": "テキスト選択時に即時表示", "ctrlkey": "Ctrlキー", @@ -2169,7 +2184,10 @@ }, "filter_modal": { "title": "アプリケーションフィルターリスト", - "user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。" + "user_tips": { + "windows": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。", + "mac": "アプリケーションのBundle IDを1行ずつ入力してください。大文字小文字は区別しません。例: com.google.Chrome, com.apple.mail, など。" + } } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index b850bf3f22..43b53f4a4d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2037,14 +2037,29 @@ "experimental": "Экспериментальные функции", "enable": { "title": "Включить", - "description": "Поддерживается только в Windows" + "description": "Поддерживается только в Windows & macOS", + "mac_process_trust_hint": { + "title": "Права доступа", + "description": [ + "Помощник выбора требует Права доступа для правильной работы.", + "Пожалуйста, перейдите в \"Настройки\" и нажмите \"Открыть системные настройки\" в запросе разрешения, который появится позже. Затем найдите \"Cherry Studio\" в списке приложений, который появится позже, и включите переключатель разрешения.", + "После завершения настроек, пожалуйста, перезапустите помощник выбора." + ], + "button": { + "open_accessibility_settings": "Открыть системные настройки", + "go_to_settings": "Настройки" + } + } }, "toolbar": { "title": "Панель инструментов", "trigger_mode": { "title": "Режим активации", "description": "Показывать панель сразу при выделении, или только при удержании Ctrl, или только при нажатии на сочетание клавиш", - "description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.", + "description_note": { + "windows": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.", + "mac": "В некоторых приложениях ⌘ может не работать. Если вы используете сочетания клавиш или инструменты для переназначения ⌘, это может привести к тому, что некоторые приложения не смогут выделить текст." + }, "selected": "При выделении", "selected_note": "После выделения", "ctrlkey": "По Ctrl", @@ -2169,7 +2184,10 @@ }, "filter_modal": { "title": "Список фильтрации", - "user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *" + "user_tips": { + "windows": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *", + "mac": "Введите Bundle ID приложения, один на строку, не учитывая регистр, можно использовать подстановку *" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2b2176a457..2dd0422004 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2037,14 +2037,29 @@ "experimental": "实验性功能", "enable": { "title": "启用", - "description": "当前仅支持 Windows 系统" + "description": "当前仅支持 Windows & macOS", + "mac_process_trust_hint": { + "title": "辅助功能权限", + "description": [ + "划词助手需「辅助功能权限」才能正常工作。", + "请点击「去设置」,并在稍后弹出的权限请求弹窗中点击 「打开系统设置」 按钮,然后在之后的应用列表中找到 「Cherry Studio」,并打开权限开关。", + "完成设置后,请再次开启划词助手。" + ], + "button": { + "open_accessibility_settings": "打开辅助功能设置", + "go_to_settings": "去设置" + } + } }, "toolbar": { "title": "工具栏", "trigger_mode": { "title": "取词方式", "description": "划词后,触发取词并显示工具栏的方式", - "description_note": "少数应用不支持通过 Ctrl 键划词。若使用了 AHK 等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", + "description_note": { + "windows": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等按键映射工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", + "mac": "若使用了快捷键或键盘映射工具对 ⌘ 键进行了重映射,可能导致部分应用无法划词。" + }, "selected": "划词", "selected_note": "划词后立即显示工具栏", "ctrlkey": "Ctrl 键", @@ -2169,7 +2184,10 @@ }, "filter_modal": { "title": "应用筛选名单", - "user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" + "user_tips": { + "windows": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等", + "mac": "请输入应用的Bundle ID,每行一个,不区分大小写,可以模糊匹配。例如:com.google.Chrome、com.apple.mail等" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8640d46428..12addbba02 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2037,14 +2037,29 @@ "experimental": "實驗性功能", "enable": { "title": "啟用", - "description": "目前僅支援 Windows 系統" + "description": "目前僅支援 Windows & macOS", + "mac_process_trust_hint": { + "title": "輔助使用權限", + "description": [ + "劃詞助手需「輔助使用權限」才能正常工作。", + "請點擊「去設定」,並在稍後彈出的權限請求彈窗中點擊 「打開系統設定」 按鈕,然後在之後的應用程式列表中找到 「Cherry Studio」,並開啟權限開關。", + "完成設定後,請再次開啟劃詞助手。" + ], + "button": { + "open_accessibility_settings": "打開輔助使用設定", + "go_to_settings": "去設定" + } + } }, "toolbar": { "title": "工具列", "trigger_mode": { "title": "取詞方式", "description": "劃詞後,觸發取詞並顯示工具列的方式", - "description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了 AHK 等工具對 Ctrl 鍵進行了重新對應,可能導致部分應用程式無法劃詞。", + "description_note": { + "windows": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了 AHK 等工具對 Ctrl 鍵進行了重新對應,可能導致部分應用程式無法劃詞。", + "mac": "若使用了快捷鍵或鍵盤映射工具對 ⌘ 鍵進行了重新對應,可能導致部分應用程式無法劃詞。" + }, "selected": "劃詞", "selected_note": "劃詞後,立即顯示工具列", "ctrlkey": "Ctrl 鍵", @@ -2169,7 +2184,10 @@ }, "filter_modal": { "title": "應用篩選名單", - "user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" + "user_tips": { + "windows": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等", + "mac": "請輸入應用的 Bundle ID,每行一個,不區分大小寫,可以模糊匹配。例如:com.google.Chrome、com.apple.mail等" + } } } } diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx index 0bbebf57e9..1707b10dd7 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx @@ -1,4 +1,4 @@ -import { isWin } from '@renderer/config/constant' +import { isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant' import { FilterMode, TriggerMode } from '@renderer/types/selectionTypes' @@ -19,8 +19,9 @@ import { SettingRowTitle, SettingTitle } from '..' -import SelectionActionsList from './SelectionActionsList' -import SelectionFilterListModal from './SelectionFilterListModal' +import MacProcessTrustHintModal from './components/MacProcessTrustHintModal' +import SelectionActionsList from './components/SelectionActionsList' +import SelectionFilterListModal from './components/SelectionFilterListModal' const SelectionAssistantSettings: FC = () => { const { theme } = useTheme() @@ -49,15 +50,43 @@ const SelectionAssistantSettings: FC = () => { setFilterMode, setFilterList } = useSelectionAssistant() + + const isSupportedOS = isWin || isMac + const [isFilterListModalOpen, setIsFilterListModalOpen] = useState(false) + const [isMacTrustModalOpen, setIsMacTrustModalOpen] = useState(false) const [opacityValue, setOpacityValue] = useState(actionWindowOpacity) // force disable selection assistant on non-windows systems useEffect(() => { - if (!isWin && selectionEnabled) { - setSelectionEnabled(false) + const checkMacProcessTrust = async () => { + const isTrusted = await window.api.mac.isProcessTrusted() + if (!isTrusted) { + setSelectionEnabled(false) + } } - }, [selectionEnabled, setSelectionEnabled]) + + if (!isSupportedOS && selectionEnabled) { + setSelectionEnabled(false) + return + } else if (isMac && selectionEnabled) { + checkMacProcessTrust() + } + }, [isSupportedOS, selectionEnabled, setSelectionEnabled]) + + const handleEnableCheckboxChange = async (checked: boolean) => { + if (!isSupportedOS) return + + if (isMac && checked) { + const isTrusted = await window.api.mac.isProcessTrusted() + if (!isTrusted) { + setIsMacTrustModalOpen(true) + return + } + } + + setSelectionEnabled(checked) + } return ( @@ -71,18 +100,18 @@ const SelectionAssistantSettings: FC = () => { style={{ fontSize: 12 }}> {'FAQ & ' + t('settings.about.feedback.button')} - {t('selection.settings.experimental')} + {isMac && {t('selection.settings.experimental')}} {t('selection.settings.enable.title')} - {!isWin && {t('selection.settings.enable.description')}} + {!isSupportedOS && {t('selection.settings.enable.description')}} setSelectionEnabled(checked)} - disabled={!isWin} + checked={isSupportedOS && selectionEnabled} + onChange={(checked) => handleEnableCheckboxChange(checked)} + disabled={!isSupportedOS} /> @@ -103,7 +132,10 @@ const SelectionAssistantSettings: FC = () => {
{t('selection.settings.toolbar.trigger_mode.title')}
- +
@@ -116,9 +148,11 @@ const SelectionAssistantSettings: FC = () => { {t('selection.settings.toolbar.trigger_mode.selected')} - - {t('selection.settings.toolbar.trigger_mode.ctrlkey')} - + {isWin && ( + + {t('selection.settings.toolbar.trigger_mode.ctrlkey')} + + )} { )} + + {isMac && setIsMacTrustModalOpen(false)} />}
) } diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/components/MacProcessTrustHintModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/MacProcessTrustHintModal.tsx new file mode 100644 index 0000000000..d3e1926552 --- /dev/null +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/MacProcessTrustHintModal.tsx @@ -0,0 +1,68 @@ +import { Button, Modal, Typography } from 'antd' +import { FC } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const { Text, Paragraph } = Typography + +interface MacProcessTrustHintModalProps { + open: boolean + onClose: () => void +} + +const MacProcessTrustHintModal: FC = ({ open, onClose }) => { + const { t } = useTranslation() + + const handleOpenAccessibility = () => { + window.api.shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility') + onClose() + } + + const handleConfirm = async () => { + window.api.mac.requestProcessTrust() + onClose() + } + + return ( + + + + + } + centered + destroyOnClose> + + + + + + + + + + + + + + + + + + + ) +} + +const ContentContainer = styled.div` + padding: 16px 0; +` + +export default MacProcessTrustHintModal diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionActionSearchModal.tsx similarity index 100% rename from src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionSearchModal.tsx rename to src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionActionSearchModal.tsx diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionActionUserModal.tsx similarity index 100% rename from src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionUserModal.tsx rename to src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionActionUserModal.tsx diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionActionsList.tsx similarity index 91% rename from src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx rename to src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionActionsList.tsx index 91106f762d..7077de2f4e 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionActionsList.tsx +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionActionsList.tsx @@ -6,13 +6,13 @@ import { Row } from 'antd' import { FC } from 'react' import styled from 'styled-components' -import { SettingDivider, SettingGroup } from '..' -import ActionsList from './components/ActionsList' -import ActionsListDivider from './components/ActionsListDivider' -import SettingsActionsListHeader from './components/SettingsActionsListHeader' -import { useActionItems } from './hooks/useSettingsActionsList' +import { SettingDivider, SettingGroup } from '../..' +import { useActionItems } from '../hooks/useSettingsActionsList' +import ActionsList from './ActionsList' +import ActionsListDivider from './ActionsListDivider' import SelectionActionSearchModal from './SelectionActionSearchModal' import SelectionActionUserModal from './SelectionActionUserModal' +import SettingsActionsListHeader from './SettingsActionsListHeader' // Component for managing selection actions in settings // Handles drag-and-drop reordering, enabling/disabling actions, and custom action management diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionFilterListModal.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionFilterListModal.tsx similarity index 89% rename from src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionFilterListModal.tsx rename to src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionFilterListModal.tsx index e3076b408b..2b98377a89 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionFilterListModal.tsx +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionFilterListModal.tsx @@ -1,3 +1,4 @@ +import { isWin } from '@renderer/config/constant' import { Button, Form, Input, Modal } from 'antd' import { FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -54,7 +55,11 @@ const SelectionFilterListModal: FC = ({ open, onC {t('common.save')} ]}> - {t('selection.settings.filter_modal.user_tips')} + + {isWin + ? t('selection.settings.filter_modal.user_tips.windows') + : t('selection.settings.filter_modal.user_tips.mac')} +
diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts b/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts index 12d6ba0d37..ee416ad404 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts @@ -4,7 +4,7 @@ import type { ActionItem } from '@renderer/types/selectionTypes' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { DEFAULT_SEARCH_ENGINES } from '../SelectionActionSearchModal' +import { DEFAULT_SEARCH_ENGINES } from '../components/SelectionActionSearchModal' const MAX_CUSTOM_ITEMS = 8 const MAX_ENABLED_ITEMS = 6 diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 9c8eec0653..909790f95a 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -24,7 +24,7 @@ const ShortcutSettings: FC = () => { //if shortcut is not available on all the platforms, block the shortcut here let shortcuts = originalShortcuts - if (!isWin) { + if (!isWin && !isMac) { //Selection Assistant only available on Windows now const excludedShortcuts = ['selection_assistant_toggle', 'selection_assistant_select_text'] shortcuts = shortcuts.filter((s) => !excludedShortcuts.includes(s.key)) diff --git a/src/renderer/src/windows/selection/action/SelectionActionApp.tsx b/src/renderer/src/windows/selection/action/SelectionActionApp.tsx index 57c2b51902..94c1c575ea 100644 --- a/src/renderer/src/windows/selection/action/SelectionActionApp.tsx +++ b/src/renderer/src/windows/selection/action/SelectionActionApp.tsx @@ -1,3 +1,4 @@ +import { isMac } from '@renderer/config/constant' import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' @@ -182,7 +183,7 @@ const SelectionActionApp: FC = () => { return ( - + {action.icon && ( { /> )} - - } onClick={handleMinimize} /> - } onClick={handleClose} className="close" /> + {!isMac && ( + <> + } onClick={handleMinimize} /> + } onClick={handleClose} className="close" /> + + )} diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 00f38e01b8..49b3c2fcf9 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -259,7 +259,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { const Container = styled.div` display: inline-flex; flex-direction: row; - align-items: center; + align-items: stretch; height: var(--selection-toolbar-height); border-radius: var(--selection-toolbar-border-radius); border: var(--selection-toolbar-border); @@ -269,6 +269,7 @@ const Container = styled.div` margin: var(--selection-toolbar-margin) !important; user-select: none; box-sizing: border-box; + overflow: hidden; ` const LogoWrapper = styled.div<{ $draggable: boolean }>` @@ -276,8 +277,13 @@ const LogoWrapper = styled.div<{ $draggable: boolean }>` align-items: center; justify-content: center; margin: var(--selection-toolbar-logo-margin); - background-color: transparent; - ${({ $draggable }) => $draggable && ' -webkit-app-region: drag;'} + padding: var(--selection-toolbar-logo-padding); + background-color: var(--selection-toolbar-logo-background); + border-width: var(--selection-toolbar-logo-border-width); + border-style: var(--selection-toolbar-logo-border-style); + border-color: var(--selection-toolbar-logo-border-color); + border-radius: var(--selection-toolbar-border-radius) 0 0 var(--selection-toolbar-border-radius); + ${({ $draggable }) => $draggable && ' -webkit-app-region: drag;'}; ` const Logo = styled(Avatar)` @@ -307,14 +313,19 @@ const ActionWrapper = styled.div` flex-direction: row; align-items: center; justify-content: center; - margin-left: 3px; background-color: transparent; + border-width: var(--selection-toolbar-buttons-border-width); + border-style: var(--selection-toolbar-buttons-border-style); + border-color: var(--selection-toolbar-buttons-border-color); + border-radius: var(--selection-toolbar-buttons-border-radius); ` const ActionButton = styled.div` + height: 100%; display: flex; flex-direction: row; align-items: center; justify-content: center; + gap: 2px; cursor: pointer !important; margin: var(--selection-toolbar-button-margin); padding: var(--selection-toolbar-button-padding); @@ -324,6 +335,10 @@ const ActionButton = styled.div` box-shadow: var(--selection-toolbar-button-box-shadow); transition: all 0.1s ease-in-out; will-change: color, background-color; + &:last-child { + border-radius: 0 var(--selection-toolbar-border-radius) var(--selection-toolbar-border-radius) 0; + padding: var(--selection-toolbar-button-last-padding); + } .btn-icon { width: var(--selection-toolbar-button-icon-size); @@ -337,6 +352,7 @@ const ActionButton = styled.div` color: var(--selection-toolbar-button-text-color); transition: color 0.1s ease-in-out; will-change: color; + line-height: 1.1; } &:hover { .btn-icon { diff --git a/yarn.lock b/yarn.lock index cc0fd6b62a..dce63d4d20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5793,7 +5793,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^0.9.23" + selection-hook: "npm:^1.0.3" shiki: "npm:^3.7.0" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" @@ -13955,6 +13955,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^8.4.0": + version: 8.4.0 + resolution: "node-addon-api@npm:8.4.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/d51be099e1b9a6ac4a72f1a60787004d44c8ffe4be1efa38755d54b2a9f4f66647cc6913070e0ed20256d0e6eacceabfff90175fba2ef71153c2d06f8db8e7a9 + languageName: node + linkType: hard + "node-api-version@npm:^0.2.0": version: 0.2.1 resolution: "node-api-version@npm:0.2.1" @@ -16714,13 +16723,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^0.9.23": - version: 0.9.23 - resolution: "selection-hook@npm:0.9.23" +"selection-hook@npm:^1.0.3": + version: 1.0.3 + resolution: "selection-hook@npm:1.0.3" dependencies: + node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/3b91193814c063e14dd788cff3b27020821bbeae24eab106d2ce5bf600c034c1b3db96ce573c456b74d0553346dfcf4c7cc8d49386a22797b42667f7ed3eee01 + checksum: 10c0/812df47050d470d398974ca9833caba3bc55fcacf76aec5207cffcef4b81cf22d5a0992263e2074afd05c21781f903ffac25177cd9553417525648136405b474 languageName: node linkType: hard From 1afbb30bfc7fe400bf6a89640d5b12eea680ce2e Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 3 Jul 2025 15:26:09 +0800 Subject: [PATCH 004/317] =?UTF-8?q?fix(migrate):=20enable=20stream=20outpu?= =?UTF-8?q?t=20for=20existing=20assistants=20in=20migrati=E2=80=A6=20(#777?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(migrate): enable stream output for existing assistants in migration process - Updated the migration logic to set the default streamOutput setting to true for assistants that do not have this property defined, enhancing the user experience by ensuring consistent behavior across all assistants. --- src/renderer/src/store/migrate.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 4e41b750eb..ef5b526d2b 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1685,6 +1685,14 @@ const migrateConfig = { apiHost: 'https://api.ppinfra.com/v3/openai/' }) } + state.assistants.assistants.forEach((assistant) => { + if (assistant.settings && assistant.settings.streamOutput === undefined) { + assistant.settings = { + ...assistant.settings, + streamOutput: true + } + } + }) return state } catch (error) { return state From e35b4d9cd174547c74616efbd7a1e5e56a62c1ad Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:23:02 +0800 Subject: [PATCH 005/317] feat(knowledge): support doc2x, mistral, MacOS, MinerU... OCR (#3734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: suyao Co-authored-by: 亢奋猫 --- .github/workflows/release.yml | 3 + electron-builder.yml | 2 + electron.vite.config.ts | 2 +- package.json | 6 + packages/shared/IpcChannel.ts | 10 +- packages/shared/config/types.ts | 5 + scripts/after-pack.js | 21 + src/main/ipc.ts | 30 +- src/main/knowledage/loader/index.ts | 6 +- src/main/ocr/BaseOcrProvider.ts | 122 ++++ src/main/ocr/DefaultOcrProvider.ts | 12 + src/main/ocr/MacSysOcrProvider.ts | 128 +++++ src/main/ocr/OcrProvider.ts | 26 + src/main/ocr/OcrProviderFactory.ts | 20 + src/main/preprocess/BasePreprocessProvider.ts | 126 +++++ .../preprocess/DefaultPreprocessProvider.ts | 16 + .../preprocess/Doc2xPreprocessProvider.ts | 329 +++++++++++ .../preprocess/MineruPreprocessProvider.ts | 399 +++++++++++++ .../preprocess/MistralPreprocessProvider.ts | 187 +++++++ src/main/preprocess/PreprocessProvider.ts | 30 + .../preprocess/PreprocessProviderFactory.ts | 21 + src/main/services/FileStorage.ts | 37 +- .../{FileService.ts => FileSystemService.ts} | 0 src/main/services/KnowledgeService.ts | 168 +++++- src/main/services/MistralClientManager.ts | 33 ++ .../services/remotefile/BaseFileService.ts | 13 + .../services/remotefile/FileServiceManager.ts | 41 ++ src/main/services/remotefile/GeminiService.ts | 190 +++++++ .../services/remotefile/MistralService.ts | 104 ++++ src/main/utils/file.ts | 18 +- src/main/utils/process.ts | 2 +- src/preload/index.ts | 56 +- .../aiCore/clients/gemini/GeminiAPIClient.ts | 84 +-- .../clients/openai/OpenAIResponseAPIClient.ts | 4 +- .../src/assets/fonts/icon-fonts/iconfont.css | 14 +- .../assets/fonts/icon-fonts/iconfont.woff2 | Bin 4408 -> 5148 bytes src/renderer/src/assets/images/ocr/doc2x.png | Bin 0 -> 49106 bytes src/renderer/src/assets/images/ocr/mineru.jpg | Bin 0 -> 16601 bytes .../src/assets/images/providers/macos.svg | 7 + .../CodeBlockView/HtmlArtifacts.tsx | 4 +- src/renderer/src/components/Icons/OcrIcon.tsx | 7 + .../src/components/Icons/ToolIcon.tsx | 7 + src/renderer/src/config/ocrProviders.ts | 12 + .../src/config/preprocessProviders.ts | 37 ++ src/renderer/src/databases/index.ts | 4 +- src/renderer/src/hooks/useKnowledge.ts | 48 +- src/renderer/src/hooks/useKnowledgeFiles.tsx | 8 +- src/renderer/src/hooks/useOcr.ts | 45 ++ src/renderer/src/hooks/usePreprocess.ts | 48 ++ src/renderer/src/i18n/locales/en-us.json | 169 +++--- src/renderer/src/i18n/locales/ja-jp.json | 177 +++--- src/renderer/src/i18n/locales/ru-ru.json | 177 +++--- src/renderer/src/i18n/locales/zh-cn.json | 181 +++--- src/renderer/src/i18n/locales/zh-tw.json | 179 +++--- src/renderer/src/pages/files/ContentView.tsx | 4 +- src/renderer/src/pages/files/FileList.tsx | 6 +- src/renderer/src/pages/files/FilesPage.tsx | 4 +- .../pages/home/Inputbar/AttachmentPreview.tsx | 8 +- .../pages/home/Inputbar/WebSearchButton.tsx | 6 +- .../src/pages/home/Messages/CitationsList.tsx | 3 +- .../home/Messages/MessageAttachments.tsx | 2 +- .../src/pages/home/Messages/MessageEditor.tsx | 4 +- .../src/pages/knowledge/KnowledgeContent.tsx | 45 +- .../src/pages/knowledge/KnowledgePage.tsx | 6 +- .../components/AddKnowledgePopup.tsx | 527 ++++++++++++++---- .../components/KnowledgeSearchPopup.tsx | 11 +- .../components/KnowledgeSettings.tsx | 442 +++++++++++++++ .../components/KnowledgeSettingsPopup.tsx | 285 ---------- .../pages/knowledge/components/QuotaTag.tsx | 66 +++ .../pages/knowledge/components/StatusIcon.tsx | 119 ++-- .../knowledge/items/KnowledgeDirectories.tsx | 19 +- .../pages/knowledge/items/KnowledgeFiles.tsx | 73 ++- .../src/pages/paintings/AihubmixPage.tsx | 8 +- .../src/pages/paintings/DmxapiPage.tsx | 12 +- .../src/pages/paintings/SiliconPage.tsx | 4 +- .../paintings/components/ImageUploader.tsx | 4 +- .../pages/paintings/utils/TokenFluxService.ts | 4 +- .../src/pages/settings/GeneralSettings.tsx | 5 +- .../GithubCopilotSettings.tsx | 2 +- .../src/pages/settings/SettingsPage.tsx | 16 +- .../ToolSettings/OcrSettings/OcrSettings.tsx | 168 ++++++ .../ToolSettings/OcrSettings/index.tsx | 58 ++ .../PreprocessSettings/PreprocessSettings.tsx | 170 ++++++ .../ToolSettings/PreprocessSettings/index.tsx | 58 ++ .../WebSearchSettings/AddSubscribePopup.tsx | 12 +- .../WebSearchSettings/BasicSettings.tsx | 10 +- .../WebSearchSettings/BlacklistSettings.tsx | 30 +- .../CompressionSettings/CutoffSettings.tsx | 13 +- .../CompressionSettings/RagSettings.tsx | 18 +- .../CompressionSettings/index.tsx | 10 +- .../WebSearchProviderSetting.tsx | 32 +- .../WebSearchSettings/index.tsx | 10 +- .../src/pages/settings/ToolSettings/index.tsx | 58 ++ src/renderer/src/queue/KnowledgeQueue.ts | 44 +- src/renderer/src/services/FileManager.ts | 37 +- src/renderer/src/services/KnowledgeService.ts | 22 +- src/renderer/src/services/MessagesService.ts | 6 +- src/renderer/src/services/PasteService.ts | 8 +- src/renderer/src/services/TokenService.ts | 10 +- src/renderer/src/store/index.ts | 6 +- src/renderer/src/store/knowledge.ts | 23 +- src/renderer/src/store/migrate.ts | 59 +- src/renderer/src/store/ocr.ts | 46 ++ src/renderer/src/store/preprocess.ts | 62 +++ src/renderer/src/store/settings.ts | 7 +- src/renderer/src/store/thunk/messageThunk.ts | 8 +- src/renderer/src/types/file.ts | 102 ++++ src/renderer/src/types/index.ts | 66 ++- src/renderer/src/types/newMessage.ts | 11 +- src/renderer/src/types/notification.ts | 2 +- .../src/utils/__tests__/export.test.ts | 23 + src/renderer/src/utils/input.ts | 6 +- src/renderer/src/utils/messageUtils/create.ts | 4 +- src/renderer/src/utils/messageUtils/find.ts | 6 +- yarn.lock | 191 ++++++- 115 files changed, 5223 insertions(+), 1233 deletions(-) create mode 100644 src/main/ocr/BaseOcrProvider.ts create mode 100644 src/main/ocr/DefaultOcrProvider.ts create mode 100644 src/main/ocr/MacSysOcrProvider.ts create mode 100644 src/main/ocr/OcrProvider.ts create mode 100644 src/main/ocr/OcrProviderFactory.ts create mode 100644 src/main/preprocess/BasePreprocessProvider.ts create mode 100644 src/main/preprocess/DefaultPreprocessProvider.ts create mode 100644 src/main/preprocess/Doc2xPreprocessProvider.ts create mode 100644 src/main/preprocess/MineruPreprocessProvider.ts create mode 100644 src/main/preprocess/MistralPreprocessProvider.ts create mode 100644 src/main/preprocess/PreprocessProvider.ts create mode 100644 src/main/preprocess/PreprocessProviderFactory.ts rename src/main/services/{FileService.ts => FileSystemService.ts} (100%) create mode 100644 src/main/services/MistralClientManager.ts create mode 100644 src/main/services/remotefile/BaseFileService.ts create mode 100644 src/main/services/remotefile/FileServiceManager.ts create mode 100644 src/main/services/remotefile/GeminiService.ts create mode 100644 src/main/services/remotefile/MistralService.ts create mode 100644 src/renderer/src/assets/images/ocr/doc2x.png create mode 100644 src/renderer/src/assets/images/ocr/mineru.jpg create mode 100644 src/renderer/src/assets/images/providers/macos.svg create mode 100644 src/renderer/src/components/Icons/OcrIcon.tsx create mode 100644 src/renderer/src/components/Icons/ToolIcon.tsx create mode 100644 src/renderer/src/config/ocrProviders.ts create mode 100644 src/renderer/src/config/preprocessProviders.ts create mode 100644 src/renderer/src/hooks/useOcr.ts create mode 100644 src/renderer/src/hooks/usePreprocess.ts create mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettings.tsx delete mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx create mode 100644 src/renderer/src/pages/knowledge/components/QuotaTag.tsx create mode 100644 src/renderer/src/pages/settings/ToolSettings/OcrSettings/OcrSettings.tsx create mode 100644 src/renderer/src/pages/settings/ToolSettings/OcrSettings/index.tsx create mode 100644 src/renderer/src/pages/settings/ToolSettings/PreprocessSettings/PreprocessSettings.tsx create mode 100644 src/renderer/src/pages/settings/ToolSettings/PreprocessSettings/index.tsx rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/AddSubscribePopup.tsx (83%) rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/BasicSettings.tsx (78%) rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/BlacklistSettings.tsx (88%) rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/CompressionSettings/CutoffSettings.tsx (75%) rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/CompressionSettings/RagSettings.tsx (88%) rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/CompressionSettings/index.tsx (73%) rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/WebSearchProviderSetting.tsx (91%) rename src/renderer/src/pages/settings/{ => ToolSettings}/WebSearchSettings/index.tsx (85%) create mode 100644 src/renderer/src/pages/settings/ToolSettings/index.tsx create mode 100644 src/renderer/src/store/ocr.ts create mode 100644 src/renderer/src/store/preprocess.ts create mode 100644 src/renderer/src/types/file.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a007e4e91..2d60f3e75c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - name: Build Mac if: matrix.os == 'macos-latest' @@ -95,6 +96,7 @@ jobs: RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - name: Build Windows if: matrix.os == 'windows-latest' @@ -105,6 +107,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - name: Release uses: ncipollo/release-action@v1 diff --git a/electron-builder.yml b/electron-builder.yml index 1303a4a3c8..4bcf025c26 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -53,6 +53,8 @@ files: - '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}' - '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}' - '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds + - '!node_modules/pdfjs-dist/web/**/*' + - '!node_modules/pdfjs-dist/legacy/web/*' - '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir - '!node_modules/selection-hook/src' # we don't need source files - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 770a47d479..2b4c5e6b92 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ }, build: { rollupOptions: { - external: ['@libsql/client', 'bufferutil', 'utf-8-validate'], + external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr'], output: { // 彻底禁用代码分割 - 返回 null 强制单文件打包 manualChunks: undefined, diff --git a/package.json b/package.json index 35a85bf162..468ef5138e 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "prepare": "husky" }, "dependencies": { + "@cherrystudio/pdf-to-img-napi": "^0.0.1", "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7", @@ -66,6 +67,7 @@ "node-stream-zip": "^1.15.0", "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", + "pdfjs-dist": "4.10.38", "selection-hook": "^1.0.3", "turndown": "7.2.0" }, @@ -101,6 +103,7 @@ "@kangfenmao/keyv-storage": "^0.1.0", "@langchain/community": "^0.3.36", "@langchain/ollama": "^0.2.1", + "@mistralai/mistralai": "^1.6.0", "@modelcontextprotocol/sdk": "^1.11.4", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", @@ -225,6 +228,9 @@ "word-extractor": "^1.0.4", "zipread": "^1.3.3" }, + "optionalDependencies": { + "@cherrystudio/mac-system-ocr": "^0.2.2" + }, "resolutions": { "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 8118065278..7dd60bab06 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -118,6 +118,7 @@ export enum IpcChannel { KnowledgeBase_Remove = 'knowledge-base:remove', KnowledgeBase_Search = 'knowledge-base:search', KnowledgeBase_Rerank = 'knowledge-base:rerank', + KnowledgeBase_Check_Quota = 'knowledge-base:check-quota', //file File_Open = 'file:open', @@ -128,9 +129,10 @@ export enum IpcChannel { File_Clear = 'file:clear', File_Read = 'file:read', File_Delete = 'file:delete', + File_DeleteDir = 'file:deleteDir', File_Get = 'file:get', File_SelectFolder = 'file:selectFolder', - File_Create = 'file:create', + File_CreateTempFile = 'file:createTempFile', File_Write = 'file:write', File_WriteWithId = 'file:writeWithId', File_SaveImage = 'file:saveImage', @@ -143,6 +145,12 @@ export enum IpcChannel { File_GetPdfInfo = 'file:getPdfInfo', Fs_Read = 'fs:read', + // file service + FileService_Upload = 'file-service:upload', + FileService_List = 'file-service:list', + FileService_Delete = 'file-service:delete', + FileService_Retrieve = 'file-service:retrieve', + Export_Word = 'export:word', Shortcuts_Update = 'shortcuts:update', diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 48a76c4778..28bb4acf65 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -1,6 +1,11 @@ +import { ProcessingStatus } from '@types' + export type LoaderReturn = { entriesAdded: number uniqueId: string uniqueIds: string[] loaderType: string + status?: ProcessingStatus + message?: string + messageSource?: 'preprocess' | 'embedding' } diff --git a/scripts/after-pack.js b/scripts/after-pack.js index a764642308..4b18d2dacd 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -23,6 +23,9 @@ exports.default = async function (context) { const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl'] keepPackageNodeFiles(node_modules_path, '@libsql', _arch) + + // 删除 macOS 专用的 OCR 包 + removeMacOnlyPackages(node_modules_path) } if (platform === 'windows') { @@ -35,6 +38,8 @@ exports.default = async function (context) { keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc']) keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) } + + removeMacOnlyPackages(node_modules_path) } if (platform === 'windows') { @@ -43,6 +48,22 @@ exports.default = async function (context) { } } +/** + * 删除 macOS 专用的包 + * @param {string} nodeModulesPath + */ +function removeMacOnlyPackages(nodeModulesPath) { + const macOnlyPackages = ['@cherrystudio/mac-system-ocr'] + + macOnlyPackages.forEach((packageName) => { + const packagePath = path.join(nodeModulesPath, packageName) + if (fs.existsSync(packagePath)) { + fs.rmSync(packagePath, { recursive: true, force: true }) + console.log(`[After Pack] Removed macOS-only package: ${packageName}`) + } + }) +} + /** * 使用指定架构的 node_modules 文件 * @param {*} nodeModulesPath diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0176a27525..a9c5169096 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7,7 +7,7 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro import { handleZoomFactor } from '@main/utils/zoom' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { Shortcut, ThemeMode } from '@types' +import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron' import log from 'electron-log' import { Notification } from 'src/renderer/src/types/notification' @@ -17,8 +17,8 @@ import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' import CopilotService from './services/CopilotService' import { ExportService } from './services/ExportService' -import FileService from './services/FileService' import FileStorage from './services/FileStorage' +import FileService from './services/FileSystemService' import KnowledgeService from './services/KnowledgeService' import mcpService from './services/MCPService' import NotificationService from './services/NotificationService' @@ -26,6 +26,7 @@ import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' +import { FileServiceManager } from './services/remotefile/FileServiceManager' import { searchService } from './services/SearchService' import { SelectionService } from './services/SelectionService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' @@ -377,9 +378,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_Clear, fileManager.clear) ipcMain.handle(IpcChannel.File_Read, fileManager.readFile) ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile) + ipcMain.handle('file:deleteDir', fileManager.deleteDir) ipcMain.handle(IpcChannel.File_Get, fileManager.getFile) ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder) - ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile) + ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile) ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile) ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId) ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage) @@ -391,6 +393,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile) ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage) + // file service + ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => { + const service = FileServiceManager.getInstance().getService(provider) + return await service.uploadFile(file) + }) + + ipcMain.handle(IpcChannel.FileService_List, async (_, provider: Provider) => { + const service = FileServiceManager.getInstance().getService(provider) + return await service.listFiles() + }) + + ipcMain.handle(IpcChannel.FileService_Delete, async (_, provider: Provider, fileId: string) => { + const service = FileServiceManager.getInstance().getService(provider) + return await service.deleteFile(fileId) + }) + + ipcMain.handle(IpcChannel.FileService_Retrieve, async (_, provider: Provider, fileId: string) => { + const service = FileServiceManager.getInstance().getService(provider) + return await service.retrieveFile(fileId) + }) + // fs ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile) @@ -420,6 +443,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove) ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search) ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank) + ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota) // window ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => { diff --git a/src/main/knowledage/loader/index.ts b/src/main/knowledage/loader/index.ts index ba66b33e3d..783e62881a 100644 --- a/src/main/knowledage/loader/index.ts +++ b/src/main/knowledage/loader/index.ts @@ -4,7 +4,7 @@ import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherry import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces' import { WebLoader } from '@cherrystudio/embedjs-loader-web' import { LoaderReturn } from '@shared/config/types' -import { FileType, KnowledgeBaseParams } from '@types' +import { FileMetadata, KnowledgeBaseParams } from '@types' import Logger from 'electron-log' import { DraftsExportLoader } from './draftsExportLoader' @@ -39,7 +39,7 @@ const FILE_LOADER_MAP: Record = { export async function addOdLoader( ragApplication: RAGApplication, - file: FileType, + file: FileMetadata, base: KnowledgeBaseParams, forceReload: boolean ): Promise { @@ -65,7 +65,7 @@ export async function addOdLoader( export async function addFileLoader( ragApplication: RAGApplication, - file: FileType, + file: FileMetadata, base: KnowledgeBaseParams, forceReload: boolean ): Promise { diff --git a/src/main/ocr/BaseOcrProvider.ts b/src/main/ocr/BaseOcrProvider.ts new file mode 100644 index 0000000000..1bc7ce8530 --- /dev/null +++ b/src/main/ocr/BaseOcrProvider.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { windowService } from '@main/services/WindowService' +import { getFileExt } from '@main/utils/file' +import { FileMetadata, OcrProvider } from '@types' +import { app } from 'electron' +import { TypedArray } from 'pdfjs-dist/types/src/display/api' + +export default abstract class BaseOcrProvider { + protected provider: OcrProvider + public storageDir = path.join(app.getPath('userData'), 'Data', 'Files') + + constructor(provider: OcrProvider) { + if (!provider) { + throw new Error('OCR provider is not set') + } + this.provider = provider + } + abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }> + + /** + * 检查文件是否已经被预处理过 + * 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理 + * @param file 文件信息 + * @returns 如果已处理返回处理后的文件信息,否则返回null + */ + public async checkIfAlreadyProcessed(file: FileMetadata): Promise { + try { + // 检查 Data/Files/{file.id} 是否是目录 + const preprocessDirPath = path.join(this.storageDir, file.id) + + if (fs.existsSync(preprocessDirPath)) { + const stats = await fs.promises.stat(preprocessDirPath) + + // 如果是目录,说明已经被预处理过 + if (stats.isDirectory()) { + // 查找目录中的处理结果文件 + const files = await fs.promises.readdir(preprocessDirPath) + + // 查找主要的处理结果文件(.md 或 .txt) + const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt')) + + if (processedFile) { + const processedFilePath = path.join(preprocessDirPath, processedFile) + const processedStats = await fs.promises.stat(processedFilePath) + const ext = getFileExt(processedFile) + + return { + ...file, + name: file.name.replace(file.ext, ext), + path: processedFilePath, + ext: ext, + size: processedStats.size, + created_at: processedStats.birthtime.toISOString() + } + } + } + } + + return null + } catch (error) { + // 如果检查过程中出现错误,返回null表示未处理 + return null + } + } + + /** + * 辅助方法:延迟执行 + */ + public delay = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + public async readPdf( + source: string | URL | TypedArray, + passwordCallback?: (fn: (password: string) => void, reason: string) => string + ) { + const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs') + const documentLoadingTask = getDocument(source) + if (passwordCallback) { + documentLoadingTask.onPassword = passwordCallback + } + + const document = await documentLoadingTask.promise + return document + } + + public async sendOcrProgress(sourceId: string, progress: number): Promise { + const mainWindow = windowService.getMainWindow() + mainWindow?.webContents.send('file-ocr-progress', { + itemId: sourceId, + progress: progress + }) + } + + /** + * 将文件移动到附件目录 + * @param fileId 文件id + * @param filePaths 需要移动的文件路径数组 + * @returns 移动后的文件路径数组 + */ + public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] { + const attachmentsPath = path.join(this.storageDir, fileId) + if (!fs.existsSync(attachmentsPath)) { + fs.mkdirSync(attachmentsPath, { recursive: true }) + } + + const movedPaths: string[] = [] + + for (const filePath of filePaths) { + if (fs.existsSync(filePath)) { + const fileName = path.basename(filePath) + const destPath = path.join(attachmentsPath, fileName) + fs.copyFileSync(filePath, destPath) + fs.unlinkSync(filePath) // 删除原文件,实现"移动" + movedPaths.push(destPath) + } + } + return movedPaths + } +} diff --git a/src/main/ocr/DefaultOcrProvider.ts b/src/main/ocr/DefaultOcrProvider.ts new file mode 100644 index 0000000000..83c8d51c91 --- /dev/null +++ b/src/main/ocr/DefaultOcrProvider.ts @@ -0,0 +1,12 @@ +import { FileMetadata, OcrProvider } from '@types' + +import BaseOcrProvider from './BaseOcrProvider' + +export default class DefaultOcrProvider extends BaseOcrProvider { + constructor(provider: OcrProvider) { + super(provider) + } + public parseFile(): Promise<{ processedFile: FileMetadata }> { + throw new Error('Method not implemented.') + } +} diff --git a/src/main/ocr/MacSysOcrProvider.ts b/src/main/ocr/MacSysOcrProvider.ts new file mode 100644 index 0000000000..df281eb60b --- /dev/null +++ b/src/main/ocr/MacSysOcrProvider.ts @@ -0,0 +1,128 @@ +import { isMac } from '@main/constant' +import { FileMetadata, OcrProvider } from '@types' +import Logger from 'electron-log' +import * as fs from 'fs' +import * as path from 'path' +import { TextItem } from 'pdfjs-dist/types/src/display/api' + +import BaseOcrProvider from './BaseOcrProvider' + +export default class MacSysOcrProvider extends BaseOcrProvider { + private readonly MIN_TEXT_LENGTH = 1000 + private MacOCR: any + + private async initMacOCR() { + if (!isMac) { + throw new Error('MacSysOcrProvider is only available on macOS') + } + if (!this.MacOCR) { + try { + // @ts-ignore This module is optional and only installed/available on macOS. Runtime checks prevent execution on other platforms. + const module = await import('@cherrystudio/mac-system-ocr') + this.MacOCR = module.default + } catch (error) { + Logger.error('[OCR] Failed to load mac-system-ocr:', error) + throw error + } + } + return this.MacOCR + } + + private getRecognitionLevel(level?: number) { + return level === 0 ? this.MacOCR.RECOGNITION_LEVEL_FAST : this.MacOCR.RECOGNITION_LEVEL_ACCURATE + } + + constructor(provider: OcrProvider) { + super(provider) + } + + private async processPages( + results: any, + totalPages: number, + sourceId: string, + writeStream: fs.WriteStream + ): Promise { + await this.initMacOCR() + // TODO: 下个版本后面使用批处理,以及p-queue来优化 + for (let i = 0; i < totalPages; i++) { + // Convert pages to buffers + const pageNum = i + 1 + const pageBuffer = await results.getPage(pageNum) + + // Process batch + const ocrResult = await this.MacOCR.recognizeFromBuffer(pageBuffer, { + ocrOptions: { + recognitionLevel: this.getRecognitionLevel(this.provider.options?.recognitionLevel), + minConfidence: this.provider.options?.minConfidence || 0.5 + } + }) + + // Write results in order + writeStream.write(ocrResult.text + '\n') + + // Update progress + await this.sendOcrProgress(sourceId, (pageNum / totalPages) * 100) + } + } + + public async isScanPdf(buffer: Buffer): Promise { + const doc = await this.readPdf(new Uint8Array(buffer)) + const pageLength = doc.numPages + let counts = 0 + const pagesToCheck = Math.min(pageLength, 10) + for (let i = 0; i < pagesToCheck; i++) { + const page = await doc.getPage(i + 1) + const pageData = await page.getTextContent() + const pageText = pageData.items.map((item) => (item as TextItem).str).join('') + counts += pageText.length + if (counts >= this.MIN_TEXT_LENGTH) { + return false + } + } + return true + } + + public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> { + Logger.info(`[OCR] Starting OCR process for file: ${file.name}`) + if (file.ext === '.pdf') { + try { + const { pdf } = await import('@cherrystudio/pdf-to-img-napi') + const pdfBuffer = await fs.promises.readFile(file.path) + const results = await pdf(pdfBuffer, { + scale: 2 + }) + const totalPages = results.length + + const baseDir = path.dirname(file.path) + const baseName = path.basename(file.path, path.extname(file.path)) + const txtFileName = `${baseName}.txt` + const txtFilePath = path.join(baseDir, txtFileName) + + const writeStream = fs.createWriteStream(txtFilePath) + await this.processPages(results, totalPages, sourceId, writeStream) + + await new Promise((resolve, reject) => { + writeStream.end(() => { + Logger.info(`[OCR] OCR process completed successfully for ${file.origin_name}`) + resolve() + }) + writeStream.on('error', reject) + }) + const movedPaths = this.moveToAttachmentsDir(file.id, [txtFilePath]) + return { + processedFile: { + ...file, + name: txtFileName, + path: movedPaths[0], + ext: '.txt', + size: fs.statSync(movedPaths[0]).size + } + } + } catch (error) { + Logger.error('[OCR] Error during OCR process:', error) + throw error + } + } + return { processedFile: file } + } +} diff --git a/src/main/ocr/OcrProvider.ts b/src/main/ocr/OcrProvider.ts new file mode 100644 index 0000000000..07587f01e0 --- /dev/null +++ b/src/main/ocr/OcrProvider.ts @@ -0,0 +1,26 @@ +import { FileMetadata, OcrProvider as Provider } from '@types' + +import BaseOcrProvider from './BaseOcrProvider' +import OcrProviderFactory from './OcrProviderFactory' + +export default class OcrProvider { + private sdk: BaseOcrProvider + constructor(provider: Provider) { + this.sdk = OcrProviderFactory.create(provider) + } + public async parseFile( + sourceId: string, + file: FileMetadata + ): Promise<{ processedFile: FileMetadata; quota?: number }> { + return this.sdk.parseFile(sourceId, file) + } + + /** + * 检查文件是否已经被预处理过 + * @param file 文件信息 + * @returns 如果已处理返回处理后的文件信息,否则返回null + */ + public async checkIfAlreadyProcessed(file: FileMetadata): Promise { + return this.sdk.checkIfAlreadyProcessed(file) + } +} diff --git a/src/main/ocr/OcrProviderFactory.ts b/src/main/ocr/OcrProviderFactory.ts new file mode 100644 index 0000000000..96d95a63ad --- /dev/null +++ b/src/main/ocr/OcrProviderFactory.ts @@ -0,0 +1,20 @@ +import { isMac } from '@main/constant' +import { OcrProvider } from '@types' +import Logger from 'electron-log' + +import BaseOcrProvider from './BaseOcrProvider' +import DefaultOcrProvider from './DefaultOcrProvider' +import MacSysOcrProvider from './MacSysOcrProvider' +export default class OcrProviderFactory { + static create(provider: OcrProvider): BaseOcrProvider { + switch (provider.id) { + case 'system': + if (!isMac) { + Logger.warn('[OCR] System OCR provider is only available on macOS') + } + return new MacSysOcrProvider(provider) + default: + return new DefaultOcrProvider(provider) + } + } +} diff --git a/src/main/preprocess/BasePreprocessProvider.ts b/src/main/preprocess/BasePreprocessProvider.ts new file mode 100644 index 0000000000..016e4d10d0 --- /dev/null +++ b/src/main/preprocess/BasePreprocessProvider.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { windowService } from '@main/services/WindowService' +import { getFileExt } from '@main/utils/file' +import { FileMetadata, PreprocessProvider } from '@types' +import { app } from 'electron' +import { TypedArray } from 'pdfjs-dist/types/src/display/api' + +export default abstract class BasePreprocessProvider { + protected provider: PreprocessProvider + protected userId?: string + public storageDir = path.join(app.getPath('userData'), 'Data', 'Files') + + constructor(provider: PreprocessProvider, userId?: string) { + if (!provider) { + throw new Error('Preprocess provider is not set') + } + this.provider = provider + this.userId = userId + } + abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }> + + abstract checkQuota(): Promise + + /** + * 检查文件是否已经被预处理过 + * 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理 + * @param file 文件信息 + * @returns 如果已处理返回处理后的文件信息,否则返回null + */ + public async checkIfAlreadyProcessed(file: FileMetadata): Promise { + try { + // 检查 Data/Files/{file.id} 是否是目录 + const preprocessDirPath = path.join(this.storageDir, file.id) + + if (fs.existsSync(preprocessDirPath)) { + const stats = await fs.promises.stat(preprocessDirPath) + + // 如果是目录,说明已经被预处理过 + if (stats.isDirectory()) { + // 查找目录中的处理结果文件 + const files = await fs.promises.readdir(preprocessDirPath) + + // 查找主要的处理结果文件(.md 或 .txt) + const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt')) + + if (processedFile) { + const processedFilePath = path.join(preprocessDirPath, processedFile) + const processedStats = await fs.promises.stat(processedFilePath) + const ext = getFileExt(processedFile) + + return { + ...file, + name: file.name.replace(file.ext, ext), + path: processedFilePath, + ext: ext, + size: processedStats.size, + created_at: processedStats.birthtime.toISOString() + } + } + } + } + + return null + } catch (error) { + // 如果检查过程中出现错误,返回null表示未处理 + return null + } + } + + /** + * 辅助方法:延迟执行 + */ + public delay = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + public async readPdf( + source: string | URL | TypedArray, + passwordCallback?: (fn: (password: string) => void, reason: string) => string + ) { + const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs') + const documentLoadingTask = getDocument(source) + if (passwordCallback) { + documentLoadingTask.onPassword = passwordCallback + } + + const document = await documentLoadingTask.promise + return document + } + + public async sendPreprocessProgress(sourceId: string, progress: number): Promise { + const mainWindow = windowService.getMainWindow() + mainWindow?.webContents.send('file-preprocess-progress', { + itemId: sourceId, + progress: progress + }) + } + + /** + * 将文件移动到附件目录 + * @param fileId 文件id + * @param filePaths 需要移动的文件路径数组 + * @returns 移动后的文件路径数组 + */ + public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] { + const attachmentsPath = path.join(this.storageDir, fileId) + if (!fs.existsSync(attachmentsPath)) { + fs.mkdirSync(attachmentsPath, { recursive: true }) + } + + const movedPaths: string[] = [] + + for (const filePath of filePaths) { + if (fs.existsSync(filePath)) { + const fileName = path.basename(filePath) + const destPath = path.join(attachmentsPath, fileName) + fs.copyFileSync(filePath, destPath) + fs.unlinkSync(filePath) // 删除原文件,实现"移动" + movedPaths.push(destPath) + } + } + return movedPaths + } +} diff --git a/src/main/preprocess/DefaultPreprocessProvider.ts b/src/main/preprocess/DefaultPreprocessProvider.ts new file mode 100644 index 0000000000..3899a3d25a --- /dev/null +++ b/src/main/preprocess/DefaultPreprocessProvider.ts @@ -0,0 +1,16 @@ +import { FileMetadata, PreprocessProvider } from '@types' + +import BasePreprocessProvider from './BasePreprocessProvider' + +export default class DefaultPreprocessProvider extends BasePreprocessProvider { + constructor(provider: PreprocessProvider) { + super(provider) + } + public parseFile(): Promise<{ processedFile: FileMetadata }> { + throw new Error('Method not implemented.') + } + + public checkQuota(): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/src/main/preprocess/Doc2xPreprocessProvider.ts b/src/main/preprocess/Doc2xPreprocessProvider.ts new file mode 100644 index 0000000000..ad311a5b83 --- /dev/null +++ b/src/main/preprocess/Doc2xPreprocessProvider.ts @@ -0,0 +1,329 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { FileMetadata, PreprocessProvider } from '@types' +import AdmZip from 'adm-zip' +import axios, { AxiosRequestConfig } from 'axios' +import Logger from 'electron-log' + +import BasePreprocessProvider from './BasePreprocessProvider' + +type ApiResponse = { + code: string + data: T + message?: string +} + +type PreuploadResponse = { + uid: string + url: string +} + +type StatusResponse = { + status: string + progress: number +} + +type ParsedFileResponse = { + status: string + url: string +} + +export default class Doc2xPreprocessProvider extends BasePreprocessProvider { + constructor(provider: PreprocessProvider) { + super(provider) + } + + private async validateFile(filePath: string): Promise { + const pdfBuffer = await fs.promises.readFile(filePath) + + const doc = await this.readPdf(new Uint8Array(pdfBuffer)) + + // 文件页数小于1000页 + if (doc.numPages >= 1000) { + throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 1000 pages`) + } + // 文件大小小于300MB + if (pdfBuffer.length >= 300 * 1024 * 1024) { + const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024)) + throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`) + } + } + + public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> { + try { + Logger.info(`Preprocess processing started: ${file.path}`) + + // 步骤1: 准备上传 + const { uid, url } = await this.preupload() + Logger.info(`Preprocess preupload completed: uid=${uid}`) + + await this.validateFile(file.path) + + // 步骤2: 上传文件 + await this.putFile(file.path, url) + + // 步骤3: 等待处理完成 + await this.waitForProcessing(sourceId, uid) + Logger.info(`Preprocess parsing completed successfully for: ${file.path}`) + + // 步骤4: 导出文件 + const { path: outputPath } = await this.exportFile(file, uid) + + // 步骤5: 创建处理后的文件信息 + return { + processedFile: this.createProcessedFileInfo(file, outputPath) + } + } catch (error) { + Logger.error( + `Preprocess processing failed for ${file.path}: ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } + + private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata { + const outputFilePath = `${outputPath}/${file.name.split('.').slice(0, -1).join('.')}.md` + return { + ...file, + name: file.name.replace('.pdf', '.md'), + path: outputFilePath, + ext: '.md', + size: fs.statSync(outputFilePath).size + } + } + + /** + * 导出文件 + * @param file 文件信息 + * @param uid 预上传响应的uid + * @returns 导出文件的路径 + */ + public async exportFile(file: FileMetadata, uid: string): Promise<{ path: string }> { + Logger.info(`Exporting file: ${file.path}`) + + // 步骤1: 转换文件 + await this.convertFile(uid, file.path) + Logger.info(`File conversion completed for: ${file.path}`) + + // 步骤2: 等待导出并获取URL + const exportUrl = await this.waitForExport(uid) + + // 步骤3: 下载并解压文件 + return this.downloadFile(exportUrl, file) + } + + /** + * 等待处理完成 + * @param sourceId 源文件ID + * @param uid 预上传响应的uid + */ + private async waitForProcessing(sourceId: string, uid: string): Promise { + while (true) { + await this.delay(1000) + const { status, progress } = await this.getStatus(uid) + await this.sendPreprocessProgress(sourceId, progress) + Logger.info(`Preprocess processing status: ${status}, progress: ${progress}%`) + + if (status === 'success') { + return + } else if (status === 'failed') { + throw new Error('Preprocess processing failed') + } + } + } + + /** + * 等待导出完成 + * @param uid 预上传响应的uid + * @returns 导出文件的url + */ + private async waitForExport(uid: string): Promise { + while (true) { + await this.delay(1000) + const { status, url } = await this.getParsedFile(uid) + Logger.info(`Export status: ${status}`) + + if (status === 'success' && url) { + return url + } else if (status === 'failed') { + throw new Error('Export failed') + } + } + } + + /** + * 预上传文件 + * @returns 预上传响应的url和uid + */ + private async preupload(): Promise { + const config = this.createAuthConfig() + const endpoint = `${this.provider.apiHost}/api/v2/parse/preupload` + + try { + const { data } = await axios.post>(endpoint, null, config) + + if (data.code === 'success' && data.data) { + return data.data + } else { + throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`) + } + } catch (error) { + Logger.error(`Failed to get preupload URL: ${error instanceof Error ? error.message : String(error)}`) + throw new Error('Failed to get preupload URL') + } + } + + /** + * 上传文件 + * @param filePath 文件路径 + * @param url 预上传响应的url + */ + private async putFile(filePath: string, url: string): Promise { + try { + const fileStream = fs.createReadStream(filePath) + const response = await axios.put(url, fileStream) + + if (response.status !== 200) { + throw new Error(`HTTP status ${response.status}: ${response.statusText}`) + } + } catch (error) { + Logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`) + throw new Error('Failed to upload file') + } + } + + private async getStatus(uid: string): Promise { + const config = this.createAuthConfig() + const endpoint = `${this.provider.apiHost}/api/v2/parse/status?uid=${uid}` + + try { + const response = await axios.get>(endpoint, config) + + if (response.data.code === 'success' && response.data.data) { + return response.data.data + } else { + throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`) + } + } catch (error) { + Logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`) + throw new Error('Failed to get processing status') + } + } + + /** + * Preprocess文件 + * @param uid 预上传响应的uid + * @param filePath 文件路径 + */ + private async convertFile(uid: string, filePath: string): Promise { + const fileName = path.basename(filePath).split('.')[0] + const config = { + ...this.createAuthConfig(), + headers: { + ...this.createAuthConfig().headers, + 'Content-Type': 'application/json' + } + } + + const payload = { + uid, + to: 'md', + formula_mode: 'normal', + filename: fileName + } + + const endpoint = `${this.provider.apiHost}/api/v2/convert/parse` + + try { + const response = await axios.post>(endpoint, payload, config) + + if (response.data.code !== 'success') { + throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`) + } + } catch (error) { + Logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`) + throw new Error('Failed to convert file') + } + } + + /** + * 获取解析后的文件信息 + * @param uid 预上传响应的uid + * @returns 解析后的文件信息 + */ + private async getParsedFile(uid: string): Promise { + const config = this.createAuthConfig() + const endpoint = `${this.provider.apiHost}/api/v2/convert/parse/result?uid=${uid}` + + try { + const response = await axios.get>(endpoint, config) + + if (response.status === 200 && response.data.data) { + return response.data.data + } else { + throw new Error(`HTTP status ${response.status}: ${response.statusText}`) + } + } catch (error) { + Logger.error( + `Failed to get parsed file for uid ${uid}: ${error instanceof Error ? error.message : String(error)}` + ) + throw new Error('Failed to get parsed file information') + } + } + + /** + * 下载文件 + * @param url 导出文件的url + * @param file 文件信息 + * @returns 下载文件的路径 + */ + private async downloadFile(url: string, file: FileMetadata): Promise<{ path: string }> { + const dirPath = this.storageDir + // 使用统一的存储路径:Data/Files/{file.id}/ + const extractPath = path.join(dirPath, file.id) + const zipPath = path.join(dirPath, `${file.id}.zip`) + + // 确保目录存在 + fs.mkdirSync(dirPath, { recursive: true }) + fs.mkdirSync(extractPath, { recursive: true }) + + Logger.info(`Downloading to export path: ${zipPath}`) + + try { + // 下载文件 + const response = await axios.get(url, { responseType: 'arraybuffer' }) + fs.writeFileSync(zipPath, response.data) + + // 确保提取目录存在 + if (!fs.existsSync(extractPath)) { + fs.mkdirSync(extractPath, { recursive: true }) + } + + // 解压文件 + const zip = new AdmZip(zipPath) + zip.extractAllTo(extractPath, true) + Logger.info(`Extracted files to: ${extractPath}`) + + // 删除临时ZIP文件 + fs.unlinkSync(zipPath) + + return { path: extractPath } + } catch (error) { + Logger.error(`Failed to download and extract file: ${error instanceof Error ? error.message : String(error)}`) + throw new Error('Failed to download and extract file') + } + } + + private createAuthConfig(): AxiosRequestConfig { + return { + headers: { + Authorization: `Bearer ${this.provider.apiKey}` + } + } + } + + public checkQuota(): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/src/main/preprocess/MineruPreprocessProvider.ts b/src/main/preprocess/MineruPreprocessProvider.ts new file mode 100644 index 0000000000..a0a9c65417 --- /dev/null +++ b/src/main/preprocess/MineruPreprocessProvider.ts @@ -0,0 +1,399 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { FileMetadata, PreprocessProvider } from '@types' +import AdmZip from 'adm-zip' +import axios from 'axios' +import Logger from 'electron-log' + +import BasePreprocessProvider from './BasePreprocessProvider' + +type ApiResponse = { + code: number + data: T + msg?: string + trace_id?: string +} + +type BatchUploadResponse = { + batch_id: string + file_urls: string[] +} + +type ExtractProgress = { + extracted_pages: number + total_pages: number + start_time: string +} + +type ExtractFileResult = { + file_name: string + state: 'done' | 'waiting-file' | 'pending' | 'running' | 'converting' | 'failed' + err_msg: string + full_zip_url?: string + extract_progress?: ExtractProgress +} + +type ExtractResultResponse = { + batch_id: string + extract_result: ExtractFileResult[] +} + +type QuotaResponse = { + code: number + data: { + user_left_quota: number + total_left_quota: number + } + msg?: string + trace_id?: string +} + +export default class MineruPreprocessProvider extends BasePreprocessProvider { + constructor(provider: PreprocessProvider, userId?: string) { + super(provider, userId) + // todo:免费期结束后删除 + this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY + } + + public async parseFile( + sourceId: string, + file: FileMetadata + ): Promise<{ processedFile: FileMetadata; quota: number }> { + try { + Logger.info(`MinerU preprocess processing started: ${file.path}`) + await this.validateFile(file.path) + + // 1. 获取上传URL并上传文件 + const batchId = await this.uploadFile(file) + Logger.info(`MinerU file upload completed: batch_id=${batchId}`) + + // 2. 等待处理完成并获取结果 + const extractResult = await this.waitForCompletion(sourceId, batchId, file.origin_name) + Logger.info(`MinerU processing completed for batch: ${batchId}`) + + // 3. 下载并解压文件 + const { path: outputPath } = await this.downloadAndExtractFile(extractResult.full_zip_url!, file) + + // 4. check quota + const quota = await this.checkQuota() + + // 5. 创建处理后的文件信息 + return { + processedFile: this.createProcessedFileInfo(file, outputPath), + quota + } + } catch (error: any) { + Logger.error(`MinerU preprocess processing failed for ${file.path}: ${error.message}`) + throw new Error(error.message) + } + } + + public async checkQuota() { + try { + const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey}`, + token: this.userId ?? '' + } + }) + if (!quota.ok) { + throw new Error(`HTTP ${quota.status}: ${quota.statusText}`) + } + const response: QuotaResponse = await quota.json() + return response.data.user_left_quota + } catch (error) { + console.error('Error checking quota:', error) + throw error + } + } + + private async validateFile(filePath: string): Promise { + const quota = await this.checkQuota() + const pdfBuffer = await fs.promises.readFile(filePath) + + const doc = await this.readPdf(new Uint8Array(pdfBuffer)) + + // 文件页数小于600页 + if (doc.numPages >= 600) { + throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`) + } + // 文件大小小于200MB + if (pdfBuffer.length >= 200 * 1024 * 1024) { + const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024)) + throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`) + } + // 检查配额 + if (quota <= 0 || quota - doc.numPages <= 0) { + throw new Error('MinerU解析配额不足,请申请企业账户或自行部署,剩余额度:' + quota) + } + } + + private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata { + // 查找解压后的主要文件 + let finalPath = '' + let finalName = file.origin_name.replace('.pdf', '.md') + + try { + const files = fs.readdirSync(outputPath) + + const mdFile = files.find((f) => f.endsWith('.md')) + if (mdFile) { + const originalMdPath = path.join(outputPath, mdFile) + const newMdPath = path.join(outputPath, finalName) + + // 重命名文件为原始文件名 + try { + fs.renameSync(originalMdPath, newMdPath) + finalPath = newMdPath + Logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`) + } catch (renameError) { + Logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`) + // 如果重命名失败,使用原文件 + finalPath = originalMdPath + finalName = mdFile + } + } + } catch (error) { + Logger.warn(`Failed to read output directory ${outputPath}: ${error}`) + finalPath = path.join(outputPath, `${file.id}.md`) + } + + return { + ...file, + name: finalName, + path: finalPath, + ext: '.md', + size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0 + } + } + + private async downloadAndExtractFile(zipUrl: string, file: FileMetadata): Promise<{ path: string }> { + const dirPath = this.storageDir + + const zipPath = path.join(dirPath, `${file.id}.zip`) + const extractPath = path.join(dirPath, `${file.id}`) + + Logger.info(`Downloading MinerU result to: ${zipPath}`) + + try { + // 下载ZIP文件 + const response = await axios.get(zipUrl, { responseType: 'arraybuffer' }) + fs.writeFileSync(zipPath, response.data) + Logger.info(`Downloaded ZIP file: ${zipPath}`) + + // 确保提取目录存在 + if (!fs.existsSync(extractPath)) { + fs.mkdirSync(extractPath, { recursive: true }) + } + + // 解压文件 + const zip = new AdmZip(zipPath) + zip.extractAllTo(extractPath, true) + Logger.info(`Extracted files to: ${extractPath}`) + + // 删除临时ZIP文件 + fs.unlinkSync(zipPath) + + return { path: extractPath } + } catch (error: any) { + Logger.error(`Failed to download and extract file: ${error.message}`) + throw new Error(error.message) + } + } + + private async uploadFile(file: FileMetadata): Promise { + try { + // 步骤1: 获取上传URL + const { batchId, fileUrls } = await this.getBatchUploadUrls(file) + Logger.info(`Got upload URLs for batch: ${batchId}`) + + console.log('batchId:', batchId, 'fileurls:', fileUrls) + // 步骤2: 上传文件到获取的URL + await this.putFileToUrl(file.path, fileUrls[0]) + Logger.info(`File uploaded successfully: ${file.path}`) + + return batchId + } catch (error: any) { + Logger.error(`Failed to upload file ${file.path}: ${error.message}`) + throw new Error(error.message) + } + } + + private async getBatchUploadUrls(file: FileMetadata): Promise<{ batchId: string; fileUrls: string[] }> { + const endpoint = `${this.provider.apiHost}/api/v4/file-urls/batch` + + const payload = { + language: 'auto', + enable_formula: true, + enable_table: true, + files: [ + { + name: file.origin_name, + is_ocr: true, + data_id: file.id + } + ] + } + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey}`, + token: this.userId ?? '', + Accept: '*/*' + }, + body: JSON.stringify(payload) + }) + + if (response.ok) { + const data: ApiResponse = await response.json() + if (data.code === 0 && data.data) { + const { batch_id, file_urls } = data.data + return { + batchId: batch_id, + fileUrls: file_urls + } + } else { + throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`) + } + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + } catch (error: any) { + Logger.error(`Failed to get batch upload URLs: ${error.message}`) + throw new Error(error.message) + } + } + + private async putFileToUrl(filePath: string, uploadUrl: string): Promise { + try { + const fileBuffer = await fs.promises.readFile(filePath) + + const response = await fetch(uploadUrl, { + method: 'PUT', + body: fileBuffer, + headers: { + 'Content-Type': 'application/pdf' + } + // headers: { + // 'Content-Length': fileBuffer.length.toString() + // } + }) + + if (!response.ok) { + // 克隆 response 以避免消费 body stream + const responseClone = response.clone() + + try { + const responseBody = await responseClone.text() + const errorInfo = { + status: response.status, + statusText: response.statusText, + url: response.url, + type: response.type, + redirected: response.redirected, + headers: Object.fromEntries(response.headers.entries()), + body: responseBody + } + + console.error('Response details:', errorInfo) + throw new Error(`Upload failed with status ${response.status}: ${responseBody}`) + } catch (parseError) { + throw new Error(`Upload failed with status ${response.status}. Could not parse response body.`) + } + } + + Logger.info(`File uploaded successfully to: ${uploadUrl}`) + } catch (error: any) { + Logger.error(`Failed to upload file to URL ${uploadUrl}: ${error}`) + throw new Error(error.message) + } + } + + private async getExtractResults(batchId: string): Promise { + const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}` + + try { + const response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey}`, + token: this.userId ?? '' + } + }) + + if (response.ok) { + const data: ApiResponse = await response.json() + if (data.code === 0 && data.data) { + return data.data + } else { + throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`) + } + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + } catch (error: any) { + Logger.error(`Failed to get extract results for batch ${batchId}: ${error.message}`) + throw new Error(error.message) + } + } + + private async waitForCompletion( + sourceId: string, + batchId: string, + fileName: string, + maxRetries: number = 60, + intervalMs: number = 5000 + ): Promise { + let retries = 0 + + while (retries < maxRetries) { + try { + const result = await this.getExtractResults(batchId) + + // 查找对应文件的处理结果 + const fileResult = result.extract_result.find((item) => item.file_name === fileName) + if (!fileResult) { + throw new Error(`File ${fileName} not found in batch results`) + } + + // 检查处理状态 + if (fileResult.state === 'done' && fileResult.full_zip_url) { + Logger.info(`Processing completed for file: ${fileName}`) + return fileResult + } else if (fileResult.state === 'failed') { + throw new Error(`Processing failed for file: ${fileName}, error: ${fileResult.err_msg}`) + } else if (fileResult.state === 'running') { + // 发送进度更新 + if (fileResult.extract_progress) { + const progress = Math.round( + (fileResult.extract_progress.extracted_pages / fileResult.extract_progress.total_pages) * 100 + ) + await this.sendPreprocessProgress(sourceId, progress) + Logger.info(`File ${fileName} processing progress: ${progress}%`) + } else { + // 如果没有具体进度信息,发送一个通用进度 + await this.sendPreprocessProgress(sourceId, 50) + Logger.info(`File ${fileName} is still processing...`) + } + } + } catch (error) { + Logger.warn(`Failed to check status for batch ${batchId}, retry ${retries + 1}/${maxRetries}`) + if (retries === maxRetries - 1) { + throw error + } + } + + retries++ + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + + throw new Error(`Processing timeout for batch: ${batchId}`) + } +} diff --git a/src/main/preprocess/MistralPreprocessProvider.ts b/src/main/preprocess/MistralPreprocessProvider.ts new file mode 100644 index 0000000000..3150162801 --- /dev/null +++ b/src/main/preprocess/MistralPreprocessProvider.ts @@ -0,0 +1,187 @@ +import fs from 'node:fs' + +import { MistralClientManager } from '@main/services/MistralClientManager' +import { MistralService } from '@main/services/remotefile/MistralService' +import { Mistral } from '@mistralai/mistralai' +import { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk' +import { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk' +import { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse' +import { FileMetadata, FileTypes, PreprocessProvider, Provider } from '@types' +import Logger from 'electron-log' +import path from 'path' + +import BasePreprocessProvider from './BasePreprocessProvider' + +type PreuploadResponse = DocumentURLChunk | ImageURLChunk + +export default class MistralPreprocessProvider extends BasePreprocessProvider { + private sdk: Mistral + private fileService: MistralService + + constructor(provider: PreprocessProvider) { + super(provider) + const clientManager = MistralClientManager.getInstance() + const aiProvider: Provider = { + id: provider.id, + type: 'mistral', + name: provider.name, + apiKey: provider.apiKey!, + apiHost: provider.apiHost!, + models: [] + } + clientManager.initializeClient(aiProvider) + this.sdk = clientManager.getClient() + this.fileService = new MistralService(aiProvider) + } + + private async preupload(file: FileMetadata): Promise { + let document: PreuploadResponse + Logger.info(`preprocess preupload started for local file: ${file.path}`) + + if (file.ext.toLowerCase() === '.pdf') { + const uploadResponse = await this.fileService.uploadFile(file) + + if (uploadResponse.status === 'failed') { + Logger.error('File upload failed:', uploadResponse) + throw new Error('Failed to upload file: ' + uploadResponse.displayName) + } + await this.sendPreprocessProgress(file.id, 15) + const fileUrl = await this.sdk.files.getSignedUrl({ + fileId: uploadResponse.fileId + }) + Logger.info('Got signed URL:', fileUrl) + await this.sendPreprocessProgress(file.id, 20) + document = { + type: 'document_url', + documentUrl: fileUrl.url + } + } else { + const base64Image = Buffer.from(fs.readFileSync(file.path)).toString('base64') + document = { + type: 'image_url', + imageUrl: `data:image/png;base64,${base64Image}` + } + } + + if (!document) { + throw new Error('Unsupported file type') + } + return document + } + + public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> { + try { + const document = await this.preupload(file) + const result = await this.sdk.ocr.process({ + model: this.provider.model!, + document: document, + includeImageBase64: true + }) + if (result) { + await this.sendPreprocessProgress(sourceId, 100) + const processedFile = this.convertFile(result, file) + return { + processedFile + } + } else { + throw new Error('preprocess processing failed: OCR response is empty') + } + } catch (error) { + throw new Error('preprocess processing failed: ' + error) + } + } + + private convertFile(result: OCRResponse, file: FileMetadata): FileMetadata { + // 使用统一的存储路径:Data/Files/{file.id}/ + const conversionId = file.id + const outputPath = path.join(this.storageDir, file.id) + // const outputPath = this.storageDir + const outputFileName = path.basename(file.path, path.extname(file.path)) + fs.mkdirSync(outputPath, { recursive: true }) + + const markdownParts: string[] = [] + let counter = 0 + + // Process each page + result.pages.forEach((page) => { + let pageMarkdown = page.markdown + + // Process images from this page + page.images.forEach((image) => { + if (image.imageBase64) { + let imageFormat = 'jpeg' // default format + let imageBase64Data = image.imageBase64 + + // Check for data URL prefix more efficiently + const prefixEnd = image.imageBase64.indexOf(';base64,') + if (prefixEnd > 0) { + const prefix = image.imageBase64.substring(0, prefixEnd) + const formatIndex = prefix.indexOf('image/') + if (formatIndex >= 0) { + imageFormat = prefix.substring(formatIndex + 6) + } + imageBase64Data = image.imageBase64.substring(prefixEnd + 8) + } + + const imageFileName = `img-${counter}.${imageFormat}` + const imagePath = path.join(outputPath, imageFileName) + + // Save image file + try { + fs.writeFileSync(imagePath, Buffer.from(imageBase64Data, 'base64')) + + // Update image reference in markdown + // Use relative path for better portability + const relativeImagePath = `./${imageFileName}` + + // Find the start and end of the image markdown + const imgStart = pageMarkdown.indexOf(image.imageBase64) + if (imgStart >= 0) { + // Find the markdown image syntax around this base64 + const mdStart = pageMarkdown.lastIndexOf('![', imgStart) + const mdEnd = pageMarkdown.indexOf(')', imgStart) + + if (mdStart >= 0 && mdEnd >= 0) { + // Replace just this specific image reference + pageMarkdown = + pageMarkdown.substring(0, mdStart) + + `![Image ${counter}](${relativeImagePath})` + + pageMarkdown.substring(mdEnd + 1) + } + } + + counter++ + } catch (error) { + Logger.error(`Failed to save image ${imageFileName}:`, error) + } + } + }) + + markdownParts.push(pageMarkdown) + }) + + // Combine all markdown content with double newlines for readability + const combinedMarkdown = markdownParts.join('\n\n') + + // Write the markdown content to a file + const mdFileName = `${outputFileName}.md` + const mdFilePath = path.join(outputPath, mdFileName) + fs.writeFileSync(mdFilePath, combinedMarkdown) + + return { + id: conversionId, + name: file.name.replace(/\.[^/.]+$/, '.md'), + origin_name: file.origin_name, + path: mdFilePath, + created_at: new Date().toISOString(), + type: FileTypes.DOCUMENT, + ext: '.md', + size: fs.statSync(mdFilePath).size, + count: 1 + } as FileMetadata + } + + public checkQuota(): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/src/main/preprocess/PreprocessProvider.ts b/src/main/preprocess/PreprocessProvider.ts new file mode 100644 index 0000000000..44a34f64ae --- /dev/null +++ b/src/main/preprocess/PreprocessProvider.ts @@ -0,0 +1,30 @@ +import { FileMetadata, PreprocessProvider as Provider } from '@types' + +import BasePreprocessProvider from './BasePreprocessProvider' +import PreprocessProviderFactory from './PreprocessProviderFactory' + +export default class PreprocessProvider { + private sdk: BasePreprocessProvider + constructor(provider: Provider, userId?: string) { + this.sdk = PreprocessProviderFactory.create(provider, userId) + } + public async parseFile( + sourceId: string, + file: FileMetadata + ): Promise<{ processedFile: FileMetadata; quota?: number }> { + return this.sdk.parseFile(sourceId, file) + } + + public async checkQuota(): Promise { + return this.sdk.checkQuota() + } + + /** + * 检查文件是否已经被预处理过 + * @param file 文件信息 + * @returns 如果已处理返回处理后的文件信息,否则返回null + */ + public async checkIfAlreadyProcessed(file: FileMetadata): Promise { + return this.sdk.checkIfAlreadyProcessed(file) + } +} diff --git a/src/main/preprocess/PreprocessProviderFactory.ts b/src/main/preprocess/PreprocessProviderFactory.ts new file mode 100644 index 0000000000..bebecd388f --- /dev/null +++ b/src/main/preprocess/PreprocessProviderFactory.ts @@ -0,0 +1,21 @@ +import { PreprocessProvider } from '@types' + +import BasePreprocessProvider from './BasePreprocessProvider' +import DefaultPreprocessProvider from './DefaultPreprocessProvider' +import Doc2xPreprocessProvider from './Doc2xPreprocessProvider' +import MineruPreprocessProvider from './MineruPreprocessProvider' +import MistralPreprocessProvider from './MistralPreprocessProvider' +export default class PreprocessProviderFactory { + static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider { + switch (provider.id) { + case 'doc2x': + return new Doc2xPreprocessProvider(provider) + case 'mistral': + return new MistralPreprocessProvider(provider) + case 'mineru': + return new MineruPreprocessProvider(provider, userId) + default: + return new DefaultPreprocessProvider(provider) + } + } +} diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 0c81a454a7..0bdcdf56f5 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1,6 +1,6 @@ import { getFilesDir, getFileType, getTempDir } from '@main/utils/file' import { documentExts, imageExts, MB } from '@shared/config/constant' -import { FileType } from '@types' +import { FileMetadata } from '@types' import * as crypto from 'crypto' import { dialog, @@ -53,8 +53,9 @@ class FileStorage { }) } - findDuplicateFile = async (filePath: string): Promise => { + findDuplicateFile = async (filePath: string): Promise => { const stats = fs.statSync(filePath) + console.log('stats', stats, filePath) const fileSize = stats.size const files = await fs.promises.readdir(this.storageDir) @@ -92,7 +93,7 @@ class FileStorage { public selectFile = async ( _: Electron.IpcMainInvokeEvent, options?: OpenDialogOptions - ): Promise => { + ): Promise => { const defaultOptions: OpenDialogOptions = { properties: ['openFile'] } @@ -151,7 +152,7 @@ class FileStorage { } } - public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileType): Promise => { + public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise => { const duplicateFile = await this.findDuplicateFile(file.path) if (duplicateFile) { @@ -175,7 +176,7 @@ class FileStorage { const stats = await fs.promises.stat(destPath) const fileType = getFileType(ext) - const fileMetadata: FileType = { + const fileMetadata: FileMetadata = { id: uuid, origin_name, name: uuid + ext, @@ -190,7 +191,7 @@ class FileStorage { return fileMetadata } - public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise => { + public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise => { if (!fs.existsSync(filePath)) { return null } @@ -199,7 +200,7 @@ class FileStorage { const ext = path.extname(filePath) const fileType = getFileType(ext) - const fileInfo: FileType = { + const fileInfo: FileMetadata = { id: uuidv4(), origin_name: path.basename(filePath), name: path.basename(filePath), @@ -215,9 +216,19 @@ class FileStorage { } public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { + if (!fs.existsSync(path.join(this.storageDir, id))) { + return + } await fs.promises.unlink(path.join(this.storageDir, id)) } + public deleteDir = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { + if (!fs.existsSync(path.join(this.storageDir, id))) { + return + } + await fs.promises.rm(path.join(this.storageDir, id), { recursive: true }) + } + public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { const filePath = path.join(this.storageDir, id) @@ -252,8 +263,8 @@ class FileStorage { if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }) } - const tempFilePath = path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`) - return tempFilePath + + return path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`) } public writeFile = async ( @@ -280,7 +291,7 @@ class FileStorage { } } - public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise => { + public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise => { try { if (!base64Data) { throw new Error('Base64 data is required') @@ -306,7 +317,7 @@ class FileStorage { await fs.promises.writeFile(destPath, buffer) - const fileMetadata: FileType = { + const fileMetadata: FileMetadata = { id: uuid, origin_name: uuid + ext, name: uuid + ext, @@ -465,7 +476,7 @@ class FileStorage { _: Electron.IpcMainInvokeEvent, url: string, isUseContentType?: boolean - ): Promise => { + ): Promise => { try { const response = await fetch(url) if (!response.ok) { @@ -507,7 +518,7 @@ class FileStorage { const stats = await fs.promises.stat(destPath) const fileType = getFileType(ext) - const fileMetadata: FileType = { + const fileMetadata: FileMetadata = { id: uuid, origin_name: filename, name: uuid + ext, diff --git a/src/main/services/FileService.ts b/src/main/services/FileSystemService.ts similarity index 100% rename from src/main/services/FileService.ts rename to src/main/services/FileSystemService.ts diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 686e643711..c57c0eb104 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -25,13 +25,15 @@ import Embeddings from '@main/knowledage/embeddings/Embeddings' import { addFileLoader } from '@main/knowledage/loader' import { NoteLoader } from '@main/knowledage/loader/noteLoader' import Reranker from '@main/knowledage/reranker/Reranker' +import OcrProvider from '@main/ocr/OcrProvider' +import PreprocessProvider from '@main/preprocess/PreprocessProvider' import { windowService } from '@main/services/WindowService' import { getDataPath } from '@main/utils' import { getAllFiles } from '@main/utils/file' import { MB } from '@shared/config/constant' import type { LoaderReturn } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' -import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types' +import { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types' import Logger from 'electron-log' import { v4 as uuidv4 } from 'uuid' @@ -39,12 +41,14 @@ export interface KnowledgeBaseAddItemOptions { base: KnowledgeBaseParams item: KnowledgeItem forceReload?: boolean + userId?: string } interface KnowledgeBaseAddItemOptionsNonNullableAttribute { base: KnowledgeBaseParams item: KnowledgeItem forceReload: boolean + userId: string } interface EvaluateTaskWorkload { @@ -96,7 +100,13 @@ class KnowledgeService { private knowledgeItemProcessingQueueMappingPromise: Map void> = new Map() private static MAXIMUM_WORKLOAD = 80 * MB private static MAXIMUM_PROCESSING_ITEM_COUNT = 30 - private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' } + private static ERROR_LOADER_RETURN: LoaderReturn = { + entriesAdded: 0, + uniqueId: '', + uniqueIds: [''], + loaderType: '', + status: 'failed' + } constructor() { this.initStorageDir() @@ -150,6 +160,7 @@ class KnowledgeService { } public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { + console.log('id', id) const dbPath = path.join(this.storageDir, id) if (fs.existsSync(dbPath)) { fs.rmSync(dbPath, { recursive: true }) @@ -162,28 +173,49 @@ class KnowledgeService { this.workload >= KnowledgeService.MAXIMUM_WORKLOAD ) } - private fileTask( ragApplication: RAGApplication, options: KnowledgeBaseAddItemOptionsNonNullableAttribute ): LoaderTask { - const { base, item, forceReload } = options - const file = item.content as FileType + const { base, item, forceReload, userId } = options + const file = item.content as FileMetadata const loaderTask: LoaderTask = { loaderTasks: [ { state: LoaderTaskItemState.PENDING, - task: () => - addFileLoader(ragApplication, file, base, forceReload) - .then((result) => { - loaderTask.loaderDoneReturn = result - return result - }) - .catch((err) => { - Logger.error(err) - return KnowledgeService.ERROR_LOADER_RETURN - }), + task: async () => { + try { + // 添加预处理逻辑 + const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId) + + // 使用处理后的文件进行加载 + return addFileLoader(ragApplication, fileToProcess, base, forceReload) + .then((result) => { + loaderTask.loaderDoneReturn = result + return result + }) + .catch((e) => { + Logger.error(`Error in addFileLoader for ${file.name}: ${e}`) + const errorResult: LoaderReturn = { + ...KnowledgeService.ERROR_LOADER_RETURN, + message: e.message, + messageSource: 'embedding' + } + loaderTask.loaderDoneReturn = errorResult + return errorResult + }) + } catch (e: any) { + Logger.error(`Preprocessing failed for ${file.name}: ${e}`) + const errorResult: LoaderReturn = { + ...KnowledgeService.ERROR_LOADER_RETURN, + message: e.message, + messageSource: 'preprocess' + } + loaderTask.loaderDoneReturn = errorResult + return errorResult + } + }, evaluateTaskWorkload: { workload: file.size } } ], @@ -192,7 +224,6 @@ class KnowledgeService { return loaderTask } - private directoryTask( ragApplication: RAGApplication, options: KnowledgeBaseAddItemOptionsNonNullableAttribute @@ -232,7 +263,11 @@ class KnowledgeService { }) .catch((err) => { Logger.error(err) - return KnowledgeService.ERROR_LOADER_RETURN + return { + ...KnowledgeService.ERROR_LOADER_RETURN, + message: `Failed to add dir loader: ${err.message}`, + messageSource: 'embedding' + } }), evaluateTaskWorkload: { workload: file.size } }) @@ -278,7 +313,11 @@ class KnowledgeService { }) .catch((err) => { Logger.error(err) - return KnowledgeService.ERROR_LOADER_RETURN + return { + ...KnowledgeService.ERROR_LOADER_RETURN, + message: `Failed to add url loader: ${err.message}`, + messageSource: 'embedding' + } }) }, evaluateTaskWorkload: { workload: 2 * MB } @@ -318,7 +357,11 @@ class KnowledgeService { }) .catch((err) => { Logger.error(err) - return KnowledgeService.ERROR_LOADER_RETURN + return { + ...KnowledgeService.ERROR_LOADER_RETURN, + message: `Failed to add sitemap loader: ${err.message}`, + messageSource: 'embedding' + } }), evaluateTaskWorkload: { workload: 20 * MB } } @@ -364,7 +407,11 @@ class KnowledgeService { }) .catch((err) => { Logger.error(err) - return KnowledgeService.ERROR_LOADER_RETURN + return { + ...KnowledgeService.ERROR_LOADER_RETURN, + message: `Failed to add note loader: ${err.message}`, + messageSource: 'embedding' + } }) }, evaluateTaskWorkload: { workload: contentBytes.length } @@ -430,10 +477,10 @@ class KnowledgeService { }) } - public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise => { + public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise => { return new Promise((resolve) => { - const { base, item, forceReload = false } = options - const optionsNonNullableAttribute = { base, item, forceReload } + const { base, item, forceReload = false, userId = '' } = options + const optionsNonNullableAttribute = { base, item, forceReload, userId } this.getRagApplication(base) .then((ragApplication) => { const task = (() => { @@ -459,12 +506,20 @@ class KnowledgeService { }) this.processingQueueHandle() } else { - resolve(KnowledgeService.ERROR_LOADER_RETURN) + resolve({ + ...KnowledgeService.ERROR_LOADER_RETURN, + message: 'Unsupported item type', + messageSource: 'embedding' + }) } }) .catch((err) => { Logger.error(err) - resolve(KnowledgeService.ERROR_LOADER_RETURN) + resolve({ + ...KnowledgeService.ERROR_LOADER_RETURN, + message: `Failed to add item: ${err.message}`, + messageSource: 'embedding' + }) }) }) } @@ -497,6 +552,69 @@ class KnowledgeService { } return await new Reranker(base).rerank(search, results) } + + public getStorageDir = (): string => { + return this.storageDir + } + + private preprocessing = async ( + file: FileMetadata, + base: KnowledgeBaseParams, + item: KnowledgeItem, + userId: string + ): Promise => { + let fileToProcess: FileMetadata = file + if (base.preprocessOrOcrProvider && file.ext.toLowerCase() === '.pdf') { + try { + let provider: PreprocessProvider | OcrProvider + if (base.preprocessOrOcrProvider.type === 'preprocess') { + provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId) + } else { + provider = new OcrProvider(base.preprocessOrOcrProvider.provider) + } + // 首先检查文件是否已经被预处理过 + const alreadyProcessed = await provider.checkIfAlreadyProcessed(file) + if (alreadyProcessed) { + Logger.info(`File already preprocess processed, using cached result: ${file.path}`) + return alreadyProcessed + } + + // 执行预处理 + Logger.info(`Starting preprocess processing for scanned PDF: ${file.path}`) + const { processedFile, quota } = await provider.parseFile(item.id, file) + fileToProcess = processedFile + const mainWindow = windowService.getMainWindow() + mainWindow?.webContents.send('file-preprocess-finished', { + itemId: item.id, + quota: quota + }) + } catch (err) { + Logger.error(`Preprocess processing failed: ${err}`) + // 如果预处理失败,使用原始文件 + // fileToProcess = file + throw new Error(`Preprocess processing failed: ${err}`) + } + } + + return fileToProcess + } + + public checkQuota = async ( + _: Electron.IpcMainInvokeEvent, + base: KnowledgeBaseParams, + userId: string + ): Promise => { + try { + if (base.preprocessOrOcrProvider && base.preprocessOrOcrProvider.type === 'preprocess') { + const provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId) + return await provider.checkQuota() + } + throw new Error('No preprocess provider configured') + } catch (err) { + Logger.error(`Failed to check quota: ${err}`) + throw new Error(`Failed to check quota: ${err}`) + } + } } export default new KnowledgeService() diff --git a/src/main/services/MistralClientManager.ts b/src/main/services/MistralClientManager.ts new file mode 100644 index 0000000000..fa4aa53df8 --- /dev/null +++ b/src/main/services/MistralClientManager.ts @@ -0,0 +1,33 @@ +import { Mistral } from '@mistralai/mistralai' +import { Provider } from '@types' + +export class MistralClientManager { + private static instance: MistralClientManager + private client: Mistral | null = null + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + public static getInstance(): MistralClientManager { + if (!MistralClientManager.instance) { + MistralClientManager.instance = new MistralClientManager() + } + return MistralClientManager.instance + } + + public initializeClient(provider: Provider): void { + if (!this.client) { + this.client = new Mistral({ + apiKey: provider.apiKey, + serverURL: provider.apiHost + }) + } + } + + public getClient(): Mistral { + if (!this.client) { + throw new Error('Mistral client not initialized. Call initializeClient first.') + } + return this.client + } +} diff --git a/src/main/services/remotefile/BaseFileService.ts b/src/main/services/remotefile/BaseFileService.ts new file mode 100644 index 0000000000..ff06eb0b44 --- /dev/null +++ b/src/main/services/remotefile/BaseFileService.ts @@ -0,0 +1,13 @@ +import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' + +export abstract class BaseFileService { + protected readonly provider: Provider + protected constructor(provider: Provider) { + this.provider = provider + } + + abstract uploadFile(file: FileMetadata): Promise + abstract deleteFile(fileId: string): Promise + abstract listFiles(): Promise + abstract retrieveFile(fileId: string): Promise +} diff --git a/src/main/services/remotefile/FileServiceManager.ts b/src/main/services/remotefile/FileServiceManager.ts new file mode 100644 index 0000000000..9cdf6f834c --- /dev/null +++ b/src/main/services/remotefile/FileServiceManager.ts @@ -0,0 +1,41 @@ +import { Provider } from '@types' + +import { BaseFileService } from './BaseFileService' +import { GeminiService } from './GeminiService' +import { MistralService } from './MistralService' + +export class FileServiceManager { + private static instance: FileServiceManager + private services: Map = new Map() + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + static getInstance(): FileServiceManager { + if (!this.instance) { + this.instance = new FileServiceManager() + } + return this.instance + } + + getService(provider: Provider): BaseFileService { + const type = provider.type + let service = this.services.get(type) + + if (!service) { + switch (type) { + case 'gemini': + service = new GeminiService(provider) + break + case 'mistral': + service = new MistralService(provider) + break + default: + throw new Error(`Unsupported service type: ${type}`) + } + this.services.set(type, service) + } + + return service + } +} diff --git a/src/main/services/remotefile/GeminiService.ts b/src/main/services/remotefile/GeminiService.ts new file mode 100644 index 0000000000..82178f5c14 --- /dev/null +++ b/src/main/services/remotefile/GeminiService.ts @@ -0,0 +1,190 @@ +import { File, Files, FileState, GoogleGenAI } from '@google/genai' +import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import Logger from 'electron-log' +import { v4 as uuidv4 } from 'uuid' + +import { CacheService } from '../CacheService' +import { BaseFileService } from './BaseFileService' + +export class GeminiService extends BaseFileService { + private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list' + private static readonly FILE_CACHE_DURATION = 48 * 60 * 60 * 1000 + private static readonly LIST_CACHE_DURATION = 3000 + + protected readonly fileManager: Files + + constructor(provider: Provider) { + super(provider) + this.fileManager = new GoogleGenAI({ + vertexai: false, + apiKey: provider.apiKey, + httpOptions: { + baseUrl: provider.apiHost + } + }).files + } + + async uploadFile(file: FileMetadata): Promise { + try { + const uploadResult = await this.fileManager.upload({ + file: file.path, + config: { + mimeType: 'application/pdf', + name: file.id, + displayName: file.origin_name + } + }) + + // 根据文件状态设置响应状态 + let status: 'success' | 'processing' | 'failed' | 'unknown' + switch (uploadResult.state) { + case FileState.ACTIVE: + status = 'success' + break + case FileState.PROCESSING: + status = 'processing' + break + case FileState.FAILED: + status = 'failed' + break + default: + status = 'unknown' + } + + const response: FileUploadResponse = { + fileId: uploadResult.name || '', + displayName: file.origin_name, + status, + originalFile: { + type: 'gemini', + file: uploadResult + } + } + + // 只缓存成功的文件 + if (status === 'success') { + const cacheKey = `${GeminiService.FILE_LIST_CACHE_KEY}_${response.fileId}` + CacheService.set(cacheKey, response, GeminiService.FILE_CACHE_DURATION) + } + + return response + } catch (error) { + Logger.error('Error uploading file to Gemini:', error) + return { + fileId: '', + displayName: file.origin_name, + status: 'failed', + originalFile: undefined + } + } + } + + async retrieveFile(fileId: string): Promise { + try { + const cachedResponse = CacheService.get(`${GeminiService.FILE_LIST_CACHE_KEY}_${fileId}`) + Logger.info('[GeminiService] cachedResponse', cachedResponse) + if (cachedResponse) { + return cachedResponse + } + const files: File[] = [] + + for await (const f of await this.fileManager.list()) { + files.push(f) + } + Logger.info('[GeminiService] files', files) + const file = files + .filter((file) => file.state === FileState.ACTIVE) + .find((file) => file.name?.substring(6) === fileId) // 去掉 files/ 前缀 + Logger.info('[GeminiService] file', file) + if (file) { + return { + fileId: fileId, + displayName: file.displayName || '', + status: 'success', + originalFile: { + type: 'gemini', + file + } + } + } + + return { + fileId: fileId, + displayName: '', + status: 'failed', + originalFile: undefined + } + } catch (error) { + Logger.error('Error retrieving file from Gemini:', error) + return { + fileId: fileId, + displayName: '', + status: 'failed', + originalFile: undefined + } + } + } + + async listFiles(): Promise { + try { + const cachedList = CacheService.get(GeminiService.FILE_LIST_CACHE_KEY) + if (cachedList) { + return cachedList + } + const geminiFiles: File[] = [] + + for await (const f of await this.fileManager.list()) { + geminiFiles.push(f) + } + const fileList: FileListResponse = { + files: geminiFiles + .filter((file) => file.state === FileState.ACTIVE) + .map((file) => { + // 更新单个文件的缓存 + const fileResponse: FileUploadResponse = { + fileId: file.name || uuidv4(), + displayName: file.displayName || '', + status: 'success', + originalFile: { + type: 'gemini', + file + } + } + CacheService.set( + `${GeminiService.FILE_LIST_CACHE_KEY}_${file.name}`, + fileResponse, + GeminiService.FILE_CACHE_DURATION + ) + + return { + id: file.name || uuidv4(), + displayName: file.displayName || '', + size: Number(file.sizeBytes), + status: 'success', + originalFile: { + type: 'gemini', + file + } + } + }) + } + + // 更新文件列表缓存 + CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, fileList, GeminiService.LIST_CACHE_DURATION) + return fileList + } catch (error) { + Logger.error('Error listing files from Gemini:', error) + return { files: [] } + } + } + + async deleteFile(fileId: string): Promise { + try { + await this.fileManager.delete({ name: fileId }) + Logger.info(`File ${fileId} deleted from Gemini`) + } catch (error) { + Logger.error('Error deleting file from Gemini:', error) + throw error + } + } +} diff --git a/src/main/services/remotefile/MistralService.ts b/src/main/services/remotefile/MistralService.ts new file mode 100644 index 0000000000..3964871ce4 --- /dev/null +++ b/src/main/services/remotefile/MistralService.ts @@ -0,0 +1,104 @@ +import fs from 'node:fs/promises' + +import { Mistral } from '@mistralai/mistralai' +import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import Logger from 'electron-log' + +import { MistralClientManager } from '../MistralClientManager' +import { BaseFileService } from './BaseFileService' + +export class MistralService extends BaseFileService { + private readonly client: Mistral + + constructor(provider: Provider) { + super(provider) + const clientManager = MistralClientManager.getInstance() + clientManager.initializeClient(provider) + this.client = clientManager.getClient() + } + + async uploadFile(file: FileMetadata): Promise { + try { + const fileBuffer = await fs.readFile(file.path) + const response = await this.client.files.upload({ + file: { + fileName: file.origin_name, + content: new Uint8Array(fileBuffer) + }, + purpose: 'ocr' + }) + + return { + fileId: response.id, + displayName: file.origin_name, + status: 'success', + originalFile: { + type: 'mistral', + file: response + } + } + } catch (error) { + Logger.error('Error uploading file:', error) + return { + fileId: '', + displayName: file.origin_name, + status: 'failed' + } + } + } + + async listFiles(): Promise { + try { + const response = await this.client.files.list({}) + return { + files: response.data.map((file) => ({ + id: file.id, + displayName: file.filename || '', + size: file.sizeBytes, + status: 'success', // All listed files are processed, + originalFile: { + type: 'mistral', + file + } + })) + } + } catch (error) { + Logger.error('Error listing files:', error) + return { files: [] } + } + } + + async deleteFile(fileId: string): Promise { + try { + await this.client.files.delete({ + fileId + }) + Logger.info(`File ${fileId} deleted`) + } catch (error) { + Logger.error('Error deleting file:', error) + throw error + } + } + + async retrieveFile(fileId: string): Promise { + try { + const response = await this.client.files.retrieve({ + fileId + }) + + return { + fileId: response.id, + displayName: response.filename || '', + status: 'success' // Retrieved files are always processed + } + } catch (error) { + Logger.error('Error retrieving file:', error) + return { + fileId: fileId, + displayName: '', + status: 'failed', + originalFile: undefined + } + } + } +} diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index a85c5cf8ed..2c52e82a71 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { isLinux, isPortable } from '@main/constant' import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant' -import { FileType, FileTypes } from '@types' +import { FileMetadata, FileTypes } from '@types' import { app } from 'electron' import { v4 as uuidv4 } from 'uuid' @@ -130,7 +130,19 @@ export function getFileType(ext: string): FileTypes { return fileTypeMap.get(ext) || FileTypes.OTHER } -export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): FileType[] { +export function getFileDir(filePath: string) { + return path.dirname(filePath) +} + +export function getFileName(filePath: string) { + return path.basename(filePath) +} + +export function getFileExt(filePath: string) { + return path.extname(filePath) +} + +export function getAllFiles(dirPath: string, arrayOfFiles: FileMetadata[] = []): FileMetadata[] { const files = fs.readdirSync(dirPath) files.forEach((file) => { @@ -152,7 +164,7 @@ export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): Fil const name = path.basename(file) const size = fs.statSync(fullPath).size - const fileItem: FileType = { + const fileItem: FileMetadata = { id: uuidv4(), name, path: fullPath, diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index 36a0d731bb..b83f8a8b26 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -49,7 +49,7 @@ export async function getBinaryPath(name?: string): Promise { const binaryName = await getBinaryName(name) const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin') - const binariesDirExists = await fs.existsSync(binariesDir) + const binariesDirExists = fs.existsSync(binariesDir) return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName } diff --git a/src/preload/index.ts b/src/preload/index.ts index beabfa1a27..3120492dde 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,18 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { electronAPI } from '@electron-toolkit/preload' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types' +import { + FileListResponse, + FileMetadata, + FileUploadResponse, + KnowledgeBaseParams, + KnowledgeItem, + MCPServer, + Provider, + Shortcut, + ThemeMode, + WebDavConfig +} from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' @@ -79,13 +90,25 @@ const api = { }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), - upload: (file: FileType) => ipcRenderer.invoke(IpcChannel.File_Upload, file), + upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), + deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId), clear: () => ipcRenderer.invoke(IpcChannel.File_Clear), get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath), - create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName), + /** + * 创建一个空的临时文件 + * @param fileName 文件名 + * @returns 临时文件路径 + */ + createTempFile: (fileName: string): Promise => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName), + /** + * 写入文件 + * @param filePath 文件路径 + * @param data 数据 + */ write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data), + writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content), open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options), openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path), @@ -93,12 +116,12 @@ const api = { ipcRenderer.invoke(IpcChannel.File_Save, path, content, options), selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder), saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data), + binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId), saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data), download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType), copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath), - binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId), getPathForFile: (file: File) => webUtils.getPathForFile(file) @@ -120,31 +143,38 @@ const api = { add: ({ base, item, + userId, forceReload = false }: { base: KnowledgeBaseParams item: KnowledgeItem + userId?: string forceReload?: boolean - }) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload }), + }) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload, userId }), remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }), search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }), rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) => - ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results }) + ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results }), + checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) => + ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId) }, window: { setMinimumSize: (width: number, height: number) => ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height), resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize) }, - gemini: { - uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) => - ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }), - base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file), - retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey), - listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey), - deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey) + fileService: { + upload: (provider: Provider, file: FileMetadata): Promise => + ipcRenderer.invoke(IpcChannel.FileService_Upload, provider, file), + list: (provider: Provider): Promise => ipcRenderer.invoke(IpcChannel.FileService_List, provider), + delete: (provider: Provider, fileId: string) => ipcRenderer.invoke(IpcChannel.FileService_Delete, provider, fileId), + retrieve: (provider: Provider, fileId: string): Promise => + ipcRenderer.invoke(IpcChannel.FileService_Retrieve, provider, fileId) + }, + selectionMenu: { + action: (action: string) => ipcRenderer.invoke('selection-menu:action', action) }, vertexAI: { diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index bfd2aff3f2..0f1bad0bf8 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -1,7 +1,7 @@ import { Content, + createPartFromUri, File, - FileState, FunctionCall, GenerateContentConfig, GenerateImagesConfig, @@ -10,7 +10,6 @@ import { HarmCategory, Modality, Model as GeminiModel, - Pager, Part, SafetySetting, SendMessageParameters, @@ -26,13 +25,13 @@ import { isSupportedThinkingTokenGeminiModel, isVisionModel } from '@renderer/config/models' -import { CacheService } from '@renderer/services/CacheService' import { estimateTextTokens } from '@renderer/services/TokenService' import { Assistant, EFFORT_RATIO, - FileType, + FileMetadata, FileTypes, + FileUploadResponse, GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -198,7 +197,7 @@ export class GeminiAPIClient extends BaseApiClient< * @param file - The file * @returns The part */ - private async handlePdfFile(file: FileType): Promise { + private async handlePdfFile(file: FileMetadata): Promise { const smallFileSize = 20 * MB const isSmallFile = file.size < smallFileSize @@ -213,26 +212,17 @@ export class GeminiAPIClient extends BaseApiClient< } // Retrieve file from Gemini uploaded files - const fileMetadata: File | undefined = await this.retrieveFile(file) + const fileMetadata: FileUploadResponse = await window.api.fileService.retrieve(this.provider, file.id) - if (fileMetadata) { - return { - fileData: { - fileUri: fileMetadata.uri, - mimeType: fileMetadata.mimeType - } as Part['fileData'] - } + if (fileMetadata.status === 'success') { + const remoteFile = fileMetadata.originalFile?.file as File + return createPartFromUri(remoteFile.uri!, remoteFile.mimeType!) } // If file is not found, upload it to Gemini - const result = await this.uploadFile(file) - - return { - fileData: { - fileUri: result.uri, - mimeType: result.mimeType - } as Part['fileData'] - } + const result = await window.api.fileService.upload(this.provider, file) + const remoteFile = result.originalFile?.file as File + return createPartFromUri(remoteFile.uri!, remoteFile.mimeType!) } /** @@ -767,61 +757,11 @@ export class GeminiAPIClient extends BaseApiClient< return [...(sdkPayload.history || []), messageParam] } - private async uploadFile(file: FileType): Promise { - return await this.sdkInstance!.files.upload({ - file: file.path, - config: { - mimeType: 'application/pdf', - name: file.id, - displayName: file.origin_name - } - }) - } - - private async base64File(file: FileType) { + private async base64File(file: FileMetadata) { const { data } = await window.api.file.base64File(file.id + file.ext) return { data, mimeType: 'application/pdf' } } - - private async retrieveFile(file: FileType): Promise { - const cachedResponse = CacheService.get('gemini_file_list') - - if (cachedResponse) { - return this.processResponse(cachedResponse, file) - } - - const response = await this.sdkInstance!.files.list() - CacheService.set('gemini_file_list', response, 3000) - - return this.processResponse(response, file) - } - - private async processResponse(response: Pager, file: FileType) { - for await (const f of response) { - if (f.state === FileState.ACTIVE) { - if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) { - return f - } - } - } - - return undefined - } - - // @ts-ignore unused - private async listFiles(): Promise { - const files: File[] = [] - for await (const f of await this.sdkInstance!.files.list()) { - files.push(f) - } - return files - } - - // @ts-ignore unused - private async deleteFile(fileId: string) { - await this.sdkInstance!.files.delete({ name: fileId }) - } } diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 0cf64fb22a..99e40ed818 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -7,7 +7,7 @@ import { } from '@renderer/config/models' import { estimateTextTokens } from '@renderer/services/TokenService' import { - FileType, + FileMetadata, FileTypes, MCPCallToolResponse, MCPTool, @@ -95,7 +95,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< return await sdk.responses.create(payload, options) } - private async handlePdfFile(file: FileType): Promise { + private async handlePdfFile(file: FileMetadata): Promise { if (file.size > 32 * MB) return undefined try { const pageCount = await window.api.file.pdfInfo(file.id + file.ext) diff --git a/src/renderer/src/assets/fonts/icon-fonts/iconfont.css b/src/renderer/src/assets/fonts/icon-fonts/iconfont.css index 71573edbf2..ae76c0026c 100644 --- a/src/renderer/src/assets/fonts/icon-fonts/iconfont.css +++ b/src/renderer/src/assets/fonts/icon-fonts/iconfont.css @@ -1,6 +1,6 @@ @font-face { font-family: 'iconfont'; /* Project id 4753420 */ - src: url('iconfont.woff2?t=1742184675192') format('woff2'); + src: url('iconfont.woff2?t=1742793497518') format('woff2'); } .iconfont { @@ -11,6 +11,18 @@ -moz-osx-font-smoothing: grayscale; } +.icon-plugin:before { + content: '\e612'; +} + +.icon-tools:before { + content: '\e762'; +} + +.icon-OCRshibie:before { + content: '\e658'; +} + .icon-obsidian:before { content: '\e677'; } diff --git a/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff2 b/src/renderer/src/assets/fonts/icon-fonts/iconfont.woff2 index 9c2ec4a51d33ca9eb2e6bfecf88be5ae1e3add95..9581311b4c103e440b1920a6af57ee16cd09201c 100644 GIT binary patch literal 5148 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 Kf~o^gUs?h|4g{b8 literal 4408 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 diff --git a/src/renderer/src/assets/images/ocr/doc2x.png b/src/renderer/src/assets/images/ocr/doc2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4b0d0efa369d7fd31031f5feea56e0d9152ace81 GIT binary patch literal 49106 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

4eJA}+gy*K{Mm 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 literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/images/ocr/mineru.jpg b/src/renderer/src/assets/images/ocr/mineru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c4295d1f650f39db5b569abedead67fa38de0b90 GIT binary patch literal 16601 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 CIc-@0 literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/images/providers/macos.svg b/src/renderer/src/assets/images/providers/macos.svg new file mode 100644 index 0000000000..3385e73504 --- /dev/null +++ b/src/renderer/src/assets/images/providers/macos.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx index 0dbb0aabb2..87dc172bd6 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx @@ -19,7 +19,7 @@ const Artifacts: FC = ({ html }) => { * 在应用内打开 */ const handleOpenInApp = async () => { - const path = await window.api.file.create('artifacts-preview.html') + const path = await window.api.file.createTempFile('artifacts-preview.html') await window.api.file.write(path, html) const filePath = `file://${path}` const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') @@ -35,7 +35,7 @@ const Artifacts: FC = ({ html }) => { * 外部链接打开 */ const handleOpenExternal = async () => { - const path = await window.api.file.create('artifacts-preview.html') + const path = await window.api.file.createTempFile('artifacts-preview.html') await window.api.file.write(path, html) const filePath = `file://${path}` diff --git a/src/renderer/src/components/Icons/OcrIcon.tsx b/src/renderer/src/components/Icons/OcrIcon.tsx new file mode 100644 index 0000000000..41367445a7 --- /dev/null +++ b/src/renderer/src/components/Icons/OcrIcon.tsx @@ -0,0 +1,7 @@ +import { FC } from 'react' + +const OcrIcon: FC, HTMLElement>> = (props) => { + return +} + +export default OcrIcon diff --git a/src/renderer/src/components/Icons/ToolIcon.tsx b/src/renderer/src/components/Icons/ToolIcon.tsx new file mode 100644 index 0000000000..69f8da260c --- /dev/null +++ b/src/renderer/src/components/Icons/ToolIcon.tsx @@ -0,0 +1,7 @@ +import { FC } from 'react' + +const ToolIcon: FC, HTMLElement>> = (props) => { + return +} + +export default ToolIcon diff --git a/src/renderer/src/config/ocrProviders.ts b/src/renderer/src/config/ocrProviders.ts new file mode 100644 index 0000000000..5e482e10ef --- /dev/null +++ b/src/renderer/src/config/ocrProviders.ts @@ -0,0 +1,12 @@ +import MacOSLogo from '@renderer/assets/images/providers/macos.svg' + +export function getOcrProviderLogo(providerId: string) { + switch (providerId) { + case 'system': + return MacOSLogo + default: + return undefined + } +} + +export const OCR_PROVIDER_CONFIG = {} diff --git a/src/renderer/src/config/preprocessProviders.ts b/src/renderer/src/config/preprocessProviders.ts new file mode 100644 index 0000000000..587e6ea7f9 --- /dev/null +++ b/src/renderer/src/config/preprocessProviders.ts @@ -0,0 +1,37 @@ +import Doc2xLogo from '@renderer/assets/images/ocr/doc2x.png' +import MinerULogo from '@renderer/assets/images/ocr/mineru.jpg' +import MistralLogo from '@renderer/assets/images/providers/mistral.png' + +export function getPreprocessProviderLogo(providerId: string) { + switch (providerId) { + case 'doc2x': + return Doc2xLogo + case 'mistral': + return MistralLogo + case 'mineru': + return MinerULogo + default: + return undefined + } +} + +export const PREPROCESS_PROVIDER_CONFIG = { + doc2x: { + websites: { + official: 'https://doc2x.noedgeai.com', + apiKey: 'https://open.noedgeai.com/apiKeys' + } + }, + mistral: { + websites: { + official: 'https://mistral.ai', + apiKey: 'https://mistral.ai/api-keys' + } + }, + mineru: { + websites: { + official: 'https://mineru.net/', + apiKey: 'https://mineru.net/apiManage' + } + } +} diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index b75c3497a9..aa765db05b 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -1,4 +1,4 @@ -import { FileType, KnowledgeItem, QuickPhrase, TranslateHistory } from '@renderer/types' +import { FileMetadata, KnowledgeItem, QuickPhrase, TranslateHistory } from '@renderer/types' // Import necessary types for blocks and new message structure import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage' import { Dexie, type EntityTable } from 'dexie' @@ -7,7 +7,7 @@ import { upgradeToV5, upgradeToV7 } from './upgrades' // Database declaration (move this to its own module also) export const db = new Dexie('CherryStudio') as Dexie & { - files: EntityTable + files: EntityTable topics: EntityTable<{ id: string; messages: NewMessage[] }, 'id'> // Correct type for topics settings: EntityTable<{ id: string; value: any }, 'id'> knowledge_notes: EntityTable diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index efdc9bd120..9d893da5b4 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -1,7 +1,5 @@ -/* eslint-disable react-hooks/rules-of-hooks */ import { db } from '@renderer/databases' import KnowledgeQueue from '@renderer/queue/KnowledgeQueue' -import FileManager from '@renderer/services/FileManager' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' import { RootState } from '@renderer/store' import { @@ -19,10 +17,9 @@ import { updateItemProcessingStatus, updateNotes } from '@renderer/store/knowledge' -import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types' +import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' -import { IpcChannel } from '@shared/IpcChannel' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { v4 as uuidv4 } from 'uuid' @@ -44,7 +41,7 @@ export const useKnowledge = (baseId: string) => { } // 批量添加文件 - const addFiles = (files: FileType[]) => { + const addFiles = (files: FileMetadata[]) => { const filesItems: KnowledgeItem[] = files.map((file) => ({ id: uuidv4(), type: 'file' as const, @@ -56,6 +53,7 @@ export const useKnowledge = (baseId: string) => { processingError: '', retryCount: 0 })) + console.log('Adding files:', filesItems) dispatch(addFilesAction({ baseId, items: filesItems })) setTimeout(() => KnowledgeQueue.checkAllBases(), 0) } @@ -147,7 +145,7 @@ export const useKnowledge = (baseId: string) => { } } if (item.type === 'file' && typeof item.content === 'object') { - await FileManager.deleteFile(item.content.id) + await window.api.file.deleteDir(item.content.id) } } // 刷新项目 @@ -190,41 +188,18 @@ export const useKnowledge = (baseId: string) => { } // 获取特定项目的处理状态 - const getProcessingStatus = (itemId: string) => { - return base?.items.find((item) => item.id === itemId)?.processingStatus - } + const getProcessingStatus = useCallback( + (itemId: string) => { + return base?.items.find((item) => item.id === itemId)?.processingStatus + }, + [base?.items] + ) // 获取特定类型的所有处理项 const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => { return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || [] } - // 获取目录处理进度 - const getDirectoryProcessingPercent = (itemId?: string) => { - const [percent, setPercent] = useState(0) - - useEffect(() => { - if (!itemId) { - return - } - - const cleanup = window.electron.ipcRenderer.on( - IpcChannel.DirectoryProcessingPercent, - (_, { itemId: id, percent }: { itemId: string; percent: number }) => { - if (itemId === id) { - setPercent(percent) - } - } - ) - - return () => { - cleanup() - } - }, [itemId]) - - return percent - } - // 清除已完成的项目 const clearCompleted = () => { dispatch(clearCompletedProcessing({ baseId })) @@ -307,7 +282,6 @@ export const useKnowledge = (baseId: string) => { refreshItem, getProcessingStatus, getProcessingItemsByType, - getDirectoryProcessingPercent, clearCompleted, clearAll, removeItem, diff --git a/src/renderer/src/hooks/useKnowledgeFiles.tsx b/src/renderer/src/hooks/useKnowledgeFiles.tsx index 8d3714d3a5..57b454e6b7 100644 --- a/src/renderer/src/hooks/useKnowledgeFiles.tsx +++ b/src/renderer/src/hooks/useKnowledgeFiles.tsx @@ -1,12 +1,12 @@ import FileManager from '@renderer/services/FileManager' -import { FileType } from '@renderer/types' +import { FileMetadata } from '@renderer/types' import { isEmpty } from 'lodash' import { useEffect, useState } from 'react' import { useKnowledgeBases } from './useKnowledge' export const useKnowledgeFiles = () => { - const [knowledgeFiles, setKnowledgeFiles] = useState([]) + const [knowledgeFiles, setKnowledgeFiles] = useState([]) const { bases, updateKnowledgeBases } = useKnowledgeBases() useEffect(() => { @@ -16,7 +16,7 @@ export const useKnowledgeFiles = () => { .filter((item) => item.type === 'file') .filter((item) => item.processingStatus === 'completed') - const files = fileItems.map((item) => item.content as FileType) + const files = fileItems.map((item) => item.content as FileMetadata) !isEmpty(files) && setKnowledgeFiles(files) }, [bases]) @@ -31,7 +31,7 @@ export const useKnowledgeFiles = () => { ? { ...item, content: { - ...(item.content as FileType), + ...(item.content as FileMetadata), size: 0 } } diff --git a/src/renderer/src/hooks/useOcr.ts b/src/renderer/src/hooks/useOcr.ts new file mode 100644 index 0000000000..7f83fd9c28 --- /dev/null +++ b/src/renderer/src/hooks/useOcr.ts @@ -0,0 +1,45 @@ +import { RootState } from '@renderer/store' +import { + setDefaultOcrProvider as _setDefaultOcrProvider, + updateOcrProvider as _updateOcrProvider, + updateOcrProviders as _updateOcrProviders +} from '@renderer/store/ocr' +import { OcrProvider } from '@renderer/types' +import { useDispatch, useSelector } from 'react-redux' + +export const useOcrProvider = (id: string) => { + const dispatch = useDispatch() + const ocrProviders = useSelector((state: RootState) => state.ocr.providers) + const provider = ocrProviders.find((provider) => provider.id === id) + if (!provider) { + throw new Error(`OCR provider with id ${id} not found`) + } + const updateOcrProvider = (ocrProvider: OcrProvider) => { + dispatch(_updateOcrProvider(ocrProvider)) + } + return { provider, updateOcrProvider } +} + +export const useOcrProviders = () => { + const dispatch = useDispatch() + const ocrProviders = useSelector((state: RootState) => state.ocr.providers) + return { + ocrProviders: ocrProviders, + updateOcrProviders: (ocrProviders: OcrProvider[]) => dispatch(_updateOcrProviders(ocrProviders)) + } +} + +export const useDefaultOcrProvider = () => { + const defaultProviderId = useSelector((state: RootState) => state.ocr.defaultProvider) + const { ocrProviders } = useOcrProviders() + const dispatch = useDispatch() + const provider = defaultProviderId ? ocrProviders.find((provider) => provider.id === defaultProviderId) : undefined + + const setDefaultOcrProvider = (ocrProvider: OcrProvider) => { + dispatch(_setDefaultOcrProvider(ocrProvider.id)) + } + const updateDefaultOcrProvider = (ocrProvider: OcrProvider) => { + dispatch(_updateOcrProvider(ocrProvider)) + } + return { provider, setDefaultOcrProvider, updateDefaultOcrProvider } +} diff --git a/src/renderer/src/hooks/usePreprocess.ts b/src/renderer/src/hooks/usePreprocess.ts new file mode 100644 index 0000000000..5a4c6649b5 --- /dev/null +++ b/src/renderer/src/hooks/usePreprocess.ts @@ -0,0 +1,48 @@ +import { RootState } from '@renderer/store' +import { + setDefaultPreprocessProvider as _setDefaultPreprocessProvider, + updatePreprocessProvider as _updatePreprocessProvider, + updatePreprocessProviders as _updatePreprocessProviders +} from '@renderer/store/preprocess' +import { PreprocessProvider } from '@renderer/types' +import { useDispatch, useSelector } from 'react-redux' + +export const usePreprocessProvider = (id: string) => { + const dispatch = useDispatch() + const preprocessProviders = useSelector((state: RootState) => state.preprocess.providers) + const provider = preprocessProviders.find((provider) => provider.id === id) + if (!provider) { + throw new Error(`preprocess provider with id ${id} not found`) + } + const updatePreprocessProvider = (preprocessProvider: PreprocessProvider) => { + dispatch(_updatePreprocessProvider(preprocessProvider)) + } + return { provider, updatePreprocessProvider } +} + +export const usePreprocessProviders = () => { + const dispatch = useDispatch() + const preprocessProviders = useSelector((state: RootState) => state.preprocess.providers) + return { + preprocessProviders: preprocessProviders, + updatePreprocessProviders: (preprocessProviders: PreprocessProvider[]) => + dispatch(_updatePreprocessProviders(preprocessProviders)) + } +} + +export const useDefaultPreprocessProvider = () => { + const defaultProviderId = useSelector((state: RootState) => state.preprocess.defaultProvider) + const { preprocessProviders } = usePreprocessProviders() + const dispatch = useDispatch() + const provider = defaultProviderId + ? preprocessProviders.find((provider) => provider.id === defaultProviderId) + : undefined + + const setDefaultPreprocessProvider = (preprocessProvider: PreprocessProvider) => { + dispatch(_setDefaultPreprocessProvider(preprocessProvider.id)) + } + const updateDefaultPreprocessProvider = (preprocessProvider: PreprocessProvider) => { + dispatch(_updatePreprocessProvider(preprocessProvider)) + } + return { provider, setDefaultPreprocessProvider, updateDefaultPreprocessProvider } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4511878b84..02645faa69 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -190,7 +190,7 @@ "input.translate": "Translate to {{target_language}}", "input.upload": "Upload image or document file", "input.upload.document": "Upload document file (model does not support images)", - "input.web_search": "Web search", + "input.web_search": "Web Search", "input.web_search.settings": "Web Search Settings", "input.web_search.button.ok": "Go to Settings", "input.web_search.enable": "Enable web search", @@ -406,9 +406,11 @@ "prompt": "Prompt", "provider": "Provider", "regenerate": "Regenerate", + "refresh": "Refresh", "rename": "Rename", "reset": "Reset", "save": "Save", + "settings": "Settings", "search": "Search", "select": "Select", "selectedMessages": "Selected {{count}} messages", @@ -423,7 +425,9 @@ "pinyin.asc": "Sort by Pinyin (A-Z)", "pinyin.desc": "Sort by Pinyin (Z-A)" }, - "no_results": "No results" + "no_results": "No results", + "enabled": "Enabled", + "disabled": "Disabled" }, "docs": { "title": "Docs" @@ -498,6 +502,8 @@ "title": "Topics Search" }, "knowledge": { + "name_required": "Knowledge Base Name is required", + "embedding_model_required": "Knowledge Base Embedding Model is required", "add": { "title": "Add Knowledge Base" }, @@ -543,13 +549,21 @@ "rename": "Rename", "search": "Search knowledge base", "search_placeholder": "Enter text to search", - "settings": "Knowledge Base Settings", + "settings": { + "title": "Knowledge Base Settings", + "preprocessing": "Preprocessing", + "preprocessing_tooltip": "Preprocess uploaded files with OCR" + }, "sitemap_placeholder": "Enter Website Map URL", "sitemaps": "Websites", "source": "Source", "status": "Status", "status_completed": "Completed", + "status_embedding_completed": "Embedding Completed", + "status_preprocess_completed": "Preprocessing Completed", "status_failed": "Failed", + "status_embedding_failed": "Embedding Failed", + "status_preprocess_failed": "Preprocessing Failed", "status_new": "Added", "status_pending": "Pending", "status_processing": "Processing", @@ -572,7 +586,9 @@ "dimensions_error_invalid": "Please enter embedding dimension size", "dimensions_size_too_large": "The embedding dimension cannot exceed the model's context limit ({{max_context}}).", "dimensions_set_right": "⚠️ Please ensure the model supports the set embedding dimension size", - "dimensions_default": "The model will use default embedding dimensions" + "dimensions_default": "The model will use default embedding dimensions", + "quota": "{{name}} Left Quota: {{quota}}", + "quota_infinity": "{{name}} Quota: Unlimited" }, "languages": { "arabic": "Arabic", @@ -835,7 +851,7 @@ "notification": { "assistant": "Assistant Response", "knowledge.success": "Successfully added {{type}} to the knowledge base", - "knowledge.error": "Failed to add {{type}} to knowledge base: {{error}}" + "knowledge.error": "{{error}}" }, "ollama": { "keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.", @@ -1828,68 +1844,88 @@ "tray.onclose": "Minimize to Tray on Close", "tray.show": "Show Tray Icon", "tray.title": "Tray", - "websearch": { - "blacklist": "Blacklist", - "blacklist_description": "Results from the following websites will not appear in search results", - "blacklist_tooltip": "Please use the following format (separated by newlines)\nPattern matching: *://*.example.com/*\nRegular expression: /example\\.(net|org)/", - "check": "Check", - "check_failed": "Verification failed", - "check_success": "Verification successful", - "get_api_key": "Get API Key", - "no_provider_selected": "Please select a search service provider before checking.", - "search_max_result": "Number of search results", - "search_provider": "Search service provider", - "search_provider_placeholder": "Choose a search service provider.", - "search_result_default": "Default", - "search_with_time": "Search with dates included", - "tavily": { - "api_key": "Tavily API Key", - "api_key.placeholder": "Enter Tavily API Key", - "description": "Tavily is a search engine tailored for AI agents, delivering real-time, accurate results, intelligent query suggestions, and in-depth research capabilities.", - "title": "Tavily" + "tool": { + "title": "Tools Settings", + "preprocessOrOcr.tooltip": "In Settings -> Tools, set a document preprocessing service provider or OCR. Document preprocessing can effectively improve the retrieval performance of complex format documents and scanned documents. OCR can only recognize text within images in documents or scanned PDF text.", + "preprocess": { + "title": "Pre Process", + "provider": "Pre Process Provider", + "provider_placeholder": "Choose a Pre Process provider" }, - "title": "Web Search", - "subscribe": "Blacklist Subscription", - "subscribe_update": "Update", - "subscribe_add": "Add Subscription", - "subscribe_url": "Subscription Url", - "subscribe_name": "Alternative name", - "subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.", - "subscribe_add_success": "Subscription feed added successfully!", - "subscribe_delete": "Delete", - "subscribe_add_failed": "Failed to add blacklist subscription", - "subscribe_update_success": "Blacklist subscription updated successfully", - "subscribe_update_failed": "Failed to update blacklist subscription", - "subscribe_source_update_failed": "Failed to update blacklist subscription source", - "overwrite": "Override search service", - "overwrite_tooltip": "Force use search service instead of LLM", - "apikey": "API key", - "free": "Free", - "compression": { - "title": "Search Result Compression", - "method": "Compression Method", - "method.none": "None", - "method.cutoff": "Cutoff", - "cutoff.limit": "Cutoff Limit", - "cutoff.limit.placeholder": "Enter length", - "cutoff.limit.tooltip": "Limit the content length of search results, content exceeding the limit will be truncated (e.g., 2000 characters)", - "cutoff.unit.char": "Char", - "cutoff.unit.token": "Token", - "method.rag": "RAG", - "rag.document_count": "Document Count", - "rag.document_count.default": "Default", - "rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.", - "rag.embedding_dimensions.auto_get": "Auto Get Dimensions", - "rag.embedding_dimensions.placeholder": "Leave empty", - "rag.embedding_dimensions.tooltip": "If left blank, the dimensions parameter will not be passed", - "info": { - "dimensions_auto_success": "Dimensions auto-obtained successfully, dimensions: {{dimensions}}" + "ocr": { + "title": "OCR", + "provider": "OCR Provider", + "provider_placeholder": "Choose an OCR provider", + "mac_system_ocr_options": { + "mode": { + "title": "Recognition Mode", + "accurate": "Accurate", + "fast": "Fast" + }, + "min_confidence": "Minimum Confidence" + } + }, + "websearch": { + "blacklist": "Blacklist", + "blacklist_description": "Results from the following websites will not appear in search results", + "blacklist_tooltip": "Please use the following format (separated by newlines)\nPattern matching: *://*.example.com/*\nRegular expression: /example\\.(net|org)/", + "check": "Check", + "check_failed": "Verification failed", + "check_success": "Verification successful", + "get_api_key": "Get API Key", + "no_provider_selected": "Please select a search service provider before checking.", + "search_max_result": "Number of search results", + "search_provider": "Search service provider", + "search_provider_placeholder": "Choose a search service provider.", + "search_result_default": "Default", + "search_with_time": "Search with dates included", + "tavily": { + "api_key": "Tavily API Key", + "api_key.placeholder": "Enter Tavily API Key", + "description": "Tavily is a search engine tailored for AI agents, delivering real-time, accurate results, intelligent query suggestions, and in-depth research capabilities.", + "title": "Tavily" }, - "error": { - "embedding_model_required": "Please select an embedding model first", - "dimensions_auto_failed": "Failed to auto-obtain dimensions", - "provider_not_found": "Provider not found", - "rag_failed": "RAG failed" + "content_limit": "Content length limit", + "content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated.", + "title": "Web Search", + "subscribe": "Blacklist Subscription", + "subscribe_update": "Update", + "subscribe_add": "Add Subscription", + "subscribe_url": "Subscription Url", + "subscribe_name": "Alternative name", + "subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.", + "subscribe_add_success": "Subscription feed added successfully!", + "subscribe_delete": "Delete", + "overwrite": "Override search service", + "overwrite_tooltip": "Force use search service instead of LLM", + "apikey": "API key", + "free": "Free", + "compression": { + "title": "Search Result Compression", + "method": "Compression Method", + "method.none": "None", + "method.cutoff": "Cutoff", + "cutoff.limit": "Cutoff Limit", + "cutoff.limit.placeholder": "Enter length", + "cutoff.limit.tooltip": "Limit the content length of search results, content exceeding the limit will be truncated (e.g., 2000 characters)", + "cutoff.unit.char": "Char", + "cutoff.unit.token": "Token", + "method.rag": "RAG", + "rag.document_count": "Document Count", + "rag.document_count.default": "Default", + "rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.", + "rag.embedding_dimensions.auto_get": "Auto Get Dimensions", + "rag.embedding_dimensions.placeholder": "Leave empty", + "rag.embedding_dimensions.tooltip": "If left blank, the dimensions parameter will not be passed", + "info": { + "dimensions_auto_success": "Dimensions auto-obtained successfully, dimensions: {{dimensions}}" + }, + "error": { + "embedding_model_required": "Please select an embedding model first", + "dimensions_auto_failed": "Failed to auto-obtain dimensions", + "provider_not_found": "Provider not found", + "rag_failed": "RAG failed" + } } } }, @@ -1938,7 +1974,8 @@ "service_tier.auto": "auto", "service_tier.default": "default", "service_tier.flex": "flex" - } + }, + "mineru.api_key": "Mineru now offers a daily free quota of 500 pages, and you do not need to enter a key." }, "translate": { "any.language": "Any language", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 2082cf0c27..f2f802cd26 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -406,9 +406,11 @@ "prompt": "プロンプト", "provider": "プロバイダー", "regenerate": "再生成", + "refresh": "更新", "rename": "名前を変更", "reset": "リセット", "save": "保存", + "settings": "設定", "search": "検索", "select": "選択", "selectedMessages": "{{count}}件のメッセージを選択しました", @@ -423,7 +425,9 @@ "pinyin.asc": "ピンインで昇順ソート", "pinyin.desc": "ピンインで降順ソート" }, - "no_results": "検索結果なし" + "no_results": "検索結果なし", + "enabled": "有効", + "disabled": "無効" }, "docs": { "title": "ドキュメント" @@ -543,7 +547,11 @@ "rename": "名前を変更", "search": "ナレッジベースを検索", "search_placeholder": "検索するテキストを入力", - "settings": "ナレッジベース設定", + "settings": { + "title": "ナレッジベース設定", + "preprocessing": "預処理", + "preprocessing_tooltip": "アップロードされたファイルのOCR預処理" + }, "sitemap_placeholder": "サイトマップURLを入力", "sitemaps": "サイトマップ", "source": "ソース", @@ -567,12 +575,20 @@ "urls": "URL", "dimensions": "埋め込み次元", "dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど埋め込み次元も大きくなりますが、消費するトークンも増えます。", + "status_embedding_completed": "埋め込み完了", + "status_preprocess_completed": "前処理完了", + "status_embedding_failed": "埋め込み失敗", + "status_preprocess_failed": "前処理に失敗しました", "dimensions_size_placeholder": " 埋め込み次元のサイズ(例:1024)", "dimensions_auto_set": "埋め込み次元を自動設定", "dimensions_error_invalid": "埋め込み次元のサイズを入力してください", "dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。", "dimensions_set_right": "⚠️ モデルが設定した埋め込み次元のサイズをサポートしていることを確認してください", - "dimensions_default": "モデルはデフォルトの埋め込み次元を使用します" + "dimensions_default": "モデルはデフォルトの埋め込み次元を使用します", + "quota": "{{name}} 残りクォータ: {{quota}}", + "quota_infinity": "{{name}} クォータ: 無制限", + "name_required": "ナレッジベース名は必須です", + "embedding_model_required": "ナレッジベース埋め込みモデルが必要です" }, "languages": { "arabic": "アラビア語", @@ -835,7 +851,7 @@ "notification": { "assistant": "助手回應", "knowledge.success": "ナレッジベースに{{type}}を正常に追加しました", - "knowledge.error": "ナレッジベースへの{{type}}の追加に失敗しました: {{error}}" + "knowledge.error": "{{error}}" }, "ollama": { "keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)", @@ -1805,6 +1821,91 @@ "theme.window.style.title": "ウィンドウスタイル", "theme.window.style.transparent": "透明ウィンドウ", "title": "設定", + "tool": { + "title": "ツール設定", + "websearch": { + "blacklist": "ブラックリスト", + "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", + "blacklist_tooltip": "以下の形式を使用してください(改行区切り)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", + "check": "チェック", + "check_failed": "検証に失敗しました", + "check_success": "検証に成功しました", + "get_api_key": "APIキーを取得", + "no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。", + "search_max_result": "検索結果の数", + "search_provider": "検索サービスプロバイダー", + "search_provider_placeholder": "検索サービスプロバイダーを選択する", + "search_result_default": "デフォルト", + "search_with_time": "日付を含む検索", + "tavily": { + "api_key": "Tavily API キー", + "api_key.placeholder": "Tavily API キーを入力してください", + "description": "Tavily は、AI エージェントのために特別に開発された検索エンジンで、最新の結果、インテリジェントな検索提案、そして深い研究能力を提供します", + "title": "Tavily" + }, + "title": "ウェブ検索", + "subscribe": "ブラックリスト購読", + "subscribe_update": "更新", + "subscribe_add": "購読を追加", + "subscribe_url": "購読URL", + "subscribe_name": "代替名", + "subscribe_name.placeholder": "ダウンロードした購読フィードに名前がない場合に使用される代替名。", + "subscribe_add_success": "購読フィードが正常に追加されました!", + "subscribe_delete": "削除", + "overwrite": "検索サービスを上書き", + "overwrite_tooltip": "LLMの代わりに検索サービスを強制的に使用する", + "apikey": "APIキー", + "free": "無料", + "content_limit": "コンテンツ制限", + "content_limit_tooltip": "検索結果のコンテンツの長さを制限します。制限を超えるコンテンツは切り捨てられます。", + "compression": { + "title": "検索結果の圧縮", + "method": "圧縮方法", + "method.none": "圧縮しない", + "method.cutoff": "切り捨て", + "cutoff.limit": "切り捨て長", + "cutoff.limit.placeholder": "長さを入力", + "cutoff.limit.tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます(例:2000文字)", + "cutoff.unit.char": "文字", + "cutoff.unit.token": "トークン", + "method.rag": "RAG", + "rag.document_count": "文書数", + "rag.document_count.default": "デフォルト", + "rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。", + "rag.embedding_dimensions.auto_get": "次元を自動取得", + "rag.embedding_dimensions.placeholder": "次元を設定しない", + "rag.embedding_dimensions.tooltip": "空の場合、dimensions パラメーターは渡されません", + "info": { + "dimensions_auto_success": "次元が自動取得されました。次元: {{dimensions}}" + }, + "error": { + "embedding_model_required": "まず埋め込みモデルを選択してください", + "dimensions_auto_failed": "次元の自動取得に失敗しました", + "provider_not_found": "プロバイダーが見つかりません", + "rag_failed": "RAG に失敗しました" + } + } + }, + "preprocess": { + "title": "前処理", + "provider": "プレプロセスプロバイダー", + "provider_placeholder": "前処理プロバイダーを選択してください" + }, + "preprocessOrOcr.tooltip": "設定 → ツールで、ドキュメント前処理サービスプロバイダーまたはOCRを設定します。ドキュメント前処理は、複雑な形式のドキュメントやスキャンされたドキュメントの検索性能を効果的に向上させます。OCRは、ドキュメント内の画像内のテキストまたはスキャンされたPDFテキストのみを認識できます。", + "ocr": { + "title": "OCR(オーシーアール)", + "provider": "OCRプロバイダー", + "provider_placeholder": "OCRプロバイダーを選択", + "mac_system_ocr_options": { + "mode": { + "title": "認識モード", + "accurate": "正確", + "fast": "速い" + }, + "min_confidence": "最小信頼度" + } + } + }, "topic.position": "トピックの位置", "topic.position.left": "左", "topic.position.right": "右", @@ -1813,71 +1914,6 @@ "tray.onclose": "閉じるときにトレイに最小化", "tray.show": "トレイアイコンを表示", "tray.title": "トレイ", - "websearch": { - "blacklist": "ブラックリスト", - "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", - "check": "チェック", - "check_failed": "検証に失敗しました", - "check_success": "検証に成功しました", - "get_api_key": "APIキーを取得", - "no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。", - "search_max_result": "検索結果の数", - "search_provider": "検索サービスプロバイダー", - "search_provider_placeholder": "検索サービスプロバイダーを選択する", - "search_result_default": "デフォルト", - "search_with_time": "日付を含む検索", - "tavily": { - "api_key": "Tavily API キー", - "api_key.placeholder": "Tavily API キーを入力してください", - "description": "Tavily は、AI エージェントのために特別に開発された検索エンジンで、最新の結果、インテリジェントな検索提案、そして深い研究能力を提供します", - "title": "Tavily" - }, - "title": "ウェブ検索", - "blacklist_tooltip": "マッチパターン: *://*.example.com/*\n正規表現: /example\\.(net|org)/", - "subscribe": "ブラックリスト購読", - "subscribe_update": "更新", - "subscribe_add": "サブスクリプションを追加", - "subscribe_url": "フィードのURL", - "subscribe_name": "代替名", - "subscribe_name.placeholder": "ダウンロードしたフィードに名前がない場合に使用される代替名", - "subscribe_add_success": "フィードの追加が成功しました!", - "subscribe_delete": "削除", - "overwrite": "サービス検索を上書き", - "overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する", - "apikey": "API キー", - "free": "無料", - "compression": { - "title": "検索結果の圧縮", - "method": "圧縮方法", - "method.none": "圧縮しない", - "method.cutoff": "切り捨て", - "cutoff.limit": "切り捨て長", - "cutoff.limit.placeholder": "長さを入力", - "cutoff.limit.tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます(例:2000文字)", - "cutoff.unit.char": "文字", - "cutoff.unit.token": "トークン", - "method.rag": "RAG", - "rag.document_count": "文書数", - "rag.document_count.default": "デフォルト", - "rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。", - "rag.embedding_dimensions.auto_get": "次元を自動取得", - "rag.embedding_dimensions.placeholder": "次元を設定しない", - "rag.embedding_dimensions.tooltip": "空の場合、dimensions パラメーターは渡されません", - "info": { - "dimensions_auto_success": "次元が自動取得されました。次元: {{dimensions}}" - }, - "error": { - "embedding_model_required": "まず埋め込みモデルを選択してください", - "dimensions_auto_failed": "次元の自動取得に失敗しました", - "provider_not_found": "プロバイダーが見つかりません", - "rag_failed": "RAG に失敗しました" - } - }, - "subscribe_add_failed": "ブラックリスト購読の追加に失敗しました", - "subscribe_update_success": "ブラックリスト購読が正常に更新されました", - "subscribe_update_failed": "ブラックリスト購読の更新に失敗しました", - "subscribe_source_update_failed": "ブラックリスト購読ソースの更新に失敗しました" - }, "general.auto_check_update.title": "自動更新", "general.test_plan.title": "テストプラン", "general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。", @@ -1941,7 +1977,8 @@ "assistant": "アシスタントメッセージ", "backup": "バックアップメッセージ", "knowledge_embed": "ナレッジベースメッセージ" - } + }, + "mineru.api_key": "Mineruでは現在、1日500ページの無料クォータを提供しており、キーを入力する必要はありません。" }, "translate": { "any.language": "任意の言語", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 43b53f4a4d..b5b5bfbd11 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -406,9 +406,11 @@ "prompt": "Промпт", "provider": "Провайдер", "regenerate": "Пересоздать", + "refresh": "Обновить", "rename": "Переименовать", "reset": "Сбросить", "save": "Сохранить", + "settings": "Настройки", "search": "Поиск", "select": "Выбрать", "selectedMessages": "Выбрано {{count}} сообщений", @@ -423,7 +425,9 @@ "pinyin.asc": "Сортировать по пиньинь (А-Я)", "pinyin.desc": "Сортировать по пиньинь (Я-А)" }, - "no_results": "Результатов не найдено" + "no_results": "Результатов не найдено", + "enabled": "Включено", + "disabled": "Отключено" }, "docs": { "title": "Документация" @@ -543,7 +547,11 @@ "rename": "Переименовать", "search": "Поиск в базе знаний", "search_placeholder": "Введите текст для поиска", - "settings": "Настройки базы знаний", + "settings": { + "title": "Настройки базы знаний", + "preprocessing": "Предварительная обработка", + "preprocessing_tooltip": "Предварительная обработка изображений с помощью OCR" + }, "sitemap_placeholder": "Введите URL карты сайта", "sitemaps": "Сайты", "source": "Источник", @@ -567,12 +575,20 @@ "urls": "URL-адреса", "dimensions": "векторное пространство", "dimensions_size_tooltip": "Размерность вложения, чем больше значение, тем больше размерность вложения, но и потребляемых токенов также становится больше.", + "status_embedding_completed": "Вложение завершено", + "status_preprocess_completed": "Предварительная обработка завершена", + "status_embedding_failed": "Не удалось встроить", + "status_preprocess_failed": "Предварительная обработка не удалась", "dimensions_size_placeholder": " Размерность эмбеддинга, например 1024", "dimensions_auto_set": "Автоматическая установка размерности эмбеддинга", "dimensions_error_invalid": "Пожалуйста, введите размерность эмбеддинга", "dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})", "dimensions_set_right": "⚠️ Убедитесь, что модель поддерживает заданный размер эмбеддинга", - "dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию" + "dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию", + "quota": "{{name}} Остаток квоты: {{quota}}", + "quota_infinity": "{{name}} Квота: Не ограничена", + "name_required": "Название базы знаний обязательно", + "embedding_model_required": "Модель встраивания базы знаний требуется" }, "languages": { "arabic": "Арабский", @@ -835,7 +851,7 @@ "notification": { "assistant": "Ответ ассистента", "knowledge.success": "Успешно добавлено {{type}} в базу знаний", - "knowledge.error": "Не удалось добавить {{type}} в базу знаний: {{error}}" + "knowledge.error": "{{error}}" }, "ollama": { "keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.", @@ -1805,6 +1821,91 @@ "theme.window.style.title": "Стиль окна", "theme.window.style.transparent": "Прозрачное окно", "title": "Настройки", + "tool": { + "title": "Настройки инструментов", + "websearch": { + "blacklist": "Черный список", + "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", + "blacklist_tooltip": "Пожалуйста, используйте следующий формат (разделенный переносами строк)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", + "check": "проверка", + "check_failed": "Проверка не прошла", + "check_success": "Проверка успешна", + "get_api_key": "Получить ключ API", + "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", + "search_max_result": "Количество результатов поиска", + "search_provider": "поиск сервисного провайдера", + "search_provider_placeholder": "Выберите поставщика поисковых услуг", + "search_result_default": "По умолчанию", + "search_with_time": "Поиск, содержащий дату", + "tavily": { + "api_key": "Ключ API Tavily", + "api_key.placeholder": "Введите ключ API Tavily", + "description": "Tavily — это поисковая система, специально разработанная для ИИ-агентов, предоставляющая актуальные результаты, умные предложения по запросам и глубокие исследовательские возможности", + "title": "Tavily" + }, + "title": "Поиск в Интернете", + "subscribe": "Подписка на черный список", + "subscribe_update": "Обновить", + "subscribe_add": "Добавить подписку", + "subscribe_url": "URL подписки", + "subscribe_name": "Альтернативное имя", + "subscribe_name.placeholder": "Альтернативное имя, используемое, когда в загруженной ленте подписки нет имени.", + "subscribe_add_success": "Лента подписки успешно добавлена!", + "subscribe_delete": "Удалить", + "overwrite": "Переопределить поисковый сервис", + "overwrite_tooltip": "Принудительно использовать поисковый сервис вместо LLM", + "apikey": "API ключ", + "free": "Бесплатно", + "content_limit": "Ограничение длины контента", + "content_limit_tooltip": "Ограничить длину контента в результатах поиска; контент, превышающий лимит, будет усечен.", + "compression": { + "title": "Сжатие результатов поиска", + "method": "Метод сжатия", + "method.none": "Не сжимать", + "method.cutoff": "Обрезка", + "cutoff.limit": "Лимит обрезки", + "cutoff.limit.placeholder": "Введите длину", + "cutoff.limit.tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан (например, 2000 символов)", + "cutoff.unit.char": "Символы", + "cutoff.unit.token": "Токены", + "method.rag": "RAG", + "rag.document_count": "Количество документов", + "rag.document_count.default": "По умолчанию", + "rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.", + "rag.embedding_dimensions.auto_get": "Автоматически получить размерности", + "rag.embedding_dimensions.placeholder": "Не устанавливать размерности", + "rag.embedding_dimensions.tooltip": "Если оставить пустым, параметр dimensions не будет передан", + "info": { + "dimensions_auto_success": "Размерности успешно получены, размерности: {{dimensions}}" + }, + "error": { + "embedding_model_required": "Пожалуйста, сначала выберите модель встраивания", + "dimensions_auto_failed": "Не удалось получить размерности", + "provider_not_found": "Поставщик не найден", + "rag_failed": "RAG не удалось" + } + } + }, + "preprocess": { + "title": "Предварительная обработка", + "provider": "Предварительная обработка Поставщик", + "provider_placeholder": "Выберите поставщика услуг предварительной обработки" + }, + "preprocessOrOcr.tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуги предварительной обработки документов или OCR. Предварительная обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов. OCR способен распознавать только текст внутри изображений в документах или текст в отсканированных PDF.", + "ocr": { + "title": "OCR (оптическое распознавание символов)", + "provider": "Поставщик OCR", + "provider_placeholder": "Выберите провайдера OCR", + "mac_system_ocr_options": { + "mode": { + "title": "Режим распознавания", + "accurate": "Точный", + "fast": "Быстро" + }, + "min_confidence": "Минимальная достоверность" + } + } + }, "topic.position": "Позиция топиков", "topic.position.left": "Слева", "topic.position.right": "Справа", @@ -1813,71 +1914,6 @@ "tray.onclose": "Свернуть в трей при закрытии", "tray.show": "Показать значок в трее", "tray.title": "Трей", - "websearch": { - "blacklist": "Черный список", - "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", - "check": "проверка", - "check_failed": "Проверка не прошла", - "check_success": "Проверка успешна", - "get_api_key": "Получить ключ API", - "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", - "search_max_result": "Количество результатов поиска", - "search_provider": "поиск сервисного провайдера", - "search_provider_placeholder": "Выберите поставщика поисковых услуг", - "search_result_default": "По умолчанию", - "search_with_time": "Поиск, содержащий дату", - "tavily": { - "api_key": "Ключ API Tavily", - "api_key.placeholder": "Введите ключ API Tavily", - "description": "Tavily — это поисковая система, специально разработанная для ИИ-агентов, предоставляющая актуальные результаты, умные предложения по запросам и глубокие исследовательские возможности", - "title": "Tavily" - }, - "title": "Поиск в Интернете", - "blacklist_tooltip": "Шаблон: *://*.example.com/*\nРегулярное выражение: /example\\.(net|org)/", - "subscribe": "Подписка на черный список", - "subscribe_update": "Обновить", - "subscribe_add": "Добавить", - "subscribe_url": "URL подписки", - "subscribe_name": "Альтернативное имя", - "subscribe_name.placeholder": "Альтернативное имя, если в подписке нет названия.", - "subscribe_add_success": "Подписка успешно добавлена!", - "subscribe_delete": "Удалить", - "overwrite": "Переопределить провайдера поиска", - "overwrite_tooltip": "Использовать провайдера поиска вместо LLM", - "apikey": "API ключ", - "free": "Бесплатно", - "compression": { - "title": "Сжатие результатов поиска", - "method": "Метод сжатия", - "method.none": "Не сжимать", - "method.cutoff": "Обрезка", - "cutoff.limit": "Лимит обрезки", - "cutoff.limit.placeholder": "Введите длину", - "cutoff.limit.tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан (например, 2000 символов)", - "cutoff.unit.char": "Символы", - "cutoff.unit.token": "Токены", - "method.rag": "RAG", - "rag.document_count": "Количество документов", - "rag.document_count.default": "По умолчанию", - "rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.", - "rag.embedding_dimensions.auto_get": "Автоматически получить размерности", - "rag.embedding_dimensions.placeholder": "Не устанавливать размерности", - "rag.embedding_dimensions.tooltip": "Если оставить пустым, параметр dimensions не будет передан", - "info": { - "dimensions_auto_success": "Размерности успешно получены, размерности: {{dimensions}}" - }, - "error": { - "embedding_model_required": "Пожалуйста, сначала выберите модель встраивания", - "dimensions_auto_failed": "Не удалось получить размерности", - "provider_not_found": "Поставщик не найден", - "rag_failed": "RAG не удалось" - } - }, - "subscribe_add_failed": "Не удалось добавить подписку на черный список", - "subscribe_update_success": "Подписка на черный список успешно обновлена", - "subscribe_update_failed": "Не удалось обновить подписку на черный список", - "subscribe_source_update_failed": "Не удалось обновить источник подписки на черный список" - }, "general.auto_check_update.title": "Автоматическое обновление", "general.test_plan.title": "Тестовый план", "general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее", @@ -1941,7 +1977,8 @@ "assistant": "Сообщение ассистента", "backup": "Резервное сообщение", "knowledge_embed": "Сообщение базы знаний" - } + }, + "mineru.api_key": "Mineru теперь предлагает ежедневную бесплатную квоту в 500 страниц, и вам не нужно вводить ключ." }, "translate": { "any.language": "Любой язык", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2dd0422004..3fec012014 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -406,9 +406,11 @@ "prompt": "提示词", "provider": "提供商", "regenerate": "重新生成", + "refresh": "刷新", "rename": "重命名", "reset": "重置", "save": "保存", + "settings": "设置", "search": "搜索", "select": "选择", "selectedMessages": "选中 {{count}} 条消息", @@ -423,7 +425,9 @@ "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" }, - "no_results": "无结果" + "no_results": "无结果", + "enabled": "已启用", + "disabled": "已禁用" }, "docs": { "title": "帮助文档" @@ -551,7 +555,11 @@ "rename": "重命名", "search": "搜索知识库", "search_placeholder": "输入查询内容", - "settings": "知识库设置", + "settings": { + "title": "知识库设置", + "preprocessing": "预处理", + "preprocessing_tooltip": "使用 OCR 预处理上传的文件" + }, "sitemap_placeholder": "请输入站点地图 URL", "sitemaps": "网站", "source": "来源", @@ -571,8 +579,16 @@ "topN_placeholder": "未设置", "topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多", "url_added": "网址已添加", - "url_placeholder": "请输入网址,多个网址用回车分隔", - "urls": "网址" + "url_placeholder": "请输入网址, 多个网址用回车分隔", + "urls": "网址", + "status_embedding_completed": "嵌入完成", + "status_preprocess_completed": "预处理完成", + "status_embedding_failed": "嵌入失败", + "status_preprocess_failed": "预处理失败", + "quota": "{{name}} 剩余额度:{{quota}}", + "quota_infinity": "{{name}} 剩余额度:无限制", + "name_required": "知识库名称为必填项", + "embedding_model_required": "知识库嵌入模型是必需的" }, "languages": { "arabic": "阿拉伯文", @@ -835,7 +851,7 @@ "notification": { "assistant": "助手响应", "knowledge.success": "成功添加 {{type}} 到知识库", - "knowledge.error": "添加 {{type}} 到知识库失败: {{error}}" + "knowledge.error": "{{error}}" }, "ollama": { "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5 分钟)", @@ -1670,7 +1686,7 @@ "title": "通知设置", "assistant": "助手消息", "backup": "备份", - "knowledge_embed": "知识嵌入" + "knowledge_embed": "知识库" }, "provider": { "add.name": "提供商名称", @@ -1831,71 +1847,6 @@ "tray.onclose": "关闭时最小化到托盘", "tray.show": "显示托盘图标", "tray.title": "托盘", - "websearch": { - "blacklist": "黑名单", - "blacklist_description": "在搜索结果中不会出现以下网站的结果", - "blacklist_tooltip": "请使用以下格式 (换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/", - "check": "检测", - "check_failed": "验证失败", - "check_success": "验证成功", - "overwrite": "覆盖服务商搜索", - "overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索", - "get_api_key": "点击这里获取密钥", - "no_provider_selected": "请选择搜索服务商后再检测", - "search_max_result": "搜索结果个数", - "search_provider": "搜索服务商", - "search_provider_placeholder": "选择一个搜索服务商", - "subscribe": "黑名单订阅", - "subscribe_update": "立即更新", - "subscribe_add": "添加订阅", - "subscribe_url": "订阅源地址", - "subscribe_name": "替代名字", - "subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称", - "subscribe_add_success": "订阅源添加成功!", - "subscribe_delete": "删除订阅源", - "search_result_default": "默认", - "search_with_time": "搜索包含日期", - "tavily": { - "api_key": "Tavily API 密钥", - "api_key.placeholder": "请输入 Tavily API 密钥", - "description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力", - "title": "Tavily" - }, - "title": "网络搜索", - "apikey": "API 密钥", - "free": "免费", - "compression": { - "title": "搜索结果压缩", - "method": "压缩方法", - "method.none": "不压缩", - "method.cutoff": "截断", - "cutoff.limit": "截断长度", - "cutoff.limit.placeholder": "输入长度", - "cutoff.limit.tooltip": "限制搜索结果的内容长度,超过限制的内容将被截断(例如 2000 字符)", - "cutoff.unit.char": "字符", - "cutoff.unit.token": "Token", - "method.rag": "RAG", - "rag.document_count": "文档数量", - "rag.document_count.default": "默认", - "rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。", - "rag.embedding_dimensions.auto_get": "自动获取维度", - "rag.embedding_dimensions.placeholder": "不设置维度", - "rag.embedding_dimensions.tooltip": "留空则不传递 dimensions 参数", - "info": { - "dimensions_auto_success": "维度自动获取成功,维度为 {{dimensions}}" - }, - "error": { - "embedding_model_required": "请先选择嵌入模型", - "dimensions_auto_failed": "维度自动获取失败", - "provider_not_found": "未找到服务商", - "rag_failed": "RAG 失败" - } - }, - "subscribe_add_failed": "添加黑名单订阅失败", - "subscribe_update_success": "黑名单订阅更新成功", - "subscribe_update_failed": "更新黑名单订阅失败", - "subscribe_source_update_failed": "更新黑名单订阅源失败" - }, "quickPhrase": { "title": "快捷短语", "add": "添加短语", @@ -1941,7 +1892,93 @@ "service_tier.auto": "自动", "service_tier.default": "默认", "service_tier.flex": "灵活" - } + }, + "tool": { + "title": "工具设置", + "preprocess": { + "title": "文档预处理", + "provider": "文档预处理服务商", + "provider_placeholder": "选择一个文档预处理服务商" + }, + "ocr": { + "title": "OCR", + "provider": "OCR 服务商", + "provider_placeholder": "选择一个 OCR 服务商", + "mac_system_ocr_options": { + "mode": { + "title": "识别模式", + "accurate": "准确", + "fast": "快速" + }, + "min_confidence": "最低置信度" + } + }, + "websearch": { + "blacklist": "黑名单", + "blacklist_description": "在搜索结果中不会出现以下网站的结果", + "blacklist_tooltip": "请使用以下格式(换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/", + "check": "检测", + "check_failed": "验证失败", + "check_success": "验证成功", + "overwrite": "覆盖服务商搜索", + "overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索", + "get_api_key": "点击这里获取密钥", + "no_provider_selected": "请选择搜索服务商后再检测", + "search_max_result": "搜索结果个数", + "search_provider": "搜索服务商", + "search_provider_placeholder": "选择一个搜索服务商", + "subscribe": "黑名单订阅", + "subscribe_update": "立即更新", + "subscribe_add": "添加订阅", + "subscribe_url": "订阅源地址", + "subscribe_name": "替代名字", + "subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称", + "subscribe_add_success": "订阅源添加成功!", + "subscribe_delete": "删除订阅源", + "search_result_default": "默认", + "search_with_time": "搜索包含日期", + "tavily": { + "api_key": "Tavily API 密钥", + "api_key.placeholder": "请输入 Tavily API 密钥", + "description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力", + "title": "Tavily" + }, + "title": "网络搜索", + "apikey": "API 密钥", + "free": "免费", + "content_limit": "内容长度限制", + "content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断", + "compression": { + "title": "搜索结果压缩", + "method": "压缩方法", + "method.none": "不压缩", + "method.cutoff": "截断", + "cutoff.limit": "截断长度", + "cutoff.limit.placeholder": "输入长度", + "cutoff.limit.tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断(例如 2000 字符)", + "cutoff.unit.char": "字符", + "cutoff.unit.token": "Token", + "method.rag": "RAG", + "rag.document_count": "文档数量", + "rag.document_count.default": "默认", + "rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。", + "rag.embedding_dimensions.auto_get": "自动获取维度", + "rag.embedding_dimensions.placeholder": "不设置维度", + "rag.embedding_dimensions.tooltip": "留空则不传递 dimensions 参数", + "info": { + "dimensions_auto_success": "维度自动获取成功,维度为 {{dimensions}}" + }, + "error": { + "embedding_model_required": "请先选择嵌入模型", + "dimensions_auto_failed": "维度自动获取失败", + "provider_not_found": "未找到服务商", + "rag_failed": "RAG 失败" + } + } + }, + "preprocessOrOcr.tooltip": "在设置 -> 工具中设置文档预处理服务商或OCR,文档预处理可以有效提升复杂格式文档与扫描版文档的检索效果,OCR仅可识别文档内图片或扫描版PDF的文本" + }, + "mineru.api_key": "MinerU现在提供每日500页的免费额度,您不需要填写密钥。" }, "translate": { "any.language": "任意语言", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 12addbba02..9e1a74d5e8 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -406,9 +406,11 @@ "prompt": "提示詞", "provider": "供應商", "regenerate": "重新生成", + "refresh": "重新整理", "rename": "重新命名", "reset": "重設", "save": "儲存", + "settings": "設定", "search": "搜尋", "select": "選擇", "selectedMessages": "選中 {{count}} 條訊息", @@ -423,7 +425,9 @@ "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" }, - "no_results": "沒有結果" + "no_results": "沒有結果", + "enabled": "已啟用", + "disabled": "已停用" }, "docs": { "title": "說明文件" @@ -543,7 +547,11 @@ "rename": "重新命名", "search": "搜尋知識庫", "search_placeholder": "輸入查詢內容", - "settings": "知識庫設定", + "settings": { + "title": "知識庫設定", + "preprocessing": "預處理", + "preprocessing_tooltip": "預處理上傳的文件" + }, "sitemap_placeholder": "請輸入網站地圖 URL", "sitemaps": "網站", "source": "來源", @@ -567,12 +575,20 @@ "urls": "網址", "dimensions": "嵌入維度", "dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多", - "dimensions_size_placeholder": "嵌入維度大小,例如 1024", + "status_embedding_completed": "嵌入完成", + "status_preprocess_completed": "預處理完成", + "status_embedding_failed": "嵌入失敗", + "status_preprocess_failed": "預處理失敗", + "dimensions_size_placeholder": " 嵌入維度大小,例如 1024", "dimensions_auto_set": "自動設定嵌入維度", "dimensions_error_invalid": "請輸入嵌入維度大小", "dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})", "dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小", - "dimensions_default": "模型將使用預設嵌入維度" + "dimensions_default": "模型將使用預設嵌入維度", + "quota": "{{name}} 剩餘配額:{{quota}}", + "quota_infinity": "{{name}} 配額:無限制", + "name_required": "知識庫名稱為必填項目", + "embedding_model_required": "知識庫嵌入模型是必需的" }, "languages": { "arabic": "阿拉伯文", @@ -1808,79 +1824,99 @@ "theme.window.style.title": "視窗樣式", "theme.window.style.transparent": "透明視窗", "title": "設定", + "tool": { + "title": "工具設定", + "websearch": { + "blacklist": "黑名單", + "blacklist_description": "以下網站不會出現在搜尋結果中", + "blacklist_tooltip": "請使用以下格式 (換行符號分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com", + "check": "檢查", + "check_failed": "驗證失敗", + "check_success": "驗證成功", + "get_api_key": "點選這裡取得金鑰", + "no_provider_selected": "請選擇搜尋服務商後再檢查", + "search_max_result": "搜尋結果個數", + "search_provider": "搜尋服務商", + "search_provider_placeholder": "選擇一個搜尋服務商", + "search_result_default": "預設", + "search_with_time": "搜尋包含日期", + "tavily": { + "api_key": "Tavily API 金鑰", + "api_key.placeholder": "請輸入 Tavily API 金鑰", + "description": "Tavily 是一個為 AI 代理量身訂製的搜尋引擎,提供即時、準確的結果、智慧查詢建議和深入的研究能力", + "title": "Tavily" + }, + "title": "網路搜尋", + "subscribe": "黑名單訂閱", + "subscribe_update": "更新", + "subscribe_add": "新增訂閱", + "subscribe_url": "訂閱網址", + "subscribe_name": "替代名稱", + "subscribe_name.placeholder": "下載的訂閱源沒有名稱時使用的替代名稱。", + "subscribe_add_success": "訂閱源新增成功!", + "subscribe_delete": "刪除", + "overwrite": "覆蓋搜尋服務", + "overwrite_tooltip": "強制使用搜尋服務而不是 LLM", + "apikey": "API 金鑰", + "free": "免費", + "content_limit": "內容長度限制", + "content_limit_tooltip": "限制搜尋結果的內容長度;超過限制的內容將被截斷。", + "compression": { + "title": "搜尋結果壓縮", + "method": "壓縮方法", + "method.none": "不壓縮", + "method.cutoff": "截斷", + "cutoff.limit": "截斷長度", + "cutoff.limit.placeholder": "輸入長度", + "cutoff.limit.tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)", + "cutoff.unit.char": "字符", + "cutoff.unit.token": "Token", + "method.rag": "RAG", + "rag.document_count": "文檔數量", + "rag.document_count.default": "預設", + "rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。", + "rag.embedding_dimensions.auto_get": "自動獲取維度", + "rag.embedding_dimensions.placeholder": "不設置維度", + "rag.embedding_dimensions.tooltip": "留空則不傳遞 dimensions 參數", + "info": { + "dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}" + }, + "error": { + "embedding_model_required": "請先選擇嵌入模型", + "dimensions_auto_failed": "維度自動獲取失敗", + "provider_not_found": "未找到服務商", + "rag_failed": "RAG 失敗" + } + } + }, + "preprocess": { + "title": "前置處理", + "provider": "前置處理供應商", + "provider_placeholder": "選擇一個預處理供應商" + }, + "preprocessOrOcr.tooltip": "在「設定」->「工具」中設定文件預處理服務供應商或OCR。文件預處理可有效提升複雜格式文件及掃描文件的檢索效能,而OCR僅能辨識文件內圖片文字或掃描PDF文字。", + "ocr": { + "title": "光學字符識別", + "provider": "OCR 供應商", + "provider_placeholder": "選擇一個OCR服務提供商", + "mac_system_ocr_options": { + "mode": { + "title": "識別模式", + "accurate": "準確", + "fast": "快速" + }, + "min_confidence": "最小置信度" + } + } + }, + "topic.pin_to_top": "固定話題置頂", "topic.position": "話題位置", "topic.position.left": "左側", "topic.position.right": "右側", "topic.show.time": "顯示話題時間", - "topic.pin_to_top": "固定話題置頂", "tray.onclose": "關閉時最小化到系统匣", "tray.show": "顯示系统匣圖示", "tray.title": "系统匣", - "websearch": { - "check_success": "驗證成功", - "get_api_key": "點選這裡取得金鑰", - "search_with_time": "搜尋包含日期", - "tavily": { - "api_key": "Tavily API 金鑰", - "api_key.placeholder": "請輸入 Tavily API 金鑰", - "description": "Tavily 是一個為 AI 代理量身訂製的搜尋引擎,提供即時、準確的結果、智慧查詢建議和深入的研究能力", - "title": "Tavily" - }, - "blacklist": "黑名單", - "blacklist_description": "以下網站不會出現在搜索結果中", - "search_max_result": "搜尋結果個數", - "search_result_default": "預設", - "check": "檢查", - "search_provider": "搜尋服務商", - "search_provider_placeholder": "選擇一個搜尋服務商", - "no_provider_selected": "請選擇搜索服務商後再檢查", - "check_failed": "驗證失敗", - "blacklist_tooltip": "匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/", - "subscribe": "黑名單訂閱", - "subscribe_update": "更新", - "subscribe_add": "添加訂閱", - "subscribe_url": "訂閱源地址", - "subscribe_name": "替代名稱", - "subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱", - "subscribe_add_success": "訂閱源添加成功!", - "subscribe_delete": "刪除", - "title": "網路搜尋", - "overwrite": "覆蓋搜尋服務商", - "overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋", - "apikey": "API 金鑰", - "free": "免費", - "compression": { - "title": "搜尋結果壓縮", - "method": "壓縮方法", - "method.none": "不壓縮", - "method.cutoff": "截斷", - "cutoff.limit": "截斷長度", - "cutoff.limit.placeholder": "輸入長度", - "cutoff.limit.tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)", - "cutoff.unit.char": "字符", - "cutoff.unit.token": "Token", - "method.rag": "RAG", - "rag.document_count": "文檔數量", - "rag.document_count.default": "預設", - "rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。", - "rag.embedding_dimensions.auto_get": "自動獲取維度", - "rag.embedding_dimensions.placeholder": "不設置維度", - "rag.embedding_dimensions.tooltip": "留空則不傳遞 dimensions 參數", - "info": { - "dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}" - }, - "error": { - "embedding_model_required": "請先選擇嵌入模型", - "dimensions_auto_failed": "維度自動獲取失敗", - "provider_not_found": "未找到服務商", - "rag_failed": "RAG 失敗" - } - }, - "subscribe_add_failed": "加入黑名單訂閱失敗", - "subscribe_update_success": "黑名單訂閱更新成功", - "subscribe_update_failed": "更新黑名單訂閱失敗", - "subscribe_source_update_failed": "更新黑名單訂閱來源失敗" - }, "general.auto_check_update.title": "自動更新", "general.test_plan.title": "測試計畫", "general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據", @@ -1941,7 +1977,8 @@ "assistant": "助手訊息", "backup": "備份訊息", "knowledge_embed": "知識庫訊息" - } + }, + "mineru.api_key": "Mineru 現在每天提供 500 頁的免費配額,且無需輸入金鑰。" }, "translate": { "any.language": "任意語言", diff --git a/src/renderer/src/pages/files/ContentView.tsx b/src/renderer/src/pages/files/ContentView.tsx index 6630962921..a1c1f00aaa 100644 --- a/src/renderer/src/pages/files/ContentView.tsx +++ b/src/renderer/src/pages/files/ContentView.tsx @@ -1,5 +1,5 @@ import FileManager from '@renderer/services/FileManager' -import { FileType, FileTypes } from '@renderer/types' +import { FileMetadata, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { Col, Image, Row, Spin, Table } from 'antd' import React, { memo } from 'react' @@ -7,7 +7,7 @@ import styled from 'styled-components' interface ContentViewProps { id: FileTypes | 'all' | string - files?: FileType[] + files?: FileMetadata[] dataSource?: any[] columns: any[] } diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx index a08de9912f..f34c2b5bdf 100644 --- a/src/renderer/src/pages/files/FileList.tsx +++ b/src/renderer/src/pages/files/FileList.tsx @@ -1,7 +1,7 @@ import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons' import { handleDelete } from '@renderer/services/FileAction' import FileManager from '@renderer/services/FileManager' -import { FileType, FileTypes } from '@renderer/types' +import { FileMetadata, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { Col, Image, Row, Spin } from 'antd' import { t } from 'i18next' @@ -16,14 +16,14 @@ interface FileItemProps { list: { key: FileTypes | 'all' | string file: React.ReactNode - files?: FileType[] + files?: FileMetadata[] count?: number size: string ext: string created_at: string actions: React.ReactNode }[] - files?: FileType[] + files?: FileMetadata[] } const FileList: React.FC = ({ id, list, files }) => { diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 2890a0cb85..550f8d551c 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -10,7 +10,7 @@ import ListItem from '@renderer/components/ListItem' import db from '@renderer/databases' import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction' import FileManager from '@renderer/services/FileManager' -import { FileType, FileTypes } from '@renderer/types' +import { FileMetadata, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { Button, Empty, Flex, Popconfirm } from 'antd' import dayjs from 'dayjs' @@ -31,7 +31,7 @@ const FilesPage: FC = () => { const [sortField, setSortField] = useState('created_at') const [sortOrder, setSortOrder] = useState('desc') - const files = useLiveQuery(() => { + const files = useLiveQuery(() => { if (fileType === 'all') { return db.files.orderBy('count').toArray().then(tempFilesSort) } diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx index 4f69ca7815..b2004994df 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx @@ -14,7 +14,7 @@ import { } from '@ant-design/icons' import CustomTag from '@renderer/components/CustomTag' import FileManager from '@renderer/services/FileManager' -import { FileType } from '@renderer/types' +import { FileMetadata } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { Flex, Image, Tooltip } from 'antd' import { isEmpty } from 'lodash' @@ -22,8 +22,8 @@ import { FC, useState } from 'react' import styled from 'styled-components' interface Props { - files: FileType[] - setFiles: (files: FileType[]) => void + files: FileMetadata[] + setFiles: (files: FileMetadata[]) => void } const MAX_FILENAME_DISPLAY_LENGTH = 20 @@ -80,7 +80,7 @@ export const getFileIcon = (type?: string) => { return } -export const FileNameRender: FC<{ file: FileType }> = ({ file }) => { +export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => { const [visible, setVisible] = useState(false) const isImage = (ext: string) => { return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext) diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index 906c7aa5aa..ec18054047 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -55,8 +55,8 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { label: p.name, description: WebSearchService.isWebSearchEnabled(p.id) ? hasObjectKey(p, 'apiKey') - ? t('settings.websearch.apikey') - : t('settings.websearch.free') + ? t('settings.tool.websearch.apikey') + : t('settings.tool.websearch.free') : t('chat.input.web_search.enable_content'), icon: , isSelected: p.id === assistant?.webSearchProviderId, @@ -81,7 +81,7 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { items.push({ label: t('chat.input.web_search.settings'), icon: , - action: () => navigate('/settings/web-search') + action: () => navigate('/settings/tool/websearch') }) items.unshift({ diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 60b2959b4b..5a61fecb72 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -182,7 +182,8 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => { {citation.showFavicon && } handleLinkClick(citation.url, e)}> - {citation.title} + {/* example title: User/path/example.pdf */} + {citation.title?.split('/').pop()} {citation.number} {citation.content && } diff --git a/src/renderer/src/pages/home/Messages/MessageAttachments.tsx b/src/renderer/src/pages/home/Messages/MessageAttachments.tsx index 0e17221423..e13e56e6ca 100644 --- a/src/renderer/src/pages/home/Messages/MessageAttachments.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAttachments.tsx @@ -20,7 +20,7 @@ const StyledUpload = styled(Upload)` ` const MessageAttachments: FC = ({ block }) => { - // const handleCopyImage = async (image: FileType) => { + // const handleCopyImage = async (image: FileMetadata) => { // const data = await FileManager.readFile(image) // const blob = new Blob([data], { type: 'image/png' }) // const item = new ClipboardItem({ [blob.type]: blob }) diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index dc90ebe517..8639855bce 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -7,7 +7,7 @@ import FileManager from '@renderer/services/FileManager' import PasteService from '@renderer/services/PasteService' import { useAppSelector } from '@renderer/store' import { selectMessagesForTopic } from '@renderer/store/newMessage' -import { FileType, FileTypes } from '@renderer/types' +import { FileMetadata, FileTypes } from '@renderer/types' import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { classNames, getFileExtension } from '@renderer/utils' import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input' @@ -36,7 +36,7 @@ interface Props { const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onCancel }) => { const allBlocks = findAllBlocks(message) const [editedBlocks, setEditedBlocks] = useState(allBlocks) - const [files, setFiles] = useState([]) + const [files, setFiles] = useState([]) const [isProcessing, setIsProcessing] = useState(false) const [isFileDragging, setIsFileDragging] = useState(false) const { assistant } = useAssistant(message.assistantId) diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index 71e0125c8d..4d4e702d4e 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -7,12 +7,13 @@ import { getProviderName } from '@renderer/services/ProviderService' import { KnowledgeBase } from '@renderer/types' import { Button, Empty, Tabs, Tag, Tooltip } from 'antd' import { Book, Folder, Globe, Link, Notebook, Search, Settings } from 'lucide-react' -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import KnowledgeSearchPopup from './components/KnowledgeSearchPopup' -import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup' +import KnowledgeSettings from './components/KnowledgeSettings' +import QuotaTag from './components/QuotaTag' import KnowledgeDirectories from './items/KnowledgeDirectories' import KnowledgeFiles from './items/KnowledgeFiles' import KnowledgeNotes from './items/KnowledgeNotes' @@ -27,16 +28,46 @@ const KnowledgeContent: FC = ({ selectedBase }) => { const { t } = useTranslation() const { base, urlItems, fileItems, directoryItems, noteItems, sitemapItems } = useKnowledge(selectedBase.id || '') const [activeKey, setActiveKey] = useState('files') + const [quota, setQuota] = useState(undefined) + const [progressMap, setProgressMap] = useState>(new Map()) + const [preprocessMap, setPreprocessMap] = useState>(new Map()) const providerName = getProviderName(base?.model.provider || '') + useEffect(() => { + const handlers = [ + window.electron.ipcRenderer.on('file-preprocess-finished', (_, { itemId, quota }) => { + setPreprocessMap((prev) => new Map(prev).set(itemId, true)) + if (quota) { + setQuota(quota) + } + }), + + window.electron.ipcRenderer.on('file-preprocess-progress', (_, { itemId, progress }) => { + setProgressMap((prev) => new Map(prev).set(itemId, progress)) + }), + + window.electron.ipcRenderer.on('file-ocr-progress', (_, { itemId, progress }) => { + setProgressMap((prev) => new Map(prev).set(itemId, progress)) + }), + + window.electron.ipcRenderer.on('directory-processing-percent', (_, { itemId, percent }) => { + console.log('[Progress] Directory:', itemId, percent) + setProgressMap((prev) => new Map(prev).set(itemId, percent)) + }) + ] + + return () => { + handlers.forEach((cleanup) => cleanup()) + } + }, []) const knowledgeItems = [ { key: 'files', title: t('files.title'), icon: activeKey === 'files' ? : , items: fileItems, - content: + content: }, { key: 'notes', @@ -50,7 +81,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { title: t('knowledge.directories'), icon: activeKey === 'directories' ? : , items: directoryItems, - content: + content: }, { key: 'urls', @@ -93,7 +124,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { - -

- maxContext) { - return Promise.reject(new Error(t('knowledge.chunk_size_too_large', { max_context: maxContext }))) - } - return Promise.resolve() - } - } - ]}> - - - ({ - validator(_, value) { - if (!value || getFieldValue('chunkSize') > value) { - return Promise.resolve() - } - return Promise.reject(new Error(t('message.error.chunk_overlap_too_large'))) - } - }) - ]} - dependencies={['chunkSize']}> - - - - 1 || value < 0)) { - return Promise.reject(new Error(t('knowledge.threshold_too_large_or_small'))) - } - return Promise.resolve() - } - } - ]}> - - - - } - /> -
- - - ) -} - -const TopViewKey = 'KnowledgeSettingsPopup' - -export default class KnowledgeSettingsPopup { - static hide() { - TopView.hide(TopViewKey) - } - - static show(props: ShowParams) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - TopView.hide(TopViewKey) - }} - />, - TopViewKey - ) - }) - } -} diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx new file mode 100644 index 0000000000..6db268cc6e --- /dev/null +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -0,0 +1,66 @@ +import { usePreprocessProvider } from '@renderer/hooks/usePreprocess' +import { getStoreSetting } from '@renderer/hooks/useSettings' +import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' +import { KnowledgeBase } from '@renderer/types' +import { Tag } from 'antd' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> = ({ + base, + providerId, + quota: _quota +}) => { + const { t } = useTranslation() + const { provider, updatePreprocessProvider } = usePreprocessProvider(providerId) + const [quota, setQuota] = useState(_quota) + + useEffect(() => { + const checkQuota = async () => { + if (provider.id !== 'mineru') return + // 使用用户的key时quota为无限 + if (provider.apiKey) { + setQuota(-9999) + updatePreprocessProvider({ ...provider, quota: -9999 }) + return + } + if (quota === undefined) { + const userId = getStoreSetting('userId') + const baseParams = getKnowledgeBaseParams(base) + try { + const response = await window.api.knowledgeBase.checkQuota({ + base: baseParams, + userId: userId as string + }) + setQuota(response) + } catch (error) { + console.error('[KnowledgeContent] Error checking quota:', error) + } + } + } + if (_quota) { + updatePreprocessProvider({ ...provider, quota: _quota }) + return + } + checkQuota() + }, [_quota, base, provider, quota, updatePreprocessProvider]) + + return ( + <> + {quota && ( + + {quota === -9999 + ? t('knowledge.quota_infinity', { + name: provider.name + }) + : t('knowledge.quota', { + name: provider.name, + quota: quota + })} + + )} + + ) +} + +export default QuotaTag diff --git a/src/renderer/src/pages/knowledge/components/StatusIcon.tsx b/src/renderer/src/pages/knowledge/components/StatusIcon.tsx index 5bf98f5a35..69435d4e14 100644 --- a/src/renderer/src/pages/knowledge/components/StatusIcon.tsx +++ b/src/renderer/src/pages/knowledge/components/StatusIcon.tsx @@ -1,7 +1,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons' import { KnowledgeBase, ProcessingStatus } from '@renderer/types' import { Progress, Tooltip } from 'antd' -import { FC } from 'react' +import React, { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -9,64 +9,83 @@ interface StatusIconProps { sourceId: string base: KnowledgeBase getProcessingStatus: (sourceId: string) => ProcessingStatus | undefined - getProcessingPercent?: (sourceId: string) => number | undefined type: string + progress?: number + isPreprocessed?: boolean } -const StatusIcon: FC = ({ sourceId, base, getProcessingStatus, getProcessingPercent, type }) => { +const StatusIcon: FC = ({ + sourceId, + base, + getProcessingStatus, + type, + progress = 0, + isPreprocessed +}) => { const { t } = useTranslation() const status = getProcessingStatus(sourceId) - const percent = getProcessingPercent?.(sourceId) const item = base.items.find((item) => item.id === sourceId) const errorText = item?.processingError + console.log('[StatusIcon] Rendering for item:', item?.id, 'Status:', status, 'Progress:', progress) - if (!status) { - if (item?.uniqueId) { + const statusDisplay = useMemo(() => { + if (!status) { + if (item?.uniqueId) { + if (isPreprocessed && item.type === 'file') { + return ( + + + + ) + } + return ( + + + + ) + } return ( - - + + ) } - return ( - - - - ) - } - switch (status) { - case 'pending': - return ( - - - - ) + switch (status) { + case 'pending': + return ( + + + + ) - case 'processing': { - return type === 'directory' ? ( - - ) : ( - - - - ) + case 'processing': { + return type === 'directory' || type === 'file' ? ( + + ) : ( + + + + ) + } + case 'completed': + return ( + + + + ) + case 'failed': + return ( + + + + ) + default: + return null } - case 'completed': - return ( - - - - ) - case 'failed': - return ( - - - - ) - default: - return null - } + }, [status, item?.uniqueId, type, progress, errorText, t]) + + return statusDisplay } const StatusDot = styled.div<{ $status: 'pending' | 'processing' | 'new' }>` @@ -91,4 +110,14 @@ const StatusDot = styled.div<{ $status: 'pending' | 'processing' | 'new' }>` } ` -export default StatusIcon +export default React.memo(StatusIcon, (prevProps, nextProps) => { + return ( + prevProps.sourceId === nextProps.sourceId && + prevProps.type === nextProps.type && + prevProps.base.id === nextProps.base.id && + prevProps.progress === nextProps.progress && + prevProps.getProcessingStatus(prevProps.sourceId) === nextProps.getProcessingStatus(nextProps.sourceId) && + prevProps.base.items.find((item) => item.id === prevProps.sourceId)?.processingError === + nextProps.base.items.find((item) => item.id === nextProps.sourceId)?.processingError + ) +}) diff --git a/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx b/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx index 27829edb61..3cdd480c04 100644 --- a/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx +++ b/src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx @@ -26,6 +26,7 @@ import { interface KnowledgeContentProps { selectedBase: KnowledgeBase + progressMap: Map } const getDisplayTime = (item: KnowledgeItem) => { @@ -33,18 +34,12 @@ const getDisplayTime = (item: KnowledgeItem) => { return dayjs(timestamp).format('MM-DD HH:mm') } -const KnowledgeDirectories: FC = ({ selectedBase }) => { +const KnowledgeDirectories: FC = ({ selectedBase, progressMap }) => { const { t } = useTranslation() - const { - base, - directoryItems, - refreshItem, - removeItem, - getProcessingStatus, - getDirectoryProcessingPercent, - addDirectory - } = useKnowledge(selectedBase.id || '') + const { base, directoryItems, refreshItem, removeItem, getProcessingStatus, addDirectory } = useKnowledge( + selectedBase.id || '' + ) const providerName = getProviderName(base?.model.provider || '') const disabled = !base?.version || !providerName @@ -53,8 +48,6 @@ const KnowledgeDirectories: FC = ({ selectedBase }) => { return null } - const getProgressingPercentForItem = (itemId: string) => getDirectoryProcessingPercent(itemId) - const handleAddDirectory = async () => { if (disabled) { return @@ -102,7 +95,7 @@ const KnowledgeDirectories: FC = ({ selectedBase }) => { sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} - getProcessingPercent={getProgressingPercentForItem} + progress={progressMap.get(item.id)} type="directory" /> diff --git a/src/renderer/src/pages/knowledge/items/KnowledgeFiles.tsx b/src/renderer/src/pages/knowledge/items/KnowledgeFiles.tsx index 074d5972a2..0bcbe5184c 100644 --- a/src/renderer/src/pages/knowledge/items/KnowledgeFiles.tsx +++ b/src/renderer/src/pages/knowledge/items/KnowledgeFiles.tsx @@ -5,8 +5,8 @@ import FileItem from '@renderer/pages/files/FileItem' import StatusIcon from '@renderer/pages/knowledge/components/StatusIcon' import FileManager from '@renderer/services/FileManager' import { getProviderName } from '@renderer/services/ProviderService' -import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types' -import { formatFileSize } from '@renderer/utils' +import { FileMetadata, FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types' +import { formatFileSize, uuid } from '@renderer/utils' import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant' import { Button, Tooltip, Upload } from 'antd' import dayjs from 'dayjs' @@ -30,6 +30,8 @@ const { Dragger } = Upload interface KnowledgeContentProps { selectedBase: KnowledgeBase + progressMap: Map + preprocessMap: Map } const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts] @@ -39,7 +41,7 @@ const getDisplayTime = (item: KnowledgeItem) => { return dayjs(timestamp).format('MM-DD HH:mm') } -const KnowledgeFiles: FC = ({ selectedBase }) => { +const KnowledgeFiles: FC = ({ selectedBase, progressMap, preprocessMap }) => { const { t } = useTranslation() const [windowHeight, setWindowHeight] = useState(window.innerHeight) @@ -82,26 +84,49 @@ const KnowledgeFiles: FC = ({ selectedBase }) => { if (disabled) { return } - if (files) { - const _files: FileType[] = files - .map((file) => ({ - id: file.name, - name: file.name, - path: window.api.file.getPathForFile(file), - size: file.size, - ext: `.${file.name.split('.').pop()}`.toLowerCase(), - count: 1, - origin_name: file.name, - type: file.type as FileTypes, - created_at: new Date().toISOString() - })) + const _files: FileMetadata[] = files + .map((file) => { + // 这个路径 filePath 很可能是在文件选择时的原始路径。 + const filePath = window.api.file.getPathForFile(file) + let nameFromPath = filePath + const lastSlash = filePath.lastIndexOf('/') + const lastBackslash = filePath.lastIndexOf('\\') + if (lastSlash !== -1 || lastBackslash !== -1) { + nameFromPath = filePath.substring(Math.max(lastSlash, lastBackslash) + 1) + } + + // 从派生的文件名中获取扩展名 + const extFromPath = nameFromPath.includes('.') ? `.${nameFromPath.split('.').pop()}` : '' + + return { + id: uuid(), + name: nameFromPath, // 使用从路径派生的文件名 + path: filePath, + size: file.size, + ext: extFromPath.toLowerCase(), + count: 1, + origin_name: file.name, // 保存 File 对象中原始的文件名 + type: file.type as FileTypes, + created_at: new Date().toISOString() + } + }) .filter(({ ext }) => fileTypes.includes(ext)) - const uploadedFiles = await FileManager.uploadFiles(_files) - addFiles(uploadedFiles) + // const uploadedFiles = await FileManager.uploadFiles(_files) + addFiles(_files) } } + const showPreprocessIcon = (item: KnowledgeItem) => { + if (base.preprocessOrOcrProvider && item.isPreprocessed !== false) { + return true + } + if (!base.preprocessOrOcrProvider && item.isPreprocessed === true) { + return true + } + return false + } + return ( @@ -161,6 +186,18 @@ const KnowledgeFiles: FC = ({ selectedBase }) => { {item.uniqueId && ( diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BasicSettings.tsx similarity index 78% rename from src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx rename to src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BasicSettings.tsx index f891e2aee1..967a4e1ae0 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BasicSettings.tsx @@ -6,7 +6,7 @@ import { Slider, Switch } from 'antd' import { t } from 'i18next' import { FC } from 'react' -import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..' const BasicSettings: FC = () => { const { theme } = useTheme() @@ -20,19 +20,19 @@ const BasicSettings: FC = () => { {t('settings.general.title')} - {t('settings.websearch.search_with_time')} + {t('settings.tool.websearch.search_with_time')} dispatch(setSearchWithTime(checked))} /> - {t('settings.websearch.search_max_result')} + {t('settings.tool.websearch.search_max_result')} dispatch(setMaxResult(value))} /> diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BlacklistSettings.tsx similarity index 88% rename from src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx rename to src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BlacklistSettings.tsx index a4a062e463..00ffe3e34a 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BlacklistSettings.tsx +++ b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BlacklistSettings.tsx @@ -10,7 +10,7 @@ import TextArea from 'antd/es/input/TextArea' import { t } from 'i18next' import { FC, useEffect, useState } from 'react' -import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..' import AddSubscribePopup from './AddSubscribePopup' type TableRowSelection = TableProps['rowSelection'] @@ -131,7 +131,7 @@ const BlacklistSettings: FC = () => { console.error(`Error updating subscribe source ${source.url}:`, error) // 显示具体源更新失败的消息 window.message.warning({ - content: t('settings.websearch.subscribe_source_update_failed', { url: source.url }), + content: t('settings.tool.websearch.subscribe_source_update_failed', { url: source.url }), duration: 3 }) } @@ -143,7 +143,7 @@ const BlacklistSettings: FC = () => { setSubscribeValid(true) // 显示成功消息 window.message.success({ - content: t('settings.websearch.subscribe_update_success'), + content: t('settings.tool.websearch.subscribe_update_success'), duration: 2 }) setTimeout(() => setSubscribeValid(false), 3000) @@ -154,7 +154,7 @@ const BlacklistSettings: FC = () => { } catch (error) { console.error('Error updating subscribes:', error) window.message.error({ - content: t('settings.websearch.subscribe_update_failed'), + content: t('settings.tool.websearch.subscribe_update_failed'), duration: 2 }) } @@ -165,7 +165,7 @@ const BlacklistSettings: FC = () => { async function handleAddSubscribe() { setSubscribeChecking(true) const result = await AddSubscribePopup.show({ - title: t('settings.websearch.subscribe_add') + title: t('settings.tool.websearch.subscribe_add') }) if (result && result.url) { @@ -185,14 +185,14 @@ const BlacklistSettings: FC = () => { setSubscribeValid(true) // 显示成功消息 window.message.success({ - content: t('settings.websearch.subscribe_add_success'), + content: t('settings.tool.websearch.subscribe_add_success'), duration: 2 }) setTimeout(() => setSubscribeValid(false), 3000) } catch (error) { setSubscribeValid(false) window.message.error({ - content: t('settings.websearch.subscribe_add_failed'), + content: t('settings.tool.websearch.subscribe_add_failed'), duration: 2 }) } @@ -218,32 +218,32 @@ const BlacklistSettings: FC = () => { return ( <> - {t('settings.websearch.blacklist')} + {t('settings.tool.websearch.blacklist')} - {t('settings.websearch.blacklist_description')} + {t('settings.tool.websearch.blacklist_description')} - ))} - {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) || - files.length > 0) && ( - - {editedBlocks - .filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) - .map( - (block) => - block.file && ( - handleFileRemove(block.id)}> - - - ) - )} - - {files.map((file) => ( - setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}> - - + <> + e.preventDefault()} onDrop={handleDrop}> + {editedBlocks + .filter((block) => block.type === MessageBlockType.MAIN_TEXT) + .map((block) => ( + ))} - - )} + {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) || + files.length > 0) && ( + + {editedBlocks + .filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) + .map( + (block) => + block.file && ( + handleFileRemove(block.id)}> + + + ) + )} + {files.map((file) => ( + setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}> + + + ))} + + )} + {isUserMessage && ( @@ -355,17 +356,17 @@ const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onC )} - + ) } const EditorContainer = styled.div` - padding: 8px 0; + padding: 18px 0; + padding-bottom: 5px; border: 0.5px solid var(--color-border); transition: all 0.2s ease; border-radius: 15px; - margin-top: 5px; - margin-bottom: 10px; + margin-top: 18px; background-color: var(--color-background-opacity); width: 100%; diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index 465ca923b8..d99bae30f1 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -18,13 +18,10 @@ import { FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import MessageTokens from './MessageTokens' - interface Props { message: Message assistant: Assistant model?: Model - index: number | undefined topic: Topic } @@ -33,7 +30,7 @@ const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { return modelId ? getModelLogo(modelId) : undefined } -const MessageHeader: FC = memo(({ assistant, model, message, index, topic }) => { +const MessageHeader: FC = memo(({ assistant, model, message, topic }) => { const avatar = useAvatar() const { theme } = useTheme() const { userName, sidebarIcons } = useSettings() @@ -61,11 +58,9 @@ const MessageHeader: FC = memo(({ assistant, model, message, index, topic const isAssistantMessage = message.role === 'assistant' const showMinappIcon = sidebarIcons.visible.includes('minapp') - const { showTokens } = useSettings() const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name]) const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName]) - const isLastMessage = index === 0 const showMiniApp = useCallback(() => { showMinappIcon && model?.provider && openMinappById(model.provider) @@ -110,8 +105,6 @@ const MessageHeader: FC = memo(({ assistant, model, message, index, topic {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} - {showTokens && | } - {isMultiSelectMode && ( @@ -149,12 +142,6 @@ const InfoWrap = styled.div` gap: 4px; ` -const DividerContainer = styled.div` - font-size: 10px; - color: var(--color-text-3); - margin: 0 2px; -` - const UserName = styled.div<{ isBubbleStyle?: boolean; theme?: string }>` font-size: 14px; font-weight: 600; diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index a8d55bbe80..44474bca21 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -49,6 +49,8 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import styled from 'styled-components' +import MessageTokens from './MessageTokens' + interface Props { message: Message assistant: Assistant @@ -398,172 +400,180 @@ const MessageMenubar: FC = (props) => { const softHoverBg = isBubbleStyle && !isLastMessage + const showMessageTokens = isBubbleStyle ? isAssistantMessage : true + return ( - - {message.role === 'user' && ( - - handleResendUserMessage()} - $softHoverBg={isBubbleStyle}> - - - - )} - {message.role === 'user' && ( - - - - - - )} - - - {!copied && } - {copied && } - - - {isAssistantMessage && ( - } - onConfirm={onRegenerate} - onOpenChange={(open) => open && setShowRegenerateTooltip(false)}> - - - + <> + {showMessageTokens && } + + {message.role === 'user' && ( + + handleResendUserMessage()} + $softHoverBg={isBubbleStyle}> + - - )} - {isAssistantMessage && ( - - - + )} + {message.role === 'user' && ( + + + + + + )} + + + {!copied && } + {copied && } - )} - {!isUserMessage && ( - ({ - label: item.emoji + ' ' + item.label(), - key: item.langCode, - onClick: () => handleTranslate(item) - })), - ...(hasTranslationBlocks - ? [ - { type: 'divider' as const }, - { - label: '📋 ' + t('common.copy'), - key: 'translate-copy', - onClick: () => { - const translationBlocks = message.blocks - .map((blockId) => blockEntities[blockId]) - .filter((block) => block?.type === 'translation') + {isAssistantMessage && ( + } + onConfirm={onRegenerate} + onOpenChange={(open) => open && setShowRegenerateTooltip(false)}> + + + + + + + )} + {isAssistantMessage && ( + + + + + + )} + {!isUserMessage && ( + ({ + label: item.emoji + ' ' + item.label(), + key: item.langCode, + onClick: () => handleTranslate(item) + })), + ...(hasTranslationBlocks + ? [ + { type: 'divider' as const }, + { + label: '📋 ' + t('common.copy'), + key: 'translate-copy', + onClick: () => { + const translationBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((block) => block?.type === 'translation') - if (translationBlocks.length > 0) { - const translationContent = translationBlocks - .map((block) => block?.content || '') - .join('\n\n') - .trim() + if (translationBlocks.length > 0) { + const translationContent = translationBlocks + .map((block) => block?.content || '') + .join('\n\n') + .trim() - if (translationContent) { - navigator.clipboard.writeText(translationContent) - window.message.success({ content: t('translate.copied'), key: 'translate-copy' }) - } else { - window.message.warning({ content: t('translate.empty'), key: 'translate-copy' }) + if (translationContent) { + navigator.clipboard.writeText(translationContent) + window.message.success({ content: t('translate.copied'), key: 'translate-copy' }) + } else { + window.message.warning({ content: t('translate.empty'), key: 'translate-copy' }) + } + } + } + }, + { + label: '✖ ' + t('translate.close'), + key: 'translate-close', + onClick: () => { + const translationBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((block) => block?.type === 'translation') + .map((block) => block?.id) + + if (translationBlocks.length > 0) { + translationBlocks.forEach((blockId) => { + if (blockId) removeMessageBlock(message.id, blockId) + }) + window.message.success({ content: t('translate.closed'), key: 'translate-close' }) } } } - }, - { - label: '✖ ' + t('translate.close'), - key: 'translate-close', - onClick: () => { - const translationBlocks = message.blocks - .map((blockId) => blockEntities[blockId]) - .filter((block) => block?.type === 'translation') - .map((block) => block?.id) - - if (translationBlocks.length > 0) { - translationBlocks.forEach((blockId) => { - if (blockId) removeMessageBlock(message.id, blockId) - }) - window.message.success({ content: t('translate.closed'), key: 'translate-close' }) - } - } - } - ] - : []) - ], - onClick: (e) => e.domEvent.stopPropagation() - }} - trigger={['click']} - placement="top" - arrow> - - e.stopPropagation()} - $softHoverBg={softHoverBg}> - + ] + : []) + ], + onClick: (e) => e.domEvent.stopPropagation() + }} + trigger={['click']} + placement="top" + arrow> + + e.stopPropagation()} + $softHoverBg={softHoverBg}> + + + + + )} + {isAssistantMessage && isGrouped && ( + + + {message.useful ? ( + + ) : ( + + )} - - )} - {isAssistantMessage && isGrouped && ( - - - {message.useful ? ( - - ) : ( - - )} - - - )} - } - onOpenChange={(open) => open && setShowDeleteTooltip(false)} - onConfirm={() => deleteMessage(message.id)}> - e.stopPropagation()} $softHoverBg={softHoverBg}> - - - - - - {!isUserMessage && ( - e.domEvent.stopPropagation() }} - trigger={['click']} - placement="topRight"> + )} + } + onOpenChange={(open) => open && setShowDeleteTooltip(false)} + onConfirm={() => deleteMessage(message.id)}> e.stopPropagation()} $softHoverBg={softHoverBg}> - + + + - - )} - + + {!isUserMessage && ( + e.domEvent.stopPropagation() }} + trigger={['click']} + placement="topRight"> + e.stopPropagation()} + $softHoverBg={softHoverBg}> + + + + )} + + ) } @@ -572,7 +582,8 @@ const MenusBar = styled.div` flex-direction: row; justify-content: flex-end; align-items: center; - gap: 6px; + gap: 8px; + margin-top: 5px; ` const ActionButton = styled.div<{ $softHoverBg?: boolean }>` @@ -582,8 +593,8 @@ const ActionButton = styled.div<{ $softHoverBg?: boolean }>` flex-direction: row; justify-content: center; align-items: center; - width: 30px; - height: 30px; + width: 26px; + height: 26px; transition: all 0.2s ease; &:hover { background-color: ${(props) => diff --git a/src/renderer/src/pages/home/Messages/MessageTokens.tsx b/src/renderer/src/pages/home/Messages/MessageTokens.tsx index 3326e061de..851350a474 100644 --- a/src/renderer/src/pages/home/Messages/MessageTokens.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTokens.tsx @@ -11,7 +11,7 @@ interface MessageTokensProps { isLastMessage?: boolean } -const MessgeTokens: React.FC = ({ message }) => { +const MessageTokens: React.FC = ({ message }) => { const { showTokens } = useSettings() // const { generating } = useRuntime() const locateMessage = () => { @@ -106,4 +106,4 @@ const MessageMetadata = styled.div` } ` -export default MessgeTokens +export default MessageTokens diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 048feb83bd..a089bd536b 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -167,6 +167,7 @@ const Container = styled(Scrollbar)` display: flex; flex-direction: column; padding: 10px; + margin-top: 3px; ` const TagsContainer = styled.div` diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index cc56f72c05..ed943cbe96 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -41,7 +41,6 @@ import { setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, setShowPrompt, - setShowTokens, setShowTranslateConfirm, setThoughtAutoCollapse } from '@renderer/store/settings' @@ -300,11 +299,6 @@ const SettingsTab: FC = (props) => { dispatch(setShowPrompt(checked))} /> - - {t('settings.messages.tokens')} - dispatch(setShowTokens(checked))} /> - - {t('settings.messages.use_serif_font')} = ({ assistant: _assistant, activeTopic, setActiveTopic className="topics-tab" list={sortedTopics} onUpdate={updateTopics} - style={{ padding: '10px 0 10px 10px' }} + style={{ padding: '13px 0 10px 10px' }} itemContainerStyle={{ paddingBottom: '8px' }}> {(topic) => { const isActive = topic.id === activeTopic?.id From 559fcecf777d4b8f3f88bc95fc1cb5010a17f58d Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 10 Jul 2025 02:17:32 +0800 Subject: [PATCH 087/317] refactor(CodeBlockView): replace HtmlArtifacts component with HtmlArtifactsCard - Removed the obsolete HtmlArtifacts component and its associated logic. - Introduced the new HtmlArtifactsCard component to enhance the rendering of HTML artifacts. - Updated the CodeBlockView to utilize HtmlArtifactsCard, improving maintainability and user experience. - Added a new HtmlArtifactsPopup component for better HTML content preview and editing capabilities. - Enhanced localization by adding translation keys for HTML artifacts in multiple languages. --- .../CodeBlockView/HtmlArtifacts.tsx | 70 --- .../CodeBlockView/HtmlArtifactsCard.tsx | 408 ++++++++++++++++ .../CodeBlockView/HtmlArtifactsPopup.tsx | 445 ++++++++++++++++++ .../src/components/CodeBlockView/index.tsx | 15 +- .../src/components/CodeEditor/index.tsx | 6 +- src/renderer/src/context/AntdProvider.tsx | 1 - src/renderer/src/i18n/locales/en-us.json | 6 + src/renderer/src/i18n/locales/ja-jp.json | 6 + src/renderer/src/i18n/locales/ru-ru.json | 6 + src/renderer/src/i18n/locales/zh-cn.json | 6 + src/renderer/src/i18n/locales/zh-tw.json | 6 + src/renderer/src/i18n/translate/el-gr.json | 6 + src/renderer/src/i18n/translate/es-es.json | 6 + src/renderer/src/i18n/translate/fr-fr.json | 6 + src/renderer/src/i18n/translate/pt-pt.json | 6 + .../src/pages/home/Messages/MessageEditor.tsx | 2 +- .../src/pages/home/Tabs/SettingsTab.tsx | 3 +- 17 files changed, 923 insertions(+), 81 deletions(-) delete mode 100644 src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx create mode 100644 src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx create mode 100644 src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx deleted file mode 100644 index 87dc172bd6..0000000000 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { ExpandOutlined, LinkOutlined } from '@ant-design/icons' -import { AppLogo } from '@renderer/config/env' -import { useMinappPopup } from '@renderer/hooks/useMinappPopup' -import { extractTitle } from '@renderer/utils/formats' -import { Button } from 'antd' -import { FC } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface Props { - html: string -} - -const Artifacts: FC = ({ html }) => { - const { t } = useTranslation() - const { openMinapp } = useMinappPopup() - - /** - * 在应用内打开 - */ - const handleOpenInApp = async () => { - const path = await window.api.file.createTempFile('artifacts-preview.html') - await window.api.file.write(path, html) - const filePath = `file://${path}` - const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview') - openMinapp({ - id: 'artifacts-preview', - name: title, - logo: AppLogo, - url: filePath - }) - } - - /** - * 外部链接打开 - */ - const handleOpenExternal = async () => { - const path = await window.api.file.createTempFile('artifacts-preview.html') - await window.api.file.write(path, html) - const filePath = `file://${path}` - - if (window.api.shell && window.api.shell.openExternal) { - window.api.shell.openExternal(filePath) - } else { - console.error(t('artifacts.preview.openExternal.error.content')) - } - } - - return ( - - - - - - ) -} - -const Container = styled.div` - margin: 10px; - display: flex; - flex-direction: row; - gap: 8px; - padding-bottom: 10px; -` - -export default Artifacts diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx new file mode 100644 index 0000000000..0691a25d18 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -0,0 +1,408 @@ +import { CodeOutlined, LinkOutlined } from '@ant-design/icons' +import { useTheme } from '@renderer/context/ThemeProvider' +import { ThemeMode } from '@renderer/types' +import { extractTitle } from '@renderer/utils/formats' +import { Button } from 'antd' +import { Code, Download, Globe, Sparkles } from 'lucide-react' +import { FC, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ClipLoader } from 'react-spinners' +import styled, { keyframes } from 'styled-components' + +import HtmlArtifactsPopup from './HtmlArtifactsPopup' + +interface Props { + html: string +} + +const HtmlArtifactsCard: FC = ({ html }) => { + const { t } = useTranslation() + const title = extractTitle(html) || 'HTML Artifacts' + const [isPopupOpen, setIsPopupOpen] = useState(false) + const { theme } = useTheme() + + const htmlContent = html || '' + const hasContent = htmlContent.trim().length > 0 + + // 判断是否正在流式生成的逻辑 + const isStreaming = useMemo(() => { + if (!hasContent) return false + + const trimmedHtml = htmlContent.trim() + + // 检查 HTML 是否看起来是完整的 + const indicators = { + // 1. 检查常见的 HTML 结构完整性 + hasHtmlTag: /]*>/i.test(trimmedHtml), + hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml), + + // 2. 检查 body 标签完整性 + hasBodyTag: /]*>/i.test(trimmedHtml), + hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml), + + // 3. 检查是否以未闭合的标签结尾 + endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml), + + // 4. 检查是否有未配对的标签 + hasUnmatchedTags: checkUnmatchedTags(trimmedHtml), + + // 5. 检查是否以常见的"流式结束"模式结尾 + endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml) + } + + // 如果有明显的未完成标志,则认为正在生成 + if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) { + return true + } + + // 如果有 HTML 结构但不完整 + if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) { + return true + } + + // 如果有 body 结构但不完整 + if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) { + return true + } + + // 对于简单的 HTML 片段,检查是否看起来是完整的 + if (!indicators.hasHtmlTag && !indicators.hasBodyTag) { + // 如果是简单片段且没有明显的结束标志,可能还在生成 + return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500 + } + + return false + }, [htmlContent, hasContent]) + + // 检查未配对标签的辅助函数 + function checkUnmatchedTags(html: string): boolean { + const stack: string[] = [] + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g + let match + + while ((match = tagRegex.exec(html)) !== null) { + const [fullTag, tagName] = match + const isClosing = fullTag.startsWith('') || ['img', 'br', 'hr', 'input', 'meta', 'link'].includes(tagName.toLowerCase()) + + if (isSelfClosing) continue + + if (isClosing) { + if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { + return true // 找到不匹配的闭合标签 + } + } else { + stack.push(tagName.toLowerCase()) + } + } + + return stack.length > 0 // 还有未闭合的标签 + } + + // 获取格式化的代码预览 + function getFormattedCodePreview(html: string): string { + const trimmed = html.trim() + const lines = trimmed.split('\n') + const lastFewLines = lines.slice(-3) // 显示最后3行 + return lastFewLines.join('\n') + } + + /** + * 在编辑器中打开 + */ + const handleOpenInEditor = () => { + setIsPopupOpen(true) + } + + /** + * 关闭弹窗 + */ + const handleClosePopup = () => { + setIsPopupOpen(false) + } + + /** + * 外部链接打开 + */ + const handleOpenExternal = async () => { + const path = await window.api.file.createTempFile('artifacts-preview.html') + await window.api.file.write(path, htmlContent) + const filePath = `file://${path}` + + if (window.api.shell && window.api.shell.openExternal) { + window.api.shell.openExternal(filePath) + } else { + console.error(t('artifacts.preview.openExternal.error.content')) + } + } + + /** + * 下载到本地 + */ + const handleDownload = async () => { + const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html` + await window.api.file.save(fileName, htmlContent) + window.message.success({ content: t('message.download.success'), key: 'download' }) + } + + return ( + <> + +
+ + {isStreaming ? : } + + + {title} + + + HTML + + + {isStreaming && ( + + + {t('html_artifacts.generating')} + + )} +
+ + {isStreaming && !hasContent ? ( + + + {t('html_artifacts.generating_content', 'Generating content...')} + + ) : isStreaming && hasContent ? ( + <> + + + + $ + + {getFormattedCodePreview(htmlContent)} + + + + + + + + + + ) : ( + + + + + + )} + +
+ + {/* 弹窗组件 */} + + + ) +} + +const shimmer = keyframes` + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +` + +const Container = styled.div<{ $isStreaming: boolean }>` + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + margin: 16px 0; +` + +const GeneratingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 20px; + min-height: 78px; +` + +const GeneratingText = styled.div` + font-size: 14px; + color: var(--color-text-secondary); +` + +const Header = styled.div` + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px 16px; + background: var(--color-background-soft); + border-bottom: 1px solid var(--color-border); + position: relative; + border-radius: 8px 8px 0 0; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4); + background-size: 200% 100%; + animation: ${shimmer} 3s ease-in-out infinite; + border-radius: 8px 8px 0 0; + } +` + +const IconWrapper = styled.div<{ $isStreaming: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + border-radius: 12px; + color: white; + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); + transition: background 0.3s ease; + + ${(props) => + props.$isStreaming && + ` + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */ + box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3); + `} +` + +const TitleSection = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +` + +const Title = styled.h3` + margin: 0 !important; + font-size: 16px; + font-weight: 600; + color: var(--color-text); + line-height: 1.4; +` + +const TypeBadge = styled.div` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--color-background-mute); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 11px; + font-weight: 500; + color: var(--color-text-secondary); + width: fit-content; +` + +const StreamingIndicator = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-status-warning); + border: 1px solid var(--color-status-warning); + border-radius: 8px; + color: var(--color-text); + font-size: 12px; + opacity: 0.9; + + [theme-mode='light'] & { + background: #fef3c7; + border-color: #fbbf24; + color: #92400e; + } +` + +const StreamingText = styled.div` + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; +` + +const Content = styled.div` + padding: 0; + background: var(--color-background); +` + +const ButtonContainer = styled.div` + margin: 16px; + display: flex; + flex-direction: row; + gap: 8px; +` + +const TerminalPreview = styled.div<{ $theme: ThemeMode }>` + margin: 16px; + background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + border-radius: 8px; + overflow: hidden; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; +` + +const TerminalContent = styled.div<{ $theme: ThemeMode }>` + padding: 12px; + background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + font-size: 13px; + line-height: 1.4; + min-height: 80px; +` + +const TerminalLine = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; +` + +const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>` + flex: 1; + white-space: pre-wrap; + word-break: break-word; + color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + background-color: transparent !important; +` + +const TerminalPrompt = styled.span<{ $theme: ThemeMode }>` + color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + font-weight: bold; + flex-shrink: 0; +` + +const TerminalCursor = styled.span<{ $theme: ThemeMode }>` + display: inline-block; + width: 2px; + height: 16px; + background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + animation: ${keyframes` + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } + `} 1s infinite; + margin-left: 2px; +` + +export default HtmlArtifactsCard diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx new file mode 100644 index 0000000000..afba9f04e1 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -0,0 +1,445 @@ +import CodeEditor from '@renderer/components/CodeEditor' +import { isMac } from '@renderer/config/constant' +import { Button, Modal } from 'antd' +import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface HtmlArtifactsPopupProps { + open: boolean + title: string + html: string + onClose: () => void +} + +type ViewMode = 'split' | 'code' | 'preview' + +// 视图模式配置 +const VIEW_MODE_CONFIG = { + split: { + key: 'split' as const, + icon: MonitorSpeaker, + i18nKey: 'html_artifacts.split' + }, + code: { + key: 'code' as const, + icon: Code, + i18nKey: 'html_artifacts.code' + }, + preview: { + key: 'preview' as const, + icon: Monitor, + i18nKey: 'html_artifacts.preview' + } +} as const + +// 抽取头部组件 +interface ModalHeaderProps { + title: string + isFullscreen: boolean + viewMode: ViewMode + onViewModeChange: (mode: ViewMode) => void + onToggleFullscreen: () => void + onCancel: () => void +} + +const ModalHeaderComponent: React.FC = ({ + title, + isFullscreen, + viewMode, + onViewModeChange, + onToggleFullscreen, + onCancel +}) => { + const { t } = useTranslation() + + const viewButtons = useMemo(() => { + return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => ( + } + onClick={() => onViewModeChange(key)}> + {t(i18nKey)} + + )) + }, [viewMode, onViewModeChange, t]) + + return ( + + + {title} + + + {viewButtons} + + + - - - diff --git a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx index 54fa3201a9..0b6a001b8a 100644 --- a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx @@ -3,7 +3,6 @@ import { nanoid } from '@reduxjs/toolkit' import logo from '@renderer/assets/images/cherry-text-logo.svg' import { Center, HStack } from '@renderer/components/Layout' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { builtinMCPServers } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' import { getMcpConfigSampleFromReadme } from '@renderer/utils' import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd' @@ -23,7 +22,7 @@ interface SearchResult { configSample?: MCPServer['configSample'] } -const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket'] +const npmScopes = ['@modelcontextprotocol', '@gongrzhe', '@mcpmarket'] let _searchResults: SearchResult[] = [] @@ -32,7 +31,7 @@ const NpxSearch: FC = () => { const { Text, Link } = Typography // Add new state variables for npm scope search - const [npmScope, setNpmScope] = useState('@cherry') + const [npmScope, setNpmScope] = useState('@modelcontextprotocol') const [searchLoading, setSearchLoading] = useState(false) const [searchResults, setSearchResults] = useState(_searchResults) const { addMCPServer, mcpServers } = useMCPServers() @@ -52,22 +51,6 @@ const NpxSearch: FC = () => { return } - if (searchScope === '@cherry') { - setSearchResults( - builtinMCPServers.map((server) => ({ - key: server.id, - name: server.name, - description: server.description || '', - version: '1.0.0', - usage: '参考下方链接中的使用说明', - npmLink: 'https://docs.cherry-ai.com/advanced-basic/mcp/in-memory', - fullName: server.name, - type: server.type || 'inMemory' - })) - ) - return - } - setSearchLoading(true) try { @@ -190,14 +173,6 @@ const NpxSearch: FC = () => { return } - const buildInServer = builtinMCPServers.find((server) => server.name === record.name) - - if (buildInServer) { - addMCPServer(buildInServer) - window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' }) - return - } - const newServer = { id: nanoid(), name: record.name, diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 9a37ceb263..59abda1ec4 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -71,18 +71,18 @@ const SettingsPage: FC = () => { {t('settings.display.title')} - - - - {t('settings.tool.title')} - - {t('settings.mcp.title')} + + + + {t('settings.tool.title')} + + From 97dbfe492ea9216d29a4f3709c5ccee134db5147 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Thu, 10 Jul 2025 14:35:40 +0800 Subject: [PATCH 093/317] test: enhance download and fetch utility test coverage with bug fix (#7891) * test: enhance download and fetch utility test coverage - Add MIME type handling tests for data URLs in download.test.ts - Add timestamp generation tests for blob and network downloads - Add Content-Type header handling test for extensionless files - Add format parameter tests (markdown/html/text) for fetchWebContent - Add timeout signal handling tests for fetch operations - Add combined signal (user + timeout) test for AbortSignal.any These tests improve coverage of edge cases and ensure critical functionality is properly tested. * fix: add missing error handling for fetch in download utility - Add .catch() handler for network request failures in download() - Use window.message.error() for user-friendly error notifications - Update tests to verify error handling behavior - Ensure proper error messages are shown to users This fixes a missing error handler that was discovered during test development. * refactor: improve test structure and add i18n support for download utility - Unified test structure with two-layer describe blocks (filename -> function name) - Added afterEach with restoreAllMocks for consistent mock cleanup - Removed individual mockRestore calls in favor of centralized cleanup - Added i18n support to download.ts for error messages - Updated error handling logic to avoid duplicate messages - Updated test expectations to match new i18n error messages * test: fix react-i18next mock for Markdown test Add missing initReactI18next to mock to resolve test failures caused by i18n initialization when download utility imports i18n module. --- .../home/Markdown/__tests__/Markdown.test.tsx | 6 +- .../src/utils/__tests__/download.test.ts | 241 ++++++++++++++++++ .../src/utils/__tests__/fetch.test.ts | 208 +++++++++++++++ src/renderer/src/utils/download.ts | 11 + 4 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/utils/__tests__/download.test.ts create mode 100644 src/renderer/src/utils/__tests__/fetch.test.ts diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index be9b18c13b..6adb5f5736 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -17,7 +17,11 @@ vi.mock('@renderer/hooks/useSettings', () => ({ })) vi.mock('react-i18next', () => ({ - useTranslation: () => mockUseTranslation() + useTranslation: () => mockUseTranslation(), + initReactI18next: { + type: '3rdParty', + init: vi.fn() + } })) // Mock services diff --git a/src/renderer/src/utils/__tests__/download.test.ts b/src/renderer/src/utils/__tests__/download.test.ts new file mode 100644 index 0000000000..3a49ccf4fb --- /dev/null +++ b/src/renderer/src/utils/__tests__/download.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock @renderer/i18n to avoid initialization issues +vi.mock('@renderer/i18n', () => ({ + default: { + t: vi.fn((key: string) => { + const translations: Record = { + 'message.download.failed': '下载失败', + 'message.download.failed.network': '下载失败,请检查网络' + } + return translations[key] || key + }) + } +})) + +import { download } from '../download' + +// Mock DOM 方法 +const mockCreateElement = vi.fn() +const mockAppendChild = vi.fn() +const mockClick = vi.fn() + +// Mock URL API +const mockCreateObjectURL = vi.fn() +const mockRevokeObjectURL = vi.fn() + +// Mock fetch +const mockFetch = vi.fn() + +// Mock window.message +const mockMessage = { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn() +} + +// 辅助函数 +const waitForAsync = () => new Promise((resolve) => setTimeout(resolve, 10)) +const createMockResponse = (options = {}) => ({ + ok: true, + headers: new Headers(), + blob: () => Promise.resolve(new Blob(['test'])), + ...options +}) + +describe('download', () => { + describe('download', () => { + beforeEach(() => { + vi.clearAllMocks() + + // 设置 window.message mock + Object.defineProperty(window, 'message', { value: mockMessage, writable: true }) + + // 设置 DOM mock + const mockElement = { + href: '', + download: '', + click: mockClick, + remove: vi.fn() + } + mockCreateElement.mockReturnValue(mockElement) + + Object.defineProperty(document, 'createElement', { value: mockCreateElement }) + Object.defineProperty(document.body, 'appendChild', { value: mockAppendChild }) + Object.defineProperty(URL, 'createObjectURL', { value: mockCreateObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { value: mockRevokeObjectURL }) + + global.fetch = mockFetch + mockCreateObjectURL.mockReturnValue('blob:mock-url') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Direct download support', () => { + it('should handle local file URLs', () => { + download('file:///path/to/document.pdf', 'test.pdf') + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe('file:///path/to/document.pdf') + expect(element.download).toBe('test.pdf') + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle blob URLs', () => { + download('blob:http://localhost:3000/12345') + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe('blob:http://localhost:3000/12345') + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle data URLs', () => { + const dataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + + download(dataUrl) + + const element = mockCreateElement.mock.results[0].value + expect(element.href).toBe(dataUrl) + expect(mockClick).toHaveBeenCalled() + }) + + it('should handle different MIME types in data URLs', async () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + // 只有 image/png 和 image/jpeg 会直接下载 + const directDownloadTests = [ + { url: 'data:image/jpeg;base64,xxx', expectedExt: '.jpg' }, + { url: 'data:image/png;base64,xxx', expectedExt: '.png' } + ] + + directDownloadTests.forEach(({ url, expectedExt }) => { + mockCreateElement.mockClear() + download(url) + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_download${expectedExt}`) + }) + + // 其他类型会通过 fetch 处理 + mockCreateElement.mockClear() + mockFetch.mockResolvedValueOnce( + createMockResponse({ + headers: new Headers({ 'Content-Type': 'application/pdf' }) + }) + ) + + download('data:application/pdf;base64,xxx') + await waitForAsync() + + expect(mockFetch).toHaveBeenCalled() + }) + + it('should generate filename with timestamp for blob URLs', () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + download('blob:http://localhost:3000/12345') + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_diagram.svg`) + }) + }) + + describe('Filename handling', () => { + it('should extract filename from file path', () => { + download('file:///Users/test/Documents/report.pdf') + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe('report.pdf') + }) + + it('should handle URL encoded filenames', () => { + download('file:///path/to/%E6%96%87%E6%A1%A3.pdf') // 编码的"文档.pdf" + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe('文档.pdf') + }) + }) + + describe('Network download', () => { + it('should handle successful network request', async () => { + mockFetch.mockResolvedValue(createMockResponse()) + + download('https://example.com/file.pdf', 'custom.pdf') + await waitForAsync() + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/file.pdf') + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(mockClick).toHaveBeenCalled() + }) + + it('should extract filename from URL and headers', async () => { + const headers = new Headers() + headers.set('Content-Disposition', 'attachment; filename="server-file.pdf"') + mockFetch.mockResolvedValue(createMockResponse({ headers })) + + download('https://example.com/files/document.docx') + await waitForAsync() + + // 验证下载被触发(具体文件名由实现决定) + expect(mockClick).toHaveBeenCalled() + }) + + it('should add timestamp to network downloaded files', async () => { + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + + mockFetch.mockResolvedValue(createMockResponse()) + + download('https://example.com/file.pdf') + await waitForAsync() + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toBe(`${now}_file.pdf`) + }) + + it('should handle Content-Type when filename has no extension', async () => { + const headers = new Headers() + headers.set('Content-Type', 'application/pdf') + mockFetch.mockResolvedValue(createMockResponse({ headers })) + + download('https://example.com/download') + await waitForAsync() + + const element = mockCreateElement.mock.results[0].value + expect(element.download).toMatch(/\d+_download\.pdf$/) + }) + }) + + describe('Error handling', () => { + it('should handle network errors gracefully', async () => { + const networkError = new Error('Network error') + mockFetch.mockRejectedValue(networkError) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + await waitForAsync() + + expect(mockMessage.error).toHaveBeenCalledWith('下载失败:Network error') + }) + + it('should handle fetch errors without message', async () => { + mockFetch.mockRejectedValue(new Error()) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + await waitForAsync() + + expect(mockMessage.error).toHaveBeenCalledWith('下载失败,请检查网络') + }) + + it('should handle HTTP errors gracefully', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }) + + expect(() => download('https://example.com/file.pdf')).not.toThrow() + }) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/fetch.test.ts b/src/renderer/src/utils/__tests__/fetch.test.ts new file mode 100644 index 0000000000..6b36cb41f8 --- /dev/null +++ b/src/renderer/src/utils/__tests__/fetch.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock 外部依赖 +vi.mock('turndown', () => ({ + default: vi.fn(() => ({ + turndown: vi.fn(() => '# Test content') + })) +})) +vi.mock('@mozilla/readability', () => ({ + Readability: vi.fn(() => ({ + parse: vi.fn(() => ({ + title: 'Test Article', + content: '

Test content

', + textContent: 'Test content' + })) + })) +})) +vi.mock('@reduxjs/toolkit', () => ({ + nanoid: vi.fn(() => 'test-id') +})) + +import { fetchRedirectUrl, fetchWebContent, fetchWebContents } from '../fetch' + +// 设置基础 mocks +global.DOMParser = vi.fn().mockImplementation(() => ({ + parseFromString: vi.fn(() => ({})) +})) as any + +global.window = { + api: { + searchService: { + openUrlInSearchWindow: vi.fn() + } + } +} as any + +// 辅助函数 +const createMockResponse = (overrides = {}) => + ({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue('Test content'), + ...overrides + }) as unknown as Response + +describe('fetch', () => { + beforeEach(() => { + // Mock fetch 和 AbortSignal + global.fetch = vi.fn() + global.AbortSignal = { + timeout: vi.fn(() => ({})), + any: vi.fn(() => ({})) + } as any + + // 清理 mock 调用历史 + vi.clearAllMocks() + }) + + describe('fetchWebContent', () => { + it('should fetch and return content successfully', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + const result = await fetchWebContent('https://example.com') + + expect(result).toEqual({ + title: 'Test Article', + url: 'https://example.com', + content: '# Test content' + }) + expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object)) + }) + + it('should use browser mode when specified', async () => { + vi.mocked(window.api.searchService.openUrlInSearchWindow).mockResolvedValueOnce( + 'Browser content' + ) + + const result = await fetchWebContent('https://example.com', 'markdown', true) + + expect(result.content).toBe('# Test content') + expect(window.api.searchService.openUrlInSearchWindow).toHaveBeenCalled() + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // 无效 URL + const invalidResult = await fetchWebContent('not-a-url') + expect(invalidResult.content).toBe('No content found') + + // 网络错误 + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')) + const networkResult = await fetchWebContent('https://example.com') + expect(networkResult.content).toBe('No content found') + + consoleSpy.mockRestore() + }) + + it('should rethrow abort errors', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + vi.mocked(global.fetch).mockRejectedValueOnce(abortError) + + await expect(fetchWebContent('https://example.com')).rejects.toThrow(DOMException) + }) + + it.each([ + ['markdown', '# Test content'], + ['html', '

Test content

'], + ['text', 'Test content'] + ])('should return %s format correctly', async (format, expectedContent) => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + const result = await fetchWebContent('https://example.com', format as any) + + expect(result.content).toBe(expectedContent) + expect(result.title).toBe('Test Article') + expect(result.url).toBe('https://example.com') + }) + + it('should handle timeout signal in AbortSignal.any', async () => { + const mockTimeoutSignal = new AbortController().signal + vi.spyOn(global.AbortSignal, 'timeout').mockReturnValue(mockTimeoutSignal) + + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + await fetchWebContent('https://example.com') + + // 验证 AbortSignal.timeout 是否被调用,并传入 30000ms + expect(global.AbortSignal.timeout).toHaveBeenCalledWith(30000) + + vi.spyOn(global.AbortSignal, 'timeout').mockRestore() + }) + + it('should combine user signal with timeout signal', async () => { + const userController = new AbortController() + const mockAnyCalls: any[] = [] + + vi.spyOn(global.AbortSignal, 'any').mockImplementation((signals) => { + mockAnyCalls.push(signals) + return new AbortController().signal + }) + + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()) + + await fetchWebContent('https://example.com', 'markdown', false, { + signal: userController.signal + }) + + // 验证 AbortSignal.any 是否被调用,并传入两个信号 + expect(mockAnyCalls).toHaveLength(1) + expect(mockAnyCalls[0]).toHaveLength(2) + expect(mockAnyCalls[0]).toContain(userController.signal) + + vi.spyOn(global.AbortSignal, 'any').mockRestore() + }) + }) + + describe('fetchWebContents', () => { + it('should fetch multiple URLs in parallel', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse()).mockResolvedValueOnce(createMockResponse()) + + const urls = ['https://example1.com', 'https://example2.com'] + const results = await fetchWebContents(urls) + + expect(results).toHaveLength(2) + expect(results[0].content).toBe('# Test content') + expect(results[1].content).toBe('# Test content') + }) + + it('should handle partial failures gracefully', async () => { + vi.mocked(global.fetch) + .mockResolvedValueOnce(createMockResponse()) + .mockRejectedValueOnce(new Error('Network error')) + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const results = await fetchWebContents(['https://success.com', 'https://fail.com']) + + expect(results).toHaveLength(2) + expect(results[0].content).toBe('# Test content') + expect(results[1].content).toBe('No content found') + + consoleSpy.mockRestore() + }) + }) + + describe('fetchRedirectUrl', () => { + it('should return final redirect URL', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + url: 'https://redirected.com/final' + } as any) + + const result = await fetchRedirectUrl('https://example.com') + + expect(result).toBe('https://redirected.com/final') + expect(global.fetch).toHaveBeenCalledWith('https://example.com', expect.any(Object)) + }) + + it('should return original URL on error', async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const result = await fetchRedirectUrl('https://example.com') + expect(result).toBe('https://example.com') + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/renderer/src/utils/download.ts b/src/renderer/src/utils/download.ts index 5e207eff67..454c1ac82a 100644 --- a/src/renderer/src/utils/download.ts +++ b/src/renderer/src/utils/download.ts @@ -1,3 +1,5 @@ +import i18n from '@renderer/i18n' + export const download = (url: string, filename?: string) => { // 处理可直接通过 标签下载的 URL: // - 本地文件 ( file:// ) @@ -76,6 +78,15 @@ export const download = (url: string, filename?: string) => { URL.revokeObjectURL(blobUrl) link.remove() }) + .catch((error) => { + console.error('Download failed:', error) + // 显示用户友好的错误提示 + if (error.message) { + window.message?.error(`${i18n.t('message.download.failed')}:${error.message}`) + } else { + window.message?.error(i18n.t('message.download.failed.network')) + } + }) } // 辅助函数:根据MIME类型获取文件扩展名 From f8c6b5c05fe4a2ca91dbe6ebf51d745985236160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Thu, 10 Jul 2025 15:09:59 +0800 Subject: [PATCH 094/317] Fix translation key for unlimited backups label (#7987) Updated the translation key for the 'unlimited' backups option in WebDavSettings to use the correct namespace. --- src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 54db33f024..977c5c1329 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -202,7 +202,7 @@ const WebDavSettings: FC = () => { onChange={onMaxBackupsChange} disabled={!webdavHost} options={[ - { label: t('settings.data.webdav.maxBackups.unlimited'), value: 0 }, + { label: t('settings.data.local.maxBackups.unlimited'), value: 0 }, { label: '1', value: 1 }, { label: '3', value: 3 }, { label: '5', value: 5 }, From 05f3b88f304df7931fd6a8fb546d17c49bf0e688 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 15:15:13 +0800 Subject: [PATCH 095/317] fix(Inputbar): update resizeTextArea call to improve functionality (#8010) --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 1a8b0fed8d..775debfd34 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -240,7 +240,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setText('') setFiles([]) setTimeout(() => setText(''), 500) - setTimeout(() => resizeTextArea(), 0) + setTimeout(() => resizeTextArea(true), 0) setExpend(false) } catch (error) { console.error('Failed to send message:', error) From f85f46c2487c517eb90de75e1c9b9a22df968f9a Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 15:15:38 +0800 Subject: [PATCH 096/317] fix(middleware): ollama qwen think (#8026) refactor(AiProvider): comment out unnecessary middleware removal for performance optimization - Commented out the removal of ThinkingTagExtractionMiddlewareName to prevent potential performance degradation while maintaining existing functionality. - Retained the removal of ThinkChunkMiddlewareName as part of the existing logic for non-reasoning scenarios. --- src/renderer/src/aiCore/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/index.ts b/src/renderer/src/aiCore/index.ts index 18bf2e8524..34edc1b755 100644 --- a/src/renderer/src/aiCore/index.ts +++ b/src/renderer/src/aiCore/index.ts @@ -75,7 +75,8 @@ export default class AiProvider { } else { // Existing logic for other models if (!params.enableReasoning) { - builder.remove(ThinkingTagExtractionMiddlewareName) + // 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降 + // builder.remove(ThinkingTagExtractionMiddlewareName) builder.remove(ThinkChunkMiddlewareName) } // 注意:用client判断会导致typescript类型收窄 From 3350c3e2e52b470c78473dd49427644d32a613d9 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 15:16:23 +0800 Subject: [PATCH 097/317] fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic (#8009) * fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic * fix: unuse log --- src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts | 7 +++++++ src/renderer/src/utils/mcp-tools.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index 0f1bad0bf8..d32564d962 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -593,6 +593,13 @@ export class GeminiAPIClient extends BaseApiClient< } } as LLMWebSearchCompleteChunk) } + if (toolCalls.length > 0) { + controller.enqueue({ + type: ChunkType.MCP_TOOL_CREATED, + tool_calls: [...toolCalls] + }) + toolCalls.length = 0 + } controller.enqueue({ type: ChunkType.LLM_RESPONSE_COMPLETE, response: { diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts index b39d9b6bc3..4b7a2e4735 100644 --- a/src/renderer/src/utils/mcp-tools.ts +++ b/src/renderer/src/utils/mcp-tools.ts @@ -391,7 +391,7 @@ export function geminiFunctionCallToMcpTool( ): MCPTool | undefined { if (!toolCall) return undefined if (!mcpTools) return undefined - const tool = mcpTools.find((tool) => tool.id === toolCall.name) + const tool = mcpTools.find((tool) => tool.id === toolCall.name || tool.name === toolCall.name) if (!tool) { return undefined } From 3afa81eb5d2aea73b672940379c236fec790bcc8 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 16:58:35 +0800 Subject: [PATCH 098/317] fix(Anthropic): content truncation (#7942) * fix(Anthropic): content truncation * feat: add start event and fix content truncation * fix (gemini): some event * revert: index.tsx * revert(messageThunk): error block * fix: ci * chore: unuse log --- .../clients/anthropic/AnthropicAPIClient.ts | 28 +-- .../aiCore/clients/gemini/GeminiAPIClient.ts | 18 +- .../aiCore/clients/openai/OpenAIApiClient.ts | 16 +- .../clients/openai/OpenAIResponseAPIClient.ts | 26 ++ .../middleware/core/TextChunkMiddleware.ts | 17 +- .../middleware/core/ThinkChunkMiddleware.ts | 12 +- .../feat/ToolUseExtractionMiddleware.ts | 1 + .../home/Messages/Blocks/MainTextBlock.tsx | 8 +- .../Blocks/__tests__/MainTextBlock.test.tsx | 45 ---- .../src/services/StreamProcessingService.ts | 12 + src/renderer/src/store/thunk/messageThunk.ts | 227 ++++++++---------- src/renderer/src/types/chunk.ts | 32 +++ 12 files changed, 213 insertions(+), 229 deletions(-) diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index c946f114fe..93176a9566 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -49,10 +49,10 @@ import { LLMWebSearchCompleteChunk, LLMWebSearchInProgressChunk, MCPToolCreatedChunk, - TextCompleteChunk, TextDeltaChunk, - ThinkingCompleteChunk, - ThinkingDeltaChunk + TextStartChunk, + ThinkingDeltaChunk, + ThinkingStartChunk } from '@renderer/types/chunk' import { type Message } from '@renderer/types/newMessage' import { @@ -519,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient< return () => { let accumulatedJson = '' const toolCalls: Record = {} - const ChunkIdTypeMap: Record = {} return { async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController) { switch (rawChunk.type) { @@ -615,16 +614,16 @@ export class AnthropicAPIClient extends BaseApiClient< break } case 'text': { - if (!ChunkIdTypeMap[rawChunk.index]) { - ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块 - } + controller.enqueue({ + type: ChunkType.TEXT_START + } as TextStartChunk) break } case 'thinking': case 'redacted_thinking': { - if (!ChunkIdTypeMap[rawChunk.index]) { - ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块 - } + controller.enqueue({ + type: ChunkType.THINKING_START + } as ThinkingStartChunk) break } } @@ -661,15 +660,6 @@ export class AnthropicAPIClient extends BaseApiClient< break } case 'content_block_stop': { - if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) { - controller.enqueue({ - type: ChunkType.TEXT_COMPLETE - } as TextCompleteChunk) - } else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) { - controller.enqueue({ - type: ChunkType.THINKING_COMPLETE - } as ThinkingCompleteChunk) - } const toolCall = toolCalls[rawChunk.index] if (toolCall) { try { diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index d32564d962..bcf7c0d592 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -41,7 +41,7 @@ import { ToolCallResponse, WebSearchSource } from '@renderer/types' -import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk' +import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { GeminiOptions, @@ -547,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient< } getResponseChunkTransformer(): ResponseChunkTransformer { + const toolCalls: FunctionCall[] = [] + let isFirstTextChunk = true + let isFirstThinkingChunk = true return () => ({ async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController) { - const toolCalls: FunctionCall[] = [] if (chunk.candidates && chunk.candidates.length > 0) { for (const candidate of chunk.candidates) { if (candidate.content) { candidate.content.parts?.forEach((part) => { const text = part.text || '' if (part.thought) { + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + } as ThinkingStartChunk) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: text }) } else if (part.text) { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + } as TextStartChunk) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: text diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index c1994dcb95..e3ccc8edd0 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -31,7 +31,7 @@ import { ToolCallResponse, WebSearchSource } from '@renderer/types' -import { ChunkType } from '@renderer/types/chunk' +import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { OpenAISdkMessageParam, @@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient< isFinished = true } + let isFirstThinkingChunk = true + let isFirstTextChunk = true return (context: ResponseChunkTransformerContext) => ({ async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController) { // 持续更新usage信息 @@ -699,6 +701,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it const reasoningText = contentSource.reasoning_content || contentSource.reasoning if (reasoningText) { + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + } as ThinkingStartChunk) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: reasoningText @@ -707,6 +715,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // 处理文本内容 if (contentSource.content) { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + } as TextStartChunk) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: contentSource.content diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 2af0b8376f..898e7eec44 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -424,6 +424,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] let hasBeenCollectedToolCalls = false let hasReasoningSummary = false + let isFirstThinkingChunk = true + let isFirstTextChunk = true return () => ({ async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController) { // 处理chunk @@ -435,6 +437,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< switch (output.type) { case 'message': if (output.content[0].type === 'output_text') { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + }) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: output.content[0].text @@ -451,6 +459,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< } break case 'reasoning': + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + }) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: output.summary.map((s) => s.text).join('\n') @@ -510,6 +524,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< hasReasoningSummary = true break case 'response.reasoning_summary_text.delta': + if (isFirstThinkingChunk) { + controller.enqueue({ + type: ChunkType.THINKING_START + }) + isFirstThinkingChunk = false + } controller.enqueue({ type: ChunkType.THINKING_DELTA, text: chunk.delta @@ -535,6 +555,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< }) break case 'response.output_text.delta': { + if (isFirstTextChunk) { + controller.enqueue({ + type: ChunkType.TEXT_START + }) + isFirstTextChunk = false + } controller.enqueue({ type: ChunkType.TEXT_DELTA, text: chunk.delta diff --git a/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts index 3905d52058..0affc6b382 100644 --- a/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts @@ -1,5 +1,5 @@ import Logger from '@renderer/config/logger' -import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk' +import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' import { CompletionsContext, CompletionsMiddleware } from '../types' @@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware = // 用于跨chunk的状态管理 let accumulatedTextContent = '' - let hasTextCompleteEventEnqueue = false const enhancedTextStream = resultFromUpstream.pipeThrough( new TransformStream({ transform(chunk: GenericChunk, controller) { @@ -53,18 +52,7 @@ export const TextChunkMiddleware: CompletionsMiddleware = // 创建新的chunk,包含处理后的文本 controller.enqueue(chunk) - } else if (chunk.type === ChunkType.TEXT_COMPLETE) { - const textChunk = chunk as TextCompleteChunk - controller.enqueue({ - ...textChunk, - text: accumulatedTextContent - }) - if (params.onResponse) { - params.onResponse(accumulatedTextContent, true) - } - hasTextCompleteEventEnqueue = true - accumulatedTextContent = '' - } else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) { + } else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) { if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) { const finalText = accumulatedTextContent ctx._internal.customState!.accumulatedText = finalText @@ -89,7 +77,6 @@ export const TextChunkMiddleware: CompletionsMiddleware = }) controller.enqueue(chunk) } - hasTextCompleteEventEnqueue = true accumulatedTextContent = '' } else { // 其他类型的chunk直接传递 diff --git a/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts index dccdde7f10..22eaabe96d 100644 --- a/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts @@ -65,17 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware = thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 } controller.enqueue(enhancedChunk) - } else if (chunk.type === ChunkType.THINKING_COMPLETE) { - const thinkingCompleteChunk = chunk as ThinkingCompleteChunk - controller.enqueue({ - ...thinkingCompleteChunk, - text: accumulatedThinkingContent, - thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 - }) - hasThinkingContent = false - accumulatedThinkingContent = '' - thinkingStartTime = 0 - } else if (hasThinkingContent && thinkingStartTime > 0) { + } else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) { // 收到任何非THINKING_DELTA的chunk时,如果有累积的思考内容,生成THINKING_COMPLETE const thinkingCompleteChunk: ThinkingCompleteChunk = { type: ChunkType.THINKING_COMPLETE, diff --git a/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts index b53d7348f1..3e606f6683 100644 --- a/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts @@ -79,6 +79,7 @@ function createToolUseExtractionTransform( toolCounter += toolUseResponses.length if (toolUseResponses.length > 0) { + controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' }) // 生成 MCP_TOOL_CREATED chunk const mcpToolCreatedChunk: MCPToolCreatedChunk = { type: ChunkType.MCP_TOOL_CREATED, diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index 524fcd4160..0f0d52907d 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -19,8 +19,6 @@ interface Props { role: Message['role'] } -const toolUseRegex = /([\s\S]*?)<\/tool_use>/g - const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions = [] }) => { // Use the passed citationBlockId directly in the selector const { renderInputMessageAsMarkdown } = useSettings() @@ -38,10 +36,6 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions return withCitationTags(block.content, rawCitations, sourceType) }, [block.content, block.citationReferences, citationBlockId, rawCitations]) - const ignoreToolUse = useMemo(() => { - return processedContent.replace(toolUseRegex, '') - }, [processedContent]) - return ( <> {/* Render mentions associated with the message */} @@ -57,7 +51,7 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions {block.content}

) : ( - + )} ) diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx index 551e0d9371..4683aae9bb 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx @@ -261,51 +261,6 @@ describe('MainTextBlock', () => { }) describe('content processing', () => { - it('should filter tool_use tags from content', () => { - const testCases = [ - { - name: 'single tool_use tag', - content: 'Before tool content after', - expectsFiltering: true - }, - { - name: 'multiple tool_use tags', - content: 'Start tool1 middle tool2 end', - expectsFiltering: true - }, - { - name: 'multiline tool_use', - content: `Text before - - multiline - tool content - -text after`, - expectsFiltering: true - }, - { - name: 'malformed tool_use', - content: 'Before unclosed tag', - expectsFiltering: false // Should preserve malformed tags - } - ] - - testCases.forEach(({ content, expectsFiltering }) => { - const block = createMainTextBlock({ content }) - const { unmount } = renderMainTextBlock({ block, role: 'assistant' }) - - const renderedContent = getRenderedMarkdown() - expect(renderedContent).toBeInTheDocument() - - if (expectsFiltering) { - // Check that tool_use content is not visible to user - expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument() - } - - unmount() - }) - }) - it('should process content through format utilities', () => { const block = createMainTextBlock({ content: 'Content to process', diff --git a/src/renderer/src/services/StreamProcessingService.ts b/src/renderer/src/services/StreamProcessingService.ts index 6c166ca6a9..c6afa85e39 100644 --- a/src/renderer/src/services/StreamProcessingService.ts +++ b/src/renderer/src/services/StreamProcessingService.ts @@ -8,10 +8,14 @@ import { AssistantMessageStatus } from '@renderer/types/newMessage' export interface StreamProcessorCallbacks { // LLM response created onLLMResponseCreated?: () => void + // Text content start + onTextStart?: () => void // Text content chunk received onTextChunk?: (text: string) => void // Full text content received onTextComplete?: (text: string) => void + // thinking content start + onThinkingStart?: () => void // Thinking/reasoning content chunk received (e.g., from Claude) onThinkingChunk?: (text: string, thinking_millsec?: number) => void onThinkingComplete?: (text: string, thinking_millsec?: number) => void @@ -54,6 +58,10 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) if (callbacks.onLLMResponseCreated) callbacks.onLLMResponseCreated() break } + case ChunkType.TEXT_START: { + if (callbacks.onTextStart) callbacks.onTextStart() + break + } case ChunkType.TEXT_DELTA: { if (callbacks.onTextChunk) callbacks.onTextChunk(data.text) break @@ -62,6 +70,10 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) if (callbacks.onTextComplete) callbacks.onTextComplete(data.text) break } + case ChunkType.THINKING_START: { + if (callbacks.onThinkingStart) callbacks.onThinkingStart() + break + } case ChunkType.THINKING_DELTA: { if (callbacks.onThinkingChunk) callbacks.onThinkingChunk(data.text, data.thinking_millsec) break diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 996044bb24..63ca59fa43 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -41,7 +41,7 @@ import { createTranslationBlock, resetAssistantMessage } from '@renderer/utils/messageUtils/create' -import { getMainTextContent } from '@renderer/utils/messageUtils/find' +import { findMainTextBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { getTopicQueue } from '@renderer/utils/queue' import { waitForTopicQueue } from '@renderer/utils/queue' import { isOnHomePage } from '@renderer/utils/window' @@ -226,31 +226,6 @@ export const cleanupMultipleBlocks = (dispatch: AppDispatch, blockIds: string[]) } } -// // 修改: 节流更新单个块的内容/状态到数据库 (仅用于 Text/Thinking Chunks) -// export const throttledBlockDbUpdate = throttle( -// async (blockId: string, blockChanges: Partial) => { -// // Check if blockId is valid before attempting update -// if (!blockId) { -// console.warn('[DB Throttle Block Update] Attempted to update with null/undefined blockId. Skipping.') -// return -// } -// const state = store.getState() -// const block = state.messageBlocks.entities[blockId] -// // throttle是异步函数,可能会在complete事件触发后才执行 -// if ( -// blockChanges.status === MessageBlockStatus.STREAMING && -// (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR) -// ) -// return -// try { -// } catch (error) { -// console.error(`[DB Throttle Block Update] Failed for block ${blockId}:`, error) -// } -// }, -// 300, // 可以调整节流间隔 -// { leading: false, trailing: true } -// ) - // 新增: 通用的、非节流的函数,用于保存消息和块的更新到数据库 const saveUpdatesToDB = async ( messageId: string, @@ -351,9 +326,9 @@ const fetchAndProcessAssistantResponseImpl = async ( let accumulatedContent = '' let accumulatedThinking = '' - // 专注于管理UI焦点和块切换 let lastBlockId: string | null = null let lastBlockType: MessageBlockType | null = null + let currentActiveBlockType: MessageBlockType | null = null // 专注于块内部的生命周期处理 let initialPlaceholderBlockId: string | null = null let citationBlockId: string | null = null @@ -365,6 +340,28 @@ const fetchAndProcessAssistantResponseImpl = async ( const toolCallIdToBlockIdMap = new Map() const notificationService = NotificationService.getInstance() + /** + * 智能更新策略:根据块类型连续性自动判断使用节流还是立即更新 + * - 连续同类块:使用节流(减少重渲染) + * - 块类型切换:立即更新(确保状态正确) + */ + const smartBlockUpdate = (blockId: string, changes: Partial, blockType: MessageBlockType) => { + const isBlockTypeChanged = currentActiveBlockType !== null && currentActiveBlockType !== blockType + + if (isBlockTypeChanged) { + if (lastBlockId && lastBlockId !== blockId) { + cancelThrottledBlockUpdate(lastBlockId) + } + dispatch(updateOneBlock({ id: blockId, changes })) + saveUpdatedBlockToDB(blockId, assistantMsgId, topicId, getState) + } else { + throttledBlockUpdate(blockId, changes) + } + + // 更新当前活跃块类型 + currentActiveBlockType = blockType + } + const handleBlockTransition = async (newBlock: MessageBlock, newBlockType: MessageBlockType) => { lastBlockId = newBlock.id lastBlockType = newBlockType @@ -428,6 +425,25 @@ const fetchAndProcessAssistantResponseImpl = async ( initialPlaceholderBlockId = baseBlock.id await handleBlockTransition(baseBlock as PlaceholderMessageBlock, MessageBlockType.UNKNOWN) }, + onTextStart: async () => { + if (initialPlaceholderBlockId) { + lastBlockType = MessageBlockType.MAIN_TEXT + const changes = { + type: MessageBlockType.MAIN_TEXT, + content: accumulatedContent, + status: MessageBlockStatus.STREAMING + } + smartBlockUpdate(initialPlaceholderBlockId, changes, MessageBlockType.MAIN_TEXT) + mainTextBlockId = initialPlaceholderBlockId + initialPlaceholderBlockId = null + } else if (!mainTextBlockId) { + const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, { + status: MessageBlockStatus.STREAMING + }) + mainTextBlockId = newBlock.id + await handleBlockTransition(newBlock, MessageBlockType.MAIN_TEXT) + } + }, onTextChunk: async (text) => { const citationBlockSource = citationBlockId ? (getState().messageBlocks.entities[citationBlockId] as CitationMessageBlock).response?.source @@ -435,31 +451,11 @@ const fetchAndProcessAssistantResponseImpl = async ( accumulatedContent += text if (mainTextBlockId) { const blockChanges: Partial = { - content: accumulatedContent, - status: MessageBlockStatus.STREAMING - } - throttledBlockUpdate(mainTextBlockId, blockChanges) - } else if (initialPlaceholderBlockId) { - // 将占位块转换为主文本块 - const initialChanges: Partial = { - type: MessageBlockType.MAIN_TEXT, content: accumulatedContent, status: MessageBlockStatus.STREAMING, citationReferences: citationBlockId ? [{ citationBlockId, citationBlockSource }] : [] } - mainTextBlockId = initialPlaceholderBlockId - // 清理占位块 - initialPlaceholderBlockId = null - lastBlockType = MessageBlockType.MAIN_TEXT - dispatch(updateOneBlock({ id: mainTextBlockId, changes: initialChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) - } else { - const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, { - status: MessageBlockStatus.STREAMING, - citationReferences: citationBlockId ? [{ citationBlockId, citationBlockSource }] : [] - }) - mainTextBlockId = newBlock.id // 立即设置ID,防止竞态条件 - await handleBlockTransition(newBlock, MessageBlockType.MAIN_TEXT) + smartBlockUpdate(mainTextBlockId, blockChanges, MessageBlockType.MAIN_TEXT) } }, onTextComplete: async (finalText) => { @@ -468,18 +464,35 @@ const fetchAndProcessAssistantResponseImpl = async ( content: finalText, status: MessageBlockStatus.SUCCESS } - cancelThrottledBlockUpdate(mainTextBlockId) - dispatch(updateOneBlock({ id: mainTextBlockId, changes })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) - if (!assistant.enableWebSearch) { - mainTextBlockId = null - } + smartBlockUpdate(mainTextBlockId, changes, MessageBlockType.MAIN_TEXT) + mainTextBlockId = null } else { console.warn( `[onTextComplete] Received text.complete but last block was not MAIN_TEXT (was ${lastBlockType}) or lastBlockId is null.` ) } }, + onThinkingStart: async () => { + if (initialPlaceholderBlockId) { + lastBlockType = MessageBlockType.THINKING + const changes = { + type: MessageBlockType.THINKING, + content: accumulatedThinking, + status: MessageBlockStatus.STREAMING, + thinking_millsec: 0 + } + thinkingBlockId = initialPlaceholderBlockId + initialPlaceholderBlockId = null + smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING) + } else if (!thinkingBlockId) { + const newBlock = createThinkingBlock(assistantMsgId, accumulatedThinking, { + status: MessageBlockStatus.STREAMING, + thinking_millsec: 0 + }) + thinkingBlockId = newBlock.id + await handleBlockTransition(newBlock, MessageBlockType.THINKING) + } + }, onThinkingChunk: async (text, thinking_millsec) => { accumulatedThinking += text if (thinkingBlockId) { @@ -488,26 +501,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.STREAMING, thinking_millsec: thinking_millsec } - throttledBlockUpdate(thinkingBlockId, blockChanges) - } else if (initialPlaceholderBlockId) { - // First chunk for this block: Update type and status immediately - lastBlockType = MessageBlockType.THINKING - const initialChanges: Partial = { - type: MessageBlockType.THINKING, - content: accumulatedThinking, - status: MessageBlockStatus.STREAMING - } - thinkingBlockId = initialPlaceholderBlockId - initialPlaceholderBlockId = null - dispatch(updateOneBlock({ id: thinkingBlockId, changes: initialChanges })) - saveUpdatedBlockToDB(thinkingBlockId, assistantMsgId, topicId, getState) - } else { - const newBlock = createThinkingBlock(assistantMsgId, accumulatedThinking, { - status: MessageBlockStatus.STREAMING, - thinking_millsec: 0 - }) - thinkingBlockId = newBlock.id // 立即设置ID,防止竞态条件 - await handleBlockTransition(newBlock, MessageBlockType.THINKING) + smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING) } }, onThinkingComplete: (finalText, final_thinking_millsec) => { @@ -518,9 +512,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.SUCCESS, thinking_millsec: final_thinking_millsec } - cancelThrottledBlockUpdate(thinkingBlockId) - dispatch(updateOneBlock({ id: thinkingBlockId, changes })) - saveUpdatedBlockToDB(thinkingBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING) } else { console.warn( `[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${lastBlockType}) or lastBlockId is null.` @@ -539,8 +531,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } toolBlockId = initialPlaceholderBlockId initialPlaceholderBlockId = null - dispatch(updateOneBlock({ id: toolBlockId, changes })) - saveUpdatedBlockToDB(toolBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(toolBlockId, changes, MessageBlockType.TOOL) toolCallIdToBlockIdMap.set(toolResponse.id, toolBlockId) } else if (toolResponse.status === 'pending') { const toolBlock = createToolBlock(assistantMsgId, toolResponse.id, { @@ -566,8 +557,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.PROCESSING, metadata: { rawMcpToolResponse: toolResponse } } - dispatch(updateOneBlock({ id: targetBlockId, changes })) - saveUpdatedBlockToDB(targetBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(targetBlockId, changes, MessageBlockType.TOOL) } else if (!targetBlockId) { console.warn( `[onToolCallInProgress] No block ID found for tool ID: ${toolResponse.id}. Available mappings:`, @@ -601,9 +591,7 @@ const fetchAndProcessAssistantResponseImpl = async ( if (finalStatus === MessageBlockStatus.ERROR) { changes.error = { message: `Tool execution failed/error`, details: toolResponse.response } } - cancelThrottledBlockUpdate(existingBlockId) - dispatch(updateOneBlock({ id: existingBlockId, changes })) - saveUpdatedBlockToDB(existingBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL) } else { console.warn( `[onToolCallComplete] Received unhandled tool status: ${toolResponse.status} for ID: ${toolResponse.id}` @@ -624,8 +612,7 @@ const fetchAndProcessAssistantResponseImpl = async ( knowledge: externalToolResult.knowledge, status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: citationBlockId, changes })) - saveUpdatedBlockToDB(citationBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(citationBlockId, changes, MessageBlockType.CITATION) } else { console.error('[onExternalToolComplete] citationBlockId is null. Cannot update.') } @@ -639,8 +626,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.PROCESSING } lastBlockType = MessageBlockType.CITATION - dispatch(updateOneBlock({ id: initialPlaceholderBlockId, changes })) - saveUpdatedBlockToDB(initialPlaceholderBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(initialPlaceholderBlockId, changes, MessageBlockType.CITATION) initialPlaceholderBlockId = null } else { const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING }) @@ -656,22 +642,19 @@ const fetchAndProcessAssistantResponseImpl = async ( response: llmWebSearchResult, status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: blockId, changes })) - saveUpdatedBlockToDB(blockId, assistantMsgId, topicId, getState) + smartBlockUpdate(blockId, changes, MessageBlockType.CITATION) - if (mainTextBlockId) { - const state = getState() - const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] - if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { - const currentRefs = existingMainTextBlock.citationReferences || [] - const mainTextChanges = { - citationReferences: [...currentRefs, { blockId, citationBlockSource: llmWebSearchResult.source }] - } - dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) + const state = getState() + const existingMainTextBlocks = findMainTextBlocks(state.messages.entities[assistantMsgId]) + if (existingMainTextBlocks.length > 0) { + const existingMainTextBlock = existingMainTextBlocks[0] + const currentRefs = existingMainTextBlock.citationReferences || [] + const mainTextChanges = { + citationReferences: [...currentRefs, { blockId, citationBlockSource: llmWebSearchResult.source }] } - mainTextBlockId = null + smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT) } + if (initialPlaceholderBlockId) { citationBlockId = initialPlaceholderBlockId initialPlaceholderBlockId = null @@ -687,21 +670,15 @@ const fetchAndProcessAssistantResponseImpl = async ( } ) citationBlockId = citationBlock.id - if (mainTextBlockId) { - const state = getState() - const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] - if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { - const currentRefs = existingMainTextBlock.citationReferences || [] - const mainTextChanges = { - citationReferences: [ - ...currentRefs, - { citationBlockId, citationBlockSource: llmWebSearchResult.source } - ] - } - dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) + const state = getState() + const existingMainTextBlocks = findMainTextBlocks(state.messages.entities[assistantMsgId]) + if (existingMainTextBlocks.length > 0) { + const existingMainTextBlock = existingMainTextBlocks[0] + const currentRefs = existingMainTextBlock.citationReferences || [] + const mainTextChanges = { + citationReferences: [...currentRefs, { citationBlockId, citationBlockSource: llmWebSearchResult.source }] } - mainTextBlockId = null + smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT) } await handleBlockTransition(citationBlock, MessageBlockType.CITATION) } @@ -716,8 +693,7 @@ const fetchAndProcessAssistantResponseImpl = async ( lastBlockType = MessageBlockType.IMAGE imageBlockId = initialPlaceholderBlockId initialPlaceholderBlockId = null - dispatch(updateOneBlock({ id: imageBlockId, changes: initialChanges })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, initialChanges, MessageBlockType.IMAGE) } else if (!imageBlockId) { const imageBlock = createImageBlock(assistantMsgId, { status: MessageBlockStatus.STREAMING @@ -734,8 +710,7 @@ const fetchAndProcessAssistantResponseImpl = async ( metadata: { generateImageResponse: imageData }, status: MessageBlockStatus.STREAMING } - dispatch(updateOneBlock({ id: imageBlockId, changes })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, changes, MessageBlockType.IMAGE) } }, onImageGenerated: (imageData) => { @@ -744,8 +719,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const changes: Partial = { status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: imageBlockId, changes })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, changes, MessageBlockType.IMAGE) } else { const imageUrl = imageData.images?.[0] || 'placeholder_image_url' const changes: Partial = { @@ -753,8 +727,7 @@ const fetchAndProcessAssistantResponseImpl = async ( metadata: { generateImageResponse: imageData }, status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: imageBlockId, changes })) - saveUpdatedBlockToDB(imageBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(imageBlockId, changes, MessageBlockType.IMAGE) } } else { console.error('[onImageGenerated] Last block was not an Image block or ID is missing.') @@ -802,9 +775,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const changes: Partial = { status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR } - cancelThrottledBlockUpdate(possibleBlockId) - dispatch(updateOneBlock({ id: possibleBlockId, changes })) - saveUpdatedBlockToDB(possibleBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(possibleBlockId, changes, MessageBlockType.MAIN_TEXT) } const errorBlock = createErrorBlock(assistantMsgId, serializableError, { status: MessageBlockStatus.SUCCESS }) @@ -846,9 +817,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const changes: Partial = { status: MessageBlockStatus.SUCCESS } - cancelThrottledBlockUpdate(possibleBlockId) - dispatch(updateOneBlock({ id: possibleBlockId, changes })) - saveUpdatedBlockToDB(possibleBlockId, assistantMsgId, topicId, getState) + smartBlockUpdate(possibleBlockId, changes, lastBlockType!) } const endTime = Date.now() diff --git a/src/renderer/src/types/chunk.ts b/src/renderer/src/types/chunk.ts index c5e84a4673..1fdbbdae6f 100644 --- a/src/renderer/src/types/chunk.ts +++ b/src/renderer/src/types/chunk.ts @@ -19,13 +19,16 @@ export enum ChunkType { EXTERNEL_TOOL_COMPLETE = 'externel_tool_complete', LLM_RESPONSE_CREATED = 'llm_response_created', LLM_RESPONSE_IN_PROGRESS = 'llm_response_in_progress', + TEXT_START = 'text.start', TEXT_DELTA = 'text.delta', TEXT_COMPLETE = 'text.complete', + AUDIO_START = 'audio.start', AUDIO_DELTA = 'audio.delta', AUDIO_COMPLETE = 'audio.complete', IMAGE_CREATED = 'image.created', IMAGE_DELTA = 'image.delta', IMAGE_COMPLETE = 'image.complete', + THINKING_START = 'thinking.start', THINKING_DELTA = 'thinking.delta', THINKING_COMPLETE = 'thinking.complete', LLM_WEB_SEARCH_IN_PROGRESS = 'llm_websearch_in_progress', @@ -56,6 +59,18 @@ export interface LLMResponseInProgressChunk { response?: Response type: ChunkType.LLM_RESPONSE_IN_PROGRESS } + +export interface TextStartChunk { + /** + * The type of the chunk + */ + type: ChunkType.TEXT_START + + /** + * The ID of the chunk + */ + chunk_id?: number +} export interface TextDeltaChunk { /** * The text content of the chunk @@ -90,6 +105,13 @@ export interface TextCompleteChunk { type: ChunkType.TEXT_COMPLETE } +export interface AudioStartChunk { + /** + * The type of the chunk + */ + type: ChunkType.AUDIO_START +} + export interface AudioDeltaChunk { /** * A chunk of Base64 encoded audio data @@ -140,6 +162,13 @@ export interface ImageCompleteChunk { image?: { type: 'url' | 'base64'; images: string[] } } +export interface ThinkingStartChunk { + /** + * The type of the chunk + */ + type: ChunkType.THINKING_START +} + export interface ThinkingDeltaChunk { /** * The text content of the chunk @@ -365,13 +394,16 @@ export type Chunk = | ExternalToolCompleteChunk // 外部工具调用完成,外部工具包含搜索互联网,知识库,MCP服务器 | LLMResponseCreatedChunk // 大模型响应创建,返回即将创建的块类型 | LLMResponseInProgressChunk // 大模型响应进行中 + | TextStartChunk // 文本内容生成开始 | TextDeltaChunk // 文本内容生成中 | TextCompleteChunk // 文本内容生成完成 + | AudioStartChunk // 音频内容生成开始 | AudioDeltaChunk // 音频内容生成中 | AudioCompleteChunk // 音频内容生成完成 | ImageCreatedChunk // 图片内容创建 | ImageDeltaChunk // 图片内容生成中 | ImageCompleteChunk // 图片内容生成完成 + | ThinkingStartChunk // 思考内容生成开始 | ThinkingDeltaChunk // 思考内容生成中 | ThinkingCompleteChunk // 思考内容生成完成 | LLMWebSearchInProgressChunk // 大模型内部搜索进行中,无明显特征 From dff44f272179fa8a741546ca5245d0f978c7157d Mon Sep 17 00:00:00 2001 From: Alaina Hardie Date: Thu, 10 Jul 2025 05:01:31 -0400 Subject: [PATCH 099/317] Fix: Require typechecking for Mac and Linux target builds (#7219) fix: Mac builds do not auto-run typecheck, but Windows builds do. This requires an extra manual step when building for Mac. Update build scripts in package.json to use `npm run build` directly for Mac and Linux targets.. --- package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 41f1f58bf7..e56b1e261c 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,12 @@ "build:win": "dotenv npm run build && electron-builder --win --x64 --arm64", "build:win:x64": "dotenv npm run build && electron-builder --win --x64", "build:win:arm64": "dotenv npm run build && electron-builder --win --arm64", - "build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64", - "build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64", - "build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64", - "build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64", - "build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64", - "build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64", + "build:mac": "dotenv npm run build && electron-builder --mac --arm64 --x64", + "build:mac:arm64": "dotenv npm run build && electron-builder --mac --arm64", + "build:mac:x64": "dotenv npm run build && electron-builder --mac --x64", + "build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64", + "build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64", + "build:linux:x64": "dotenv npm run build && electron-builder --linux --x64", "build:npm": "node scripts/build-npm.js", "release": "node scripts/version.js", "publish": "yarn build:check && yarn release patch push", From ffbd6445df811194dca7d3764eb5a1abd6a131cf Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 17:26:38 +0800 Subject: [PATCH 100/317] refactor(Inputbar): make button tooltips disappear faster (#8011) --- .../src/components/TranslateButton.tsx | 1 + .../pages/home/Inputbar/AttachmentButton.tsx | 6 +++++- .../home/Inputbar/GenerateImageButton.tsx | 1 + .../src/pages/home/Inputbar/Inputbar.tsx | 2 +- .../src/pages/home/Inputbar/InputbarTools.tsx | 18 +++++++++++++++--- .../home/Inputbar/KnowledgeBaseButton.tsx | 2 +- .../src/pages/home/Inputbar/MCPToolsButton.tsx | 2 +- .../home/Inputbar/MentionModelsButton.tsx | 2 +- .../pages/home/Inputbar/NewContextButton.tsx | 6 +++++- .../pages/home/Inputbar/QuickPhrasesButton.tsx | 2 +- .../src/pages/home/Inputbar/ThinkingButton.tsx | 2 +- .../pages/home/Inputbar/WebSearchButton.tsx | 6 +++++- 12 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx index d52448b488..41a29a6fa4 100644 --- a/src/renderer/src/components/TranslateButton.tsx +++ b/src/renderer/src/components/TranslateButton.tsx @@ -77,6 +77,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa {isTranslating ? : } diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index a06229bd7c..576f034a24 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -54,7 +54,11 @@ const AttachmentButton: FC = ({ })) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx index 889919b7f5..c7d930bf5d 100644 --- a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx @@ -21,6 +21,7 @@ const GenerateImageButton: FC = ({ model, ToolbarButton, assistant, onEna title={ isGenerateImageModel(model) ? t('chat.input.generate_image') : t('chat.input.generate_image_not_supported') } + mouseLeaveDelay={0} arrow> diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 775debfd34..a07f7b73b2 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -909,7 +909,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = /> {loading && ( - + diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index a6596aed19..7609b8cfe3 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -290,7 +290,11 @@ const InputbarTools = ({ key: 'new_topic', label: t('chat.input.new_topic', { Command: '' }), component: ( - + @@ -395,7 +399,11 @@ const InputbarTools = ({ key: 'clear_topic', label: t('chat.input.clear', { Command: '' }), component: ( - + @@ -406,7 +414,11 @@ const InputbarTools = ({ key: 'toggle_expand', label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'), component: ( - + {isExpended ? : } diff --git a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx index 3462788bdc..8e4782c8a7 100644 --- a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx @@ -85,7 +85,7 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled })) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index ec64e7d4a7..cc8bc67ca5 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -454,7 +454,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar })) return ( - + = ({ })) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx index c8c2b9fced..26c9941cff 100644 --- a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx @@ -16,7 +16,11 @@ const NewContextButton: FC = ({ onNewContext, ToolbarButton }) => { useShortcut('toggle_new_context', onNewContext) return ( - + diff --git a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx index d15c982a87..d6e9621fd5 100644 --- a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx @@ -148,7 +148,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, return ( <> - + diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 1338b03fcc..810d0158f4 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -190,7 +190,7 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re })) return ( - + {getThinkingIcon()} diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index c17f5fe2cc..6eeed391f4 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -127,7 +127,11 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { })) return ( - + Date: Thu, 10 Jul 2025 17:26:57 +0800 Subject: [PATCH 101/317] =?UTF-8?q?fix(McpToolChunkMiddleware):=20add=20lo?= =?UTF-8?q?gging=20for=20tool=20calls=20and=20enhance=20l=E2=80=A6=20(#802?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(McpToolChunkMiddleware): add logging for tool calls and enhance lookup logic --- .../src/aiCore/middleware/core/McpToolChunkMiddleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts index 29c35b0e15..b74c4895dc 100644 --- a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts @@ -253,7 +253,8 @@ async function executeToolCalls( (toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) || confirmed.tool.name === toolCall.id || confirmed.tool.id === toolCall.id || - ('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) + ('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) || + ('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase())) ) }) }) From 7e672d86e73383b83b8970205a57ca7b0b80d627 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 17:29:43 +0800 Subject: [PATCH 102/317] refactor: do not jump on enabling content search (#7922) * fix: content search count on enable * refactor(ContentSearch): do not jump on enabling content search * refactor: simplify result count --- src/renderer/src/components/ContentSearch.tsx | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index a172d40570..6842312137 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -140,7 +140,7 @@ export const ContentSearch = React.forwardRef( const [isCaseSensitive, setIsCaseSensitive] = useState(false) const [isWholeWord, setIsWholeWord] = useState(false) const [allRanges, setAllRanges] = useState([]) - const [currentIndex, setCurrentIndex] = useState(0) + const [currentIndex, setCurrentIndex] = useState(-1) const prevSearchText = useRef('') const { t } = useTranslation() @@ -182,15 +182,18 @@ export const ContentSearch = React.forwardRef( [allRanges, currentIndex] ) - const search = useCallback(() => { - const searchText = searchInputRef.current?.value.trim() ?? null - setSearchCompleted(SearchCompletedState.Searched) - if (target && searchText !== null && searchText !== '') { - const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord) - setAllRanges(ranges) - setCurrentIndex(0) - } - }, [target, filter, isCaseSensitive, isWholeWord]) + const search = useCallback( + (jump = false) => { + const searchText = searchInputRef.current?.value.trim() ?? null + setSearchCompleted(SearchCompletedState.Searched) + if (target && searchText !== null && searchText !== '') { + const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord) + setAllRanges(ranges) + setCurrentIndex(jump && ranges.length > 0 ? 0 : -1) + } + }, + [target, filter, isCaseSensitive, isWholeWord] + ) const implementation = useMemo( () => ({ @@ -207,7 +210,7 @@ export const ContentSearch = React.forwardRef( requestAnimationFrame(() => { inputEl.focus() inputEl.select() - search() + search(false) }) } else { requestAnimationFrame(() => { @@ -231,11 +234,11 @@ export const ContentSearch = React.forwardRef( setSearchCompleted(SearchCompletedState.NotSearched) }, search: () => { - search() + search(true) locateByIndex(true) }, silentSearch: () => { - search() + search(false) locateByIndex(false) }, focus: () => { @@ -302,7 +305,7 @@ export const ContentSearch = React.forwardRef( useEffect(() => { if (enableContentSearch && searchInputRef.current?.value.trim()) { - search() + search(true) } }, [isCaseSensitive, isWholeWord, enableContentSearch, search]) @@ -365,16 +368,12 @@ export const ContentSearch = React.forwardRef( - {searchCompleted !== SearchCompletedState.NotSearched ? ( - allRanges.length > 0 ? ( - <> - {currentIndex + 1} - / - {allRanges.length} - - ) : ( - {t('common.no_results')} - ) + {searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? ( + <> + {currentIndex + 1} + / + {allRanges.length} + ) : ( 0/0 )} @@ -477,10 +476,6 @@ const SearchResultsPlaceholder = styled.span` opacity: 0.5; ` -const NoResults = styled.span` - color: var(--color-text-1); -` - const SearchResultCount = styled.span` color: var(--color-text); ` From fca93b6c5132683e0879d2ec3088323a618f6ea6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 10 Jul 2025 18:59:00 +0800 Subject: [PATCH 103/317] style: update various component styles for improved layout and readability - Adjusted color for list items in color.scss for better contrast. - Modified line-height and margins in markdown.scss for improved text readability. - Changed height property in FloatingSidebar.tsx for consistent layout. - Increased padding in AgentsPage.tsx for better spacing. - Updated padding and border-radius in Inputbar.tsx for enhanced aesthetics. - Reduced margin in MessageHeader.tsx for tighter layout. - Refactored GroupTitle styles in AssistantsTab.tsx for better alignment and spacing. --- src/renderer/src/assets/styles/color.scss | 2 +- src/renderer/src/assets/styles/markdown.scss | 5 ++-- .../src/components/Popups/FloatingSidebar.tsx | 5 +++- src/renderer/src/pages/agents/AgentsPage.tsx | 2 +- .../src/pages/home/Inputbar/Inputbar.tsx | 4 ++-- .../src/pages/home/Messages/MessageHeader.tsx | 2 +- .../src/pages/home/Tabs/AssistantsTab.tsx | 24 ++++++++++++------- 7 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 3f23425afc..224566e199 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -44,7 +44,7 @@ --color-reference-text: #ffffff; --color-reference-background: #0b0e12; - --color-list-item: #222; + --color-list-item: #252525; --color-list-item-hover: #1e1e1e; --modal-background: #111111; diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index b5c2ee17d1..19e10df41f 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -1,6 +1,6 @@ .markdown { color: var(--color-text); - line-height: 2; + line-height: 1.6; user-select: text; word-break: break-word; letter-spacing: 0.02em; @@ -21,7 +21,7 @@ h4, h5, h6 { - margin: 2em 0 1em 0; + margin: 1.5em 0 1em 0; line-height: 1.3; font-weight: bold; font-family: var(--font-family); @@ -60,6 +60,7 @@ margin: 1.3em 0; white-space: pre-wrap; text-align: justify; + line-height: 2; &:last-child { margin-bottom: 5px; diff --git a/src/renderer/src/components/Popups/FloatingSidebar.tsx b/src/renderer/src/components/Popups/FloatingSidebar.tsx index 1c281bc0d2..3fc464465e 100644 --- a/src/renderer/src/components/Popups/FloatingSidebar.tsx +++ b/src/renderer/src/components/Popups/FloatingSidebar.tsx @@ -54,7 +54,7 @@ const FloatingSidebar: FC = ({ style={{ background: 'transparent', border: 'none', - maxHeight: maxHeight + height: '100%' }} /> @@ -82,6 +82,9 @@ const FloatingSidebar: FC = ({ const PopoverContent = styled.div<{ maxHeight: number }>` max-height: ${(props) => props.maxHeight}px; + &.ant-popover-inner-content { + overflow-y: hidden; + } ` export default FloatingSidebar diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index 1a8e3ce990..dbcb7c5e57 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -266,7 +266,7 @@ const AgentsGroupList = styled(Scrollbar)` display: flex; flex-direction: column; gap: 8px; - padding: 8px 0; + padding: 12px 0; border-right: 0.5px solid var(--color-border); border-top-left-radius: inherit; border-bottom-left-radius: inherit; diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index a07f7b73b2..6bc5623b75 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -955,14 +955,14 @@ const Container = styled.div` flex-direction: column; position: relative; z-index: 2; - padding: 0 16px 16px 16px; + padding: 0 20px 18px 20px; ` const InputBarContainer = styled.div` border: 0.5px solid var(--color-border); transition: all 0.2s ease; position: relative; - border-radius: 15px; + border-radius: 20px; padding-top: 8px; // 为拖动手柄留出空间 background-color: var(--color-background-opacity); diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index ddeae08c27..d3f76232a9 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -126,7 +126,7 @@ const Container = styled.div` align-items: center; gap: 10px; position: relative; - margin-bottom: 8px; + margin-bottom: 5px; ` const UserWrap = styled.div` diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index a089bd536b..20e3456be6 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -6,7 +6,7 @@ import { useAssistants } from '@renderer/hooks/useAssistant' import { useAssistantsTabSortType } from '@renderer/hooks/useStore' import { useTags } from '@renderer/hooks/useTags' import { Assistant, AssistantsSortType } from '@renderer/types' -import { Divider, Tooltip } from 'antd' +import { Tooltip } from 'antd' import { FC, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -87,7 +87,7 @@ const Assistants: FC = ({ {group.tag} - + )} {!collapsedTags[group.tag] && ( @@ -198,13 +198,16 @@ const AssistantAddItem = styled.div` ` const GroupTitle = styled.div` - padding: 8px 0; - position: relative; color: var(--color-text-2); font-size: 12px; font-weight: 500; - margin-bottom: -8px; cursor: pointer; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + height: 24px; + margin: 5px 0; ` const GroupTitleName = styled.div` @@ -212,13 +215,18 @@ const GroupTitleName = styled.div` text-overflow: ellipsis; white-space: nowrap; overflow: hidden; - background-color: var(--color-background); box-sizing: border-box; padding: 0 4px; color: var(--color-text); - position: absolute; - transform: translateY(2px); font-size: 13px; + line-height: 24px; + margin-right: 5px; + display: flex; +` + +const GroupTitleDivider = styled.div` + flex: 1; + border-top: 1px solid var(--color-border); ` const AssistantName = styled.div` From db642f08372b70310efcc22d1330cf70a7f3a2df Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 10 Jul 2025 19:27:53 +0800 Subject: [PATCH 104/317] feat(models): support Grok4 (#8032) refactor(models): rename and enhance reasoning model functions for clarity and functionality --- src/renderer/src/config/models.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index f05a0447b3..c162dab6ea 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2487,7 +2487,7 @@ export function isGrokModel(model?: Model): boolean { return model.id.includes('grok') } -export function isGrokReasoningModel(model?: Model): boolean { +export function isSupportedReasoningEffortGrokModel(model?: Model): boolean { if (!model) { return false } @@ -2499,7 +2499,16 @@ export function isGrokReasoningModel(model?: Model): boolean { return false } -export const isSupportedReasoningEffortGrokModel = isGrokReasoningModel +export function isGrokReasoningModel(model?: Model): boolean { + if (!model) { + return false + } + if (isSupportedReasoningEffortGrokModel(model) || model.id.includes('grok-4')) { + return true + } + + return false +} export function isGeminiReasoningModel(model?: Model): boolean { if (!model) { From 2a72f391b71debb9b26b67319a1a602526e49bb9 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 19:32:51 +0800 Subject: [PATCH 105/317] feat: codeblock dot language (#6783) * feat(CodeBlock): support dot language in code block - render DOT using @viz-js/viz - highlight DOT using @viz-js/lang-dot (CodeEditor only) - extract a special view map, update file structure - extract and reuse the PreviewError component across special views - update dependencies, fix peer dependencies * chore: prepare for merge --- package.json | 4 + src/renderer/src/assets/styles/markdown.scss | 4 +- .../components/CodeBlockView/CodePreview.tsx | 8 +- .../CodeBlockView/GraphvizPreview.tsx | 102 +++++++++++++++ .../CodeBlockView/MermaidPreview.tsx | 22 +--- .../CodeBlockView/PlantUmlPreview.tsx | 13 +- .../components/CodeBlockView/PreviewError.tsx | 14 ++ .../components/CodeBlockView/SvgPreview.tsx | 11 +- .../src/components/CodeBlockView/constants.ts | 20 +++ .../src/components/CodeBlockView/index.ts | 2 + .../src/components/CodeBlockView/types.ts | 14 ++ .../CodeBlockView/{index.tsx => view.tsx} | 31 ++--- .../src/components/CodeEditor/hooks.ts | 122 ++++++++++++++---- .../src/context/CodeStyleProvider.tsx | 3 +- .../src/pages/home/Markdown/CodeBlock.tsx | 2 +- yarn.lock | 32 ++++- 16 files changed, 314 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx create mode 100644 src/renderer/src/components/CodeBlockView/PreviewError.tsx create mode 100644 src/renderer/src/components/CodeBlockView/constants.ts create mode 100644 src/renderer/src/components/CodeBlockView/index.ts create mode 100644 src/renderer/src/components/CodeBlockView/types.ts rename src/renderer/src/components/CodeBlockView/{index.tsx => view.tsx} (91%) diff --git a/package.json b/package.json index e56b1e261c..c718dce1a7 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31", + "@codemirror/view": "^6.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", @@ -141,6 +142,8 @@ "@vitest/coverage-v8": "^3.1.4", "@vitest/ui": "^3.1.4", "@vitest/web-worker": "^3.1.4", + "@viz-js/lang-dot": "^1.0.5", + "@viz-js/viz": "^3.14.0", "@xyflow/react": "^12.4.4", "antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch", "archiver": "^7.0.1", @@ -225,6 +228,7 @@ "tiny-pinyin": "^1.3.2", "tokenx": "^1.1.0", "typescript": "^5.6.2", + "unified": "^11.0.5", "uuid": "^10.0.0", "vite": "6.2.6", "vitest": "^3.1.4", diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 19e10df41f..e34f649129 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -129,9 +129,7 @@ overflow-x: auto; font-family: 'Fira Code', 'Courier New', Courier, monospace; background-color: var(--color-background-mute); - &:has(.mermaid), - &:has(.plantuml-preview), - &:has(.svg-preview) { + &:has(.special-preview) { background-color: transparent; } &:not(pre pre) { diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 3df9491e13..b4da2d4eee 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -1,4 +1,4 @@ -import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' +import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight' import { useSettings } from '@renderer/hooks/useSettings' @@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next' import { ThemedToken } from 'shiki/core' import styled from 'styled-components' -interface CodePreviewProps { - children: string +import { BasicPreviewProps } from './types' + +interface CodePreviewProps extends BasicPreviewProps { language: string - setTools?: (value: React.SetStateAction) => void } const MAX_COLLAPSE_HEIGHT = 350 diff --git a/src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx b/src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx new file mode 100644 index 0000000000..452ed1261b --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx @@ -0,0 +1,102 @@ +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' +import { AsyncInitializer } from '@renderer/utils/asyncInitializer' +import { Flex, Spin } from 'antd' +import { debounce } from 'lodash' +import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import styled from 'styled-components' + +import PreviewError from './PreviewError' +import { BasicPreviewProps } from './types' + +// 管理 viz 实例 +const vizInitializer = new AsyncInitializer(async () => { + const module = await import('@viz-js/viz') + return await module.instance() +}) + +/** 预览 Graphviz 图表 + * 通过防抖渲染提供比较统一的体验,减少闪烁。 + */ +const GraphvizPreview: React.FC = ({ children, setTools }) => { + const graphvizRef = useRef(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + // 使用通用图像工具 + const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, { + imgSelector: 'svg', + prefix: 'graphviz', + enableWheelZoom: true + }) + + // 使用工具栏 + usePreviewTools({ + setTools, + handleZoom, + handleCopyImage, + handleDownload + }) + + // 实际的渲染函数 + const renderGraphviz = useCallback(async (content: string) => { + if (!content || !graphvizRef.current) return + + try { + setIsLoading(true) + + const viz = await vizInitializer.get() + const svgElement = viz.renderSVGElement(content) + + // 清空容器并添加新的 SVG + graphvizRef.current.innerHTML = '' + graphvizRef.current.appendChild(svgElement) + + // 渲染成功,清除错误记录 + setError(null) + } catch (error) { + setError((error as Error).message || 'DOT syntax error or rendering failed') + } finally { + setIsLoading(false) + } + }, []) + + // debounce 渲染 + const debouncedRender = useMemo( + () => + debounce((content: string) => { + startTransition(() => renderGraphviz(content)) + }, 300), + [renderGraphviz] + ) + + // 触发渲染 + useEffect(() => { + if (children) { + setIsLoading(true) + debouncedRender(children) + } else { + debouncedRender.cancel() + setIsLoading(false) + } + + return () => { + debouncedRender.cancel() + } + }, [children, debouncedRender]) + + return ( + }> + + {error && {error}} + + + + ) +} + +const StyledGraphviz = styled.div` + overflow: auto; +` + +export default memo(GraphvizPreview) diff --git a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx index d461b2899c..be3e0b7e08 100644 --- a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx @@ -1,5 +1,5 @@ import { nanoid } from '@reduxjs/toolkit' -import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import { useMermaid } from '@renderer/hooks/useMermaid' import { Flex, Spin } from 'antd' @@ -7,16 +7,14 @@ import { debounce } from 'lodash' import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' -interface Props { - children: string - setTools?: (value: React.SetStateAction) => void -} +import PreviewError from './PreviewError' +import { BasicPreviewProps } from './types' /** 预览 Mermaid 图表 * 通过防抖渲染提供比较统一的体验,减少闪烁。 * FIXME: 等将来容易判断代码块结束位置时再重构。 */ -const MermaidPreview: React.FC = ({ children, setTools }) => { +const MermaidPreview: React.FC = ({ children, setTools }) => { const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid() const mermaidRef = useRef(null) const diagramId = useRef(`mermaid-${nanoid(6)}`).current @@ -143,7 +141,7 @@ const MermaidPreview: React.FC = ({ children, setTools }) => { return ( }> - {(mermaidError || error) && {mermaidError || error}} + {(mermaidError || error) && {mermaidError || error}} @@ -154,14 +152,4 @@ const StyledMermaid = styled.div` overflow: auto; ` -const StyledError = styled.div` - overflow: auto; - padding: 16px; - color: #ff4d4f; - border: 1px solid #ff4d4f; - border-radius: 4px; - word-wrap: break-word; - white-space: pre-wrap; -` - export default memo(MermaidPreview) diff --git a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx index 35ef90e12e..0916056039 100644 --- a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx @@ -1,11 +1,13 @@ import { LoadingOutlined } from '@ant-design/icons' -import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { Spin } from 'antd' import pako from 'pako' import React, { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { BasicPreviewProps } from './types' + const PlantUMLServer = 'https://www.plantuml.com/plantuml' function encode64(data: Uint8Array) { let r = '' @@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC = ({ format, diagr ) } -interface PlantUMLProps { - children: string - setTools?: (value: React.SetStateAction) => void -} - -const PlantUmlPreview: React.FC = ({ children, setTools }) => { +const PlantUmlPreview: React.FC = ({ children, setTools }) => { const { t } = useTranslation() const containerRef = useRef(null) @@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC = ({ children, setTools }) => { return (
- +
) } diff --git a/src/renderer/src/components/CodeBlockView/PreviewError.tsx b/src/renderer/src/components/CodeBlockView/PreviewError.tsx new file mode 100644 index 0000000000..1139dea7ff --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/PreviewError.tsx @@ -0,0 +1,14 @@ +import { memo } from 'react' +import { styled } from 'styled-components' + +const PreviewError = styled.div` + overflow: auto; + padding: 16px; + color: #ff4d4f; + border: 1px solid #ff4d4f; + border-radius: 4px; + word-wrap: break-word; + white-space: pre-wrap; +` + +export default memo(PreviewError) diff --git a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx index 9180aef297..fe60101519 100644 --- a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx @@ -1,15 +1,12 @@ -import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { memo, useEffect, useRef } from 'react' -interface Props { - children: string - setTools?: (value: React.SetStateAction) => void -} +import { BasicPreviewProps } from './types' /** * 使用 Shadow DOM 渲染 SVG */ -const SvgPreview: React.FC = ({ children, setTools }) => { +const SvgPreview: React.FC = ({ children, setTools }) => { const svgContainerRef = useRef(null) useEffect(() => { @@ -58,7 +55,7 @@ const SvgPreview: React.FC = ({ children, setTools }) => { handleDownload }) - return
+ return
} export default memo(SvgPreview) diff --git a/src/renderer/src/components/CodeBlockView/constants.ts b/src/renderer/src/components/CodeBlockView/constants.ts new file mode 100644 index 0000000000..fc6687d5f1 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/constants.ts @@ -0,0 +1,20 @@ +import GraphvizPreview from './GraphvizPreview' +import MermaidPreview from './MermaidPreview' +import PlantUmlPreview from './PlantUmlPreview' +import SvgPreview from './SvgPreview' + +/** + * 特殊视图语言列表 + */ +export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz'] + +/** + * 特殊视图组件映射表 + */ +export const SPECIAL_VIEW_COMPONENTS = { + mermaid: MermaidPreview, + plantuml: PlantUmlPreview, + svg: SvgPreview, + dot: GraphvizPreview, + graphviz: GraphvizPreview +} as const diff --git a/src/renderer/src/components/CodeBlockView/index.ts b/src/renderer/src/components/CodeBlockView/index.ts new file mode 100644 index 0000000000..dfb88d252d --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './view' diff --git a/src/renderer/src/components/CodeBlockView/types.ts b/src/renderer/src/components/CodeBlockView/types.ts new file mode 100644 index 0000000000..5ec413658f --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/types.ts @@ -0,0 +1,14 @@ +import { CodeTool } from '@renderer/components/CodeToolbar' + +/** + * 预览组件的基本 props + */ +export interface BasicPreviewProps { + children: string + setTools?: (value: React.SetStateAction) => void +} + +/** + * 视图模式 + */ +export type ViewMode = 'source' | 'special' | 'split' diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/view.tsx similarity index 91% rename from src/renderer/src/components/CodeBlockView/index.tsx rename to src/renderer/src/components/CodeBlockView/view.tsx index 0a03b68754..7d557d9247 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import CodePreview from './CodePreview' +import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants' import HtmlArtifactsCard from './HtmlArtifactsCard' -import MermaidPreview from './MermaidPreview' -import PlantUmlPreview from './PlantUmlPreview' import StatusBar from './StatusBar' -import SvgPreview from './SvgPreview' - -type ViewMode = 'source' | 'special' | 'split' +import { ViewMode } from './types' interface Props { children: string @@ -42,7 +39,7 @@ interface Props { * - quick 工具 * - core 工具 */ -const CodeBlockView: React.FC = ({ children, language, onSave }) => { +export const CodeBlockView: React.FC = memo(({ children, language, onSave }) => { const { t } = useTranslation() const { codeEditor, codeExecution } = useSettings() @@ -57,7 +54,7 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { return codeExecution.enabled && language === 'python' }, [codeExecution.enabled, language]) - const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language]) + const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language]) const isInSpecialView = useMemo(() => { return hasSpecialView && viewMode === 'special' @@ -201,14 +198,16 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { // 特殊视图组件映射 const specialView = useMemo(() => { - if (language === 'mermaid') { - return {children} - } else if (language === 'plantuml' && isValidPlantUML(children)) { - return {children} - } else if (language === 'svg') { - return {children} + const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS] + + if (!SpecialView) return null + + // PlantUML 语法验证 + if (language === 'plantuml' && !isValidPlantUML(children)) { + return null } - return null + + return {children} }, [children, language]) const renderHeader = useMemo(() => { @@ -242,7 +241,7 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { {isExecutable && output && {output}} ) -} +}) const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` position: relative; @@ -293,5 +292,3 @@ const SplitViewWrapper = styled.div` overflow: hidden; } ` - -export default memo(CodeBlockView) diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index 71d74ca3a5..5a04b2178d 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -12,45 +12,111 @@ const linterLoaders: Record Promise> = { } } +/** + * 特殊语言加载器 + */ +const specialLanguageLoaders: Record Promise> = { + dot: async () => { + const mod = await import('@viz-js/lang-dot') + return mod.dot() + } +} + +/** + * 加载语言扩展 + */ +async function loadLanguageExtension(language: string, languageMap: Record): Promise { + let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + + // 如果语言名包含 `-`,转换为驼峰命名法 + if (normalizedLang.includes('-')) { + normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) + } + + // 尝试加载特殊语言 + const specialLoader = specialLanguageLoaders[normalizedLang] + if (specialLoader) { + try { + return await specialLoader() + } catch (error) { + console.debug(`Failed to load language ${normalizedLang}`, error) + return null + } + } + + // 回退到 uiw/codemirror 包含的语言 + try { + const { loadLanguage } = await import('@uiw/codemirror-extensions-langs') + const extension = loadLanguage(normalizedLang as any) + return extension || null + } catch (error) { + console.debug(`Failed to load language ${normalizedLang}`, error) + return null + } +} + +/** + * 加载 linter 扩展 + */ +async function loadLinterExtension(language: string): Promise { + const loader = linterLoaders[language] + if (!loader) return null + + try { + return await loader() + } catch (error) { + console.debug(`Failed to load linter for ${language}`, error) + return null + } +} + +/** + * 加载语言相关扩展 + */ export const useLanguageExtensions = (language: string, lint?: boolean) => { const { languageMap } = useCodeStyle() const [extensions, setExtensions] = useState([]) - // 加载语言 useEffect(() => { - let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + let cancelled = false - // 如果语言名包含 `-`,转换为驼峰命名法 - if (normalizedLang.includes('-')) { - normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) - } + const loadAllExtensions = async () => { + try { + // 加载所有扩展 + const [languageResult, linterResult] = await Promise.allSettled([ + loadLanguageExtension(language, languageMap), + lint ? loadLinterExtension(language) : Promise.resolve(null) + ]) - import('@uiw/codemirror-extensions-langs') - .then(({ loadLanguage }) => { - const extension = loadLanguage(normalizedLang as any) - if (extension) { - setExtensions((prev) => [...prev, extension]) + if (cancelled) return + + const results: Extension[] = [] + + // 语言扩展 + if (languageResult.status === 'fulfilled' && languageResult.value) { + results.push(languageResult.value) } - }) - .catch((error) => { - console.debug(`Failed to load language: ${normalizedLang}`, error) - }) - }, [language, languageMap]) - useEffect(() => { - if (!lint) return + // linter 扩展 + if (linterResult.status === 'fulfilled' && linterResult.value) { + results.push(linterResult.value) + } - const loader = linterLoaders[language] - if (loader) { - loader() - .then((extension) => { - setExtensions((prev) => [...prev, extension]) - }) - .catch((error) => { - console.error(`Failed to load linter for ${language}`, error) - }) + setExtensions(results) + } catch (error) { + if (!cancelled) { + console.debug('Failed to load language extensions:', error) + setExtensions([]) + } + } } - }, [language, lint]) + + loadAllExtensions() + + return () => { + cancelled = true + } + }, [language, lint, languageMap]) return extensions } diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx index e702d4847d..c14364b7b0 100644 --- a/src/renderer/src/context/CodeStyleProvider.tsx +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -99,7 +99,8 @@ export const CodeStyleProvider: React.FC = ({ children }) => bash: 'shell', 'objective-c++': 'objective-cpp', svg: 'xml', - vab: 'vb' + vab: 'vb', + graphviz: 'dot' } as Record }, []) diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index f6a2905448..a496706cbb 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -1,4 +1,4 @@ -import CodeBlockView from '@renderer/components/CodeBlockView' +import { CodeBlockView } from '@renderer/components/CodeBlockView' import React, { memo, useCallback } from 'react' interface Props { diff --git a/yarn.lock b/yarn.lock index 3514bc0850..e2a45ec2ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3412,7 +3412,7 @@ __metadata: languageName: node linkType: hard -"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1": +"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.0.3, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1": version: 1.2.3 resolution: "@lezer/common@npm:1.2.3" checksum: 10c0/fe9f8e111080ef94037a34ca2af1221c8d01c1763ba5ecf708a286185c76119509a5d19d924c8842172716716ddce22d7834394670c4a9432f0ba9f3b7c0f50d @@ -3515,7 +3515,7 @@ __metadata: languageName: node linkType: hard -"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.4.0": +"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2": version: 1.4.2 resolution: "@lezer/lr@npm:1.4.2" dependencies: @@ -3578,7 +3578,7 @@ __metadata: languageName: node linkType: hard -"@lezer/xml@npm:^1.0.0": +"@lezer/xml@npm:^1.0.0, @lezer/xml@npm:^1.0.2": version: 1.0.6 resolution: "@lezer/xml@npm:1.0.6" dependencies: @@ -6962,6 +6962,26 @@ __metadata: languageName: node linkType: hard +"@viz-js/lang-dot@npm:^1.0.5": + version: 1.0.5 + resolution: "@viz-js/lang-dot@npm:1.0.5" + dependencies: + "@codemirror/language": "npm:^6.8.0" + "@lezer/common": "npm:^1.0.3" + "@lezer/highlight": "npm:^1.1.6" + "@lezer/lr": "npm:^1.4.2" + "@lezer/xml": "npm:^1.0.2" + checksum: 10c0/86e81bf077e0a6f418fe2d5cfd8d7f7a7c032bdec13e5dfe3d21620c548e674832f6c9b300eeaad7b0842a3c4044d4ce33d5af9e359ae1efeda0a84d772b77a4 + languageName: node + linkType: hard + +"@viz-js/viz@npm:^3.14.0": + version: 3.14.0 + resolution: "@viz-js/viz@npm:3.14.0" + checksum: 10c0/901afa2d99e8f33cc4abf352f1559e0c16958e01f0750a65a33799aebfe175a18d74f6945f1ff93f64b53b69976dc3d07d39d65c58dda955abd0979dacc4294c + languageName: node + linkType: hard + "@vue/compiler-core@npm:3.5.17": version: 3.5.17 resolution: "@vue/compiler-core@npm:3.5.17" @@ -7075,6 +7095,7 @@ __metadata: "@cherrystudio/embedjs-openai": "npm:^0.1.31" "@cherrystudio/mac-system-ocr": "npm:^0.2.2" "@cherrystudio/pdf-to-img-napi": "npm:^0.0.1" + "@codemirror/view": "npm:^6.0.0" "@electron-toolkit/eslint-config-prettier": "npm:^3.0.0" "@electron-toolkit/eslint-config-ts": "npm:^3.0.0" "@electron-toolkit/preload": "npm:^3.0.0" @@ -7127,6 +7148,8 @@ __metadata: "@vitest/coverage-v8": "npm:^3.1.4" "@vitest/ui": "npm:^3.1.4" "@vitest/web-worker": "npm:^3.1.4" + "@viz-js/lang-dot": "npm:^1.0.5" + "@viz-js/viz": "npm:^3.14.0" "@xyflow/react": "npm:^12.4.4" antd: "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch" archiver: "npm:^7.0.1" @@ -7221,6 +7244,7 @@ __metadata: tokenx: "npm:^1.1.0" turndown: "npm:7.2.0" typescript: "npm:^5.6.2" + unified: "npm:^11.0.5" uuid: "npm:^10.0.0" vite: "npm:6.2.6" vitest: "npm:^3.1.4" @@ -19565,7 +19589,7 @@ __metadata: languageName: node linkType: hard -"unified@npm:^11.0.0": +"unified@npm:^11.0.0, unified@npm:^11.0.5": version: 11.0.5 resolution: "unified@npm:11.0.5" dependencies: From 92be3c0f568d7e6cc67b8d1fa15751131bfbb90a Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 19:34:57 +0800 Subject: [PATCH 106/317] chore: update vscode settings (#7974) * chore: update vscode settings * refactor: add editorconfig to extensions --- .vscode/extensions.json | 2 +- .vscode/settings.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 940260d856..ef0b29b6a6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["dbaeumer.vscode-eslint"] + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index ef4dc3954a..edf514d5ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "source.fixAll.eslint": "explicit", "source.organizeImports": "never" }, + "files.eol": "\n", "search.exclude": { "**/dist/**": true, ".yarn/releases/**": true From 855499681f1425fea824e114d3fd18a2e9480867 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Thu, 10 Jul 2025 19:37:18 +0800 Subject: [PATCH 107/317] feat: add confirm for unsaved content in creating agent (#7965) --- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../pages/agents/components/AddAgentPopup.tsx | 35 ++++++++++++++++--- 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5f27ecefed..1d22b73878 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -13,6 +13,7 @@ "title": "Available variables" }, "add.title": "Create Agent", + "add.unsaved_changes_warning": "You have unsaved changes. Are you sure you want to close?", "delete.popup.content": "Are you sure you want to delete this agent?", "edit.model.select.title": "Select Model", "edit.title": "Edit Agent", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index a803e2a081..f76623aec0 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -13,6 +13,7 @@ "title": "利用可能な変数" }, "add.title": "エージェントを作成", + "add.unsaved_changes_warning": "未保存の変更があります。続行しますか?", "delete.popup.content": "このエージェントを削除してもよろしいですか?", "edit.model.select.title": "モデルを選択", "edit.title": "エージェントを編集", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 6fd32362a1..445a6c8cda 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -13,6 +13,7 @@ "title": "Доступные переменные" }, "add.title": "Создать агента", + "add.unsaved_changes_warning": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?", "delete.popup.content": "Вы уверены, что хотите удалить этого агента?", "edit.model.select.title": "Выбрать модель", "edit.title": "Редактировать агента", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index d8503085fd..6bfb3d71bf 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -13,6 +13,7 @@ "title": "可用的变量" }, "add.title": "创建智能体", + "add.unsaved_changes_warning": "你有未保存的内容,确定要关闭吗?", "delete.popup.content": "确定要删除此智能体吗?", "edit.model.select.title": "选择模型", "edit.title": "编辑智能体", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 55759eca01..480edf41e2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -13,6 +13,7 @@ "title": "可用的變數" }, "add.title": "建立智慧代理人", + "add.unsaved_changes_warning": "有未保存的變更,確定要關閉嗎?", "delete.popup.content": "確定要刪除此智慧代理人嗎?", "edit.model.select.title": "選擇模型", "edit.title": "編輯智慧代理人", diff --git a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx index 108052a701..c213614d35 100644 --- a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx +++ b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx @@ -41,6 +41,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const [showUndoButton, setShowUndoButton] = useState(false) const [originalPrompt, setOriginalPrompt] = useState('') const [tokenCount, setTokenCount] = useState(0) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const knowledgeState = useAppSelector((state) => state.knowledge) const showKnowledgeIcon = useSidebarIconShow('knowledge') const knowledgeOptions: SelectProps['options'] = [] @@ -92,8 +93,21 @@ const PopupContainer: React.FC = ({ resolve }) => { setOpen(false) } - const onCancel = () => { - setOpen(false) + const handleCancel = () => { + if (hasUnsavedChanges) { + window.modal.confirm({ + title: t('common.confirm'), + content: t('agents.add.unsaved_changes_warning'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk: () => { + setOpen(false) + } + }) + } else { + setOpen(false) + } } const onClose = () => { @@ -124,6 +138,7 @@ const PopupContainer: React.FC = ({ resolve }) => { form.setFieldsValue({ prompt: generatedText }) setShowUndoButton(true) setOriginalPrompt(content) + setHasUnsavedChanges(true) } catch (error) { console.error('Error fetching data:', error) } @@ -146,7 +161,7 @@ const PopupContainer: React.FC = ({ resolve }) => { title={t('agents.add.title')} open={open} onOk={() => formRef.current?.submit()} - onCancel={onCancel} + onCancel={handleCancel} maskClosable={false} afterClose={onClose} okText={t('agents.add.title')} @@ -167,9 +182,21 @@ const PopupContainer: React.FC = ({ resolve }) => { setTokenCount(count) setShowUndoButton(false) } + + const currentValues = form.getFieldsValue() + setHasUnsavedChanges(currentValues.name?.trim() || currentValues.prompt?.trim() || emoji) }}> - } arrow> + { + setEmoji(selectedEmoji) + setHasUnsavedChanges(true) + }} + /> + } + arrow> From 7c6db809bb294f3fad39b02cc7a401670a394da3 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:41:01 +0800 Subject: [PATCH 108/317] fix(SelectionAssistant): [macOS] show actionWindow on fullscreen app (#8004) * feat(SelectionService): enhance action window handling for macOS fullscreen mode - Updated processAction and showActionWindow methods to support fullscreen mode on macOS. - Added isFullScreen parameter to manage action window visibility and positioning. - Improved action window positioning logic to ensure it remains within screen boundaries. - Adjusted IPC channel to pass fullscreen state from the renderer to the service. - Updated SelectionToolbar to track fullscreen state and pass it to the action processing function. * chore(deps): update selection-hook to version 1.0.6 in package.json and yarn.lock * fix(SelectionService): improve macOS fullscreen handling and action window focus - Added app import to manage dock visibility on macOS. - Enhanced fullscreen handling logic to ensure the dock icon is restored correctly. - Updated action window focus behavior to prevent unintended hiding when blurred. - Refactored SelectionActionApp to streamline auto pinning logic and remove redundant useEffect. - Cleaned up SelectionToolbar by removing unnecessary window size updates when demo is false. * refactor(SelectionService): remove commented-out code for clarity * refactor(SelectionService): streamline macOS handling and improve code clarity --- package.json | 2 +- src/main/services/SelectionService.ts | 235 +++++++++++------- src/preload/index.ts | 3 +- .../selection/action/SelectionActionApp.tsx | 22 +- .../selection/toolbar/SelectionToolbar.tsx | 8 +- yarn.lock | 10 +- 6 files changed, 177 insertions(+), 103 deletions(-) diff --git a/package.json b/package.json index c718dce1a7..b5073b4b2c 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", "pdfjs-dist": "4.10.38", - "selection-hook": "^1.0.5", + "selection-hook": "^1.0.6", "turndown": "7.2.0" }, "devDependencies": { diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 3be2d5a95a..718025cd89 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1,7 +1,7 @@ import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig' import { isDev, isMac, isWin } from '@main/constant' import { IpcChannel } from '@shared/IpcChannel' -import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron' +import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron' import Logger from 'electron-log' import { join } from 'path' import type { @@ -509,54 +509,55 @@ export class SelectionService { //should set every time the window is shown this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver') - // [macOS] a series of hacky ways only for macOS - if (isMac) { - // [macOS] a hacky way - // when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing - // so we just don't set `skipTransformProcessType: true` when in self app - const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName) - - if (!isSelf) { - // [macOS] an ugly hacky way - // `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces` - // so we set `focusable: true` before showing, and then set false after showing - this.toolbarWindow!.setFocusable(false) - - // [macOS] - // force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again - // set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces` - this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { - visibleOnFullScreen: true, - skipTransformProcessType: true - }) - } - - // [macOS] MUST use `showInactive()` to prevent other windows bring to front together - // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` - this.toolbarWindow!.showInactive() - - // [macOS] restore the focusable status - this.toolbarWindow!.setFocusable(true) - + if (!isMac) { + this.toolbarWindow!.show() + /** + * [Windows] + * In Windows 10, setOpacity(1) will make the window completely transparent + * It's a strange behavior, so we don't use it for compatibility + */ + // this.toolbarWindow!.setOpacity(1) this.startHideByMouseKeyListener() - return } - /** - * The following is for Windows - */ + /************************************************ + * [macOS] the following code is only for macOS + * + * WARNING: + * DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!! + *************************************************/ - this.toolbarWindow!.show() + // [macOS] a hacky way + // when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing + // so we just don't set `skipTransformProcessType: true` when in self app + const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName) - /** - * [Windows] - * In Windows 10, setOpacity(1) will make the window completely transparent - * It's a strange behavior, so we don't use it for compatibility - */ - // this.toolbarWindow!.setOpacity(1) + if (!isSelf) { + // [macOS] an ugly hacky way + // `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces` + // so we set `focusable: true` before showing, and then set false after showing + this.toolbarWindow!.setFocusable(false) + + // [macOS] + // force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again + // set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces` + this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true + }) + } + + // [macOS] MUST use `showInactive()` to prevent other windows bring to front together + // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` + this.toolbarWindow!.showInactive() + + // [macOS] restore the focusable status + this.toolbarWindow!.setFocusable(true) this.startHideByMouseKeyListener() + + return } /** @@ -911,6 +912,7 @@ export class SelectionService { refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } } + // [macOS] isFullscreen is only available on macOS this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName) this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData) } @@ -1218,20 +1220,26 @@ export class SelectionService { return actionWindow } - public processAction(actionItem: ActionItem): void { + /** + * Process action item + * @param actionItem Action item to process + * @param isFullScreen [macOS] only macOS has the available isFullscreen mode + */ + public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void { const actionWindow = this.popActionWindow() actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem) - this.showActionWindow(actionWindow) + this.showActionWindow(actionWindow, isFullScreen) } /** * Show action window with proper positioning relative to toolbar * Ensures window stays within screen boundaries * @param actionWindow Window to position and show + * @param isFullScreen [macOS] only macOS has the available isFullscreen mode */ - private showActionWindow(actionWindow: BrowserWindow): void { + private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void { let actionWindowWidth = this.ACTION_WINDOW_WIDTH let actionWindowHeight = this.ACTION_WINDOW_HEIGHT @@ -1241,11 +1249,14 @@ export class SelectionService { actionWindowHeight = this.lastActionWindowSize.height } - //center way - if (!this.isFollowToolbar || !this.toolbarWindow) { - const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) - const workArea = display.workArea + /******************************************** + * Setting the position of the action window + ********************************************/ + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + const workArea = display.workArea + // Center of the screen + if (!this.isFollowToolbar || !this.toolbarWindow) { const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2 const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2 @@ -1255,54 +1266,107 @@ export class SelectionService { x: Math.round(centerX), y: Math.round(centerY) }) + } else { + // Follow toolbar position + const toolbarBounds = this.toolbarWindow!.getBounds() + const GAP = 6 // 6px gap from screen edges + //make sure action window is inside screen + if (actionWindowWidth > workArea.width - 2 * GAP) { + actionWindowWidth = workArea.width - 2 * GAP + } + + if (actionWindowHeight > workArea.height - 2 * GAP) { + actionWindowHeight = workArea.height - 2 * GAP + } + + // Calculate initial position to center action window horizontally below toolbar + let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2) + let posY = Math.round(toolbarBounds.y) + + // Ensure action window stays within screen boundaries with a small gap + if (posX + actionWindowWidth > workArea.x + workArea.width) { + posX = workArea.x + workArea.width - actionWindowWidth - GAP + } else if (posX < workArea.x) { + posX = workArea.x + GAP + } + if (posY + actionWindowHeight > workArea.y + workArea.height) { + // If window would go below screen, try to position it above toolbar + posY = workArea.y + workArea.height - actionWindowHeight - GAP + } else if (posY < workArea.y) { + posY = workArea.y + GAP + } + + actionWindow.setPosition(posX, posY, false) + //KEY to make window not resize + actionWindow.setBounds({ + width: actionWindowWidth, + height: actionWindowHeight, + x: posX, + y: posY + }) + } + + if (!isMac) { actionWindow.show() - return } - //follow toolbar - const toolbarBounds = this.toolbarWindow!.getBounds() - const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) - const workArea = display.workArea - const GAP = 6 // 6px gap from screen edges + /************************************************ + * [macOS] the following code is only for macOS + * + * WARNING: + * DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!! + *************************************************/ - //make sure action window is inside screen - if (actionWindowWidth > workArea.width - 2 * GAP) { - actionWindowWidth = workArea.width - 2 * GAP + // act normally when the app is not in fullscreen mode + if (!isFullScreen) { + actionWindow.show() + return } - if (actionWindowHeight > workArea.height - 2 * GAP) { - actionWindowHeight = workArea.height - 2 * GAP - } + // [macOS] an UGLY HACKY way for fullscreen override settings - // Calculate initial position to center action window horizontally below toolbar - let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2) - let posY = Math.round(toolbarBounds.y) + // FIXME sometimes the dock will be shown when the action window is shown + // FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown + // FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app + // use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled - // Ensure action window stays within screen boundaries with a small gap - if (posX + actionWindowWidth > workArea.x + workArea.width) { - posX = workArea.x + workArea.width - actionWindowWidth - GAP - } else if (posX < workArea.x) { - posX = workArea.x + GAP - } - if (posY + actionWindowHeight > workArea.y + workArea.height) { - // If window would go below screen, try to position it above toolbar - posY = workArea.y + workArea.height - actionWindowHeight - GAP - } else if (posY < workArea.y) { - posY = workArea.y + GAP - } + // setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled) + actionWindow.setFocusable(false) + actionWindow.setAlwaysOnTop(true, 'floating') - actionWindow.setPosition(posX, posY, false) - //KEY to make window not resize - actionWindow.setBounds({ - width: actionWindowWidth, - height: actionWindowHeight, - x: posX, - y: posY + // `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared + // just store the dock icon status, and show it again + const isDockShown = app.dock?.isVisible() + + // DO NOT set `skipTransformProcessType: true`, + // it will cause the action window to be shown on other space + actionWindow.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true }) - actionWindow.show() + actionWindow.showInactive() + + // show the dock again if last time it was shown + // do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled + if (!app.dock?.isVisible() && isDockShown) { + app.dock?.show() + } + + // unset everything + setTimeout(() => { + actionWindow.setVisibleOnAllWorkspaces(false, { + visibleOnFullScreen: true, + skipTransformProcessType: true + }) + actionWindow.setAlwaysOnTop(false) + + actionWindow.setFocusable(true) + + // regain the focus when all the works done + actionWindow.focus() + }, 50) } public closeActionWindow(actionWindow: BrowserWindow): void { @@ -1408,8 +1472,9 @@ export class SelectionService { configManager.setSelectionAssistantFilterList(filterList) }) - ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => { - selectionService?.processAction(actionItem) + // [macOS] only macOS has the available isFullscreen mode + ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => { + selectionService?.processAction(actionItem, isFullScreen) }) ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index ea1a2897f9..5221761dfb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -309,7 +309,8 @@ const api = { ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize), setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode), setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList), - processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem), + processAction: (actionItem: ActionItem, isFullScreen: boolean = false) => + ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen), closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose), minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) diff --git a/src/renderer/src/windows/selection/action/SelectionActionApp.tsx b/src/renderer/src/windows/selection/action/SelectionActionApp.tsx index 94c1c575ea..5112caf945 100644 --- a/src/renderer/src/windows/selection/action/SelectionActionApp.tsx +++ b/src/renderer/src/windows/selection/action/SelectionActionApp.tsx @@ -36,10 +36,6 @@ const SelectionActionApp: FC = () => { const lastScrollHeight = useRef(0) useEffect(() => { - if (isAutoPin) { - window.api.selection.pinActionWindow(true) - } - const actionListenRemover = window.electron?.ipcRenderer.on( IpcChannel.Selection_UpdateActionData, (_, actionItem: ActionItem) => { @@ -60,6 +56,20 @@ const SelectionActionApp: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + if (isAutoPin) { + window.api.selection.pinActionWindow(true) + setIsPinned(true) + } else if (!isActionLoaded.current) { + window.api.selection.pinActionWindow(false) + setIsPinned(false) + } + }, [isAutoPin]) + + useEffect(() => { + shouldCloseWhenBlur.current = isAutoClose && !isPinned + }, [isAutoClose, isPinned]) + useEffect(() => { i18n.changeLanguage(language || navigator.language || defaultLanguage) }, [language]) @@ -100,10 +110,6 @@ const SelectionActionApp: FC = () => { } }, [action, t]) - useEffect(() => { - shouldCloseWhenBlur.current = isAutoClose && !isPinned - }, [isAutoClose, isPinned]) - useEffect(() => { //if the action is loaded, we should not set the opacity update from settings if (!isActionLoaded.current) { diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 49b3c2fcf9..342e4122a7 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -107,6 +107,8 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { }, [actionItems]) const selectedText = useRef('') + // [macOS] only macOS has the fullscreen mode + const isFullScreen = useRef(false) // listen to selectionService events useEffect(() => { @@ -115,6 +117,7 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { IpcChannel.Selection_TextSelected, (_, selectionData: TextSelectionData) => { selectedText.current = selectionData.text + isFullScreen.current = selectionData.isFullscreen ?? false setTimeout(() => { //make sure the animation is active setAnimateKey((prev) => prev + 1) @@ -133,8 +136,6 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } ) - if (!demo) updateWindowSize() - return () => { textSelectionListenRemover() toolbarVisibilityChangeListenRemover() @@ -234,7 +235,8 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } const handleDefaultAction = (action: ActionItem) => { - window.api?.selection.processAction(action) + // [macOS] only macOS has the available isFullscreen mode + window.api?.selection.processAction(action, isFullScreen.current) window.api?.selection.hideToolbar() } diff --git a/yarn.lock b/yarn.lock index e2a45ec2ac..bbaadab77c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7235,7 +7235,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^1.0.5" + selection-hook: "npm:^1.0.6" shiki: "npm:^3.7.0" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" @@ -18229,14 +18229,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^1.0.5": - version: 1.0.5 - resolution: "selection-hook@npm:1.0.5" +"selection-hook@npm:^1.0.6": + version: 1.0.6 + resolution: "selection-hook@npm:1.0.6" dependencies: node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/d188e2bafa6d820779e57a721bd2480dc1fde3f9daa2e3f92f1b69712637079e5fd9443575bc8624c98a057608f867d82fb2abf2d0796777db1f18ea50ea0028 + checksum: 10c0/c7d28db51fc16b5648530344cbe1d5b72a7469cfb7edbb9c56d7be4bea2d93ddd01993fb27b344e44865f9eb0f3211b1be638caaacd0f9165b2bc03bada7c360 languageName: node linkType: hard From ba742b7b1fd900186b3ead99dce256480b676fb5 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 10 Jul 2025 21:34:01 +0800 Subject: [PATCH 109/317] feat: save to knowledge (#7528) * feat: save to knowledge * refactor: simplify checkbox * feat(i18n): add 'Save to Local File' translation key for multiple languages --------- Co-authored-by: suyao --- .../Popups/SaveToKnowledgePopup.tsx | 353 ++++++++++++++++++ src/renderer/src/i18n/locales/en-us.json | 29 ++ src/renderer/src/i18n/locales/ja-jp.json | 31 +- src/renderer/src/i18n/locales/ru-ru.json | 31 +- src/renderer/src/i18n/locales/zh-cn.json | 29 ++ src/renderer/src/i18n/locales/zh-tw.json | 33 +- .../pages/home/Messages/MessageMenubar.tsx | 32 +- src/renderer/src/utils/knowledge.ts | 269 +++++++++++++ 8 files changed, 794 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx create mode 100644 src/renderer/src/utils/knowledge.ts diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx new file mode 100644 index 0000000000..b6f0577c4f --- /dev/null +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -0,0 +1,353 @@ +import CustomTag from '@renderer/components/CustomTag' +import { TopView } from '@renderer/components/TopView' +import Logger from '@renderer/config/logger' +import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { Message } from '@renderer/types/newMessage' +import { + analyzeMessageContent, + CONTENT_TYPES, + ContentType, + MessageContentStats, + processMessageContent +} from '@renderer/utils/knowledge' +import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd' +import { Check, CircleHelp } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const { Text } = Typography + +// 内容类型配置 +const CONTENT_TYPE_CONFIG = { + [CONTENT_TYPES.TEXT]: { + label: 'chat.save.knowledge.content.maintext.title', + description: 'chat.save.knowledge.content.maintext.description' + }, + [CONTENT_TYPES.CODE]: { + label: 'chat.save.knowledge.content.code.title', + description: 'chat.save.knowledge.content.code.description' + }, + [CONTENT_TYPES.THINKING]: { + label: 'chat.save.knowledge.content.thinking.title', + description: 'chat.save.knowledge.content.thinking.description' + }, + [CONTENT_TYPES.TOOL_USE]: { + label: 'chat.save.knowledge.content.tool_use.title', + description: 'chat.save.knowledge.content.tool_use.description' + }, + [CONTENT_TYPES.CITATION]: { + label: 'chat.save.knowledge.content.citation.title', + description: 'chat.save.knowledge.content.citation.description' + }, + [CONTENT_TYPES.TRANSLATION]: { + label: 'chat.save.knowledge.content.translation.title', + description: 'chat.save.knowledge.content.translation.description' + }, + [CONTENT_TYPES.ERROR]: { + label: 'chat.save.knowledge.content.error.title', + description: 'chat.save.knowledge.content.error.description' + }, + [CONTENT_TYPES.FILE]: { + label: 'chat.save.knowledge.content.file.title', + description: 'chat.save.knowledge.content.file.description' + } +} as const + +// Tag 颜色常量 +const TAG_COLORS = { + SELECTED: '#008001', + UNSELECTED: '#8c8c8c' +} as const + +interface ContentTypeOption { + type: ContentType + label: string + count: number + enabled: boolean + description?: string +} + +interface ShowParams { + message: Message + title?: string +} + +interface SaveResult { + success: boolean + savedCount: number +} + +interface Props extends ShowParams { + resolve: (data: SaveResult | null) => void +} + +const PopupContainer: React.FC = ({ message, title, resolve }) => { + const [open, setOpen] = useState(true) + const [loading, setLoading] = useState(false) + const [selectedBaseId, setSelectedBaseId] = useState() + const [selectedTypes, setSelectedTypes] = useState([]) + const [hasInitialized, setHasInitialized] = useState(false) + const { bases } = useKnowledgeBases() + const { addNote, addFiles } = useKnowledge(selectedBaseId || '') + const { t } = useTranslation() + + // 分析消息内容统计 + const contentStats = useMemo(() => analyzeMessageContent(message), [message]) + + // 生成内容类型选项(只显示有内容的类型) + const contentTypeOptions: ContentTypeOption[] = useMemo(() => { + return Object.entries(CONTENT_TYPE_CONFIG) + .map(([type, config]) => { + const contentType = type as ContentType + const count = contentStats[contentType as keyof MessageContentStats] || 0 + return { + type: contentType, + count, + enabled: count > 0, + label: t(config.label), + description: t(config.description) + } + }) + .filter((option) => option.enabled) // 只显示有内容的类型 + }, [contentStats, t]) + + // 知识库选项 + const knowledgeBaseOptions = useMemo( + () => + bases.map((base) => ({ + label: base.name, + value: base.id, + disabled: !base.version // 如果知识库没有配置好就禁用 + })), + [bases] + ) + + // 合并状态计算 + const formState = useMemo(() => { + const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version + const hasContent = contentTypeOptions.length > 0 + const selectedCount = contentTypeOptions + .filter((option) => selectedTypes.includes(option.type)) + .reduce((sum, option) => sum + option.count, 0) + + return { + hasValidBase, + hasContent, + canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent, + selectedCount, + hasNoSelection: selectedTypes.length === 0 && hasContent + } + }, [selectedBaseId, bases, contentTypeOptions, selectedTypes]) + + // 默认选择第一个可用的知识库 + useEffect(() => { + if (!selectedBaseId) { + const firstAvailableBase = bases.find((base) => base.version) + if (firstAvailableBase) { + setSelectedBaseId(firstAvailableBase.id) + } + } + }, [bases, selectedBaseId]) + + // 默认选择所有可用的内容类型(仅在初始化时) + useEffect(() => { + if (!hasInitialized && contentTypeOptions.length > 0) { + const availableTypes = contentTypeOptions.map((option) => option.type) + setSelectedTypes(availableTypes) + setHasInitialized(true) + } + }, [contentTypeOptions, hasInitialized]) + + // 计算UI状态 + const uiState = useMemo(() => { + if (!formState.hasContent) { + return { type: 'empty', message: t('chat.save.knowledge.empty.no_content') } + } + if (bases.length === 0) { + return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') } + } + return { type: 'form' } + }, [formState.hasContent, bases.length, t]) + + // 处理内容类型选择切换 + const handleContentTypeToggle = (type: ContentType) => { + setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type])) + } + + const onOk = async () => { + if (!formState.canSubmit) { + return + } + + setLoading(true) + let savedCount = 0 + + try { + const result = processMessageContent(message, selectedTypes) + + // 保存文本内容 + if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) { + await addNote(result.text) + savedCount++ + } + + // 保存文件 + if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) { + addFiles(result.files) + savedCount += result.files.length + } + + setOpen(false) + resolve({ success: true, savedCount }) + } catch (error) { + Logger.error('[SaveToKnowledgePopup] save failed:', error) + window.message.error(t('chat.save.knowledge.error.save_failed')) + setLoading(false) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + // 渲染空状态 + const renderEmptyState = () => ( + + {uiState.message} + + ) + + // 渲染表单内容 + const renderFormContent = () => ( + <> +
+ + onSyncIntervalChange(value as number)} + onChange={onSyncIntervalChange} disabled={!localBackupDir} - style={{ minWidth: 120 }}> - {t('settings.data.local.autoSync.off')} - {t('settings.data.local.minute_interval', { count: 1 })} - {t('settings.data.local.minute_interval', { count: 5 })} - {t('settings.data.local.minute_interval', { count: 15 })} - {t('settings.data.local.minute_interval', { count: 30 })} - {t('settings.data.local.hour_interval', { count: 1 })} - {t('settings.data.local.hour_interval', { count: 2 })} - {t('settings.data.local.hour_interval', { count: 6 })} - {t('settings.data.local.hour_interval', { count: 12 })} - {t('settings.data.local.hour_interval', { count: 24 })} - + options={[ + { label: t('settings.data.local.autoSync.off'), value: 0 }, + { label: t('settings.data.local.minute_interval', { count: 1 }), value: 1 }, + { label: t('settings.data.local.minute_interval', { count: 5 }), value: 5 }, + { label: t('settings.data.local.minute_interval', { count: 15 }), value: 15 }, + { label: t('settings.data.local.minute_interval', { count: 30 }), value: 30 }, + { label: t('settings.data.local.hour_interval', { count: 1 }), value: 60 }, + { label: t('settings.data.local.hour_interval', { count: 2 }), value: 120 }, + { label: t('settings.data.local.hour_interval', { count: 6 }), value: 360 }, + { label: t('settings.data.local.hour_interval', { count: 12 }), value: 720 }, + { label: t('settings.data.local.hour_interval', { count: 24 }), value: 1440 } + ]} + /> {t('settings.data.local.maxBackups')} - + options={[ + { label: t('settings.data.local.maxBackups.unlimited'), value: 0 }, + { label: '1', value: 1 }, + { label: '3', value: 3 }, + { label: '5', value: 5 }, + { label: '10', value: 10 }, + { label: '20', value: 20 }, + { label: '50', value: 50 } + ]} + /> diff --git a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx index c261c5b736..c24d67cd44 100644 --- a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx @@ -2,6 +2,7 @@ import { FolderOpenOutlined, InfoCircleOutlined, SaveOutlined, SyncOutlined, War import { HStack } from '@renderer/components/Layout' import { S3BackupManager } from '@renderer/components/S3BackupManager' import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals' +import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useSettings } from '@renderer/hooks/useSettings' @@ -9,7 +10,7 @@ import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setS3Partial } from '@renderer/store/settings' import { S3Config } from '@renderer/types' -import { Button, Input, Select, Switch, Tooltip } from 'antd' +import { Button, Input, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -54,9 +55,9 @@ const S3Settings: FC = () => { setSyncInterval(value) dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 })) if (value === 0) { - stopAutoSync() + stopAutoSync('s3') } else { - startAutoSync() + startAutoSync(false, 's3') } } @@ -211,39 +212,43 @@ const S3Settings: FC = () => { {t('settings.data.s3.autoSync')} - + options={[ + { label: t('settings.data.s3.autoSync.off'), value: 0 }, + { label: t('settings.data.s3.autoSync.minute', { count: 1 }), value: 1 }, + { label: t('settings.data.s3.autoSync.minute', { count: 5 }), value: 5 }, + { label: t('settings.data.s3.autoSync.minute', { count: 15 }), value: 15 }, + { label: t('settings.data.s3.autoSync.minute', { count: 30 }), value: 30 }, + { label: t('settings.data.s3.autoSync.hour', { count: 1 }), value: 60 }, + { label: t('settings.data.s3.autoSync.hour', { count: 2 }), value: 120 }, + { label: t('settings.data.s3.autoSync.hour', { count: 6 }), value: 360 }, + { label: t('settings.data.s3.autoSync.hour', { count: 12 }), value: 720 }, + { label: t('settings.data.s3.autoSync.hour', { count: 24 }), value: 1440 } + ]} + /> {t('settings.data.s3.maxBackups')} - + options={[ + { label: t('settings.data.s3.maxBackups.unlimited'), value: 0 }, + { label: '1', value: 1 }, + { label: '3', value: 3 }, + { label: '5', value: 5 }, + { label: '10', value: 10 }, + { label: '20', value: 20 }, + { label: '50', value: 50 } + ]} + /> diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 977c5c1329..7f42c9fe30 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -59,10 +59,10 @@ const WebDavSettings: FC = () => { dispatch(_setWebdavSyncInterval(value)) if (value === 0) { dispatch(setWebdavAutoSync(false)) - stopAutoSync() + stopAutoSync('webdav') } else { dispatch(setWebdavAutoSync(true)) - startAutoSync() + startAutoSync(false, 'webdav') } } diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index bf5fea3c5a..9e594f85c2 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -444,85 +444,144 @@ export async function restoreFromS3(fileName?: string) { }) const data = JSON.parse(restoreData) await handleData(data) - store.dispatch( - setS3SyncState({ - lastSyncTime: Date.now(), - syncing: false, - lastSyncError: null - }) - ) } } -let autoSyncStarted = false -let syncTimeout: NodeJS.Timeout | null = null -let isAutoBackupRunning = false let isManualBackupRunning = false -export function startAutoSync(immediate = false) { - if (autoSyncStarted) { - return - } +// 为每种备份类型维护独立的状态 +let webdavAutoSyncStarted = false +let webdavSyncTimeout: NodeJS.Timeout | null = null +let isWebdavAutoBackupRunning = false - const settings = store.getState().settings - const { webdavAutoSync, webdavHost } = settings - const s3Settings = settings.s3 +let s3AutoSyncStarted = false +let s3SyncTimeout: NodeJS.Timeout | null = null +let isS3AutoBackupRunning = false - const s3AutoSync = s3Settings?.autoSync - const s3Endpoint = s3Settings?.endpoint +let localAutoSyncStarted = false +let localSyncTimeout: NodeJS.Timeout | null = null +let isLocalAutoBackupRunning = false - const localBackupAutoSync = settings.localBackupAutoSync - const localBackupDir = settings.localBackupDir - - // 检查WebDAV或S3自动同步配置 - const hasWebdavConfig = webdavAutoSync && webdavHost - const hasS3Config = s3AutoSync && s3Endpoint - const hasLocalConfig = localBackupAutoSync && localBackupDir - - if (!hasWebdavConfig && !hasS3Config && !hasLocalConfig) { - Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') - return - } - - autoSyncStarted = true - - stopAutoSync() - - scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime') - - /** - * @param type 'immediate' | 'fromLastSyncTime' | 'fromNow' - * 'immediate', first backup right now - * 'fromLastSyncTime', schedule next backup from last sync time - * 'fromNow', schedule next backup from now - */ - function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') { - if (syncTimeout) { - clearTimeout(syncTimeout) - syncTimeout = null - } +type BackupType = 'webdav' | 's3' | 'local' +export function startAutoSync(immediate = false, type?: BackupType) { + // 如果没有指定类型,启动所有配置的自动同步 + if (!type) { const settings = store.getState().settings - const _webdavSyncInterval = settings.webdavSyncInterval - const _s3SyncInterval = settings.s3?.syncInterval - const { webdavSync, s3Sync } = store.getState().backup + const { webdavAutoSync, webdavHost, localBackupAutoSync, localBackupDir } = settings + const s3Settings = settings.s3 - // 使用当前激活的同步配置 - const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval - const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime + if (webdavAutoSync && webdavHost) { + startAutoSync(immediate, 'webdav') + } + if (s3Settings?.autoSync && s3Settings?.endpoint) { + startAutoSync(immediate, 's3') + } + if (localBackupAutoSync && localBackupDir) { + startAutoSync(immediate, 'local') + } + return + } - if (!syncInterval || syncInterval <= 0) { - Logger.log('[AutoSync] Invalid sync interval, auto sync disabled') - stopAutoSync() + // 根据类型启动特定的自动同步 + if (type === 'webdav') { + if (webdavAutoSyncStarted) { return } - // 用户指定的自动备份时间间隔(毫秒) - const requiredInterval = syncInterval * 60 * 1000 + const settings = store.getState().settings + const { webdavAutoSync, webdavHost } = settings - let timeUntilNextSync = 1000 //also immediate - switch (type) { - case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间 + if (!webdavAutoSync || !webdavHost) { + Logger.log('[WebdavAutoSync] Invalid sync settings, auto sync disabled') + return + } + + webdavAutoSyncStarted = true + stopAutoSync('webdav') + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime', 'webdav') + } else if (type === 's3') { + if (s3AutoSyncStarted) { + return + } + + const settings = store.getState().settings + const s3Settings = settings.s3 + + if (!s3Settings?.autoSync || !s3Settings?.endpoint) { + Logger.log('[S3AutoSync] Invalid sync settings, auto sync disabled') + return + } + + s3AutoSyncStarted = true + stopAutoSync('s3') + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime', 's3') + } else if (type === 'local') { + if (localAutoSyncStarted) { + return + } + + const settings = store.getState().settings + const { localBackupAutoSync, localBackupDir } = settings + + if (!localBackupAutoSync || !localBackupDir) { + Logger.log('[LocalAutoSync] Invalid sync settings, auto sync disabled') + return + } + + localAutoSyncStarted = true + stopAutoSync('local') + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime', 'local') + } + + function scheduleNextBackup(scheduleType: 'immediate' | 'fromLastSyncTime' | 'fromNow', backupType: BackupType) { + let syncInterval: number + let lastSyncTime: number | undefined + let logPrefix: string + + // 根据备份类型获取相应的配置和状态 + const settings = store.getState().settings + const backup = store.getState().backup + + if (backupType === 'webdav') { + if (webdavSyncTimeout) { + clearTimeout(webdavSyncTimeout) + webdavSyncTimeout = null + } + syncInterval = settings.webdavSyncInterval + lastSyncTime = backup.webdavSync?.lastSyncTime || undefined + logPrefix = '[WebdavAutoSync]' + } else if (backupType === 's3') { + if (s3SyncTimeout) { + clearTimeout(s3SyncTimeout) + s3SyncTimeout = null + } + syncInterval = settings.s3?.syncInterval || 0 + lastSyncTime = backup.s3Sync?.lastSyncTime || undefined + logPrefix = '[S3AutoSync]' + } else if (backupType === 'local') { + if (localSyncTimeout) { + clearTimeout(localSyncTimeout) + localSyncTimeout = null + } + syncInterval = settings.localBackupSyncInterval + lastSyncTime = backup.localBackupSync?.lastSyncTime || undefined + logPrefix = '[LocalAutoSync]' + } else { + return + } + + if (!syncInterval || syncInterval <= 0) { + Logger.log(`${logPrefix} Invalid sync interval, auto sync disabled`) + stopAutoSync(backupType) + return + } + + const requiredInterval = syncInterval * 60 * 1000 + let timeUntilNextSync = 1000 + + switch (scheduleType) { + case 'fromLastSyncTime': timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now()) break case 'fromNow': @@ -530,33 +589,64 @@ export function startAutoSync(immediate = false) { break } - syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + const timeout = setTimeout(() => performAutoBackup(backupType), timeUntilNextSync) + + // 保存对应类型的 timeout + if (backupType === 'webdav') { + webdavSyncTimeout = timeout + } else if (backupType === 's3') { + s3SyncTimeout = timeout + } else if (backupType === 'local') { + localSyncTimeout = timeout + } - const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' Logger.log( - `[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + `${logPrefix} Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( (timeUntilNextSync / 1000) % 60 )} seconds` ) } - async function performAutoBackup() { - if (isAutoBackupRunning || isManualBackupRunning) { - Logger.log('[AutoSync] Backup already in progress, rescheduling') - scheduleNextBackup() + async function performAutoBackup(backupType: BackupType) { + let isRunning: boolean + let logPrefix: string + + if (backupType === 'webdav') { + isRunning = isWebdavAutoBackupRunning + logPrefix = '[WebdavAutoSync]' + } else if (backupType === 's3') { + isRunning = isS3AutoBackupRunning + logPrefix = '[S3AutoSync]' + } else if (backupType === 'local') { + isRunning = isLocalAutoBackupRunning + logPrefix = '[LocalAutoSync]' + } else { return } - isAutoBackupRunning = true + if (isRunning || isManualBackupRunning) { + Logger.log(`${logPrefix} Backup already in progress, rescheduling`) + scheduleNextBackup('fromNow', backupType) + return + } + + // 设置运行状态 + if (backupType === 'webdav') { + isWebdavAutoBackupRunning = true + } else if (backupType === 's3') { + isS3AutoBackupRunning = true + } else if (backupType === 'local') { + isLocalAutoBackupRunning = true + } + const maxRetries = 4 let retryCount = 0 while (retryCount < maxRetries) { try { - const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' - Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`) + Logger.log(`${logPrefix} Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) - if (hasWebdavConfig) { + if (backupType === 'webdav') { await backupToWebdav({ autoBackupProcess: true }) store.dispatch( setWebDAVSyncState({ @@ -565,7 +655,7 @@ export function startAutoSync(immediate = false) { syncing: false }) ) - } else if (hasS3Config) { + } else if (backupType === 's3') { await backupToS3({ autoBackupProcess: true }) store.dispatch( setS3SyncState({ @@ -574,19 +664,34 @@ export function startAutoSync(immediate = false) { syncing: false }) ) + } else if (backupType === 'local') { + await backupToLocal({ autoBackupProcess: true }) + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) } - isAutoBackupRunning = false - scheduleNextBackup() + // 重置运行状态 + if (backupType === 'webdav') { + isWebdavAutoBackupRunning = false + } else if (backupType === 's3') { + isS3AutoBackupRunning = false + } else if (backupType === 'local') { + isLocalAutoBackupRunning = false + } + scheduleNextBackup('fromNow', backupType) break } catch (error: any) { retryCount++ if (retryCount === maxRetries) { - const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' - Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error) + Logger.error(`${logPrefix} Auto backup failed after all retries:`, error) - if (hasWebdavConfig) { + if (backupType === 'webdav') { store.dispatch( setWebDAVSyncState({ lastSyncError: 'Auto backup failed', @@ -594,7 +699,7 @@ export function startAutoSync(immediate = false) { syncing: false }) ) - } else if (hasS3Config) { + } else if (backupType === 's3') { store.dispatch( setS3SyncState({ lastSyncError: 'Auto backup failed', @@ -602,26 +707,49 @@ export function startAutoSync(immediate = false) { syncing: false }) ) + } else if (backupType === 'local') { + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) } - //only show 1 time error modal, and autoback stopped until user click ok await window.modal.error({ title: i18n.t('message.backup.failed'), - content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message + content: `${logPrefix} ${new Date().toLocaleString()} ` + error.message }) - scheduleNextBackup('fromNow') - isAutoBackupRunning = false + scheduleNextBackup('fromNow', backupType) + + // 重置运行状态 + if (backupType === 'webdav') { + isWebdavAutoBackupRunning = false + } else if (backupType === 's3') { + isS3AutoBackupRunning = false + } else if (backupType === 'local') { + isLocalAutoBackupRunning = false + } } else { - //Exponential Backoff with Base 2: 7s、17s、37s const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000 - Logger.log(`[AutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) + Logger.log(`${logPrefix} Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) await new Promise((resolve) => setTimeout(resolve, backoffDelay)) - //in case auto backup is stopped by user - if (!isAutoBackupRunning) { - Logger.log('[AutoSync] retry cancelled by user, exit') + // 检查是否被用户停止 + let currentRunning: boolean + if (backupType === 'webdav') { + currentRunning = isWebdavAutoBackupRunning + } else if (backupType === 's3') { + currentRunning = isS3AutoBackupRunning + } else { + currentRunning = isLocalAutoBackupRunning + } + + if (!currentRunning) { + Logger.log(`${logPrefix} retry cancelled by user, exit`) break } } @@ -630,14 +758,40 @@ export function startAutoSync(immediate = false) { } } -export function stopAutoSync() { - if (syncTimeout) { - Logger.log('[AutoSync] Stopping auto sync') - clearTimeout(syncTimeout) - syncTimeout = null +export function stopAutoSync(type?: BackupType) { + // 如果没有指定类型,停止所有自动同步 + if (!type) { + stopAutoSync('webdav') + stopAutoSync('s3') + stopAutoSync('local') + return + } + + if (type === 'webdav') { + if (webdavSyncTimeout) { + Logger.log('[WebdavAutoSync] Stopping auto sync') + clearTimeout(webdavSyncTimeout) + webdavSyncTimeout = null + } + isWebdavAutoBackupRunning = false + webdavAutoSyncStarted = false + } else if (type === 's3') { + if (s3SyncTimeout) { + Logger.log('[S3AutoSync] Stopping auto sync') + clearTimeout(s3SyncTimeout) + s3SyncTimeout = null + } + isS3AutoBackupRunning = false + s3AutoSyncStarted = false + } else if (type === 'local') { + if (localSyncTimeout) { + Logger.log('[LocalAutoSync] Stopping auto sync') + clearTimeout(localSyncTimeout) + localSyncTimeout = null + } + isLocalAutoBackupRunning = false + localAutoSyncStarted = false } - isAutoBackupRunning = false - autoSyncStarted = false } export async function getBackupData() { @@ -727,7 +881,7 @@ async function clearDatabase() { /** * Backup to local directory */ -export async function backupToLocalDir({ +export async function backupToLocal({ showMessage = false, customFileName = '', autoBackupProcess = false @@ -812,10 +966,31 @@ export async function backupToLocalDir({ Logger.error('[LocalBackup] Failed to clean up old backups:', error) } } + } else { + if (autoBackupProcess) { + throw new Error(i18n.t('message.backup.failed')) + } + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: 'Backup failed' + }) + ) + + if (showMessage) { + window.modal.error({ + title: i18n.t('message.backup.failed'), + content: 'Backup failed' + }) + } } return result } catch (error: any) { + if (autoBackupProcess) { + throw error + } + Logger.error('[LocalBackup] Backup failed:', error) store.dispatch( @@ -845,157 +1020,18 @@ export async function backupToLocalDir({ } } -export async function restoreFromLocalBackup(fileName: string) { +export async function restoreFromLocal(fileName: string) { + const { localBackupDir } = store.getState().settings + try { - const { localBackupDir } = store.getState().settings - await window.api.backup.restoreFromLocalBackup(fileName, localBackupDir) + const restoreData = await window.api.backup.restoreFromLocalBackup(fileName, localBackupDir) + const data = JSON.parse(restoreData) + await handleData(data) + return true } catch (error) { Logger.error('[LocalBackup] Restore failed:', error) + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) throw error } } - -// Local backup auto sync -let localBackupAutoSyncStarted = false -let localBackupSyncTimeout: NodeJS.Timeout | null = null -let isLocalBackupAutoRunning = false - -export function startLocalBackupAutoSync(immediate = false) { - if (localBackupAutoSyncStarted) { - return - } - - const { localBackupAutoSync, localBackupDir } = store.getState().settings - - if (!localBackupAutoSync || !localBackupDir) { - Logger.log('[LocalBackupAutoSync] Invalid sync settings, auto sync disabled') - return - } - - localBackupAutoSyncStarted = true - - stopLocalBackupAutoSync() - - scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime') - - /** - * @param type 'immediate' | 'fromLastSyncTime' | 'fromNow' - * 'immediate', first backup right now - * 'fromLastSyncTime', schedule next backup from last sync time - * 'fromNow', schedule next backup from now - */ - function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') { - if (localBackupSyncTimeout) { - clearTimeout(localBackupSyncTimeout) - localBackupSyncTimeout = null - } - - const { localBackupSyncInterval } = store.getState().settings - const { localBackupSync } = store.getState().backup - - if (localBackupSyncInterval <= 0) { - Logger.log('[LocalBackupAutoSync] Invalid sync interval, auto sync disabled') - stopLocalBackupAutoSync() - return - } - - // User specified auto backup interval (milliseconds) - const requiredInterval = localBackupSyncInterval * 60 * 1000 - - let timeUntilNextSync = 1000 // immediate by default - switch (type) { - case 'fromLastSyncTime': // If last sync time exists, use it as reference - timeUntilNextSync = Math.max(1000, (localBackupSync?.lastSyncTime || 0) + requiredInterval - Date.now()) - break - case 'fromNow': - timeUntilNextSync = requiredInterval - break - } - - localBackupSyncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) - - Logger.log( - `[LocalBackupAutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( - (timeUntilNextSync / 1000) % 60 - )} seconds` - ) - } - - async function performAutoBackup() { - if (isLocalBackupAutoRunning || isManualBackupRunning) { - Logger.log('[LocalBackupAutoSync] Backup already in progress, rescheduling') - scheduleNextBackup() - return - } - - isLocalBackupAutoRunning = true - const maxRetries = 4 - let retryCount = 0 - - while (retryCount < maxRetries) { - try { - Logger.log(`[LocalBackupAutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) - - await backupToLocalDir({ autoBackupProcess: true }) - - store.dispatch( - setLocalBackupSyncState({ - lastSyncError: null, - lastSyncTime: Date.now(), - syncing: false - }) - ) - - isLocalBackupAutoRunning = false - scheduleNextBackup() - - break - } catch (error: any) { - retryCount++ - if (retryCount === maxRetries) { - Logger.error('[LocalBackupAutoSync] Auto backup failed after all retries:', error) - - store.dispatch( - setLocalBackupSyncState({ - lastSyncError: 'Auto backup failed', - lastSyncTime: Date.now(), - syncing: false - }) - ) - - // Only show error modal once and wait for user acknowledgment - await window.modal.error({ - title: i18n.t('message.backup.failed'), - content: `[Local Backup Auto Backup] ${new Date().toLocaleString()} ` + error.message - }) - - scheduleNextBackup('fromNow') - isLocalBackupAutoRunning = false - } else { - // Exponential Backoff with Base 2: 7s, 17s, 37s - const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000 - Logger.log(`[LocalBackupAutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) - - await new Promise((resolve) => setTimeout(resolve, backoffDelay)) - - // Check if auto backup was stopped by user - if (!isLocalBackupAutoRunning) { - Logger.log('[LocalBackupAutoSync] retry cancelled by user, exit') - break - } - } - } - } - } -} - -export function stopLocalBackupAutoSync() { - if (localBackupSyncTimeout) { - Logger.log('[LocalBackupAutoSync] Stopping auto sync') - clearTimeout(localBackupSyncTimeout) - localBackupSyncTimeout = null - } - isLocalBackupAutoRunning = false - localBackupAutoSyncStarted = false -} diff --git a/src/renderer/src/services/MessagesService.ts b/src/renderer/src/services/MessagesService.ts index 615103a11d..25b822cd21 100644 --- a/src/renderer/src/services/MessagesService.ts +++ b/src/renderer/src/services/MessagesService.ts @@ -248,7 +248,7 @@ export async function getMessageTitle(message: Message, length = 30): Promise Date: Fri, 11 Jul 2025 22:50:13 +0800 Subject: [PATCH 132/317] refactor: improve environment variable handling in electron.vite.config.ts - Introduced `isDev` and `isProd` constants for clearer environment checks. - Simplified sourcemap and noDiscovery settings based on environment. - Enhanced esbuild configuration for production to drop console and debugger statements. --- electron.vite.config.ts | 42 +++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2b4c5e6b92..b867f4e989 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,6 +8,9 @@ const visualizerPlugin = (type: 'renderer' | 'main') => { return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] } +const isDev = process.env.NODE_ENV === 'development' +const isProd = process.env.NODE_ENV === 'production' + export default defineConfig({ main: { plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')], @@ -21,17 +24,21 @@ export default defineConfig({ build: { rollupOptions: { external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr'], - output: { - // 彻底禁用代码分割 - 返回 null 强制单文件打包 - manualChunks: undefined, - // 内联所有动态导入,这是关键配置 - inlineDynamicImports: true - } + output: isProd + ? { + manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包 + inlineDynamicImports: true // 内联所有动态导入,这是关键配置 + } + : {} }, - sourcemap: process.env.NODE_ENV === 'development' + sourcemap: isDev + }, + esbuild: { + drop: ['console', 'debugger'], + legalComments: 'none' }, optimizeDeps: { - noDiscovery: process.env.NODE_ENV === 'development' + noDiscovery: isDev } }, preload: { @@ -42,7 +49,7 @@ export default defineConfig({ } }, build: { - sourcemap: process.env.NODE_ENV === 'development' + sourcemap: isDev } }, renderer: { @@ -60,14 +67,7 @@ export default defineConfig({ ] ] }), - // 只在开发环境下启用 CodeInspectorPlugin - ...(process.env.NODE_ENV === 'development' - ? [ - CodeInspectorPlugin({ - bundler: 'vite' - }) - ] - : []), + ...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin ...visualizerPlugin('renderer') ], resolve: { @@ -95,6 +95,12 @@ export default defineConfig({ selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html') } } - } + }, + esbuild: isProd + ? { + drop: ['console', 'debugger'], + legalComments: 'none' + } + : {} } }) From bea664af0ff07ef003371633a3b5fd7571b10a62 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 11 Jul 2025 22:36:45 +0800 Subject: [PATCH 133/317] refactor: simplify HtmlArtifactsPopup component and improve preview functionality - Removed unnecessary extracted components and integrated their logic directly into HtmlArtifactsPopup. - Enhanced preview functionality with a debounced update mechanism for HTML content. - Updated styling for better layout and responsiveness, including fullscreen handling. - Adjusted view mode management for clearer code structure and improved user experience. --- .../CodeBlockView/HtmlArtifactsCard.tsx | 300 +++++-------- .../CodeBlockView/HtmlArtifactsPopup.tsx | 410 +++++++----------- .../src/pages/home/Messages/MessageHeader.tsx | 2 +- .../ProviderSettings/EditModelsPopup.tsx | 19 +- 4 files changed, 282 insertions(+), 449 deletions(-) diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index 68d75da9fd..b3389570c1 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -11,10 +11,100 @@ import styled, { keyframes } from 'styled-components' import HtmlArtifactsPopup from './HtmlArtifactsPopup' +const HTML_VOID_ELEMENTS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr' +]) + +const HTML_COMPLETION_PATTERNS = [ + /<\/html\s*>/i, + //i, + /<\/div\s*>/i, + /<\/script\s*>/i, + /<\/style\s*>/i +] + interface Props { html: string } +function hasUnmatchedTags(html: string): boolean { + const stack: string[] = [] + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g + let match + + while ((match = tagRegex.exec(html)) !== null) { + const [fullTag, tagName] = match + const isClosing = fullTag.startsWith('') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase()) + + if (isSelfClosing) continue + + if (isClosing) { + if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { + return true + } + } else { + stack.push(tagName.toLowerCase()) + } + } + + return stack.length > 0 +} + +function checkIsStreaming(html: string): boolean { + if (!html?.trim()) return false + + const trimmed = html.trim() + + // 快速检查:如果有明显的完成标志,直接返回false + for (const pattern of HTML_COMPLETION_PATTERNS) { + if (pattern.test(trimmed)) { + // 特殊情况:同时有DOCTYPE和 + if (trimmed.includes('/i.test(trimmed)) { + return false + } + // 如果只是以结尾,也认为是完成的 + if (/<\/html\s*>$/i.test(trimmed)) { + return false + } + } + } + + // 检查未完成的标志 + const hasIncompleteTag = /<[^>]*$/.test(trimmed) + const hasUnmatched = hasUnmatchedTags(trimmed) + + if (hasIncompleteTag || hasUnmatched) return true + + // 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成 + const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed) + if (!hasStructureTags && trimmed.length < 500) { + return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed)) + } + + return false +} + +const getTerminalStyles = (theme: ThemeMode) => ({ + background: theme === 'dark' ? '#1e1e1e' : '#f0f0f0', + color: theme === 'dark' ? '#cccccc' : '#333333', + promptColor: theme === 'dark' ? '#00ff00' : '#007700' +}) + const HtmlArtifactsCard: FC = ({ html }) => { const { t } = useTranslation() const title = extractTitle(html) || 'HTML Artifacts' @@ -23,151 +113,20 @@ const HtmlArtifactsCard: FC = ({ html }) => { const htmlContent = html || '' const hasContent = htmlContent.trim().length > 0 + const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent]) - // 判断是否正在流式生成的逻辑 - const isStreaming = useMemo(() => { - if (!hasContent) return false - - const trimmedHtml = htmlContent.trim() - - // 提前检查:如果包含关键的结束标签,直接判断为完整文档 - if (/<\/html\s*>/i.test(trimmedHtml)) { - return false - } - - // 如果同时包含 DOCTYPE 和 ,通常也是完整文档 - if (//i.test(trimmedHtml)) { - return false - } - - // 检查 HTML 是否看起来是完整的 - const indicators = { - // 1. 检查常见的 HTML 结构完整性 - hasHtmlTag: /]*>/i.test(trimmedHtml), - hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml), - - // 2. 检查 body 标签完整性 - hasBodyTag: /]*>/i.test(trimmedHtml), - hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml), - - // 3. 检查是否以未闭合的标签结尾 - endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml), - - // 4. 检查是否有未配对的标签 - hasUnmatchedTags: checkUnmatchedTags(trimmedHtml), - - // 5. 检查是否以常见的"流式结束"模式结尾 - endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml) - } - - // 如果有明显的未完成标志,则认为正在生成 - if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) { - return true - } - - // 如果有 HTML 结构但不完整 - if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) { - return true - } - - // 如果有 body 结构但不完整 - if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) { - return true - } - - // 对于简单的 HTML 片段,检查是否看起来是完整的 - if (!indicators.hasHtmlTag && !indicators.hasBodyTag) { - // 如果是简单片段且没有明显的结束标志,可能还在生成 - return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500 - } - - return false - }, [htmlContent, hasContent]) - - // 检查未配对标签的辅助函数 - function checkUnmatchedTags(html: string): boolean { - const stack: string[] = [] - const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g - - // HTML5 void 元素(自闭合元素)的完整列表 - const voidElements = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr' - ] - - let match - - while ((match = tagRegex.exec(html)) !== null) { - const [fullTag, tagName] = match - const isClosing = fullTag.startsWith('') || voidElements.includes(tagName.toLowerCase()) - - if (isSelfClosing) continue - - if (isClosing) { - if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) { - return true // 找到不匹配的闭合标签 - } - } else { - stack.push(tagName.toLowerCase()) - } - } - - return stack.length > 0 // 还有未闭合的标签 - } - - // 获取格式化的代码预览 - function getFormattedCodePreview(html: string): string { - const trimmed = html.trim() - const lines = trimmed.split('\n') - const lastFewLines = lines.slice(-3) // 显示最后3行 - return lastFewLines.join('\n') - } - - /** - * 在编辑器中打开 - */ - const handleOpenInEditor = () => { - setIsPopupOpen(true) - } - - /** - * 关闭弹窗 - */ - const handleClosePopup = () => { - setIsPopupOpen(false) - } - - /** - * 外部链接打开 - */ const handleOpenExternal = async () => { const path = await window.api.file.createTempFile('artifacts-preview.html') await window.api.file.write(path, htmlContent) const filePath = `file://${path}` - if (window.api.shell && window.api.shell.openExternal) { + if (window.api.shell?.openExternal) { window.api.shell.openExternal(filePath) } else { console.error(t('artifacts.preview.openExternal.error.content')) } } - /** - * 下载到本地 - */ const handleDownload = async () => { const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html` await window.api.file.save(fileName, htmlContent) @@ -202,27 +161,27 @@ const HtmlArtifactsCard: FC = ({ html }) => { $ - {getFormattedCodePreview(htmlContent)} + {htmlContent.trim().split('\n').slice(-3).join('\n')} - ) : ( - - - @@ -230,21 +189,11 @@ const HtmlArtifactsCard: FC = ({ html }) => { - {/* 弹窗组件 */} - + setIsPopupOpen(false)} /> ) } -const shimmer = keyframes` - 0% { - background-position: -200px 0; - } - 100% { - background-position: calc(200px + 100%) 0; - } -` - const Container = styled.div<{ $isStreaming: boolean }>` background: var(--color-background); border: 1px solid var(--color-border); @@ -274,21 +223,7 @@ const Header = styled.div` padding: 20px 24px 16px; background: var(--color-background-soft); border-bottom: 1px solid var(--color-border); - position: relative; border-radius: 8px 8px 0 0; - - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4); - background-size: 200% 100%; - animation: ${shimmer} 3s ease-in-out infinite; - border-radius: 8px 8px 0 0; - } ` const IconWrapper = styled.div<{ $isStreaming: boolean }>` @@ -297,18 +232,15 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>` justify-content: center; width: 40px; height: 40px; - background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + background: ${(props) => + props.$isStreaming + ? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' + : 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'}; border-radius: 12px; color: white; - box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); + box-shadow: ${(props) => + props.$isStreaming ? '0 4px 6px -1px rgba(245, 158, 11, 0.3)' : '0 4px 6px -1px rgba(59, 130, 246, 0.3)'}; transition: background 0.3s ease; - - ${(props) => - props.$isStreaming && - ` - background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */ - box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3); - `} ` const TitleSection = styled.div` @@ -346,7 +278,7 @@ const Content = styled.div` ` const ButtonContainer = styled.div` - margin: 16px !important; + margin: 10px 16px !important; display: flex; flex-direction: row; gap: 8px; @@ -354,7 +286,7 @@ const ButtonContainer = styled.div` const TerminalPreview = styled.div<{ $theme: ThemeMode }>` margin: 16px; - background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; + background: ${(props) => getTerminalStyles(props.$theme).background}; border-radius: 8px; overflow: hidden; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; @@ -362,8 +294,8 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>` const TerminalContent = styled.div<{ $theme: ThemeMode }>` padding: 12px; - background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')}; - color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + background: ${(props) => getTerminalStyles(props.$theme).background}; + color: ${(props) => getTerminalStyles(props.$theme).color}; font-size: 13px; line-height: 1.4; min-height: 80px; @@ -379,25 +311,27 @@ const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>` flex: 1; white-space: pre-wrap; word-break: break-word; - color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')}; + color: ${(props) => getTerminalStyles(props.$theme).color}; background-color: transparent !important; ` const TerminalPrompt = styled.span<{ $theme: ThemeMode }>` - color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; + color: ${(props) => getTerminalStyles(props.$theme).promptColor}; font-weight: bold; flex-shrink: 0; ` +const blinkAnimation = keyframes` + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +` + const TerminalCursor = styled.span<{ $theme: ThemeMode }>` display: inline-block; width: 2px; height: 16px; - background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')}; - animation: ${keyframes` - 0%, 50% { opacity: 1; } - 51%, 100% { opacity: 0; } - `} 1s infinite; + background: ${(props) => getTerminalStyles(props.$theme).promptColor}; + animation: ${blinkAnimation} 1s infinite; margin-left: 2px; ` diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 59988a0b1e..5e491c4052 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,9 +1,9 @@ import CodeEditor from '@renderer/components/CodeEditor' -import { isMac } from '@renderer/config/constant' +import { isLinux, isMac, isWin } from '@renderer/config/constant' import { classNames } from '@renderer/utils' import { Button, Modal } from 'antd' import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -16,140 +16,41 @@ interface HtmlArtifactsPopupProps { type ViewMode = 'split' | 'code' | 'preview' -// 视图模式配置 -const VIEW_MODE_CONFIG = { - split: { - key: 'split' as const, - icon: MonitorSpeaker, - i18nKey: 'html_artifacts.split' - }, - code: { - key: 'code' as const, - icon: Code, - i18nKey: 'html_artifacts.code' - }, - preview: { - key: 'preview' as const, - icon: Monitor, - i18nKey: 'html_artifacts.preview' - } -} as const - -// 抽取头部组件 -interface ModalHeaderProps { - title: string - isFullscreen: boolean - viewMode: ViewMode - onViewModeChange: (mode: ViewMode) => void - onToggleFullscreen: () => void - onCancel: () => void -} - -const ModalHeaderComponent: React.FC = ({ - title, - isFullscreen, - viewMode, - onViewModeChange, - onToggleFullscreen, - onCancel -}) => { +const HtmlArtifactsPopup: React.FC = ({ open, title, html, onClose }) => { const { t } = useTranslation() + const [viewMode, setViewMode] = useState('split') + const [currentHtml, setCurrentHtml] = useState(html) + const [isFullscreen, setIsFullscreen] = useState(false) - const viewButtons = useMemo(() => { - return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => ( - } - onClick={() => onViewModeChange(key)}> - {t(i18nKey)} - - )) - }, [viewMode, onViewModeChange, t]) - - return ( - - - {title} - - - {viewButtons} - - -