From ce642f17d954bac2f1161f899f1fc85353d05d1b Mon Sep 17 00:00:00 2001 From: Tristan Zhang Date: Thu, 9 Oct 2025 13:20:40 +0800 Subject: [PATCH 1/8] fix: layout for antrophic api tips (#10579) * fix: layout for antrophic api tips * lint --- .../pages/settings/ProviderSettings/ProviderSetting.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 208716edd9..e5d86bd5ac 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -467,14 +467,13 @@ const ProviderSetting: FC = ({ providerId }) => { onBlur={onUpdateAnthropicHost} /> - - + + {t('settings.provider.anthropic_api_host_preview', { url: anthropicHostPreview || '—' })} - + {t('settings.provider.anthropic_api_host_tip')} From 654f19eaa913ed55e8f171062bbb5ea0170f6f46 Mon Sep 17 00:00:00 2001 From: Tristan Zhang Date: Thu, 9 Oct 2025 13:37:07 +0800 Subject: [PATCH 2/8] fix: change the url for qwen (#10584) --- src/renderer/src/config/minapps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index e11718bc62..5ffb5bfe76 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -145,7 +145,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ { id: 'dashscope', name: i18n.t('minapps.qwen'), - url: 'https://tongyi.aliyun.com/qianwen/', + url: 'https://www.tongyi.com/', logo: QwenModelLogo }, { From 62774b34d3cb48a132e3212340df4ac50bd17f2b Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 9 Oct 2025 15:58:24 +0800 Subject: [PATCH 3/8] feat: add updating dialog in render (#10569) * feat: replace update dialog handling with quit and install functionality * refactor: remove App_ShowUpdateDialog and implement App_QuitAndInstall in IpcChannel * update ipc.ts to handle quit and install action * modify AppUpdater to include quitAndInstall method * adjust preload index to invoke new quit and install action * enhance AboutSettings to manage update dialog state and trigger quit and install * fix(AboutSettings): handle null update info in update dialog state management * fix(UpdateDialog): improve error handling during update installation and enhance release notes processing * fix(AppUpdater): remove redundant assignment of releaseInfo after update download * fix(IpcChannel): remove UpdateDownloadedCancelled enum value * format code * fix(UpdateDialog): enhance installation process with loading state and error handling * update i18n * fix(i18n): Auto update translations for PR #10569 * feat(UpdateAppButton): integrate UpdateDialog and update button functionality for better user experience * fix(UpdateDialog): update installation handler to support async operation and ensure modal closes after installation * refactor(AppUpdater.test): remove deprecated formatReleaseNotes tests to streamline test suite * refactor(update-dialog): simplify dialog close handling Replace onOpenChange with onClose prop to directly handle dialog closing Remove redundant handleClose function and simplify button onPress handler --------- Co-authored-by: GitHub Action Co-authored-by: icarus --- packages/shared/IpcChannel.ts | 3 +- src/main/ipc.ts | 2 +- src/main/services/AppUpdater.ts | 69 +----------- .../services/__tests__/AppUpdater.test.ts | 42 -------- src/preload/index.ts | 2 +- src/renderer/src/components/UpdateDialog.tsx | 101 ++++++++++++++++++ src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + .../pages/home/components/UpdateAppButton.tsx | 7 +- .../src/pages/settings/AboutSettings.tsx | 15 ++- 17 files changed, 135 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/components/UpdateDialog.tsx diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index bad43b1b7e..27f35f9840 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -5,8 +5,8 @@ export enum IpcChannel { App_SetLanguage = 'app:set-language', App_SetEnableSpellCheck = 'app:set-enable-spell-check', App_SetSpellCheckLanguages = 'app:set-spell-check-languages', - App_ShowUpdateDialog = 'app:show-update-dialog', App_CheckForUpdate = 'app:check-for-update', + App_QuitAndInstall = 'app:quit-and-install', App_Reload = 'app:reload', App_Quit = 'app:quit', App_Info = 'app:info', @@ -234,7 +234,6 @@ export enum IpcChannel { // events BackupProgress = 'backup-progress', ThemeUpdated = 'theme:updated', - UpdateDownloadedCancelled = 'update-downloaded-cancelled', RestoreProgress = 'restore-progress', UpdateError = 'update-error', UpdateAvailable = 'update-available', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c148f60124..514fa18cec 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -142,7 +142,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url)) // Update - ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow)) + ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall()) // language ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => { diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 66b88bce84..ec0f1b97e0 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,17 +1,15 @@ import { loggerService } from '@logger' import { isWin } from '@main/constant' import { getIpCountry } from '@main/utils/ipService' -import { locales } from '@main/utils/locales' import { generateUserAgent } from '@main/utils/systemInfo' import { FeedUrl, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { CancellationToken, UpdateInfo } from 'builder-util-runtime' -import { app, BrowserWindow, dialog, net } from 'electron' +import { app, net } from 'electron' import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' import path from 'path' import semver from 'semver' -import icon from '../../../build/icon.png?asset' import { configManager } from './ConfigManager' import { windowService } from './WindowService' @@ -26,7 +24,6 @@ const LANG_MARKERS = { export default class AppUpdater { autoUpdater: _AppUpdater = autoUpdater - private releaseInfo: UpdateInfo | undefined private cancellationToken: CancellationToken = new CancellationToken() private updateCheckResult: UpdateCheckResult | null = null @@ -66,7 +63,6 @@ export default class AppUpdater { autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => { const processedReleaseInfo = this.processReleaseInfo(releaseInfo) windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo) - this.releaseInfo = processedReleaseInfo logger.info('update downloaded', processedReleaseInfo) }) @@ -247,37 +243,9 @@ export default class AppUpdater { } } - public async showUpdateDialog(mainWindow: BrowserWindow) { - if (!this.releaseInfo) { - return - } - const locale = locales[configManager.getLanguage()] - const { update: updateLocale } = locale.translation - - let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes) - if (detail === '') { - detail = updateLocale.noReleaseNotes - } - - dialog - .showMessageBox({ - type: 'info', - title: updateLocale.title, - icon, - message: updateLocale.message.replace('{{version}}', this.releaseInfo.version), - detail, - buttons: [updateLocale.later, updateLocale.install], - defaultId: 1, - cancelId: 0 - }) - .then(({ response }) => { - if (response === 1) { - app.isQuitting = true - setImmediate(() => autoUpdater.quitAndInstall()) - } else { - mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled) - } - }) + public quitAndInstall() { + app.isQuitting = true + setImmediate(() => autoUpdater.quitAndInstall()) } /** @@ -349,38 +317,9 @@ export default class AppUpdater { return processedInfo } - - /** - * Format release notes for display - * @param releaseNotes - Release notes in various formats - * @returns Formatted string for display - */ - private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string { - if (!releaseNotes) { - return '' - } - - if (typeof releaseNotes === 'string') { - // Check if it contains multi-language markers - if (this.hasMultiLanguageMarkers(releaseNotes)) { - return this.parseMultiLangReleaseNotes(releaseNotes) - } - return releaseNotes - } - - if (Array.isArray(releaseNotes)) { - return releaseNotes.map((note) => note.note).join('\n') - } - - return '' - } } interface GithubReleaseInfo { draft: boolean prerelease: boolean tag_name: string } -interface ReleaseNoteInfo { - readonly version: string - readonly note: string | null -} diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index bb6a7827cb..4b3ac70d45 100644 --- a/src/main/services/__tests__/AppUpdater.test.ts +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -274,46 +274,4 @@ describe('AppUpdater', () => { expect(result.releaseNotes).toBeNull() }) }) - - describe('formatReleaseNotes', () => { - it('should format string release notes with markers', () => { - vi.mocked(configManager.getLanguage).mockReturnValue('en-US') - const notes = `English中文` - - const result = (appUpdater as any).formatReleaseNotes(notes) - - expect(result).toBe('English') - }) - - it('should format string release notes without markers', () => { - const notes = 'Simple notes' - - const result = (appUpdater as any).formatReleaseNotes(notes) - - expect(result).toBe('Simple notes') - }) - - it('should format array release notes', () => { - const notes = [ - { version: '1.0.0', note: 'Note 1' }, - { version: '1.0.1', note: 'Note 2' } - ] - - const result = (appUpdater as any).formatReleaseNotes(notes) - - expect(result).toBe('Note 1\nNote 2') - }) - - it('should handle null release notes', () => { - const result = (appUpdater as any).formatReleaseNotes(null) - - expect(result).toBe('') - }) - - it('should handle undefined release notes', () => { - const result = (appUpdater as any).formatReleaseNotes(undefined) - - expect(result).toBe('') - }) - }) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index c1801f39d4..91281bacff 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -51,7 +51,7 @@ const api = { setProxy: (proxy: string | undefined, bypassRules?: string) => ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules), checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), - showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog), + quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall), setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable), setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages), diff --git a/src/renderer/src/components/UpdateDialog.tsx b/src/renderer/src/components/UpdateDialog.tsx new file mode 100644 index 0000000000..e5cc8fa144 --- /dev/null +++ b/src/renderer/src/components/UpdateDialog.tsx @@ -0,0 +1,101 @@ +import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react' +import { loggerService } from '@logger' +import { handleSaveData } from '@renderer/store' +import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Markdown from 'react-markdown' + +const logger = loggerService.withContext('UpdateDialog') + +interface UpdateDialogProps { + isOpen: boolean + onClose: () => void + releaseInfo: UpdateInfo | null +} + +const UpdateDialog: React.FC = ({ isOpen, onClose, releaseInfo }) => { + const { t } = useTranslation() + const [isInstalling, setIsInstalling] = useState(false) + + useEffect(() => { + if (isOpen && releaseInfo) { + logger.info('Update dialog opened', { version: releaseInfo.version }) + } + }, [isOpen, releaseInfo]) + + const handleInstall = async () => { + setIsInstalling(true) + try { + await handleSaveData() + await window.api.quitAndInstall() + } catch (error) { + logger.error('Failed to save data before update', error as Error) + setIsInstalling(false) + window.toast.error(t('update.saveDataError')) + } + } + + const releaseNotes = releaseInfo?.releaseNotes + + return ( + + + {(onModalClose) => ( + <> + +

{t('update.title')}

+

+ {t('update.message').replace('{{version}}', releaseInfo?.version || '')} +

+
+ + + +
+ + {typeof releaseNotes === 'string' + ? releaseNotes + : Array.isArray(releaseNotes) + ? releaseNotes + .map((note: ReleaseNoteInfo) => note.note) + .filter(Boolean) + .join('\n\n') + : t('update.noReleaseNotes')} + +
+
+
+ + + + + + + + )} +
+
+ ) +} + +export default UpdateDialog diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 6a8164edbc..74b2911dd1 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4641,6 +4641,7 @@ "later": "Later", "message": "New version {{version}} is ready, do you want to install it now?", "noReleaseNotes": "No release notes", + "saveDataError": "Failed to save data, please try again.", "title": "Update" }, "warning": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0cc2bfc87b..821d755aa8 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4641,6 +4641,7 @@ "later": "稍后", "message": "发现新版本 {{version}},是否立即安装?", "noReleaseNotes": "暂无更新日志", + "saveDataError": "保存数据失败,请重试", "title": "更新提示" }, "warning": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 93ce754828..bac03e14a2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4641,6 +4641,7 @@ "later": "稍後", "message": "新版本 {{version}} 已準備就緒,是否立即安裝?", "noReleaseNotes": "暫無更新日誌", + "saveDataError": "保存數據失敗,請重試", "title": "更新提示" }, "warning": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 37ab5c391d..a9e730734f 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4641,6 +4641,7 @@ "later": "Μετά", "message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;", "noReleaseNotes": "Χωρίς σημειώσεις", + "saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά", "title": "Ενημέρωση" }, "warning": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 27879fd08a..3cb4d7a3ea 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4641,6 +4641,7 @@ "later": "Más tarde", "message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?", "noReleaseNotes": "Sin notas de la versión", + "saveDataError": "Error al guardar los datos, inténtalo de nuevo", "title": "Actualización" }, "warning": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 2abd44d4fa..c0f4ff3def 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4641,6 +4641,7 @@ "later": "Plus tard", "message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?", "noReleaseNotes": "Aucune note de version", + "saveDataError": "Échec de la sauvegarde des données, veuillez réessayer", "title": "Mise à jour" }, "warning": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 5f30e21c75..dc047aec76 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4641,6 +4641,7 @@ "later": "後で", "message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?", "noReleaseNotes": "暫無更新日誌", + "saveDataError": "データの保存に失敗しました。もう一度お試しください。", "title": "更新" }, "warning": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index e9f3a4c661..f24fea2013 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -4641,6 +4641,7 @@ "later": "Mais tarde", "message": "Nova versão {{version}} disponível, deseja instalar agora?", "noReleaseNotes": "Sem notas de versão", + "saveDataError": "Falha ao salvar os dados, tente novamente", "title": "Atualização" }, "warning": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 755c06c636..a210be8dd4 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4641,6 +4641,7 @@ "later": "Позже", "message": "Новая версия {{version}} готова, установить сейчас?", "noReleaseNotes": "Нет заметок об обновлении", + "saveDataError": "Ошибка сохранения данных, повторите попытку", "title": "Обновление" }, "warning": { diff --git a/src/renderer/src/pages/home/components/UpdateAppButton.tsx b/src/renderer/src/pages/home/components/UpdateAppButton.tsx index 997590ef10..4d8f3f1261 100644 --- a/src/renderer/src/pages/home/components/UpdateAppButton.tsx +++ b/src/renderer/src/pages/home/components/UpdateAppButton.tsx @@ -1,4 +1,6 @@ import { SyncOutlined } from '@ant-design/icons' +import { useDisclosure } from '@heroui/react' +import UpdateDialog from '@renderer/components/UpdateDialog' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { Button } from 'antd' @@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => { const { update } = useRuntime() const { autoCheckUpdate } = useSettings() const { t } = useTranslation() + const { isOpen, onOpen, onClose } = useDisclosure() if (!update) { return null @@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => { window.api.showUpdateDialog()} + onClick={onOpen} icon={} color="orange" variant="outlined" size="small"> {t('button.update_available')} + + ) } diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index d611ed458e..1cbb8ce694 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -1,18 +1,21 @@ import { GithubOutlined } from '@ant-design/icons' +import { useDisclosure } from '@heroui/react' import IndicatorLight from '@renderer/components/IndicatorLight' import { HStack } from '@renderer/components/Layout' +import UpdateDialog from '@renderer/components/UpdateDialog' import { APP_NAME, AppLogo } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' -import { handleSaveData, useAppDispatch } from '@renderer/store' +import { useAppDispatch } from '@renderer/store' import { setUpdateState } from '@renderer/store/runtime' import { ThemeMode } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd' +import { UpdateInfo } from 'builder-util-runtime' import { debounce } from 'lodash' import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react' import { BadgeQuestionMark } from 'lucide-react' @@ -27,6 +30,8 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl const AboutSettings: FC = () => { const [version, setVersion] = useState('') const [isPortable, setIsPortable] = useState(false) + const [updateDialogInfo, setUpdateDialogInfo] = useState(null) + const { isOpen, onOpen, onClose } = useDisclosure() const { t } = useTranslation() const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings() const { theme } = useTheme() @@ -41,8 +46,9 @@ const AboutSettings: FC = () => { } if (update.downloaded) { - await handleSaveData() - window.api.showUpdateDialog() + // Open update dialog directly in renderer + setUpdateDialogInfo(update.info || null) + onOpen() return } @@ -341,6 +347,9 @@ const AboutSettings: FC = () => { + + {/* Update Dialog */} + ) } From 2399db49449b495570326ba205628a637873dd74 Mon Sep 17 00:00:00 2001 From: Tristan Zhang Date: Thu, 9 Oct 2025 20:40:46 +0800 Subject: [PATCH 4/8] fix: adding multiple keys to the zhipu model service is not detected properly (#10583) --- .../Popups/ApiKeyListPopup/popup.tsx | 23 ++++++++++++------- .../ProviderSettings/ProviderSetting.tsx | 13 ++++++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx index b4ca91186b..aba48b125e 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx @@ -10,6 +10,7 @@ interface ShowParams { providerId: string title?: string showHealthCheck?: boolean + providerType?: 'llm' | 'webSearch' | 'preprocess' } interface Props extends ShowParams { @@ -19,7 +20,7 @@ interface Props extends ShowParams { /** * API Key 列表弹窗容器组件 */ -const PopupContainer: React.FC = ({ providerId, title, resolve, showHealthCheck = true }) => { +const PopupContainer: React.FC = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => { const [open, setOpen] = useState(true) const { t } = useTranslation() @@ -32,14 +33,20 @@ const PopupContainer: React.FC = ({ providerId, title, resolve, showHealt } const ListComponent = useMemo(() => { - if (isWebSearchProviderId(providerId)) { - return + const type = + providerType || + (isWebSearchProviderId(providerId) ? 'webSearch' : isPreprocessProviderId(providerId) ? 'preprocess' : 'llm') + + switch (type) { + case 'webSearch': + return + case 'preprocess': + return + case 'llm': + default: + return } - if (isPreprocessProviderId(providerId)) { - return - } - return - }, [providerId, showHealthCheck]) + }, [providerId, showHealthCheck, providerType]) return ( = ({ providerId }) => { const onUpdateApiVersion = () => updateProvider({ apiVersion }) const openApiKeyList = async () => { + if (localApiKey !== provider.apiKey) { + updateProvider({ apiKey: formatApiKeys(localApiKey) }) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + await ApiKeyListPopup.show({ providerId: provider.id, - title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}` + title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`, + providerType: 'llm' }) } const onCheckApi = async () => { + const formattedLocalKey = formatApiKeys(localApiKey) // 如果存在多个密钥,直接打开管理窗口 - if (provider.apiKey.includes(',')) { + if (formattedLocalKey.includes(',')) { await openApiKeyList() return } @@ -204,7 +211,7 @@ const ProviderSetting: FC = ({ providerId }) => { try { setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED })) - await checkApi({ ...provider, apiHost }, model) + await checkApi({ ...provider, apiHost, apiKey: formattedLocalKey }, model) window.toast.success({ timeout: 2000, From 89bb830b605e8592d225bfcec4aa7dded3f6c0a0 Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:29:52 +0800 Subject: [PATCH 5/8] fix: knowledge base not delete and websearch rag error (#10595) * fix: knowledge base not delete * fix: websearch rag error * chore: add comment --- src/main/services/KnowledgeService.ts | 12 +++++++++--- src/preload/index.ts | 2 +- src/renderer/src/hooks/useKnowledge.ts | 2 +- src/renderer/src/services/WebSearchService.ts | 12 ++++++++---- src/renderer/src/store/knowledge.ts | 13 +++---------- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 1ccd25008d..4e8707b2b7 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker' import { fileStorage } from '@main/services/FileStorage' import { windowService } from '@main/services/WindowService' import { getDataPath } from '@main/utils' -import { getAllFiles } from '@main/utils/file' +import { getAllFiles, sanitizeFilename } from '@main/utils/file' import { TraceMethod } from '@mcp-trace/trace-core' import { MB } from '@shared/config/constant' import type { LoaderReturn } from '@shared/config/types' @@ -147,11 +147,16 @@ class KnowledgeService { } } + private getDbPath = (id: string): string => { + // 消除网络搜索requestI d中的特殊字符 + return path.join(this.storageDir, sanitizeFilename(id, '_')) + } + /** * Delete knowledge base file */ private deleteKnowledgeFile = (id: string): boolean => { - const dbPath = path.join(this.storageDir, id) + const dbPath = this.getDbPath(id) if (fs.existsSync(dbPath)) { try { fs.rmSync(dbPath, { recursive: true }) @@ -244,7 +249,8 @@ class KnowledgeService { dimensions }) try { - const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) }) + const dbPath = this.getDbPath(id) + const libSqlDb = new LibSqlDb({ path: dbPath }) // Save database instance for later closing this.dbInstances.set(id, libSqlDb) diff --git a/src/preload/index.ts b/src/preload/index.ts index 91281bacff..b8211813b4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -223,7 +223,7 @@ const api = { create: (base: KnowledgeBaseParams, context?: SpanContext) => tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base), reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base), - delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id), + delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id), add: ({ base, item, diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index ca9d5466ea..e4dc8f12fb 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -360,7 +360,7 @@ export const useKnowledgeBases = () => { const deleteKnowledgeBase = (baseId: string) => { const base = bases.find((b) => b.id === baseId) if (!base) return - dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) })) + dispatch(deleteBase({ baseId })) // remove assistant knowledge_base const _assistants = assistants.map((assistant) => { diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index d7d30c1dbc..bdcadb9785 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -15,7 +15,7 @@ import { WebSearchProviderResult, WebSearchStatus } from '@renderer/types' -import { hasObjectKey, uuid } from '@renderer/utils' +import { hasObjectKey, removeSpecialCharactersForFileName, uuid } from '@renderer/utils' import { addAbortController } from '@renderer/utils/abortController' import { formatErrorMessage } from '@renderer/utils/error' import { ExtractResults } from '@renderer/utils/extract' @@ -55,7 +55,7 @@ class WebSearchService { dispose: (requestState: RequestState, requestId: string) => { if (!requestState.searchBase) return window.api.knowledgeBase - .delete(getKnowledgeBaseParams(requestState.searchBase), requestState.searchBase.id) + .delete(removeSpecialCharactersForFileName(requestState.searchBase.id)) .catch((error) => logger.warn(`Failed to cleanup search base for ${requestId}:`, error)) } }) @@ -216,6 +216,7 @@ class WebSearchService { documentCount: number, requestId: string ): Promise { + // requestId: eg: openai-responses-openai/gpt-5-timestamp-uuid const baseId = `websearch-compression-${requestId}` const state = this.getRequestState(requestId) @@ -226,7 +227,8 @@ class WebSearchService { // 清理旧的知识库 if (state.searchBase) { - await window.api.knowledgeBase.delete(getKnowledgeBaseParams(state.searchBase), state.searchBase.id) + // 将requestId中的 '/' 映射为 '_' + await window.api.knowledgeBase.delete(removeSpecialCharactersForFileName(state.searchBase.id)) } if (!config.embeddingModel) { @@ -462,7 +464,9 @@ class WebSearchService { // 处理 summarize if (questions[0] === 'summarize' && links && links.length > 0) { - const contents = await fetchWebContents(links, undefined, undefined, { signal }) + const contents = await fetchWebContents(links, undefined, undefined, { + signal + }) webSearchProvider.topicId && endSpan({ topicId: webSearchProvider.topicId, diff --git a/src/renderer/src/store/knowledge.ts b/src/renderer/src/store/knowledge.ts index 49f14d4883..f74752b21e 100644 --- a/src/renderer/src/store/knowledge.ts +++ b/src/renderer/src/store/knowledge.ts @@ -1,14 +1,7 @@ import { loggerService } from '@logger' import { createSlice, PayloadAction } from '@reduxjs/toolkit' import FileManager from '@renderer/services/FileManager' -import { - FileMetadata, - KnowledgeBase, - KnowledgeBaseParams, - KnowledgeItem, - PreprocessProvider, - ProcessingStatus -} from '@renderer/types' +import { FileMetadata, KnowledgeBase, KnowledgeItem, PreprocessProvider, ProcessingStatus } from '@renderer/types' const logger = loggerService.withContext('Store:Knowledge') @@ -28,13 +21,13 @@ const knowledgeSlice = createSlice({ state.bases.push(action.payload) }, - deleteBase(state, action: PayloadAction<{ baseId: string; baseParams: KnowledgeBaseParams }>) { + deleteBase(state, action: PayloadAction<{ baseId: string }>) { const base = state.bases.find((b) => b.id === action.payload.baseId) if (base) { state.bases = state.bases.filter((b) => b.id !== action.payload.baseId) const files = base.items.filter((item) => item.type === 'file') FileManager.deleteFiles(files.map((item) => item.content) as FileMetadata[]) - window.api.knowledgeBase.delete(action.payload.baseParams, action.payload.baseId) + window.api.knowledgeBase.delete(action.payload.baseId) } }, From 73b2a375ad006dfb80aeb569f4552bc8fc3d4487 Mon Sep 17 00:00:00 2001 From: Tristan Zhang Date: Thu, 9 Oct 2025 22:32:13 +0800 Subject: [PATCH 6/8] fix: insert reasoning block before the content block (#10545) fix: always insert reasoning block before the content block --- .../src/services/messageStreaming/BlockManager.ts | 3 ++- src/renderer/src/store/newMessage.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/services/messageStreaming/BlockManager.ts b/src/renderer/src/services/messageStreaming/BlockManager.ts index fb0d65913a..c07c67f804 100644 --- a/src/renderer/src/services/messageStreaming/BlockManager.ts +++ b/src/renderer/src/services/messageStreaming/BlockManager.ts @@ -121,7 +121,8 @@ export class BlockManager { newMessagesActions.upsertBlockReference({ messageId: this.deps.assistantMsgId, blockId: newBlock.id, - status: newBlock.status + status: newBlock.status, + blockType: newBlock.type }) ) diff --git a/src/renderer/src/store/newMessage.ts b/src/renderer/src/store/newMessage.ts index f5b171f07d..82b81adb8b 100644 --- a/src/renderer/src/store/newMessage.ts +++ b/src/renderer/src/store/newMessage.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { createEntityAdapter, createSlice, EntityState, PayloadAction } from '@reduxjs/toolkit' // Separate type-only imports from value imports import type { Message } from '@renderer/types/newMessage' -import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage' +import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' const logger = loggerService.withContext('newMessage') @@ -50,6 +50,7 @@ interface UpsertBlockReferencePayload { messageId: string blockId: string status?: MessageBlockStatus + blockType?: MessageBlockType } // Payload for removing a single message @@ -218,7 +219,7 @@ export const messagesSlice = createSlice({ messagesAdapter.removeMany(state, messageIds) }, upsertBlockReference(state, action: PayloadAction) { - const { messageId, blockId, status } = action.payload + const { messageId, blockId, status, blockType } = action.payload const messageToUpdate = state.entities[messageId] if (!messageToUpdate) { @@ -231,7 +232,11 @@ export const messagesSlice = createSlice({ // Update Block ID const currentBlocks = messageToUpdate.blocks || [] if (!currentBlocks.includes(blockId)) { - changes.blocks = [...currentBlocks, blockId] + if (blockType === MessageBlockType.THINKING) { + changes.blocks = [blockId, ...currentBlocks] + } else { + changes.blocks = [...currentBlocks, blockId] + } } // Update Message Status based on Block Status From 6c201228d9bd4186194eb7fd01a991ff390d4237 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 10 Oct 2025 07:00:45 -0700 Subject: [PATCH 7/8] feat: support search in mini app page (#10609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: add webview find-in-page overlay * 🐛 fix: reset webview search on tab change * fix clear search issue * 🐛 fix: rebind webview search events * 🐛 fix: disable spellcheck in search input * fix spellcheck * 🐛 fix: webview search can now reopen after closing Fixed an issue where the search overlay couldn't be reopened after closing. The openSearch callback was unnecessarily depending on webviewRef.current, causing event listener rebinding issues. Removed the redundant webviewRef check as isWebviewReady is sufficient to ensure webview readiness. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Payne Fu Co-authored-by: Payne Fu Co-authored-by: Claude --- src/renderer/src/pages/minapps/MinAppPage.tsx | 2 + .../minapps/components/WebviewSearch.tsx | 298 ++++++++++++++++++ .../__tests__/WebviewSearch.test.tsx | 237 ++++++++++++++ 3 files changed, 537 insertions(+) create mode 100644 src/renderer/src/pages/minapps/components/WebviewSearch.tsx create mode 100644 src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx index c85afab22c..75c6c284c2 100644 --- a/src/renderer/src/pages/minapps/MinAppPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppPage.tsx @@ -14,6 +14,7 @@ import styled from 'styled-components' // Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool import MinimalToolbar from './components/MinimalToolbar' +import WebviewSearch from './components/WebviewSearch' const logger = loggerService.withContext('MinAppPage') @@ -184,6 +185,7 @@ const MinAppPage: FC = () => { onOpenDevTools={handleOpenDevTools} /> + {!isReady && ( diff --git a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx new file mode 100644 index 0000000000..80b88f9c1f --- /dev/null +++ b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx @@ -0,0 +1,298 @@ +import { Button, Input } from '@heroui/react' +import { loggerService } from '@logger' +import type { WebviewTag } from 'electron' +import { ChevronDown, ChevronUp, X } from 'lucide-react' +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type FoundInPageResult = Electron.FoundInPageResult + +interface WebviewSearchProps { + webviewRef: React.RefObject + isWebviewReady: boolean + appId: string +} + +const logger = loggerService.withContext('WebviewSearch') + +const WebviewSearch: FC = ({ webviewRef, isWebviewReady, appId }) => { + const { t } = useTranslation() + const [isVisible, setIsVisible] = useState(false) + const [query, setQuery] = useState('') + const [matchCount, setMatchCount] = useState(0) + const [activeIndex, setActiveIndex] = useState(0) + const [currentWebview, setCurrentWebview] = useState(null) + const inputRef = useRef(null) + const focusFrameRef = useRef(null) + const lastAppIdRef = useRef(appId) + const attachedWebviewRef = useRef(null) + + const focusInput = useCallback(() => { + if (focusFrameRef.current !== null) { + window.cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + focusFrameRef.current = window.requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + }, []) + + const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => { + if (!options?.keepQuery) { + setQuery('') + } + setMatchCount(0) + setActiveIndex(0) + }, []) + + const stopSearch = useCallback(() => { + const target = webviewRef.current ?? attachedWebviewRef.current + if (!target) return + try { + target.stopFindInPage('clearSelection') + } catch (error) { + logger.error('stopFindInPage failed', { error }) + } + }, [webviewRef]) + + const closeSearch = useCallback(() => { + setIsVisible(false) + stopSearch() + resetSearchState({ keepQuery: true }) + }, [resetSearchState, stopSearch]) + + const performSearch = useCallback( + (text: string, options?: Electron.FindInPageOptions) => { + const target = webviewRef.current ?? attachedWebviewRef.current + if (!target) { + logger.debug('Skip performSearch: webview not attached') + return + } + if (!text) { + stopSearch() + resetSearchState({ keepQuery: true }) + return + } + try { + target.findInPage(text, options) + } catch (error) { + logger.error('findInPage failed', { error }) + window.toast?.error(t('common.error')) + } + }, + [resetSearchState, stopSearch, t, webviewRef] + ) + + const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => { + if (!event.result) return + + const { activeMatchOrdinal, matches } = event.result + + if (matches !== undefined) { + setMatchCount(matches) + } + + if (activeMatchOrdinal !== undefined) { + setActiveIndex(activeMatchOrdinal) + } + }, []) + + const openSearch = useCallback(() => { + if (!isWebviewReady) { + logger.debug('Skip openSearch: webview not ready') + return + } + setIsVisible(true) + focusInput() + }, [focusInput, isWebviewReady]) + + const goToNext = useCallback(() => { + if (!query) return + performSearch(query, { forward: true, findNext: true }) + }, [performSearch, query]) + + const goToPrevious = useCallback(() => { + if (!query) return + performSearch(query, { forward: false, findNext: true }) + }, [performSearch, query]) + + useEffect(() => { + const nextWebview = webviewRef.current ?? null + if (currentWebview === nextWebview) return + setCurrentWebview(nextWebview) + }) + + useEffect(() => { + const target = currentWebview + if (!target) { + attachedWebviewRef.current = null + return + } + + const handle = handleFoundInPage + attachedWebviewRef.current = target + target.addEventListener('found-in-page', handle) + + return () => { + target.removeEventListener('found-in-page', handle) + if (attachedWebviewRef.current === target) { + try { + target.stopFindInPage('clearSelection') + } catch (error) { + logger.error('stopFindInPage failed', { error }) + } + attachedWebviewRef.current = null + } + } + }, [currentWebview, handleFoundInPage]) + + useEffect(() => { + if (!isVisible) return + focusInput() + }, [focusInput, isVisible]) + + useEffect(() => { + if (!isVisible) return + if (!query) { + performSearch('') + return + } + performSearch(query) + }, [currentWebview, isVisible, performSearch, query]) + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') { + event.preventDefault() + openSearch() + return + } + + if (!isVisible) return + + if (event.key === 'Escape') { + event.preventDefault() + closeSearch() + return + } + + if (event.key === 'Enter') { + event.preventDefault() + if (event.shiftKey) { + goToPrevious() + } else { + goToNext() + } + } + } + + window.addEventListener('keydown', handleKeydown, true) + return () => { + window.removeEventListener('keydown', handleKeydown, true) + } + }, [closeSearch, goToNext, goToPrevious, isVisible, openSearch]) + + useEffect(() => { + if (!isWebviewReady) { + setIsVisible(false) + resetSearchState() + stopSearch() + return + } + }, [isWebviewReady, resetSearchState, stopSearch]) + + useEffect(() => { + if (!appId) return + if (lastAppIdRef.current === appId) return + lastAppIdRef.current = appId + setIsVisible(false) + resetSearchState() + stopSearch() + }, [appId, resetSearchState, stopSearch]) + + useEffect(() => { + return () => { + stopSearch() + if (focusFrameRef.current !== null) { + window.cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + } + }, [stopSearch]) + + if (!isVisible) { + return null + } + + const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}` + const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined + const disableNavigation = !query || matchCount === 0 + + return ( +
+ + + {matchLabel} + +
+ + +
+ +
+ ) +} + +export default WebviewSearch diff --git a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx new file mode 100644 index 0000000000..0f1985f6bb --- /dev/null +++ b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx @@ -0,0 +1,237 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { WebviewTag } from 'electron' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import WebviewSearch from '../WebviewSearch' + +const translations: Record = { + 'common.close': 'Close', + 'common.error': 'Error', + 'common.no_results': 'No results', + 'common.search': 'Search' +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => translations[key] ?? key + }) +})) + +const createWebviewMock = () => { + const listeners = new Map void>>() + const findInPageMock = vi.fn() + const stopFindInPageMock = vi.fn() + const webview = { + addEventListener: vi.fn( + (type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => { + if (!listeners.has(type)) { + listeners.set(type, new Set()) + } + listeners.get(type)!.add(listener) + } + ), + removeEventListener: vi.fn( + (type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => { + listeners.get(type)?.delete(listener) + } + ), + findInPage: findInPageMock as unknown as WebviewTag['findInPage'], + stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage'] + } as unknown as WebviewTag + + const emit = (type: string, result?: Electron.FoundInPageResult) => { + listeners.get(type)?.forEach((listener) => { + const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult } + event.result = result + listener(event) + }) + } + + return { + emit, + findInPageMock, + stopFindInPageMock, + webview + } +} + +const openSearchOverlay = async () => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true })) + }) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() + }) +} + +const originalRAF = window.requestAnimationFrame +const originalCAF = window.cancelAnimationFrame + +const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => { + callback(0) + return 1 +}) +const cancelAnimationFrameMock = vi.fn() + +beforeAll(() => { + Object.defineProperty(window, 'requestAnimationFrame', { + value: requestAnimationFrameMock, + writable: true + }) + Object.defineProperty(window, 'cancelAnimationFrame', { + value: cancelAnimationFrameMock, + writable: true + }) +}) + +afterAll(() => { + Object.defineProperty(window, 'requestAnimationFrame', { + value: originalRAF + }) + Object.defineProperty(window, 'cancelAnimationFrame', { + value: originalCAF + }) +}) + +describe('WebviewSearch', () => { + const toastMock = { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + addToast: vi.fn() + } + + beforeEach(() => { + Object.assign(window, { toast: toastMock }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('opens the search overlay with keyboard shortcut', async () => { + const { webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + + render() + + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument() + + await openSearchOverlay() + + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() + }) + + it('performs searches and navigates between results', async () => { + const { emit, findInPageMock, webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + const user = userEvent.setup() + + render() + await openSearchOverlay() + + const input = screen.getByRole('textbox') + await user.type(input, 'Cherry') + + await waitFor(() => { + expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined) + }) + + await act(async () => { + emit('found-in-page', { + requestId: 1, + matches: 3, + activeMatchOrdinal: 1, + selectionArea: undefined as unknown as Electron.Rectangle, + finalUpdate: false + } as Electron.FoundInPageResult) + }) + + const nextButton = screen.getByRole('button', { name: 'Next match' }) + await waitFor(() => { + expect(nextButton).not.toBeDisabled() + }) + await user.click(nextButton) + await waitFor(() => { + expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true }) + }) + + const previousButton = screen.getByRole('button', { name: 'Previous match' }) + await user.click(previousButton) + await waitFor(() => { + expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true }) + }) + }) + + it('clears search state when appId changes', async () => { + const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + const user = userEvent.setup() + + const { rerender } = render() + await openSearchOverlay() + + const input = screen.getByRole('textbox') + await user.type(input, 'Cherry') + await waitFor(() => { + expect(findInPageMock).toHaveBeenCalled() + }) + + await act(async () => { + rerender() + }) + + await waitFor(() => { + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument() + }) + expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection') + }) + + it('shows toast error when search fails', async () => { + const { findInPageMock, webview } = createWebviewMock() + findInPageMock.mockImplementation(() => { + throw new Error('findInPage failed') + }) + const webviewRef = { current: webview } as React.RefObject + const user = userEvent.setup() + + render() + await openSearchOverlay() + + const input = screen.getByRole('textbox') + await user.type(input, 'Cherry') + + await waitFor(() => { + expect(toastMock.error).toHaveBeenCalledWith('Error') + }) + }) + + it('stops search when component unmounts', async () => { + const { stopFindInPageMock, webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + + const { unmount } = render() + await openSearchOverlay() + + stopFindInPageMock.mockClear() + unmount() + + expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection') + }) + + it('ignores keyboard shortcut when webview is not ready', async () => { + const { findInPageMock, webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + + render() + + await act(async () => { + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }) + }) + + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument() + expect(findInPageMock).not.toHaveBeenCalled() + }) +}) From acdbe6b9ed3cac1167df9b13eadcaf67172bea9f Mon Sep 17 00:00:00 2001 From: ABucket Date: Fri, 10 Oct 2025 23:58:14 +0800 Subject: [PATCH 8/8] feat: allow right click to create note and folder (#10523) * feat: allow right click to create note and folder * fix: duplicate menu for notes or folder * fix: create notes in folder when a folder is selected --- src/renderer/src/pages/notes/NotesPage.tsx | 28 +- src/renderer/src/pages/notes/NotesSidebar.tsx | 298 +++++++++++------- 2 files changed, 200 insertions(+), 126 deletions(-) diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 0bad446acf..aa3555cead 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -385,21 +385,25 @@ const NotesPage: FC = () => { }, [activeFilePath]) // 获取目标文件夹路径(选中文件夹或根目录) - const getTargetFolderPath = useCallback(() => { - if (selectedFolderId) { - const selectedNode = findNode(notesTree, selectedFolderId) - if (selectedNode && selectedNode.type === 'folder') { - return selectedNode.externalPath + const getTargetFolderPath = useCallback( + (targetFolderId?: string) => { + const folderId = targetFolderId || selectedFolderId + if (folderId) { + const selectedNode = findNode(notesTree, folderId) + if (selectedNode && selectedNode.type === 'folder') { + return selectedNode.externalPath + } } - } - return notesPath // 默认返回根目录 - }, [selectedFolderId, notesTree, notesPath]) + return notesPath // 默认返回根目录 + }, + [selectedFolderId, notesTree, notesPath] + ) // 创建文件夹 const handleCreateFolder = useCallback( - async (name: string) => { + async (name: string, targetFolderId?: string) => { try { - const targetPath = getTargetFolderPath() + const targetPath = getTargetFolderPath(targetFolderId) if (!targetPath) { throw new Error('No folder path selected') } @@ -415,11 +419,11 @@ const NotesPage: FC = () => { // 创建笔记 const handleCreateNote = useCallback( - async (name: string) => { + async (name: string, targetFolderId?: string) => { try { isCreatingNoteRef.current = true - const targetPath = getTargetFolderPath() + const targetPath = getTargetFolderPath(targetFolderId) if (!targetPath) { throw new Error('No folder path selected') } diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index a2917f6316..47a27030cd 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -34,8 +34,8 @@ import { useSelector } from 'react-redux' import styled from 'styled-components' interface NotesSidebarProps { - onCreateFolder: (name: string, parentId?: string) => void - onCreateNote: (name: string, parentId?: string) => void + onCreateFolder: (name: string, targetFolderId?: string) => void + onCreateNote: (name: string, targetFolderId?: string) => void onSelectNode: (node: NotesTreeNode) => void onDeleteNode: (nodeId: string) => void onRenameNode: (nodeId: string, newName: string) => void @@ -71,6 +71,8 @@ interface TreeNodeProps { onDrop: (e: React.DragEvent, node: NotesTreeNode) => void onDragEnd: () => void renderChildren?: boolean // 控制是否渲染子节点 + openDropdownKey: string | null + onDropdownOpenChange: (key: string | null) => void } const TreeNode = memo( @@ -94,7 +96,9 @@ const TreeNode = memo( onDragLeave, onDrop, onDragEnd, - renderChildren = true + renderChildren = true, + openDropdownKey, + onDropdownOpenChange }) => { const { t } = useTranslation() @@ -119,8 +123,12 @@ const TreeNode = memo( return (
- -
+ onDropdownOpenChange(open ? node.id : null)}> +
e.stopPropagation()}> ( onDrop={onDrop} onDragEnd={onDragEnd} renderChildren={renderChildren} + openDropdownKey={openDropdownKey} + onDropdownOpenChange={onDropdownOpenChange} /> ))}
@@ -244,6 +254,7 @@ const NotesSidebar: FC = ({ const [isShowSearch, setIsShowSearch] = useState(false) const [searchKeyword, setSearchKeyword] = useState('') const [isDragOverSidebar, setIsDragOverSidebar] = useState(false) + const [openDropdownKey, setOpenDropdownKey] = useState(null) const dragNodeRef = useRef(null) const scrollbarRef = useRef(null) @@ -588,6 +599,28 @@ const NotesSidebar: FC = ({ }) } + if (node.type === 'folder') { + baseMenuItems.push( + { + label: t('notes.new_note'), + key: 'new_note', + icon: , + onClick: () => { + onCreateNote(t('notes.untitled_note'), node.id) + } + }, + { + label: t('notes.new_folder'), + key: 'new_folder', + icon: , + onClick: () => { + onCreateFolder(t('notes.untitled_folder'), node.id) + } + }, + { type: 'divider' } + ) + } + baseMenuItems.push( { label: t('notes.rename'), @@ -702,7 +735,9 @@ const NotesSidebar: FC = ({ handleDeleteNode, renamingNodeIds, handleAutoRename, - exportMenuOptions + exportMenuOptions, + onCreateNote, + onCreateFolder ] ) @@ -783,6 +818,23 @@ const NotesSidebar: FC = ({ fileInput.click() }, [onUploadFiles]) + const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => { + return [ + { + label: t('notes.new_note'), + key: 'new_note', + icon: , + onClick: handleCreateNote + }, + { + label: t('notes.new_folder'), + key: 'new_folder', + icon: , + onClick: handleCreateFolder + } + ] + }, [t, handleCreateNote, handleCreateFolder]) + return ( { @@ -812,31 +864,90 @@ const NotesSidebar: FC = ({ {shouldUseVirtualization ? ( - -
- {virtualizer.getVirtualItems().map((virtualItem) => { - const { node, depth } = flattenedNodes[virtualItem.index] - return ( -
-
+ setOpenDropdownKey(open ? 'empty-area' : null)}> + +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const { node, depth } = flattenedNodes[virtualItem.index] + return ( +
+
+ +
+
+ ) + })} +
+ {!isShowStarred && !isShowSearch && ( + + + + + + + {t('notes.drop_markdown_hint')} + + + + )} +
+
+ ) : ( + setOpenDropdownKey(open ? 'empty-area' : null)}> + + + {isShowStarred || isShowSearch + ? filteredTree.map((node) => ( = ({ onDragLeave={handleDragLeave} onDrop={handleDrop} onDragEnd={handleDragEnd} - renderChildren={false} + openDropdownKey={openDropdownKey} + onDropdownOpenChange={setOpenDropdownKey} /> -
-
- ) - })} -
- {!isShowStarred && !isShowSearch && ( - - - - - - - {t('notes.drop_markdown_hint')} - - - - )} -
- ) : ( - - - {isShowStarred || isShowSearch - ? filteredTree.map((node) => ( - - )) - : notesTree.map((node) => ( - - ))} - {!isShowStarred && !isShowSearch && ( - - - - - - - {t('notes.drop_markdown_hint')} - - - - )} - - + )) + : notesTree.map((node) => ( + + ))} + {!isShowStarred && !isShowSearch && ( + + + + + + + {t('notes.drop_markdown_hint')} + + + + )} + + +
)}