diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 355e340853..493c08acbc 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', @@ -251,7 +251,6 @@ export enum IpcChannel { BackupProgress = 'backup-progress', DataMigrateProgress = 'data-migrate-progress', NativeThemeUpdated = 'native-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 b4b2e552ea..c2e41dbc39 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -143,7 +143,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 1aa53f67be..a9fcab82e5 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -3,21 +3,18 @@ import { loggerService } from '@logger' import { isWin } from '@main/constant' import { configManager } from '@main/services/ConfigManager' import { getIpCountry } from '@main/utils/ipService' -import { getI18n } from '@main/utils/language' import { generateUserAgent } from '@main/utils/systemInfo' import { FeedUrl } from '@shared/config/constant' import { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' import type { UpdateInfo } from 'builder-util-runtime' import { CancellationToken } from 'builder-util-runtime' -import type { BrowserWindow } from 'electron' -import { app, dialog, net } from 'electron' +import { app, net } from 'electron' import type { AppUpdater as _AppUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' import { autoUpdater } from 'electron-updater' import path from 'path' import semver from 'semver' -import icon from '../../../build/icon.png?asset' import { windowService } from './WindowService' const logger = loggerService.withContext('AppUpdater') @@ -31,7 +28,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 @@ -71,7 +67,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) }) @@ -252,37 +247,9 @@ export default class AppUpdater { } } - public showUpdateDialog(mainWindow: BrowserWindow) { - if (!this.releaseInfo) { - return - } - const i18n = getI18n() - const { update: updateLocale } = i18n.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()) } /** @@ -354,38 +321,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/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 856667595f..f139b3e2c1 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -30,7 +30,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' @@ -148,11 +148,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 }) @@ -245,7 +250,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/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index 3fcb34f0c7..2611cf608c 100644 --- a/src/main/services/__tests__/AppUpdater.test.ts +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -283,46 +283,4 @@ describe('AppUpdater', () => { expect(result.releaseNotes).toBeNull() }) }) - - describe('formatReleaseNotes', () => { - it('should format string release notes with markers', () => { - MockMainPreferenceServiceUtils.setPreferenceValue('app.language', '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 e10352619a..b574733c83 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -56,7 +56,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), @@ -228,7 +228,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/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 ( 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/config/minapps.ts b/src/renderer/src/config/minapps.ts index 093c44d758..0b1ea89e4c 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 }, { diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index 97cf2c4b42..464195aff3 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -353,7 +353,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/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 db82c32655..55ea0096d6 100644 --- a/src/renderer/src/pages/home/components/UpdateAppButton.tsx +++ b/src/renderer/src/pages/home/components/UpdateAppButton.tsx @@ -1,5 +1,7 @@ import { SyncOutlined } from '@ant-design/icons' import { Button } from '@cherrystudio/ui' +import { useDisclosure } from '@heroui/react' +import UpdateDialog from '@renderer/components/UpdateDialog' import { usePreference } from '@data/hooks/usePreference' import { useAppUpdateState } from '@renderer/hooks/useAppUpdate' import type { FC } from 'react' @@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => { const { appUpdateState } = useAppUpdateState() const [autoCheckUpdate] = usePreference('app.dist.auto_update.enabled') const { t } = useTranslation() + const { isOpen, onOpen, onClose } = useDisclosure() if (!appUpdateState) { return null @@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => { window.api.showUpdateDialog()} + onPress={onOpen} startContent={} color="warning" variant="bordered" size="sm"> {t('button.update_available')} + + ) } diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx index 031cf14ec3..4ee4549303 100644 --- a/src/renderer/src/pages/minapps/MinAppPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppPage.tsx @@ -15,6 +15,7 @@ import styled from 'styled-components' // Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool import MinimalToolbar from './components/MinimalToolbar' +import WebviewSearch from './components/WebviewSearch' const logger = loggerService.withContext('MinAppPage') @@ -185,6 +186,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() + }) +}) diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 4c1eebc7d5..75a545c45c 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -386,21 +386,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') } @@ -416,11 +420,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 095c77e235..abcce46f11 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -37,8 +37,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 @@ -74,6 +74,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( @@ -97,7 +99,9 @@ const TreeNode = memo( onDragLeave, onDrop, onDragEnd, - renderChildren = true + renderChildren = true, + openDropdownKey, + onDropdownOpenChange }) => { const { t } = useTranslation() @@ -122,8 +126,12 @@ const TreeNode = memo( return (
- -
+ onDropdownOpenChange(open ? node.id : null)}> +
e.stopPropagation()}> ( onDrop={onDrop} onDragEnd={onDragEnd} renderChildren={renderChildren} + openDropdownKey={openDropdownKey} + onDropdownOpenChange={onDropdownOpenChange} /> ))}
@@ -247,6 +257,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) @@ -591,6 +602,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'), @@ -705,7 +738,9 @@ const NotesSidebar: FC = ({ handleDeleteNode, renamingNodeIds, handleAutoRename, - exportMenuOptions + exportMenuOptions, + onCreateNote, + onCreateFolder ] ) @@ -786,6 +821,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 ( { @@ -815,31 +867,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')} + + + + )} + + +
)} diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index d50b101ad7..2351a0f0b9 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -1,22 +1,22 @@ import { GithubOutlined } from '@ant-design/icons' -import { RowFlex } from '@cherrystudio/ui' -import { Switch } from '@cherrystudio/ui' -import { Avatar, Button, Tooltip } from '@cherrystudio/ui' +import { RowFlex, Switch, Tooltip, Avatar, Button } from '@cherrystudio/ui' import { usePreference } from '@data/hooks/usePreference' -import { Radio, RadioGroup } from '@heroui/react' +import { Radio, RadioGroup, useDisclosure } from '@heroui/react' import IndicatorLight from '@renderer/components/IndicatorLight' +import UpdateDialog from '@renderer/components/UpdateDialog' import { APP_NAME, AppLogo } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' import { useAppUpdateState } from '@renderer/hooks/useAppUpdate' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' // import { useRuntime } from '@renderer/hooks/useRuntime' import i18n from '@renderer/i18n' -import { handleSaveData } from '@renderer/store' +// import { handleSaveData } from '@renderer/store' // import { setUpdateState as setAppUpdateState } from '@renderer/store/runtime' import { runAsyncFunction } from '@renderer/utils' import { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { ThemeMode } from '@shared/data/preference/preferenceTypes' import { Progress, Row, Tag } from 'antd' +import type { UpdateInfo } from 'builder-util-runtime' import { debounce } from 'lodash' import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react' import { BadgeQuestionMark } from 'lucide-react' @@ -36,6 +36,8 @@ 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 { theme } = useTheme() // const dispatch = useAppDispatch() @@ -51,8 +53,9 @@ const AboutSettings: FC = () => { } if (appUpdateState.downloaded) { - await handleSaveData() - window.api.showUpdateDialog() + // Open update dialog directly in renderer + setUpdateDialogInfo(appUpdateState.info || null) + onOpen() return } @@ -347,6 +350,9 @@ const AboutSettings: FC = () => { + + {/* Update Dialog */} + ) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index a0891d500e..548948c13a 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -174,15 +174,22 @@ const ProviderSetting: FC = ({ 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 } @@ -206,7 +213,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, @@ -475,14 +482,13 @@ const ProviderSetting: FC = ({ providerId }) => { onBlur={onUpdateAnthropicHost} /> - - + + {t('settings.provider.anthropic_api_host_preview', { url: anthropicHostPreview || '—' })} - + {t('settings.provider.anthropic_api_host_tip')} diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index 43e0b7719e..f2e667fdf9 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -15,7 +15,7 @@ import type { 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 type { 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)) } }) @@ -219,6 +219,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) @@ -229,7 +230,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) { @@ -465,7 +467,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/services/messageStreaming/BlockManager.ts b/src/renderer/src/services/messageStreaming/BlockManager.ts index 62dad360b1..9e638ebf14 100644 --- a/src/renderer/src/services/messageStreaming/BlockManager.ts +++ b/src/renderer/src/services/messageStreaming/BlockManager.ts @@ -122,7 +122,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/knowledge.ts b/src/renderer/src/store/knowledge.ts index 64d62aeda4..6280a99e8d 100644 --- a/src/renderer/src/store/knowledge.ts +++ b/src/renderer/src/store/knowledge.ts @@ -2,14 +2,7 @@ import { loggerService } from '@logger' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import FileManager from '@renderer/services/FileManager' -import type { - FileMetadata, - KnowledgeBase, - KnowledgeBaseParams, - KnowledgeItem, - PreprocessProvider, - ProcessingStatus -} from '@renderer/types' +import type { FileMetadata, KnowledgeBase, KnowledgeItem, PreprocessProvider, ProcessingStatus } from '@renderer/types' const logger = loggerService.withContext('Store:Knowledge') @@ -29,13 +22,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) } }, diff --git a/src/renderer/src/store/newMessage.ts b/src/renderer/src/store/newMessage.ts index 602de595d8..cd8c0dde83 100644 --- a/src/renderer/src/store/newMessage.ts +++ b/src/renderer/src/store/newMessage.ts @@ -3,7 +3,7 @@ import type { EntityState, PayloadAction } from '@reduxjs/toolkit' import { createEntityAdapter, createSlice } 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') @@ -51,6 +51,7 @@ interface UpsertBlockReferencePayload { messageId: string blockId: string status?: MessageBlockStatus + blockType?: MessageBlockType } // Payload for removing a single message @@ -219,7 +220,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) { @@ -232,7 +233,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