From bbe380cc9e43e68eb8a6c175edd7f7c9f057f97b Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 23 Jun 2025 21:19:21 +0800 Subject: [PATCH 01/56] feat(ContextMenu): add spell check and dictionary suggestions to context menu (#7067) * feat(ContextMenu): add spell check and dictionary suggestions to context menu - Implemented spell check functionality in the context menu with options to learn spelling and view dictionary suggestions. - Updated WindowService to enable spellcheck in the webview. - Enabled spell check in Inputbar and MessageEditor components. * feat(SpellCheck): implement spell check language settings and initialization - Added support for configuring spell check languages based on user-selected language. - Introduced IPC channel for setting spell check languages. - Updated settings to manage spell check enablement and languages. - Enhanced UI to allow users to toggle spell check functionality and select languages. - Default spell check languages are set based on the current UI language if none are specified. * refactor(SpellCheck): enhance spell check language mapping and UI settings - Updated spell check language mapping to default to English for unsupported languages. - Improved UI logic to only update spell check languages when enabled and no manual selections are made. - Added a new selection component for users to choose from commonly supported spell check languages. * feat(SpellCheck): integrate spell check functionality into Inputbar and MessageEditor - Added enableSpellCheck setting to control spell check functionality in both Inputbar and MessageEditor components. - Updated spellCheck prop to utilize the new setting, enhancing user experience by allowing customization of spell check behavior. * refactor(SpellCheck): move spell check initialization to WindowService - Removed spell check language initialization from index.ts and integrated it into WindowService. - Added setupSpellCheck method to configure spell check languages based on user settings. - Enhanced error handling for spell check language setup. * feat(SpellCheck): add enable spell check functionality and IPC channel - Introduced a new IPC channel for enabling/disabling spell check functionality. - Updated the preload API to include a method for setting spell check enablement. - Modified the main IPC handler to manage spell check settings based on user input. - Simplified spell check language handling in the settings component by directly invoking the new API method. * refactor(SpellCheck): remove spellcheck option from WindowService configuration - Removed the spellcheck property from the WindowService configuration object. - This change streamlines the configuration setup as spell check functionality is now managed through IPC channels. * feat(i18n): add spell check translations for Japanese, Russian, and Traditional Chinese - Added new translations for spell check functionality in ja-jp, ru-ru, and zh-tw locale files. - Included descriptions and language selection options for spell check settings to enhance user experience. * feat(migrate): add spell check configuration migration - Implemented migration for spell check settings, disabling spell check and clearing selected languages in the new configuration. - Enhanced error handling to ensure state consistency during migration process. * fix(migrate): ensure spell check settings are updated safely - Added a check to ensure state.settings exists before modifying spell check settings during migration. - Removed redundant error handling that returned the state unmodified in case of an error. * fix(WindowService): set default values for spell check configuration and update related UI texts * refactor(Inputbar, MessageEditor): remove contextMenu attribute and add context menu handling in MessageEditor --------- Co-authored-by: beyondkmp --- packages/shared/IpcChannel.ts | 2 + src/main/ipc.ts | 20 ++++++ src/main/services/ContextMenu.ts | 60 +++++++++++++++- src/main/services/WindowService.ts | 13 ++++ src/preload/index.ts | 2 + src/renderer/src/i18n/locales/en-us.json | 4 +- src/renderer/src/i18n/locales/ja-jp.json | 2 + src/renderer/src/i18n/locales/ru-ru.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 12 ++-- src/renderer/src/i18n/locales/zh-tw.json | 2 + .../src/pages/home/Inputbar/Inputbar.tsx | 6 +- .../src/pages/home/Messages/MessageEditor.tsx | 9 ++- .../src/pages/settings/GeneralSettings.tsx | 69 ++++++++++++++++++- src/renderer/src/store/migrate.ts | 4 ++ src/renderer/src/store/settings.ts | 14 +++- 15 files changed, 204 insertions(+), 17 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 4c6988cf6..a3988d1c4 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -3,6 +3,8 @@ export enum IpcChannel { App_ClearCache = 'app:clear-cache', App_SetLaunchOnBoot = 'app:set-launch-on-boot', 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_Reload = 'app:reload', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5f54d64e0..88d66d4a3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -87,6 +87,26 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setLanguage(language) }) + // spell check + ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => { + const windows = BrowserWindow.getAllWindows() + windows.forEach((window) => { + window.webContents.session.setSpellCheckerEnabled(isEnable) + }) + }) + + // spell check languages + ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => { + if (languages.length === 0) { + return + } + const windows = BrowserWindow.getAllWindows() + windows.forEach((window) => { + window.webContents.session.setSpellCheckerLanguages(languages) + }) + configManager.set('spellCheckLanguages', languages) + }) + // launch on boot ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => { // Set login item settings for windows and mac diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 2f4f5aa20..34ec4b911 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -9,7 +9,18 @@ class ContextMenu { const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties) const filtered = template.filter((item) => item.visible !== false) if (filtered.length > 0) { - const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)]) + let template = [...filtered, ...this.createInspectMenuItems(w)] + const dictionarySuggestions = this.createDictionarySuggestions(properties, w) + if (dictionarySuggestions.length > 0) { + template = [ + ...dictionarySuggestions, + { type: 'separator' }, + this.createSpellCheckMenuItem(properties, w), + { type: 'separator' }, + ...template + ] + } + const menu = Menu.buildFromTemplate(template) menu.popup() } }) @@ -72,6 +83,53 @@ class ContextMenu { return template } + + private createSpellCheckMenuItem( + properties: Electron.ContextMenuParams, + mainWindow: Electron.BrowserWindow + ): MenuItemConstructorOptions { + const hasText = properties.selectionText.length > 0 + + return { + id: 'learnSpelling', + label: '&Learn Spelling', + visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), + click: () => { + mainWindow.webContents.session.addWordToSpellCheckerDictionary(properties.misspelledWord) + } + } + } + + private createDictionarySuggestions( + properties: Electron.ContextMenuParams, + mainWindow: Electron.BrowserWindow + ): MenuItemConstructorOptions[] { + const hasText = properties.selectionText.length > 0 + + if (!hasText || !properties.misspelledWord) { + return [] + } + + if (properties.dictionarySuggestions.length === 0) { + return [ + { + id: 'dictionarySuggestions', + label: 'No Guesses Found', + visible: true, + enabled: false + } + ] + } + + return properties.dictionarySuggestions.map((suggestion) => ({ + id: 'dictionarySuggestions', + label: suggestion, + visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), + click: (menuItem: Electron.MenuItem) => { + mainWindow.webContents.replaceMisspelling(menuItem.label) + } + })) + } } export const contextMenu = new ContextMenu() diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index f6322e893..78784120b 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -95,6 +95,7 @@ export class WindowService { this.setupMaximize(mainWindow, mainWindowState.isMaximized) this.setupContextMenu(mainWindow) + this.setupSpellCheck(mainWindow) this.setupWindowEvents(mainWindow) this.setupWebContentsHandlers(mainWindow) this.setupWindowLifecycleEvents(mainWindow) @@ -102,6 +103,18 @@ export class WindowService { this.loadMainWindowContent(mainWindow) } + private setupSpellCheck(mainWindow: BrowserWindow) { + const enableSpellCheck = configManager.get('enableSpellCheck', false) + if (enableSpellCheck) { + try { + const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[] + spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages) + } catch (error) { + Logger.error('Failed to set spell check languages:', error as Error) + } + } + } + private setupMainWindowMonitor(mainWindow: BrowserWindow) { mainWindow.webContents.on('render-process-gone', (_, details) => { Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`) diff --git a/src/preload/index.ts b/src/preload/index.ts index 5138d4e4d..114ad13ef 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,8 @@ const api = { checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog), setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), + setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable), + setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages), setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive), setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive), setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive), diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index fefebd639..fa1f493d8 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -864,7 +864,7 @@ "paint_course": "tutorial", "prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap", "prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts", - "proxy_required": "Open the proxy and enable “TUN mode” to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported", + "proxy_required": "Open the proxy and enable \"TUN mode\" to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported", "image_file_required": "Please upload an image first", "image_file_retry": "Please re-upload an image first", "image_placeholder": "No image available", @@ -1392,6 +1392,8 @@ "general.user_name": "User Name", "general.user_name.placeholder": "Enter your name", "general.view_webdav_settings": "View WebDAV settings", + "general.spell_check": "Spell Check", + "general.spell_check.languages": "Use spell check for", "input.auto_translate_with_space": "Quickly translate with 3 spaces", "input.show_translate_confirm": "Show translation confirmation dialog", "input.target_language": "Target language", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 612df65d7..ffa579d56 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1387,6 +1387,8 @@ "general.user_name": "ユーザー名", "general.user_name.placeholder": "ユーザー名を入力", "general.view_webdav_settings": "WebDAV設定を表示", + "general.spell_check": "スペルチェック", + "general.spell_check.languages": "スペルチェック言語", "input.auto_translate_with_space": "スペースを3回押して翻訳", "input.target_language": "目標言語", "input.target_language.chinese": "簡体字中国語", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index ec2ad7785..a713da42e 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1387,6 +1387,8 @@ "general.user_name": "Имя пользователя", "general.user_name.placeholder": "Введите ваше имя", "general.view_webdav_settings": "Просмотр настроек WebDAV", + "general.spell_check": "Проверка орфографии", + "general.spell_check.languages": "Языки проверки орфографии", "input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов", "input.target_language": "Целевой язык", "input.target_language.chinese": "Китайский упрощенный", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8d7c30f32..c16089db5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -863,8 +863,8 @@ "learn_more": "了解更多", "paint_course": "教程", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", - "prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词", - "proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开,后续会支持国内直连", + "prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词", + "proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连", "image_file_required": "请先上传图片", "image_file_retry": "请重新上传图片", "image_placeholder": "暂无图片", @@ -960,7 +960,7 @@ "magic_prompt_option_tip": "智能优化放大提示词" }, "text_desc_required": "请先输入图片描述", - "req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。", + "req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。", "req_error_token": "请检查令牌有效性", "req_error_no_balance": "请检查令牌有效性", "image_handle_required": "请先上传图片", @@ -1390,9 +1390,11 @@ "general.restore.button": "恢复", "general.title": "常规设置", "general.user_name": "用户名", - "general.user_name.placeholder": "请输入用户名", + "general.user_name.placeholder": "输入您的姓名", "general.view_webdav_settings": "查看 WebDAV 设置", - "input.auto_translate_with_space": "快速敲击3次空格翻译", + "general.spell_check": "拼写检查", + "general.spell_check.languages": "拼写检查语言", + "input.auto_translate_with_space": "3个空格快速翻译", "input.show_translate_confirm": "显示翻译确认对话框", "input.target_language": "目标语言", "input.target_language.chinese": "简体中文", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 1d23fb540..72be4f02e 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1389,6 +1389,8 @@ "general.user_name": "使用者名稱", "general.user_name.placeholder": "輸入您的名稱", "general.view_webdav_settings": "檢視 WebDAV 設定", + "general.spell_check": "拼寫檢查", + "general.spell_check.languages": "拼寫檢查語言", "input.auto_translate_with_space": "快速敲擊 3 次空格翻譯", "input.show_translate_confirm": "顯示翻譯確認對話框", "input.target_language": "目標語言", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 958b77903..360a76d8a 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -77,7 +77,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = showInputEstimatedTokens, autoTranslateWithSpace, enableQuickPanelTriggers, - enableBackspaceDeleteModel + enableBackspaceDeleteModel, + enableSpellCheck } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) @@ -780,9 +781,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = : t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) }) } autoFocus - contextMenu="true" variant="borderless" - spellCheck={false} + spellCheck={enableSpellCheck} rows={2} ref={textareaRef} style={{ diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 895eb787d..62636ccd6 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -40,7 +40,7 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) const model = assistant.model || assistant.defaultModel const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) - const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut } = useSettings() + const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings() const { t } = useTranslation() const textareaRef = useRef(null) const attachmentButtonRef = useRef(null) @@ -222,13 +222,16 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) }} onKeyDown={(e) => handleKeyDown(e, block.id)} autoFocus - contextMenu="true" - spellCheck={false} + spellCheck={enableSpellCheck} onPaste={(e) => onPaste(e.nativeEvent)} onFocus={() => { // 记录当前聚焦的组件 PasteService.setLastFocusedComponent('messageEditor') }} + onContextMenu={(e) => { + // 阻止事件冒泡,避免触发全局的 Electron contextMenu + e.stopPropagation() + }} style={{ fontSize, padding: '0px 15px 8px 15px' diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 7665099f2..ba0de7fdf 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -2,8 +2,15 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import { RootState, useAppDispatch } from '@renderer/store' -import { setEnableDataCollection, setLanguage, setNotificationSettings } from '@renderer/store/settings' -import { setProxyMode, setProxyUrl as _setProxyUrl } from '@renderer/store/settings' +import { + setEnableDataCollection, + setEnableSpellCheck, + setLanguage, + setNotificationSettings, + setProxyMode, + setProxyUrl as _setProxyUrl, + setSpellCheckLanguages +} from '@renderer/store/settings' import { LanguageVarious } from '@renderer/types' import { NotificationSource } from '@renderer/types/notification' import { isValidProxyUrl } from '@renderer/utils' @@ -26,7 +33,8 @@ const GeneralSettings: FC = () => { trayOnClose, tray, proxyMode: storeProxyMode, - enableDataCollection + enableDataCollection, + enableSpellCheck } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) const { theme } = useTheme() @@ -69,6 +77,11 @@ const GeneralSettings: FC = () => { i18n.changeLanguage(value) } + const handleSpellCheckChange = (checked: boolean) => { + dispatch(setEnableSpellCheck(checked)) + window.api.setEnableSpellCheck(checked) + } + const onSetProxyUrl = () => { if (proxyUrl && !isValidProxyUrl(proxyUrl)) { window.message.error({ content: t('message.error.invalid.proxy.url'), key: 'proxy-error' }) @@ -109,11 +122,30 @@ const GeneralSettings: FC = () => { ] const notificationSettings = useSelector((state: RootState) => state.settings.notification) + const spellCheckLanguages = useSelector((state: RootState) => state.settings.spellCheckLanguages) const handleNotificationChange = (type: NotificationSource, value: boolean) => { dispatch(setNotificationSettings({ ...notificationSettings, [type]: value })) } + // Define available spell check languages with display names (only commonly supported languages) + const spellCheckLanguageOptions = [ + { value: 'en-US', label: 'English (US)', flag: '🇺🇸' }, + { value: 'es', label: 'Español', flag: '🇪🇸' }, + { value: 'fr', label: 'Français', flag: '🇫🇷' }, + { value: 'de', label: 'Deutsch', flag: '🇩🇪' }, + { value: 'it', label: 'Italiano', flag: '🇮🇹' }, + { value: 'pt', label: 'Português', flag: '🇵🇹' }, + { value: 'ru', label: 'Русский', flag: '🇷🇺' }, + { value: 'nl', label: 'Nederlands', flag: '🇳🇱' }, + { value: 'pl', label: 'Polski', flag: '🇵🇱' } + ] + + const handleSpellCheckLanguagesChange = (selectedLanguages: string[]) => { + dispatch(setSpellCheckLanguages(selectedLanguages)) + window.api.setSpellCheckLanguages(selectedLanguages) + } + return ( @@ -135,6 +167,37 @@ const GeneralSettings: FC = () => { + + {t('settings.general.spell_check')} + + + {enableSpellCheck && ( + <> + + + {t('settings.general.spell_check.languages')} + ) => { state.enableDataCollection = action.payload }, + setEnableSpellCheck: (state, action: PayloadAction) => { + state.enableSpellCheck = action.payload + }, + setSpellCheckLanguages: (state, action: PayloadAction) => { + state.spellCheckLanguages = action.payload + }, setExportMenuOptions: (state, action: PayloadAction) => { state.exportMenuOptions = action.payload }, @@ -776,8 +786,10 @@ export const { setShowOpenedMinappsInSidebar, setMinappsOpenLinkExternal, setEnableDataCollection, - setEnableQuickPanelTriggers, + setEnableSpellCheck, + setSpellCheckLanguages, setExportMenuOptions, + setEnableQuickPanelTriggers, setEnableBackspaceDeleteModel, setOpenAISummaryText, setOpenAIServiceTier, From f69ea8648c2b5c1ce1bf9854016b76018889be1e Mon Sep 17 00:00:00 2001 From: Ying-xi <62348590+Ying-xi@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:06:52 +0800 Subject: [PATCH 02/56] fix: display updated timestamp when available in knowledge base (#7453) * fix: display updated timestamp when available in knowledge base - Add updated_at field when creating knowledge items - Show updated_at timestamp if it's newer than created_at - Fallback to created_at if updated_at is not available or older Fixes #4587 Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com> * refactor(knowledge): extract display time logic into a reusable function Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com> --------- Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com> --- src/renderer/src/hooks/useKnowledge.ts | 3 ++- .../src/pages/knowledge/KnowledgeContent.tsx | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index 662829612..efdc9bd12 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -169,7 +169,8 @@ export const useKnowledge = (baseId: string) => { processingStatus: 'pending', processingProgress: 0, processingError: '', - uniqueId: undefined + uniqueId: undefined, + updated_at: Date.now() }) setTimeout(() => KnowledgeQueue.checkAllBases(), 0) } diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index 37362bd1d..7d5d25166 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -35,6 +35,11 @@ interface KnowledgeContentProps { const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts] +const getDisplayTime = (item: KnowledgeItem) => { + const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at + return dayjs(timestamp).format('MM-DD HH:mm') +} + const KnowledgeContent: FC = ({ selectedBase }) => { const { t } = useTranslation() const [expandAll, setExpandAll] = useState(false) @@ -335,7 +340,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { ), ext: file.ext, - extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`, + extra: `${getDisplayTime(item)} · ${formatFileSize(file.size)}`, actions: ( {item.uniqueId && ( @@ -392,7 +397,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { ), ext: '.folder', - extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`, + extra: getDisplayTime(item), actions: ( {item.uniqueId && - - - - - - - }> - - ( - - - {maskApiKey(status.key)} - - {status.checking && ( - - } /> - - )} - {status.isValid === true && !status.checking && } - {status.isValid === false && !status.checking && } - {status.isValid === undefined && !status.checking && ( - {t('settings.provider.not_checked')} - )} - - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1 - }} - /> - - - - )} - /> - - - ) -} - -export default class ApiCheckPopup { - static topviewId = 0 - static hide() { - TopView.hide('ApiCheckPopup') - } - static show(props: ShowParams) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.hide() - }} - />, - 'ApiCheckPopup' - ) - }) - } -} - -const RemoveIcon = styled(MinusCircleOutlined)` - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - color: var(--color-error); - cursor: pointer; - transition: all 0.2s ease-in-out; -` diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx new file mode 100644 index 000000000..def4ba061 --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx @@ -0,0 +1,638 @@ +import { + CheckCircleFilled, + CloseCircleFilled, + CloseCircleOutlined, + DeleteOutlined, + EditOutlined, + LoadingOutlined, + MinusCircleOutlined, + PlusOutlined +} from '@ant-design/icons' +import Scrollbar from '@renderer/components/Scrollbar' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { checkApi, formatApiKeys } from '@renderer/services/ApiService' +import { isProviderSupportAuth } from '@renderer/services/ProviderService' +import WebSearchService from '@renderer/services/WebSearchService' +import { Model, Provider, WebSearchProvider } from '@renderer/types' +import { maskApiKey, splitApiKeyString } from '@renderer/utils/api' +import { Button, Card, Flex, Input, List, Space, Spin, Tooltip, Typography } from 'antd' +import { isEmpty } from 'lodash' +import { FC, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import SelectProviderModelPopup from './SelectProviderModelPopup' + +interface Props { + provider: Provider | WebSearchProvider + apiKeys: string + onChange: (keys: string) => void + type?: 'provider' | 'websearch' +} + +interface KeyStatus { + key: string + isValid?: boolean + checking?: boolean + error?: string + model?: Model + latency?: number +} + +const STATUS_COLORS = { + success: '#52c41a', + error: '#ff4d4f' +} + +const formatAndConvertKeysToArray = (apiKeys: string): KeyStatus[] => { + const formattedApiKeys = formatApiKeys(apiKeys) + if (formattedApiKeys.includes(',')) { + const keys = splitApiKeyString(formattedApiKeys) + const uniqueKeys = new Set(keys) + return Array.from(uniqueKeys).map((key) => ({ key })) + } else { + return formattedApiKeys ? [{ key: formattedApiKeys }] : [] + } +} + +const ApiKeyList: FC = ({ provider, apiKeys, onChange, type = 'provider' }) => { + const [keyStatuses, setKeyStatuses] = useState(() => formatAndConvertKeysToArray(apiKeys)) + const [isAddingNew, setIsAddingNew] = useState(false) + const [newApiKey, setNewApiKey] = useState('') + const [isCancelingNewKey, setIsCancelingNewKey] = useState(false) + const newInputRef = useRef(null) + const [editingIndex, setEditingIndex] = useState(null) + const [editValue, setEditValue] = useState('') + const editInputRef = useRef(null) + const { t } = useTranslation() + const [isChecking, setIsChecking] = useState(false) + const [isCheckingSingle, setIsCheckingSingle] = useState(false) + const [confirmDeleteIndex, setConfirmDeleteIndex] = useState(null) + const isCopilot = provider.id === 'copilot' + + useEffect(() => { + if (isAddingNew && newInputRef.current) { + newInputRef.current.focus() + } + }, [isAddingNew]) + + useEffect(() => { + const newKeyStatuses = formatAndConvertKeysToArray(apiKeys) + + setKeyStatuses((currentStatuses) => { + const newKeys = newKeyStatuses.map((k) => k.key) + const currentKeys = currentStatuses.map((k) => k.key) + + // If the keys are the same, no need to update, prevents re-render loops. + if (newKeys.join(',') === currentKeys.join(',')) { + return currentStatuses + } + + // Merge new keys with existing statuses to preserve them. + const statusesMap = new Map(currentStatuses.map((s) => [s.key, s])) + return newKeyStatuses.map((k) => statusesMap.get(k.key) || k) + }) + }, [apiKeys]) + + useEffect(() => { + if (editingIndex !== null && editInputRef.current) { + editInputRef.current.focus() + } + }, [editingIndex]) + + const handleAddNewKey = () => { + setIsCancelingNewKey(false) + setIsAddingNew(true) + setNewApiKey('') + } + + const handleSaveNewKey = () => { + if (isCancelingNewKey) { + setIsCancelingNewKey(false) + return + } + + if (newApiKey.trim()) { + // Check if the key already exists + const keyExists = keyStatuses.some((status) => status.key === newApiKey.trim()) + + if (keyExists) { + window.message.error({ + key: 'duplicate-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.key_already_exists') + }) + return + } + + if (newApiKey.includes(',')) { + window.message.error({ + key: 'invalid-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.invalid_key') + }) + return + } + + const updatedKeyStatuses = [...keyStatuses, { key: newApiKey.trim() }] + setKeyStatuses(updatedKeyStatuses) + // Update parent component with new keys + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + } + + // Add a small delay before resetting to prevent immediate re-triggering + setTimeout(() => { + setIsAddingNew(false) + setNewApiKey('') + }, 100) + } + + const handleCancelNewKey = () => { + setIsCancelingNewKey(true) + setIsAddingNew(false) + setNewApiKey('') + } + + const getModelForCheck = async (selectedModel?: Model): Promise => { + if (type !== 'provider') return null + + const modelsToCheck = (provider as Provider).models.filter( + (model) => !isEmbeddingModel(model) && !isRerankModel(model) + ) + + if (isEmpty(modelsToCheck)) { + window.message.error({ + key: 'no-models', + style: { marginTop: '3vh' }, + duration: 5, + content: t('settings.provider.no_models_for_check') + }) + return null + } + + try { + return ( + selectedModel || + (await SelectProviderModelPopup.show({ + provider: provider as Provider + })) + ) + } catch (err) { + // User canceled the popup + return null + } + } + + const checkSingleKey = async (keyIndex: number, selectedModel?: Model, isCheckingAll: boolean = false) => { + if (isChecking || keyStatuses[keyIndex].checking) { + return + } + + try { + let latency: number + let model: Model | undefined + + if (type === 'provider') { + const selectedModelForCheck = await getModelForCheck(selectedModel) + if (!selectedModelForCheck) { + setKeyStatuses((prev) => + prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false } : status)) + ) + setIsCheckingSingle(false) + return + } + model = selectedModelForCheck + + setIsCheckingSingle(true) + setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) + + const startTime = Date.now() + await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) + latency = Date.now() - startTime + } else { + setIsCheckingSingle(true) + setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) + + const startTime = Date.now() + await WebSearchService.checkSearch({ + ...(provider as WebSearchProvider), + apiKey: keyStatuses[keyIndex].key + }) + latency = Date.now() - startTime + } + + // Only show notification when checking a single key + if (!isCheckingAll) { + window.message.success({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 2, + content: t('message.api.connection.success') + }) + } + + setKeyStatuses((prev) => + prev.map((status, idx) => + idx === keyIndex + ? { + ...status, + checking: false, + isValid: true, + model: selectedModel || model, + latency + } + : status + ) + ) + } catch (error: any) { + // Only show notification when checking a single key + if (!isCheckingAll) { + const errorMessage = error?.message ? ' ' + error.message : '' + window.message.error({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 8, + content: t('message.api.connection.failed') + errorMessage + }) + } + + setKeyStatuses((prev) => + prev.map((status, idx) => + idx === keyIndex + ? { + ...status, + checking: false, + isValid: false, + error: error instanceof Error ? error.message : String(error) + } + : status + ) + ) + } finally { + setIsCheckingSingle(false) + } + } + + const checkAllKeys = async () => { + setIsChecking(true) + + try { + let selectedModel + if (type === 'provider') { + selectedModel = await getModelForCheck() + if (!selectedModel) { + return + } + } + + await Promise.all(keyStatuses.map((_, index) => checkSingleKey(index, selectedModel, true))) + } finally { + setIsChecking(false) + } + } + + const removeInvalidKeys = () => { + const updatedKeyStatuses = keyStatuses.filter((status) => status.isValid !== false) + setKeyStatuses(updatedKeyStatuses) + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + } + + const removeKey = (keyIndex: number) => { + if (confirmDeleteIndex === keyIndex) { + // Second click - actually remove the key + const updatedKeyStatuses = keyStatuses.filter((_, idx) => idx !== keyIndex) + setKeyStatuses(updatedKeyStatuses) + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + setConfirmDeleteIndex(null) + } else { + // First click - show confirmation state + setConfirmDeleteIndex(keyIndex) + // Auto-reset after 3 seconds + setTimeout(() => { + setConfirmDeleteIndex(null) + }, 3000) + } + } + + const renderKeyCheckResultTooltip = (status: KeyStatus) => { + if (status.checking) { + return t('settings.models.check.checking') + } + + const statusTitle = status.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed') + const statusColor = status.isValid ? STATUS_COLORS.success : STATUS_COLORS.error + + return ( +
+ {statusTitle} + {type === 'provider' && status.model && ( +
+ {t('common.model')}: {status.model.name} +
+ )} + {status.latency && status.isValid && ( +
+ {t('settings.provider.check_tooltip.latency')}: {(status.latency / 1000).toFixed(2)}s +
+ )} + {status.error &&
{status.error}
} +
+ ) + } + + const shouldAutoFocus = () => { + if (type === 'provider') { + return (provider as Provider).enabled && apiKeys === '' && !isProviderSupportAuth(provider as Provider) + } else if (type === 'websearch') { + return apiKeys === '' + } + return false + } + + const handleEditKey = (index: number) => { + setEditingIndex(index) + setEditValue(keyStatuses[index].key) + } + + const handleSaveEdit = () => { + if (editingIndex === null) return + + if (editValue.trim()) { + const keyExists = keyStatuses.some((status, idx) => idx !== editingIndex && status.key === editValue.trim()) + + if (keyExists) { + window.message.error({ + key: 'duplicate-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.key_already_exists') + }) + return + } + + if (editValue.includes(',')) { + window.message.error({ + key: 'invalid-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.invalid_key') + }) + return + } + + const updatedKeyStatuses = [...keyStatuses] + updatedKeyStatuses[editingIndex] = { + ...updatedKeyStatuses[editingIndex], + key: editValue.trim(), + isValid: undefined + } + + setKeyStatuses(updatedKeyStatuses) + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + } + + // Add a small delay before resetting to prevent immediate re-triggering + setTimeout(() => { + setEditingIndex(null) + setEditValue('') + }, 100) + } + + const handleCancelEdit = () => { + setEditingIndex(null) + setEditValue('') + } + + return ( + <> + + {keyStatuses.length === 0 && !isAddingNew ? ( + + {t('error.no_api_key')} + + ) : ( + <> + {keyStatuses.length > 0 && ( + + ( + + + + {editingIndex === index ? ( + setEditValue(e.target.value)} + onBlur={handleSaveEdit} + onPressEnter={handleSaveEdit} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault() + handleCancelEdit() + } + }} + style={{ width: '100%', fontSize: '14px' }} + spellCheck={false} + type="password" + /> + ) : ( + {maskApiKey(status.key)} + )} + + + {editingIndex === index ? ( + + ) : ( + <> + + {status.checking && ( + + } /> + + )} + {status.isValid === true && !status.checking && ( + + )} + {status.isValid === false && !status.checking && ( + + )} + + + {!isCopilot && ( + <> + !isChecking && !isCheckingSingle && handleEditKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1, + fontSize: '16px' + }} + title={t('common.edit')} + /> + {confirmDeleteIndex === index ? ( + !isChecking && !isCheckingSingle && removeKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1, + fontSize: '16px', + color: 'var(--color-error)' + }} + title={t('common.delete')} + /> + ) : ( + !isChecking && !isCheckingSingle && removeKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1, + fontSize: '16px', + color: 'var(--color-error)' + }} + title={t('common.delete')} + /> + )} + + )} + + )} + + + + )} + /> + + )} + {isAddingNew && ( + + + setNewApiKey(e.target.value)} + placeholder={t('settings.provider.enter_new_api_key')} + style={{ width: '60%', fontSize: '14px' }} + onPressEnter={handleSaveNewKey} + onBlur={handleSaveNewKey} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault() + handleCancelNewKey() + } + }} + spellCheck={false} + type="password" + /> + + + + + + )} + + )} + + + + {!isCopilot && ( + <> + + + + {keyStatuses.length > 1 && ( + + + + + )} + + )} + + + ) +} + +// Styled components for the list items +const ApiKeyListItem = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0; + margin: 0; +` + +const ApiKeyContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; +` + +const ApiKeyActions = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + + @keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } + } +` + +export default ApiKeyList diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 74a414d81..f708b2867 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,14 +1,11 @@ -import { CheckOutlined, LoadingOutlined } from '@ant-design/icons' import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' import { HStack } from '@renderer/components/Layout' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { isRerankModel } from '@renderer/config/models' import { PROVIDER_CONFIG } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' -import i18n from '@renderer/i18n' -import { checkApi, formatApiKeys } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' @@ -16,7 +13,7 @@ import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' -import { debounce, isEmpty } from 'lodash' +import { isEmpty } from 'lodash' import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' @@ -31,7 +28,7 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from './ApiCheckPopup' +import ApiKeyList from './ApiKeyList' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -41,7 +38,6 @@ import ModelList, { ModelStatus } from './ModelList' import ModelListSearchBar from './ModelListSearchBar' import ProviderOAuth from './ProviderOAuth' import ProviderSettingsPopup from './ProviderSettingsPopup' -import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' interface Props { @@ -55,14 +51,11 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [apiKey, setApiKey] = useState(provider.apiKey) const [apiHost, setApiHost] = useState(provider.apiHost) const [apiVersion, setApiVersion] = useState(provider.apiVersion) - const [apiValid, setApiValid] = useState(false) - const [apiChecking, setApiChecking] = useState(false) const [modelSearchText, setModelSearchText] = useState('') const deferredModelSearchText = useDeferredValue(modelSearchText) const { updateProvider, models } = useProvider(provider.id) const { t } = useTranslation() const { theme } = useTheme() - const [inputValue, setInputValue] = useState(apiKey) const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -76,14 +69,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [modelStatuses, setModelStatuses] = useState([]) const [isHealthChecking, setIsHealthChecking] = useState(false) - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedSetApiKey = useCallback( - debounce((value) => { - setApiKey(formatApiKeys(value)) - }, 100), - [] - ) - const moveProviderToTop = useCallback( (providerId: string) => { const reorderedProviders = [...allProviders] @@ -99,12 +84,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) - const onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } - } - const onUpdateApiHost = () => { if (apiHost.trim()) { updateProvider({ ...provider, apiHost }) @@ -113,6 +92,11 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } } + const handleApiKeyChange = (newApiKey: string) => { + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) + } + const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) const onHealthCheck = async () => { @@ -192,75 +176,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { setIsHealthChecking(false) } - const onCheckApi = async () => { - const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) - - if (isEmpty(modelsToCheck)) { - window.message.error({ - key: 'no-models', - style: { marginTop: '3vh' }, - duration: 5, - content: t('settings.provider.no_models_for_check') - }) - return - } - - const model = await SelectProviderModelPopup.show({ provider }) - - if (!model) { - window.message.error({ content: i18n.t('message.error.enter.model'), key: 'api-check' }) - return - } - - if (apiKey.includes(',')) { - const keys = splitApiKeyString(apiKey) - - const result = await ApiCheckPopup.show({ - title: t('settings.provider.check_multiple_keys'), - provider: { ...provider, apiHost }, - model, - apiKeys: keys, - type: 'provider' - }) - - if (result?.validKeys) { - const newApiKey = result.validKeys.join(',') - setInputValue(newApiKey) - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) - } - } else { - setApiChecking(true) - - try { - await checkApi({ ...provider, apiKey, apiHost }, model) - - window.message.success({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 2, - content: i18n.t('message.api.connection.success') - }) - - setApiValid(true) - setTimeout(() => setApiValid(false), 3000) - } catch (error: any) { - const errorMessage = error?.message ? ' ' + error.message : '' - - window.message.error({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 8, - content: i18n.t('message.api.connection.failed') + errorMessage - }) - - setApiValid(false) - } finally { - setApiChecking(false) - } - } - } - const onReset = () => { setApiHost(configedApiHost) updateProvider({ ...provider, apiHost: configedApiHost }) @@ -329,7 +244,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { provider={provider} setApiKey={(v) => { setApiKey(v) - setInputValue(v) updateProvider({ ...provider, apiKey: v }) }} /> @@ -338,35 +252,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { {isDmxapi && } {provider.id !== 'vertexai' && ( <> - {t('settings.provider.api_key')} - - { - setInputValue(e.target.value) - debouncedSetApiKey(e.target.value) - }} - onBlur={() => { - const formattedValue = formatApiKeys(inputValue) - setInputValue(formattedValue) - setApiKey(formattedValue) - onUpdateApiKey() - }} - spellCheck={false} - autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)} - disabled={provider.id === 'copilot'} - /> - - + + + {t('settings.provider.api_key')} + + + {apiKeyWebsite && ( - + {!isDmxapi && ( @@ -374,7 +267,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} - {t('settings.provider.api_key.tip')} )} {!isDmxapi && ( diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 44a3f0171..97d01d4f5 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,19 +1,17 @@ -import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' +import { ExportOutlined } from '@ant-design/icons' import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' -import { formatApiKeys } from '@renderer/services/ApiService' -import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' -import { Button, Divider, Flex, Form, Input, Tooltip } from 'antd' +import { Divider, Flex, Form, Input, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { Info } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup' +import { SettingDivider, SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' +import ApiKeyList from '../ProviderSettings/ApiKeyList' interface Props { provider: WebSearchProvider @@ -24,19 +22,16 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { const { t } = useTranslation() const [apiKey, setApiKey] = useState(provider.apiKey || '') const [apiHost, setApiHost] = useState(provider.apiHost || '') - const [apiChecking, setApiChecking] = useState(false) const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '') const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '') - const [apiValid, setApiValid] = useState(false) const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey const officialWebsite = webSearchProviderConfig?.websites?.official - const onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } + const handleApiKeyChange = (newApiKey: string) => { + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) } const onUpdateApiHost = () => { @@ -71,65 +66,6 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { } } - async function checkSearch() { - if (!provider) { - window.message.error({ - content: t('settings.websearch.no_provider_selected'), - duration: 3, - icon: , - key: 'no-provider-selected' - }) - return - } - - if (apiKey.includes(',')) { - const keys = apiKey - .split(',') - .map((k) => k.trim()) - .filter((k) => k) - - const result = await ApiCheckPopup.show({ - title: t('settings.provider.check_multiple_keys'), - provider: { ...provider, apiHost }, - apiKeys: keys, - type: 'websearch' - }) - - if (result?.validKeys) { - setApiKey(result.validKeys.join(',')) - updateProvider({ ...provider, apiKey: result.validKeys.join(',') }) - } - return - } - - try { - setApiChecking(true) - const { valid, error } = await WebSearchService.checkSearch(provider) - - const errorMessage = error && error?.message ? ' ' + error?.message : '' - window.message[valid ? 'success' : 'error']({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: valid ? 2 : 8, - content: valid ? t('settings.websearch.check_success') : t('settings.websearch.check_failed') + errorMessage - }) - - setApiValid(valid) - } catch (err) { - console.error('Check search error:', err) - setApiValid(false) - window.message.error({ - key: 'check-search-error', - style: { marginTop: '3vh' }, - duration: 8, - content: t('settings.websearch.check_failed') - }) - } finally { - setApiChecking(false) - setTimeout(() => setApiValid(false), 2500) - } - } - useEffect(() => { setApiKey(provider.apiKey ?? '') setApiHost(provider.apiHost ?? '') @@ -154,30 +90,14 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {hasObjectKey(provider, 'apiKey') && ( <> {t('settings.provider.api_key')} - - setApiKey(formatApiKeys(e.target.value))} - onBlur={onUpdateApiKey} - spellCheck={false} - type="password" - autoFocus={apiKey === ''} - /> - - - - - {t('settings.websearch.get_api_key')} - - {t('settings.provider.api_key.tip')} - + + {apiKeyWebsite && ( + + + {t('settings.websearch.get_api_key')} + + + )} )} {hasObjectKey(provider, 'apiHost') && ( From f2c9bf433e25912bced3bca0ba525f986dc4ae60 Mon Sep 17 00:00:00 2001 From: one Date: Tue, 24 Jun 2025 04:01:05 +0800 Subject: [PATCH 04/56] refactor(CodePreview): auto resize gutters (#7481) * refactor(CodePreview): auto resize gutters * refactor: remove unnecessary usememo --- .../components/CodeBlockView/CodePreview.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index d3c56f295..566a980f6 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -168,9 +168,15 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { } }, [highlightCode]) - const hasHighlightedCode = useMemo(() => { - return tokenLines.length > 0 - }, [tokenLines.length]) + useEffect(() => { + const container = codeContentRef.current + if (!container || !codeShowLineNumbers) return + + const digits = Math.max(tokenLines.length.toString().length, 1) + container.style.setProperty('--line-digits', digits.toString()) + }, [codeShowLineNumbers, tokenLines.length]) + + const hasHighlightedCode = tokenLines.length > 0 return ( (props.$lineNumbers ? '2rem' : '0')}; + padding-left: ${(props) => (props.$lineNumbers ? 'var(--gutter-width)' : '0')}; * { overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; From e2b813372950b041cf81c777d5b9236d097ff1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Tue, 24 Jun 2025 18:51:58 +0800 Subject: [PATCH 05/56] refactor: file actions into FileAction service (#7413) * refactor: file actions into FileAction service Moved file sorting, deletion, and renaming logic from FilesPage to a new FileAction service for better modularity and reuse. Updated FileList and FilesPage to use the new service functions, and improved the delete button UI in FileList. --- src/renderer/src/pages/files/FileList.tsx | 42 +++++++ src/renderer/src/pages/files/FilesPage.tsx | 137 +-------------------- src/renderer/src/services/FileAction.ts | 98 +++++++++++++++ 3 files changed, 143 insertions(+), 134 deletions(-) create mode 100644 src/renderer/src/services/FileAction.ts diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx index cdb042143..a08de9912 100644 --- a/src/renderer/src/pages/files/FileList.tsx +++ b/src/renderer/src/pages/files/FileList.tsx @@ -1,3 +1,5 @@ +import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons' +import { handleDelete } from '@renderer/services/FileAction' import FileManager from '@renderer/services/FileManager' import { FileType, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' @@ -48,6 +50,24 @@ const FileList: React.FC = ({ id, list, files }) => {
{formatFileSize(file.size)}
+ { + e.stopPropagation() + window.modal.confirm({ + title: t('files.delete.title'), + content: t('files.delete.content'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk: () => { + handleDelete(file.id, t) + }, + icon: + }) + }}> + + ))} @@ -159,4 +179,26 @@ const ImageInfo = styled.div` } ` +const DeleteButton = styled.div` + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.6); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1; + + &:hover { + background-color: rgba(255, 0, 0, 0.8); + } +` + export default memo(FileList) diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index c070478fe..2890a0cb8 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -7,13 +7,10 @@ import { } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import ListItem from '@renderer/components/ListItem' -import TextEditPopup from '@renderer/components/Popups/TextEditPopup' -import Logger from '@renderer/config/logger' import db from '@renderer/databases' +import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction' import FileManager from '@renderer/services/FileManager' -import store from '@renderer/store' import { FileType, FileTypes } from '@renderer/types' -import { Message } from '@renderer/types/newMessage' import { formatFileSize } from '@renderer/utils' import { Button, Empty, Flex, Popconfirm } from 'antd' import dayjs from 'dayjs' @@ -34,34 +31,6 @@ const FilesPage: FC = () => { const [sortField, setSortField] = useState('created_at') const [sortOrder, setSortOrder] = useState('desc') - const tempFilesSort = (files: FileType[]) => { - return files.sort((a, b) => { - const aIsTemp = a.origin_name.startsWith('temp_file') - const bIsTemp = b.origin_name.startsWith('temp_file') - if (aIsTemp && !bIsTemp) return 1 - if (!aIsTemp && bIsTemp) return -1 - return 0 - }) - } - - const sortFiles = (files: FileType[]) => { - return [...files].sort((a, b) => { - let comparison = 0 - switch (sortField) { - case 'created_at': - comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix() - break - case 'size': - comparison = a.size - b.size - break - case 'name': - comparison = a.origin_name.localeCompare(b.origin_name) - break - } - return sortOrder === 'asc' ? comparison : -comparison - }) - } - const files = useLiveQuery(() => { if (fileType === 'all') { return db.files.orderBy('count').toArray().then(tempFilesSort) @@ -69,106 +38,7 @@ const FilesPage: FC = () => { return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort) }, [fileType]) - const sortedFiles = files ? sortFiles(files) : [] - - const handleDelete = async (fileId: string) => { - const file = await FileManager.getFile(fileId) - if (!file) return - - const paintings = await store.getState().paintings.paintings - const paintingsFiles = paintings.flatMap((p) => p.files) - - if (paintingsFiles.some((p) => p.id === fileId)) { - window.modal.warning({ content: t('files.delete.paintings.warning'), centered: true }) - return - } - if (file) { - await FileManager.deleteFile(fileId, true) - } - - const relatedBlocks = await db.message_blocks.where('file.id').equals(fileId).toArray() - - const blockIdsToDelete = relatedBlocks.map((block) => block.id) - - const blocksByMessageId: Record = {} - for (const block of relatedBlocks) { - if (!blocksByMessageId[block.messageId]) { - blocksByMessageId[block.messageId] = [] - } - blocksByMessageId[block.messageId].push(block.id) - } - - try { - const affectedMessageIds = [...new Set(relatedBlocks.map((b) => b.messageId))] - - if (affectedMessageIds.length === 0 && blockIdsToDelete.length > 0) { - // This case should ideally not happen if relatedBlocks were found, - // but handle it just in case: only delete blocks. - await db.message_blocks.bulkDelete(blockIdsToDelete) - Logger.log( - `Deleted ${blockIdsToDelete.length} blocks related to file ${fileId}. No associated messages found (unexpected).` - ) - return - } - - await db.transaction('rw', db.topics, db.message_blocks, async () => { - // Fetch all topics (potential performance bottleneck if many topics) - const allTopics = await db.topics.toArray() - const topicsToUpdate: Record = {} // Store updates keyed by topicId - - for (const topic of allTopics) { - let topicModified = false - // Ensure topic.messages exists and is an array before mapping - const currentMessages = Array.isArray(topic.messages) ? topic.messages : [] - const updatedMessages = currentMessages.map((message) => { - // Check if this message is affected - if (affectedMessageIds.includes(message.id)) { - // Ensure message.blocks exists and is an array - const currentBlocks = Array.isArray(message.blocks) ? message.blocks : [] - const originalBlockCount = currentBlocks.length - // Filter out the blocks marked for deletion - const newBlocks = currentBlocks.filter((blockId) => !blockIdsToDelete.includes(blockId)) - if (newBlocks.length < originalBlockCount) { - topicModified = true - return { ...message, blocks: newBlocks } // Return updated message - } - } - return message // Return original message - }) - - if (topicModified) { - // Store the update for this topic - topicsToUpdate[topic.id] = { messages: updatedMessages } - } - } - - // Apply updates to topics - const updatePromises = Object.entries(topicsToUpdate).map(([topicId, updateData]) => - db.topics.update(topicId, updateData) - ) - await Promise.all(updatePromises) - - // Finally, delete the MessageBlocks - await db.message_blocks.bulkDelete(blockIdsToDelete) - }) - - Logger.log(`Deleted ${blockIdsToDelete.length} blocks and updated relevant topic messages for file ${fileId}.`) - } catch (error) { - Logger.error(`Error updating topics or deleting blocks for file ${fileId}:`, error) - window.modal.error({ content: t('files.delete.db_error'), centered: true }) // 提示数据库操作失败 - // Consider whether to attempt to restore the physical file (usually difficult) - } - } - - const handleRename = async (fileId: string) => { - const file = await FileManager.getFile(fileId) - if (file) { - const newName = await TextEditPopup.show({ text: file.origin_name }) - if (newName) { - FileManager.updateFile({ ...file, origin_name: newName }) - } - } - } + const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : [] const dataSource = sortedFiles?.map((file) => { return { @@ -189,7 +59,7 @@ const FilesPage: FC = () => { description={t('files.delete.content')} okText={t('common.confirm')} cancelText={t('common.cancel')} - onConfirm={() => handleDelete(file.id)} + onConfirm={() => handleDelete(file.id, t)} icon={}> + + + + + + + }> + + ( + + + {maskApiKey(status.key)} + + {status.checking && ( + + } /> + + )} + {status.isValid === true && !status.checking && } + {status.isValid === false && !status.checking && } + {status.isValid === undefined && !status.checking && ( + {t('settings.provider.not_checked')} + )} + + !isChecking && !isCheckingSingle && removeKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1 + }} + /> + + + + )} + /> + + + ) +} + +export default class ApiCheckPopup { + static topviewId = 0 + static hide() { + TopView.hide('ApiCheckPopup') + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + 'ApiCheckPopup' + ) + }) + } +} + +const RemoveIcon = styled(MinusCircleOutlined)` + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--color-error); + cursor: pointer; + transition: all 0.2s ease-in-out; +` diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx deleted file mode 100644 index def4ba061..000000000 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx +++ /dev/null @@ -1,638 +0,0 @@ -import { - CheckCircleFilled, - CloseCircleFilled, - CloseCircleOutlined, - DeleteOutlined, - EditOutlined, - LoadingOutlined, - MinusCircleOutlined, - PlusOutlined -} from '@ant-design/icons' -import Scrollbar from '@renderer/components/Scrollbar' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import { checkApi, formatApiKeys } from '@renderer/services/ApiService' -import { isProviderSupportAuth } from '@renderer/services/ProviderService' -import WebSearchService from '@renderer/services/WebSearchService' -import { Model, Provider, WebSearchProvider } from '@renderer/types' -import { maskApiKey, splitApiKeyString } from '@renderer/utils/api' -import { Button, Card, Flex, Input, List, Space, Spin, Tooltip, Typography } from 'antd' -import { isEmpty } from 'lodash' -import { FC, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -import SelectProviderModelPopup from './SelectProviderModelPopup' - -interface Props { - provider: Provider | WebSearchProvider - apiKeys: string - onChange: (keys: string) => void - type?: 'provider' | 'websearch' -} - -interface KeyStatus { - key: string - isValid?: boolean - checking?: boolean - error?: string - model?: Model - latency?: number -} - -const STATUS_COLORS = { - success: '#52c41a', - error: '#ff4d4f' -} - -const formatAndConvertKeysToArray = (apiKeys: string): KeyStatus[] => { - const formattedApiKeys = formatApiKeys(apiKeys) - if (formattedApiKeys.includes(',')) { - const keys = splitApiKeyString(formattedApiKeys) - const uniqueKeys = new Set(keys) - return Array.from(uniqueKeys).map((key) => ({ key })) - } else { - return formattedApiKeys ? [{ key: formattedApiKeys }] : [] - } -} - -const ApiKeyList: FC = ({ provider, apiKeys, onChange, type = 'provider' }) => { - const [keyStatuses, setKeyStatuses] = useState(() => formatAndConvertKeysToArray(apiKeys)) - const [isAddingNew, setIsAddingNew] = useState(false) - const [newApiKey, setNewApiKey] = useState('') - const [isCancelingNewKey, setIsCancelingNewKey] = useState(false) - const newInputRef = useRef(null) - const [editingIndex, setEditingIndex] = useState(null) - const [editValue, setEditValue] = useState('') - const editInputRef = useRef(null) - const { t } = useTranslation() - const [isChecking, setIsChecking] = useState(false) - const [isCheckingSingle, setIsCheckingSingle] = useState(false) - const [confirmDeleteIndex, setConfirmDeleteIndex] = useState(null) - const isCopilot = provider.id === 'copilot' - - useEffect(() => { - if (isAddingNew && newInputRef.current) { - newInputRef.current.focus() - } - }, [isAddingNew]) - - useEffect(() => { - const newKeyStatuses = formatAndConvertKeysToArray(apiKeys) - - setKeyStatuses((currentStatuses) => { - const newKeys = newKeyStatuses.map((k) => k.key) - const currentKeys = currentStatuses.map((k) => k.key) - - // If the keys are the same, no need to update, prevents re-render loops. - if (newKeys.join(',') === currentKeys.join(',')) { - return currentStatuses - } - - // Merge new keys with existing statuses to preserve them. - const statusesMap = new Map(currentStatuses.map((s) => [s.key, s])) - return newKeyStatuses.map((k) => statusesMap.get(k.key) || k) - }) - }, [apiKeys]) - - useEffect(() => { - if (editingIndex !== null && editInputRef.current) { - editInputRef.current.focus() - } - }, [editingIndex]) - - const handleAddNewKey = () => { - setIsCancelingNewKey(false) - setIsAddingNew(true) - setNewApiKey('') - } - - const handleSaveNewKey = () => { - if (isCancelingNewKey) { - setIsCancelingNewKey(false) - return - } - - if (newApiKey.trim()) { - // Check if the key already exists - const keyExists = keyStatuses.some((status) => status.key === newApiKey.trim()) - - if (keyExists) { - window.message.error({ - key: 'duplicate-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.key_already_exists') - }) - return - } - - if (newApiKey.includes(',')) { - window.message.error({ - key: 'invalid-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.invalid_key') - }) - return - } - - const updatedKeyStatuses = [...keyStatuses, { key: newApiKey.trim() }] - setKeyStatuses(updatedKeyStatuses) - // Update parent component with new keys - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - // Add a small delay before resetting to prevent immediate re-triggering - setTimeout(() => { - setIsAddingNew(false) - setNewApiKey('') - }, 100) - } - - const handleCancelNewKey = () => { - setIsCancelingNewKey(true) - setIsAddingNew(false) - setNewApiKey('') - } - - const getModelForCheck = async (selectedModel?: Model): Promise => { - if (type !== 'provider') return null - - const modelsToCheck = (provider as Provider).models.filter( - (model) => !isEmbeddingModel(model) && !isRerankModel(model) - ) - - if (isEmpty(modelsToCheck)) { - window.message.error({ - key: 'no-models', - style: { marginTop: '3vh' }, - duration: 5, - content: t('settings.provider.no_models_for_check') - }) - return null - } - - try { - return ( - selectedModel || - (await SelectProviderModelPopup.show({ - provider: provider as Provider - })) - ) - } catch (err) { - // User canceled the popup - return null - } - } - - const checkSingleKey = async (keyIndex: number, selectedModel?: Model, isCheckingAll: boolean = false) => { - if (isChecking || keyStatuses[keyIndex].checking) { - return - } - - try { - let latency: number - let model: Model | undefined - - if (type === 'provider') { - const selectedModelForCheck = await getModelForCheck(selectedModel) - if (!selectedModelForCheck) { - setKeyStatuses((prev) => - prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false } : status)) - ) - setIsCheckingSingle(false) - return - } - model = selectedModelForCheck - - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - const startTime = Date.now() - await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) - latency = Date.now() - startTime - } else { - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - const startTime = Date.now() - await WebSearchService.checkSearch({ - ...(provider as WebSearchProvider), - apiKey: keyStatuses[keyIndex].key - }) - latency = Date.now() - startTime - } - - // Only show notification when checking a single key - if (!isCheckingAll) { - window.message.success({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 2, - content: t('message.api.connection.success') - }) - } - - setKeyStatuses((prev) => - prev.map((status, idx) => - idx === keyIndex - ? { - ...status, - checking: false, - isValid: true, - model: selectedModel || model, - latency - } - : status - ) - ) - } catch (error: any) { - // Only show notification when checking a single key - if (!isCheckingAll) { - const errorMessage = error?.message ? ' ' + error.message : '' - window.message.error({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 8, - content: t('message.api.connection.failed') + errorMessage - }) - } - - setKeyStatuses((prev) => - prev.map((status, idx) => - idx === keyIndex - ? { - ...status, - checking: false, - isValid: false, - error: error instanceof Error ? error.message : String(error) - } - : status - ) - ) - } finally { - setIsCheckingSingle(false) - } - } - - const checkAllKeys = async () => { - setIsChecking(true) - - try { - let selectedModel - if (type === 'provider') { - selectedModel = await getModelForCheck() - if (!selectedModel) { - return - } - } - - await Promise.all(keyStatuses.map((_, index) => checkSingleKey(index, selectedModel, true))) - } finally { - setIsChecking(false) - } - } - - const removeInvalidKeys = () => { - const updatedKeyStatuses = keyStatuses.filter((status) => status.isValid !== false) - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - const removeKey = (keyIndex: number) => { - if (confirmDeleteIndex === keyIndex) { - // Second click - actually remove the key - const updatedKeyStatuses = keyStatuses.filter((_, idx) => idx !== keyIndex) - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - setConfirmDeleteIndex(null) - } else { - // First click - show confirmation state - setConfirmDeleteIndex(keyIndex) - // Auto-reset after 3 seconds - setTimeout(() => { - setConfirmDeleteIndex(null) - }, 3000) - } - } - - const renderKeyCheckResultTooltip = (status: KeyStatus) => { - if (status.checking) { - return t('settings.models.check.checking') - } - - const statusTitle = status.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed') - const statusColor = status.isValid ? STATUS_COLORS.success : STATUS_COLORS.error - - return ( -
- {statusTitle} - {type === 'provider' && status.model && ( -
- {t('common.model')}: {status.model.name} -
- )} - {status.latency && status.isValid && ( -
- {t('settings.provider.check_tooltip.latency')}: {(status.latency / 1000).toFixed(2)}s -
- )} - {status.error &&
{status.error}
} -
- ) - } - - const shouldAutoFocus = () => { - if (type === 'provider') { - return (provider as Provider).enabled && apiKeys === '' && !isProviderSupportAuth(provider as Provider) - } else if (type === 'websearch') { - return apiKeys === '' - } - return false - } - - const handleEditKey = (index: number) => { - setEditingIndex(index) - setEditValue(keyStatuses[index].key) - } - - const handleSaveEdit = () => { - if (editingIndex === null) return - - if (editValue.trim()) { - const keyExists = keyStatuses.some((status, idx) => idx !== editingIndex && status.key === editValue.trim()) - - if (keyExists) { - window.message.error({ - key: 'duplicate-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.key_already_exists') - }) - return - } - - if (editValue.includes(',')) { - window.message.error({ - key: 'invalid-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.invalid_key') - }) - return - } - - const updatedKeyStatuses = [...keyStatuses] - updatedKeyStatuses[editingIndex] = { - ...updatedKeyStatuses[editingIndex], - key: editValue.trim(), - isValid: undefined - } - - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - // Add a small delay before resetting to prevent immediate re-triggering - setTimeout(() => { - setEditingIndex(null) - setEditValue('') - }, 100) - } - - const handleCancelEdit = () => { - setEditingIndex(null) - setEditValue('') - } - - return ( - <> - - {keyStatuses.length === 0 && !isAddingNew ? ( - - {t('error.no_api_key')} - - ) : ( - <> - {keyStatuses.length > 0 && ( - - ( - - - - {editingIndex === index ? ( - setEditValue(e.target.value)} - onBlur={handleSaveEdit} - onPressEnter={handleSaveEdit} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault() - handleCancelEdit() - } - }} - style={{ width: '100%', fontSize: '14px' }} - spellCheck={false} - type="password" - /> - ) : ( - {maskApiKey(status.key)} - )} - - - {editingIndex === index ? ( - - ) : ( - <> - - {status.checking && ( - - } /> - - )} - {status.isValid === true && !status.checking && ( - - )} - {status.isValid === false && !status.checking && ( - - )} - - - {!isCopilot && ( - <> - !isChecking && !isCheckingSingle && handleEditKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px' - }} - title={t('common.edit')} - /> - {confirmDeleteIndex === index ? ( - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px', - color: 'var(--color-error)' - }} - title={t('common.delete')} - /> - ) : ( - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px', - color: 'var(--color-error)' - }} - title={t('common.delete')} - /> - )} - - )} - - )} - - - - )} - /> - - )} - {isAddingNew && ( - - - setNewApiKey(e.target.value)} - placeholder={t('settings.provider.enter_new_api_key')} - style={{ width: '60%', fontSize: '14px' }} - onPressEnter={handleSaveNewKey} - onBlur={handleSaveNewKey} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault() - handleCancelNewKey() - } - }} - spellCheck={false} - type="password" - /> - - - - - - )} - - )} - - - - {!isCopilot && ( - <> - - - - {keyStatuses.length > 1 && ( - - - - - )} - - )} - - - ) -} - -// Styled components for the list items -const ApiKeyListItem = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 0; - margin: 0; -` - -const ApiKeyContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; -` - -const ApiKeyActions = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - - @keyframes pulse { - 0% { - opacity: 1; - } - 50% { - opacity: 0.5; - } - 100% { - opacity: 1; - } - } -` - -export default ApiKeyList diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index f708b2867..74a414d81 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,11 +1,14 @@ +import { CheckOutlined, LoadingOutlined } from '@ant-design/icons' import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' import { HStack } from '@renderer/components/Layout' -import { isRerankModel } from '@renderer/config/models' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { PROVIDER_CONFIG } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' +import i18n from '@renderer/i18n' +import { checkApi, formatApiKeys } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' @@ -13,7 +16,7 @@ import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' -import { isEmpty } from 'lodash' +import { debounce, isEmpty } from 'lodash' import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' @@ -28,7 +31,7 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiKeyList from './ApiKeyList' +import ApiCheckPopup from './ApiCheckPopup' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -38,6 +41,7 @@ import ModelList, { ModelStatus } from './ModelList' import ModelListSearchBar from './ModelListSearchBar' import ProviderOAuth from './ProviderOAuth' import ProviderSettingsPopup from './ProviderSettingsPopup' +import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' interface Props { @@ -51,11 +55,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [apiKey, setApiKey] = useState(provider.apiKey) const [apiHost, setApiHost] = useState(provider.apiHost) const [apiVersion, setApiVersion] = useState(provider.apiVersion) + const [apiValid, setApiValid] = useState(false) + const [apiChecking, setApiChecking] = useState(false) const [modelSearchText, setModelSearchText] = useState('') const deferredModelSearchText = useDeferredValue(modelSearchText) const { updateProvider, models } = useProvider(provider.id) const { t } = useTranslation() const { theme } = useTheme() + const [inputValue, setInputValue] = useState(apiKey) const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -69,6 +76,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [modelStatuses, setModelStatuses] = useState([]) const [isHealthChecking, setIsHealthChecking] = useState(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedSetApiKey = useCallback( + debounce((value) => { + setApiKey(formatApiKeys(value)) + }, 100), + [] + ) + const moveProviderToTop = useCallback( (providerId: string) => { const reorderedProviders = [...allProviders] @@ -84,6 +99,12 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) + const onUpdateApiKey = () => { + if (apiKey !== provider.apiKey) { + updateProvider({ ...provider, apiKey }) + } + } + const onUpdateApiHost = () => { if (apiHost.trim()) { updateProvider({ ...provider, apiHost }) @@ -92,11 +113,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } } - const handleApiKeyChange = (newApiKey: string) => { - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) - } - const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) const onHealthCheck = async () => { @@ -176,6 +192,75 @@ const ProviderSetting: FC = ({ provider: _provider }) => { setIsHealthChecking(false) } + const onCheckApi = async () => { + const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) + + if (isEmpty(modelsToCheck)) { + window.message.error({ + key: 'no-models', + style: { marginTop: '3vh' }, + duration: 5, + content: t('settings.provider.no_models_for_check') + }) + return + } + + const model = await SelectProviderModelPopup.show({ provider }) + + if (!model) { + window.message.error({ content: i18n.t('message.error.enter.model'), key: 'api-check' }) + return + } + + if (apiKey.includes(',')) { + const keys = splitApiKeyString(apiKey) + + const result = await ApiCheckPopup.show({ + title: t('settings.provider.check_multiple_keys'), + provider: { ...provider, apiHost }, + model, + apiKeys: keys, + type: 'provider' + }) + + if (result?.validKeys) { + const newApiKey = result.validKeys.join(',') + setInputValue(newApiKey) + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) + } + } else { + setApiChecking(true) + + try { + await checkApi({ ...provider, apiKey, apiHost }, model) + + window.message.success({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 2, + content: i18n.t('message.api.connection.success') + }) + + setApiValid(true) + setTimeout(() => setApiValid(false), 3000) + } catch (error: any) { + const errorMessage = error?.message ? ' ' + error.message : '' + + window.message.error({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 8, + content: i18n.t('message.api.connection.failed') + errorMessage + }) + + setApiValid(false) + } finally { + setApiChecking(false) + } + } + } + const onReset = () => { setApiHost(configedApiHost) updateProvider({ ...provider, apiHost: configedApiHost }) @@ -244,6 +329,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { provider={provider} setApiKey={(v) => { setApiKey(v) + setInputValue(v) updateProvider({ ...provider, apiKey: v }) }} /> @@ -252,14 +338,35 @@ const ProviderSetting: FC = ({ provider: _provider }) => { {isDmxapi && } {provider.id !== 'vertexai' && ( <> - - - {t('settings.provider.api_key')} - - - + {t('settings.provider.api_key')} + + { + setInputValue(e.target.value) + debouncedSetApiKey(e.target.value) + }} + onBlur={() => { + const formattedValue = formatApiKeys(inputValue) + setInputValue(formattedValue) + setApiKey(formattedValue) + onUpdateApiKey() + }} + spellCheck={false} + autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)} + disabled={provider.id === 'copilot'} + /> + + {apiKeyWebsite && ( - + {!isDmxapi && ( @@ -267,6 +374,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} + {t('settings.provider.api_key.tip')} )} {!isDmxapi && ( diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 97d01d4f5..44a3f0171 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,17 +1,19 @@ -import { ExportOutlined } from '@ant-design/icons' +import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' +import { formatApiKeys } from '@renderer/services/ApiService' +import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' -import { Divider, Flex, Form, Input, Tooltip } from 'antd' +import { Button, Divider, Flex, Form, Input, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { Info } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { SettingDivider, SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' -import ApiKeyList from '../ProviderSettings/ApiKeyList' +import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' +import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup' interface Props { provider: WebSearchProvider @@ -22,16 +24,19 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { const { t } = useTranslation() const [apiKey, setApiKey] = useState(provider.apiKey || '') const [apiHost, setApiHost] = useState(provider.apiHost || '') + const [apiChecking, setApiChecking] = useState(false) const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '') const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '') + const [apiValid, setApiValid] = useState(false) const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey const officialWebsite = webSearchProviderConfig?.websites?.official - const handleApiKeyChange = (newApiKey: string) => { - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) + const onUpdateApiKey = () => { + if (apiKey !== provider.apiKey) { + updateProvider({ ...provider, apiKey }) + } } const onUpdateApiHost = () => { @@ -66,6 +71,65 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { } } + async function checkSearch() { + if (!provider) { + window.message.error({ + content: t('settings.websearch.no_provider_selected'), + duration: 3, + icon: , + key: 'no-provider-selected' + }) + return + } + + if (apiKey.includes(',')) { + const keys = apiKey + .split(',') + .map((k) => k.trim()) + .filter((k) => k) + + const result = await ApiCheckPopup.show({ + title: t('settings.provider.check_multiple_keys'), + provider: { ...provider, apiHost }, + apiKeys: keys, + type: 'websearch' + }) + + if (result?.validKeys) { + setApiKey(result.validKeys.join(',')) + updateProvider({ ...provider, apiKey: result.validKeys.join(',') }) + } + return + } + + try { + setApiChecking(true) + const { valid, error } = await WebSearchService.checkSearch(provider) + + const errorMessage = error && error?.message ? ' ' + error?.message : '' + window.message[valid ? 'success' : 'error']({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: valid ? 2 : 8, + content: valid ? t('settings.websearch.check_success') : t('settings.websearch.check_failed') + errorMessage + }) + + setApiValid(valid) + } catch (err) { + console.error('Check search error:', err) + setApiValid(false) + window.message.error({ + key: 'check-search-error', + style: { marginTop: '3vh' }, + duration: 8, + content: t('settings.websearch.check_failed') + }) + } finally { + setApiChecking(false) + setTimeout(() => setApiValid(false), 2500) + } + } + useEffect(() => { setApiKey(provider.apiKey ?? '') setApiHost(provider.apiHost ?? '') @@ -90,14 +154,30 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {hasObjectKey(provider, 'apiKey') && ( <> {t('settings.provider.api_key')} - - {apiKeyWebsite && ( - - - {t('settings.websearch.get_api_key')} - - - )} + + setApiKey(formatApiKeys(e.target.value))} + onBlur={onUpdateApiKey} + spellCheck={false} + type="password" + autoFocus={apiKey === ''} + /> + + + + + {t('settings.websearch.get_api_key')} + + {t('settings.provider.api_key.tip')} + )} {hasObjectKey(provider, 'apiHost') && ( From 64b01cce47b234d0f11e9492d275e996e843aa21 Mon Sep 17 00:00:00 2001 From: Teo Date: Wed, 25 Jun 2025 14:34:18 +0800 Subject: [PATCH 12/56] =?UTF-8?q?feat:=20=20=E4=B8=80=E4=BA=9BUI=E4=B8=8A?= =?UTF-8?q?=E7=9A=84=E4=BC=98=E5=8C=96=E5=92=8C=E9=87=8D=E6=9E=84=20(#7479?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整AntdProvider中主题配置,包括颜色、尺寸 - 重构聊天气泡模式的样式 - 重构多选模式的样式 - 添加Selector组件取代ant Select组件 - 重构消息搜索弹窗界面 - 重构知识库搜索弹窗界面 - 优化其他弹框UI --- src/renderer/src/assets/styles/ant.scss | 214 +++++----------- src/renderer/src/assets/styles/color.scss | 14 +- src/renderer/src/assets/styles/index.scss | 71 +++--- src/renderer/src/assets/styles/markdown.scss | 28 ++- .../components/CodeBlockView/CodePreview.tsx | 6 +- .../src/components/CodeBlockView/index.tsx | 5 + .../src/components/CodeEditor/index.tsx | 6 +- .../src/components/ContextMenu/index.tsx | 106 +++----- .../src/components/CustomCollapse.tsx | 9 + .../src/components/EditableNumber/index.tsx | 114 +++++++++ .../components/Popups/MultiSelectionPopup.tsx | 68 +++-- .../src/components/Popups/SearchPopup.tsx | 13 +- .../Popups/SelectModelPopup/popup.tsx | 7 +- .../src/components/Scrollbar/index.tsx | 6 +- src/renderer/src/components/Selector.tsx | 192 ++++++++++++++ src/renderer/src/context/AntdProvider.tsx | 55 +++- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../pages/agents/components/AddAgentPopup.tsx | 4 +- .../agents/components/ImportAgentPopup.tsx | 20 +- .../src/pages/history/HistoryPage.tsx | 102 ++++---- .../history/components/SearchMessage.tsx | 21 +- .../history/components/SearchResults.tsx | 3 +- .../history/components/TopicMessages.tsx | 17 +- .../history/components/TopicsHistory.tsx | 3 +- src/renderer/src/pages/home/Chat.tsx | 3 +- .../src/pages/home/Inputbar/Inputbar.tsx | 60 ++--- .../src/pages/home/Inputbar/TokenCount.tsx | 2 +- .../pages/home/Markdown/CitationTooltip.tsx | 3 +- .../src/pages/home/Markdown/CodeBlock.tsx | 2 +- .../__tests__/CitationTooltip.test.tsx | 2 +- .../CitationTooltip.test.tsx.snap | 2 +- .../pages/home/Messages/Blocks/ImageBlock.tsx | 5 +- .../home/Messages/Blocks/ThinkingBlock.tsx | 10 +- .../src/pages/home/Messages/Blocks/index.tsx | 12 +- .../src/pages/home/Messages/CitationsList.tsx | 137 ++++++---- .../src/pages/home/Messages/Message.tsx | 138 +++-------- .../pages/home/Messages/MessageAnchorLine.tsx | 32 ++- .../src/pages/home/Messages/MessageEditor.tsx | 3 +- .../src/pages/home/Messages/MessageGroup.tsx | 223 ++++++++--------- .../home/Messages/MessageGroupMenuBar.tsx | 9 +- .../home/Messages/MessageGroupSettings.tsx | 19 +- .../src/pages/home/Messages/MessageHeader.tsx | 128 +++++----- .../pages/home/Messages/MessageMenubar.tsx | 3 +- .../src/pages/home/Messages/Messages.tsx | 43 ++-- .../src/pages/home/Messages/NarrowLayout.tsx | 6 +- .../src/pages/home/Messages/Prompt.tsx | 4 +- .../src/pages/home/Tabs/SettingsTab.tsx | 119 ++++----- .../Tabs/components/OpenAISettingsGroup.tsx | 6 +- .../components/AddKnowledgePopup.tsx | 24 +- .../components/KnowledgeSearchPopup.tsx | 199 +++++++++------ .../components/KnowledgeSettingsPopup.tsx | 46 ++-- .../AssistantKnowledgeBaseSettings.tsx | 3 +- .../AssistantModelSettings.tsx | 234 +++++++++++------- .../AssistantPromptSettings.tsx | 2 +- .../settings/AssistantSettings/index.tsx | 16 +- .../AgentsSubscribeUrlSettings.tsx | 1 - .../settings/DataSettings/JoplinSettings.tsx | 19 +- .../settings/DataSettings/NotionSettings.tsx | 21 +- .../DataSettings/NutstoreSettings.tsx | 32 ++- .../settings/DataSettings/SiyuanSettings.tsx | 19 +- .../settings/DataSettings/WebDavSettings.tsx | 57 +++-- .../settings/DataSettings/YuqueSettings.tsx | 19 +- .../DisplaySettings/DisplaySettings.tsx | 8 +- .../src/pages/settings/GeneralSettings.tsx | 96 +++---- .../DefaultAssistantSettings.tsx | 24 +- .../ProviderSettings/AddModelPopup.tsx | 2 +- .../ProviderSettings/EditModelsPopup.tsx | 10 +- .../ProviderSettings/ModelEditContent.tsx | 49 ++-- .../WebSearchSettings/AddSubscribePopup.tsx | 8 +- .../settings/WebSearchSettings/index.tsx | 6 +- .../src/pages/translate/TranslatePage.tsx | 7 +- .../windows/mini/chat/components/Message.tsx | 1 - 76 files changed, 1637 insertions(+), 1326 deletions(-) create mode 100644 src/renderer/src/components/EditableNumber/index.tsx create mode 100644 src/renderer/src/components/Selector.tsx diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index ebe45ef5c..225cbe8a9 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -58,166 +58,80 @@ } } -.mention-models-dropdown { - &.ant-dropdown { - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - animation-duration: 0.15s !important; - } - - /* 移动其他样式到 mention-models-dropdown 类下 */ - .ant-slide-up-enter .ant-dropdown-menu, - .ant-slide-up-appear .ant-dropdown-menu, - .ant-slide-up-leave .ant-dropdown-menu, - .ant-slide-up-enter-active .ant-dropdown-menu, - .ant-slide-up-appear-active .ant-dropdown-menu, - .ant-slide-up-leave-active .ant-dropdown-menu { - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - } - - .ant-dropdown-menu { - /* 保持原有的下拉菜单样式,但限定在 mention-models-dropdown 类下 */ - max-height: 400px; - overflow-y: auto; - overflow-x: hidden; - padding: 4px 12px; - position: relative; - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - border: 0.5px solid rgba(var(--color-border-rgb), 0.3); - border-radius: 10px; - box-shadow: - 0 0 0 0.5px rgba(0, 0, 0, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.12), - inset 0 0 0 0.5px rgba(255, 255, 255, var(--inner-glow-opacity, 0.1)); - transform-origin: top; - will-change: transform, opacity; - transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); - margin-bottom: 0; - - &.no-scrollbar { - padding-right: 12px; - } - - &.has-scrollbar { - padding-right: 2px; - } - - // Scrollbar styles - &::-webkit-scrollbar { - width: 14px; - height: 6px; - } - - &::-webkit-scrollbar-thumb { - border: 4px solid transparent; - background-clip: padding-box; - border-radius: 7px; - background-color: var(--color-scrollbar-thumb); - min-height: 50px; - transition: all 0.2s; - } - - &:hover::-webkit-scrollbar-thumb { - background-color: var(--color-scrollbar-thumb); - } - - &::-webkit-scrollbar-thumb:hover { - background-color: var(--color-scrollbar-thumb-hover); - } - - &::-webkit-scrollbar-thumb:active { - background-color: var(--color-scrollbar-thumb-hover); - } - - &::-webkit-scrollbar-track { - background: transparent; - border-radius: 7px; - } - } - - .ant-dropdown-menu-item-group { - margin-bottom: 4px; - - &:not(:first-child) { - margin-top: 4px; - } - - .ant-dropdown-menu-item-group-title { - padding: 5px 12px; - color: var(--color-text-3); - font-size: 12px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; - opacity: 0.7; - } - } - - // Handle no-results case margin - .no-results { - padding: 8px 12px; - color: var(--color-text-3); - cursor: default; - font-size: 13px; - opacity: 0.8; - margin-bottom: 40px; - - &:hover { - background: none; - } - } - - .ant-dropdown-menu-item { - padding: 5px 12px; - margin: 0 -12px; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - align-items: center; - gap: 8px; - border-radius: 6px; - font-size: 13px; - - &:hover { - background: rgba(var(--color-hover-rgb), 0.5); - } - - &.ant-dropdown-menu-item-selected { - background-color: rgba(var(--color-primary-rgb), 0.12); - color: var(--color-primary); - } - - .ant-dropdown-menu-item-icon { - margin-right: 0; - opacity: 0.9; - } - } +.ant-dropdown-menu .ant-dropdown-menu-sub { + max-height: 50vh; + width: max-content; + overflow-y: auto; + overflow-x: hidden; + border: 0.5px solid var(--color-border); } - .ant-dropdown { + background-color: var(--ant-color-bg-elevated); + overflow: hidden; + border-radius: var(--ant-border-radius-lg); .ant-dropdown-menu { max-height: 50vh; overflow-y: auto; border: 0.5px solid var(--color-border); - .ant-dropdown-menu-sub { - max-height: 50vh; - width: max-content; - overflow-y: auto; - overflow-x: hidden; - border: 0.5px solid var(--color-border); - } } .ant-dropdown-arrow + .ant-dropdown-menu { border: none; } } - .ant-select-dropdown { border: 0.5px solid var(--color-border); } +.ant-dropdown-menu-submenu { + background-color: var(--ant-color-bg-elevated); + overflow: hidden; + border-radius: var(--ant-border-radius-lg); +} + +.ant-popover { + .ant-popover-inner { + border: 0.5px solid var(--color-border); + .ant-popover-inner-content { + max-height: 70vh; + overflow-y: auto; + } + } + .ant-popover-arrow + .ant-popover-content { + .ant-popover-inner { + border: none; + } + } +} + +.ant-modal:not(.ant-modal-confirm) { + .ant-modal-confirm-body-has-title { + padding: 16px 0 0 0; + } + .ant-modal-content { + border-radius: 10px; + border: 0.5px solid var(--color-border); + padding: 0 0 8px 0; + .ant-modal-header { + padding: 16px 16px 0 16px; + border-radius: 10px; + } + .ant-modal-body { + max-height: 80vh; + overflow-y: auto; + padding: 0 16px 0 16px; + } + .ant-modal-footer { + padding: 0 16px 8px 16px; + } + .ant-modal-confirm-btns { + margin-bottom: 8px; + } + } +} +.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm { + .ant-modal-content { + padding: 16px; + } +} .ant-collapse { border: 1px solid var(--color-border); @@ -227,8 +141,14 @@ } .ant-collapse-content { - border-top: 1px solid var(--color-border) !important; + border-top: 0.5px solid var(--color-border) !important; .ant-color-picker & { border-top: none !important; } } + +.ant-slider { + .ant-slider-handle::after { + box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important; + } +} diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 6100e1d0e..ce7e9cefe 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -47,7 +47,7 @@ --color-list-item: #222; --color-list-item-hover: #1e1e1e; - --modal-background: #1f1f1f; + --modal-background: #111111; --color-highlight: rgba(0, 0, 0, 1); --color-background-highlight: rgba(255, 255, 0, 0.9); @@ -66,9 +66,9 @@ --settings-width: 250px; --scrollbar-width: 5px; - --chat-background: #111111; - --chat-background-user: #28b561; - --chat-background-assistant: #2c2c2c; + --chat-background: transparent; + --chat-background-user: rgba(255, 255, 255, 0.08); + --chat-background-assistant: transparent; --chat-text-user: var(--color-black); --list-item-border-radius: 20px; @@ -132,8 +132,8 @@ --navbar-background-mac: rgba(255, 255, 255, 0.55); --navbar-background: rgba(244, 244, 244); - --chat-background: #f3f3f3; - --chat-background-user: #95ec69; - --chat-background-assistant: #ffffff; + --chat-background: transparent; + --chat-background-user: rgba(0, 0, 0, 0.045); + --chat-background-assistant: transparent; --chat-text-user: var(--color-text); } diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 9974f1959..da28abc8c 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -111,27 +111,7 @@ ul { word-wrap: break-word; } -.bubble { - background-color: var(--chat-background); - #chat-main { - background-color: var(--chat-background); - } - #messages { - background-color: var(--chat-background); - } - #inputbar { - margin: -5px 15px 15px 15px; - background: var(--color-background); - } - .system-prompt { - background-color: var(--chat-background-assistant); - } - .message-content-container { - margin: 5px 0; - border-radius: 8px; - padding: 0.5rem 1rem; - } - +.bubble:not(.multi-select-mode) { .block-wrapper { display: flow-root; } @@ -149,30 +129,35 @@ ul { } .message-user { - color: var(--chat-text-user); - .message-content-container-user .anticon { - color: var(--chat-text-user) !important; + .message-header { + flex-direction: row-reverse; + text-align: right; + .message-header-info-wrap { + flex-direction: row-reverse; + text-align: right; + } } - - .markdown { - color: var(--chat-text-user); - } - } - .group-grid-container.horizontal, - .group-grid-container.grid { - .message-content-container-assistant { - padding: 0; - } - } - .group-message-wrapper { - background-color: var(--color-background); .message-content-container { - width: 100%; + border-radius: 10px 0 10px 10px; + padding: 10px 16px 10px 16px; + background-color: var(--chat-background-user); + align-self: self-end; + } + .MessageFooter { + margin-top: 2px; + align-self: self-end; } } - .group-menu-bar { - background-color: var(--color-background); + + .message-assistant { + .message-content-container { + padding-left: 0; + } + .MessageFooter { + margin-left: 0; + } } + code { color: var(--color-text); } @@ -196,3 +181,9 @@ span.highlight { span.highlight.selected { background-color: var(--color-background-highlight-accent); } + +textarea { + &::-webkit-resizer { + display: none; + } +} diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 0c80d9f68..eea9070ca 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -98,7 +98,6 @@ border: none; border-top: 0.5px solid var(--color-border); margin: 20px 0; - background-color: var(--color-border); } span { @@ -119,7 +118,7 @@ } pre { - border-radius: 5px; + border-radius: 8px; overflow-x: auto; font-family: 'Fira Code', 'Courier New', Courier, monospace; background-color: var(--color-background-mute); @@ -157,15 +156,28 @@ } table { - border-collapse: collapse; + --table-border-radius: 8px; margin: 1em 0; width: 100%; + border-radius: var(--table-border-radius); + overflow: hidden; + border-collapse: separate; + border: 0.5px solid var(--color-border); + border-spacing: 0; } th, td { - border: 0.5px solid var(--color-border); + border-right: 0.5px solid var(--color-border); + border-bottom: 0.5px solid var(--color-border); padding: 0.5em; + &:last-child { + border-right: none; + } + } + + tr:last-child td { + border-bottom: none; } th { @@ -238,6 +250,10 @@ text-decoration: underline; } } + + > *:last-child { + margin-bottom: 0 !important; + } } .footnotes { @@ -309,7 +325,7 @@ mjx-container { /* CodeMirror 相关样式 */ .cm-editor { - border-radius: 5px; + border-radius: inherit; &.cm-focused { outline: none; @@ -317,7 +333,7 @@ mjx-container { .cm-scroller { font-family: var(--code-font-family); - border-radius: 5px; + border-radius: inherit; .cm-gutters { line-height: 1.6; diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 566a980f6..dde163283 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -244,8 +244,7 @@ const ContentContainer = styled.div<{ }>` position: relative; overflow: auto; - border: 0.5px solid transparent; - border-radius: 5px; + border-radius: inherit; margin-top: 0; /* 动态宽度计算 */ @@ -254,6 +253,7 @@ const ContentContainer = styled.div<{ .shiki { padding: 1em; + border-radius: inherit; code { display: flex; @@ -301,7 +301,7 @@ const ContentContainer = styled.div<{ } } - animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')}; + animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')}; ` const CodePlaceholder = styled.div` diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx index 811b8665c..c25ab3079 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -273,6 +273,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` align-items: center; color: var(--color-text); font-size: 14px; + line-height: 1; font-weight: bold; padding: 0 10px; border-top-left-radius: 8px; @@ -288,6 +289,10 @@ const SplitViewWrapper = styled.div` flex: 1 1 auto; width: 100%; } + + &:not(:has(+ [class*='Container'])) { + border-radius: 0 0 8px 8px; + } ` export default memo(CodeBlockView) diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index d92fd91e8..db699fa03 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -227,10 +227,10 @@ const CodeEditor = ({ ...customBasicSetup // override basicSetup }} style={{ - ...style, fontSize: `${fontSize - 1}px`, - border: '0.5px solid transparent', - marginTop: 0 + marginTop: 0, + borderRadius: 'inherit', + ...style }} /> ) diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx index 195fcb2a3..610afa695 100644 --- a/src/renderer/src/components/ContextMenu/index.tsx +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -1,87 +1,59 @@ import { Dropdown } from 'antd' -import { useCallback, useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface ContextMenuProps { children: React.ReactNode - onContextMenu?: (e: React.MouseEvent) => void - style?: React.CSSProperties } -const ContextMenu: React.FC = ({ children, onContextMenu, style }) => { +const ContextMenu: React.FC = ({ children }) => { const { t } = useTranslation() - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) - const [selectedText, setSelectedText] = useState('') + const [selectedText, setSelectedText] = useState(undefined) - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - const _selectedText = window.getSelection()?.toString() - if (_selectedText) { - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setSelectedText(_selectedText) - } - onContextMenu?.(e) - }, - [onContextMenu] - ) + const contextMenuItems = useMemo(() => { + if (!selectedText) return [] - useEffect(() => { - const handleClick = () => { - setContextMenuPosition(null) - } - document.addEventListener('click', handleClick) - return () => { - document.removeEventListener('click', handleClick) - } - }, []) - - // 获取右键菜单项 - const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [ - { - key: 'copy', - label: t('common.copy'), - onClick: () => { - if (selectedText) { - navigator.clipboard - .writeText(selectedText) - .then(() => { - window.message.success({ content: t('message.copied'), key: 'copy-message' }) - }) - .catch(() => { - window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) - }) - } - } - }, - { - key: 'quote', - label: t('chat.message.quote'), - onClick: () => { - if (selectedText) { - window.api?.quoteToMainWindow(selectedText) + return [ + { + key: 'copy', + label: t('common.copy'), + onClick: () => { + if (selectedText) { + navigator.clipboard + .writeText(selectedText) + .then(() => { + window.message.success({ content: t('message.copied'), key: 'copy-message' }) + }) + .catch(() => { + window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) + }) + } + } + }, + { + key: 'quote', + label: t('chat.message.quote'), + onClick: () => { + if (selectedText) { + window.api?.quoteToMainWindow(selectedText) + } } } + ] + }, [selectedText, t]) + + const onOpenChange = (open: boolean) => { + if (open) { + const selectedText = window.getSelection()?.toString() + setSelectedText(selectedText) } - ] + } return ( - - {contextMenuPosition && ( - -
- - )} + {children} - + ) } -const ContextContainer = styled.div`` - export default ContextMenu diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 9c94084d7..c6f4f79a7 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,5 +1,6 @@ import { Collapse } from 'antd' import { merge } from 'lodash' +import { ChevronRight } from 'lucide-react' import { FC, memo, useMemo, useState } from 'react' interface CustomCollapseProps { @@ -78,6 +79,14 @@ const CustomCollapse: FC = ({ destroyInactivePanel={destroyInactivePanel} collapsible={collapsible} onChange={setActiveKeys} + expandIcon={({ isActive }) => ( + + )} items={[ { styles: collapseItemStyles, diff --git a/src/renderer/src/components/EditableNumber/index.tsx b/src/renderer/src/components/EditableNumber/index.tsx new file mode 100644 index 000000000..3cc0f0950 --- /dev/null +++ b/src/renderer/src/components/EditableNumber/index.tsx @@ -0,0 +1,114 @@ +import { InputNumber } from 'antd' +import { FC, useEffect, useRef, useState } from 'react' +import styled from 'styled-components' + +export interface EditableNumberProps { + value?: number | null + min?: number + max?: number + step?: number + precision?: number + placeholder?: string + disabled?: boolean + changeOnBlur?: boolean + onChange?: (value: number | null) => void + onBlur?: () => void + style?: React.CSSProperties + className?: string + size?: 'small' | 'middle' | 'large' + suffix?: string + prefix?: string + align?: 'start' | 'center' | 'end' +} + +const EditableNumber: FC = ({ + value, + min, + max, + step = 0.01, + precision, + placeholder, + disabled = false, + onChange, + onBlur, + changeOnBlur = false, + style, + className, + size = 'middle', + align = 'end' +}) => { + const [isEditing, setIsEditing] = useState(false) + const [inputValue, setInputValue] = useState(value) + const inputRef = useRef(null) + + useEffect(() => { + setInputValue(value) + }, [value]) + + const handleFocus = () => { + if (disabled) return + setIsEditing(true) + } + + const handleInputChange = (newValue: number | null) => { + onChange?.(newValue ?? null) + } + + const handleBlur = () => { + setIsEditing(false) + onBlur?.() + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleBlur() + } else if (e.key === 'Escape') { + setInputValue(value) + setIsEditing(false) + } + } + + return ( + + + + {value ?? placeholder} + + + ) +} + +const Container = styled.div` + display: inline-block; + position: relative; +` + +const DisplayText = styled.div<{ + $align: 'start' | 'center' | 'end' + $isEditing: boolean +}>` + position: absolute; + inset: 0; + display: ${({ $isEditing }) => ($isEditing ? 'none' : 'flex')}; + align-items: center; + justify-content: ${({ $align }) => $align}; + pointer-events: none; +` + +export default EditableNumber diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx index f021b631f..f277fbe3a 100644 --- a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -35,17 +35,38 @@ const MultiSelectActionPopup: FC = ({ topic }) => { {t('common.selectedMessages', { count: selectedMessageIds.length })} - } disabled={isActionDisabled} onClick={() => handleAction('save')} /> + + + + } transitionName="animation-move-down" centered> @@ -120,15 +127,6 @@ const PopupContainer: React.FC = ({ resolve }) => { )} - - - - - - - ) diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index f1b389066..d20accfd8 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -1,11 +1,11 @@ -import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' import { useAppDispatch } from '@renderer/store' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' -import { Input, InputRef } from 'antd' +import { Divider, Input, InputRef } from 'antd' import { last } from 'lodash' -import { Search } from 'lucide-react' +import { ChevronLeft, CornerDownLeft, Search } from 'lucide-react' import { FC, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -73,26 +73,35 @@ const TopicsPage: FC = () => { return ( -
- {stack.length > 1 && ( - - - - - - )} - + 1 ? ( + + + + ) : ( + + + + ) + } + suffix={search.length >= 2 ? : null} ref={inputRef} + placeholder={t('history.search.placeholder')} + value={search} onChange={(e) => setSearch(e.target.value.trimStart())} - suffix={search.length >= 2 ? : } + allowClear + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" onPressEnter={onSearch} /> -
+ + + { const SearchMessage: FC = ({ message, ...props }) => { const navigate = NavigationService.navigate! - const { messageStyle } = useSettings() const { t } = useTranslation() const [topic, setTopic] = useState(null) @@ -43,18 +41,18 @@ const SearchMessage: FC = ({ message, ...props }) => { return ( - - + + @@ -74,12 +72,11 @@ const MessagesContainer = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; display: flex; flex-direction: column; - .message { - padding: 0; - } + padding: 16px; + position: relative; ` export default SearchMessage diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 5882f4945..2fd299a38 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -151,7 +151,8 @@ const Container = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 0 16px; display: flex; flex-direction: column; ` diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 27372db4f..1b4be0002 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -1,9 +1,8 @@ -import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons' +import { MessageOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' -import { useSettings } from '@renderer/hooks/useSettings' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' @@ -13,6 +12,7 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' +import { Forward } from 'lucide-react' import { FC, useEffect } from 'react' import styled from 'styled-components' @@ -25,7 +25,6 @@ interface Props extends React.HTMLAttributes { const TopicMessages: FC = ({ topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') - const { messageStyle } = useSettings() const dispatch = useAppDispatch() useEffect(() => { @@ -48,8 +47,8 @@ const TopicMessages: FC = ({ topic, ...props }) => { return ( - - + + {topic?.messages.map((message) => (
@@ -58,7 +57,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { size="middle" style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }} onClick={() => locateToMessage(navigate, message)} - icon={} + icon={} />
@@ -86,12 +85,10 @@ const MessagesContainer = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 16px; display: flex; flex-direction: column; - .message { - padding: 0; - } ` export default TopicMessages diff --git a/src/renderer/src/pages/history/components/TopicsHistory.tsx b/src/renderer/src/pages/history/components/TopicsHistory.tsx index 85d8ef5a2..d95a3f7ae 100644 --- a/src/renderer/src/pages/history/components/TopicsHistory.tsx +++ b/src/renderer/src/pages/history/components/TopicsHistory.tsx @@ -78,7 +78,8 @@ const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props } const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 0 16px; display: flex; flex-direction: column; ` diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index e2fdbb740..8d16c5a36 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -7,6 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' import { Assistant, Topic } from '@renderer/types' +import { classNames } from '@renderer/utils' import { Flex } from 'antd' import { debounce } from 'lodash' import React, { FC, useMemo, useState } from 'react' @@ -106,7 +107,7 @@ const Chat: FC = (props) => { } return ( - +
= ({ assistant: _assistant, setActiveTopic, topic }) = _text = text _files = files - const resizeTextArea = useCallback(() => { - const textArea = textareaRef.current?.resizableTextArea?.textArea - if (textArea) { - // 如果已经手动设置了高度,则不自动调整 - if (textareaHeight) { - return + const resizeTextArea = useCallback( + (force: boolean = false) => { + const textArea = textareaRef.current?.resizableTextArea?.textArea + if (textArea) { + // 如果已经手动设置了高度,则不自动调整 + if (textareaHeight && !force) { + return + } + if (textArea?.scrollHeight) { + textArea.style.height = Math.min(textArea.scrollHeight, 400) + 'px' + } } - textArea.style.height = 'auto' - textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px` - } - }, [textareaHeight]) + }, + [textareaHeight] + ) const sendMessage = useCallback(async () => { if (inputEmpty || loading) { @@ -749,13 +753,13 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } return ( - - + + = ({ assistant: _assistant, setActiveTopic, topic }) = ref={textareaRef} style={{ fontSize, - minHeight: textareaHeight ? `${textareaHeight}px` : undefined + minHeight: textareaHeight ? `${textareaHeight}px` : '30px' }} styles={{ textarea: TextareaStyle }} onFocus={(e: React.FocusEvent) => { @@ -851,8 +855,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = - - + + ) } @@ -887,16 +891,15 @@ const Container = styled.div` flex-direction: column; position: relative; z-index: 2; + padding: 0 16px 16px 16px; ` const InputBarContainer = styled.div` border: 0.5px solid var(--color-border); transition: all 0.2s ease; position: relative; - margin: 14px 20px; - margin-top: 0; border-radius: 15px; - padding-top: 6px; // 为拖动手柄留出空间 + padding-top: 8px; // 为拖动手柄留出空间 background-color: var(--color-background-opacity); &.file-dragging { @@ -919,7 +922,7 @@ const InputBarContainer = styled.div` const TextareaStyle: CSSProperties = { paddingLeft: 0, - padding: '6px 15px 8px' // 减小顶部padding + padding: '6px 15px 0px' // 减小顶部padding } const Textarea = styled(TextArea)` @@ -934,16 +937,17 @@ const Textarea = styled(TextArea)` &.ant-input { line-height: 1.4; } + &::-webkit-scrollbar { + width: 3px; + } ` const Toolbar = styled.div` display: flex; flex-direction: row; justify-content: space-between; - padding: 0 8px; - padding-bottom: 0; - margin-bottom: 4px; - height: 30px; + padding: 5px 8px; + height: 40px; gap: 16px; position: relative; z-index: 2; diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index 0f556a1d1..bad0729b8 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -45,7 +45,7 @@ const TokenCount: FC = ({ estimateTokenCount, inputTokenCount, contextCou return ( - + {contextCount.current} / {formatMaxCount(contextCount.max)} diff --git a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx index 45b804c85..6041b562a 100644 --- a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx +++ b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx @@ -54,9 +54,10 @@ const CitationTooltip: React.FC = ({ children, citation }) return ( = ({ children, className, id, onSave }) => { {children} ) : ( - + {children} ) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx index 06a390c06..072bf3047 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx @@ -93,7 +93,7 @@ describe('CitationTooltip', () => { const tooltip = screen.getByTestId('tooltip-wrapper') expect(tooltip).toHaveAttribute('data-placement', 'top') - expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)') + expect(tooltip).toHaveAttribute('data-color', 'var(--color-background)') const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}') expect(styles.body).toEqual({ diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap index ff5c69767..e9c6def35 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap @@ -47,7 +47,7 @@ exports[`CitationTooltip > basic rendering > should match snapshot 1`] = ` }
= ({ block }) => { ? [`file://${block?.file?.path}`] : [] return ( - + {images.map((src, index) => ( ))} @@ -34,6 +34,5 @@ const Container = styled.div` display: flex; flex-direction: row; gap: 10px; - margin-top: 8px; ` export default React.memo(ImageBlock) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 74d16a80f..e1420ba6c 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -3,7 +3,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Collapse, message as antdMessage, Tooltip } from 'antd' -import { Lightbulb } from 'lucide-react' +import { ChevronRight, Lightbulb } from 'lucide-react' import { motion } from 'motion/react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -57,6 +57,14 @@ const ThinkingBlock: React.FC = ({ block }) => { size="small" onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} className="message-thought-container" + expandIcon={({ isActive }) => ( + + )} expandIconPosition="end" items={[ { diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index b469f0326..9f4d6e838 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -164,17 +164,7 @@ export default React.memo(MessageBlockRenderer) const ImageBlockGroup = styled.div` display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(3, minmax(200px, 1fr)); gap: 8px; max-width: 960px; - /* > * { - min-width: 200px; - } */ - @media (min-width: 1536px) { - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - max-width: 1280px; - > * { - min-width: 250px; - } - } ` diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 672587fff..0c40f83ed 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,10 +1,9 @@ import ContextMenu from '@renderer/components/ContextMenu' import Favicon from '@renderer/components/Icons/FallbackFavicon' -import { HStack } from '@renderer/components/Layout' import { fetchWebContent } from '@renderer/utils/fetch' import { cleanMarkdownContent } from '@renderer/utils/formats' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' -import { Button, Drawer, message, Skeleton } from 'antd' +import { Button, message, Popover, Skeleton } from 'antd' import { Check, Copy, FileSearch } from 'lucide-react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -48,16 +47,49 @@ const truncateText = (text: string, maxLength = 100) => { const CitationsList: React.FC = ({ citations }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) const previewItems = citations.slice(0, 3) const count = citations.length if (!count) return null + const popoverContent = ( + + {citations.map((citation) => ( + + {citation.type === 'websearch' ? ( + + ) : ( + + )} + + ))} + + ) + return ( - <> - setOpen(true)}> + + {t('message.citations')} +
+ } + placement="right" + trigger="hover" + styles={{ + body: { + padding: '0 0 8px 0' + } + }}> + {previewItems.map((c, i) => ( @@ -71,27 +103,7 @@ const CitationsList: React.FC = ({ citations }) => { {t('message.citation', { count })} - - setOpen(false)} - open={open} - width={680} - styles={{ header: { border: 'none' }, body: { paddingTop: 0 } }} - destroyOnClose={false}> - {open && - citations.map((citation) => ( - - {citation.type === 'websearch' ? ( - - ) : ( - - )} - - ))} - - +
) } @@ -136,16 +148,17 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { }) return ( - - + + - {citation.number} {citation.showFavicon && citation.url && ( )} handleLinkClick(citation.url, e)}> {citation.title || {citation.hostname}} + + {citation.number} {fetchedContent && } {isLoading ? ( @@ -153,28 +166,29 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { ) : ( {fetchedContent} )} - - + + ) } const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => { return ( - - + + - {citation.number} {citation.showFavicon && } handleLinkClick(citation.url, e)}> {citation.title} + + {citation.number} {citation.content && } {citation.content && truncateText(citation.content, 100)} - - + + ) } @@ -213,10 +227,19 @@ const PreviewIcon = styled.div` ` const CitationIndex = styled.div` - font-size: 14px; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--color-reference); + font-size: 10px; line-height: 1.6; - color: var(--color-text-2); - margin-right: 8px; + color: var(--color-reference-text); + flex-shrink: 0; + opacity: 1; + transition: opacity 0.3s ease; ` const CitationLink = styled.a` @@ -224,7 +247,7 @@ const CitationLink = styled.a` line-height: 1.6; color: var(--color-text-1); text-decoration: none; - + flex: 1; .hostname { color: var(--color-link); } @@ -236,10 +259,14 @@ const CopyIconWrapper = styled.div` align-items: center; justify-content: center; color: var(--color-text-2); - opacity: 0.6; - margin-left: auto; + opacity: 0; padding: 4px; border-radius: 4px; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + transition: opacity 0.3s ease; &:hover { opacity: 1; @@ -251,11 +278,17 @@ const WebSearchCard = styled.div` display: flex; flex-direction: column; width: 100%; - padding: 12px; - border-radius: var(--list-item-border-radius); - background-color: var(--color-background); + padding: 12px 0; transition: all 0.3s ease; position: relative; + &:hover { + ${CopyIconWrapper} { + opacity: 1; + } + ${CitationIndex} { + opacity: 0; + } + } ` const WebSearchCardHeader = styled.div` @@ -265,6 +298,7 @@ const WebSearchCardHeader = styled.div` gap: 8px; margin-bottom: 6px; width: 100%; + position: relative; ` const WebSearchCardContent = styled.div` @@ -273,6 +307,7 @@ const WebSearchCardContent = styled.div` color: var(--color-text-2); user-select: text; cursor: text; + word-break: break-all; &.selectable-text { -webkit-user-select: text; @@ -282,4 +317,16 @@ const WebSearchCardContent = styled.div` } ` +const PopoverContent = styled.div` + max-width: min(340px, 60vw); + max-height: 60vh; + padding: 0 12px; +` +const PopoverContentItem = styled.div` + border-bottom: 0.5px solid var(--color-border); + &:last-child { + border-bottom: none; + } +` + export default CitationsList diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 19fcbdade..96067bc37 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -1,9 +1,8 @@ -import ContextMenu from '@renderer/components/ContextMenu' import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useModel } from '@renderer/hooks/useModel' -import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' +import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -42,14 +41,12 @@ const MessageItem: FC = ({ index, hideMenuBar = false, isGrouped, - isStreaming = false, - style + isStreaming = false }) => { const { t } = useTranslation() const { assistant, setModel } = useAssistant(message.assistantId) const model = useModel(getMessageModelId(message), message.model?.provider) || message.model - const { isBubbleStyle } = useMessageStyle() - const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings() + const { messageFont, fontSize } = useSettings() const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() @@ -101,9 +98,6 @@ const MessageItem: FC = ({ const isAssistantMessage = message.role === 'assistant' const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing - const messageBorder = !isBubbleStyle && showMessageDivider ? '1px dotted var(--color-border)' : 'none' - const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) - const messageHighlightHandler = useCallback((highlight: boolean = true) => { if (messageContainerRef.current) { messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) @@ -140,101 +134,38 @@ const MessageItem: FC = ({ 'message-assistant': isAssistantMessage, 'message-user': !isAssistantMessage })} - ref={messageContainerRef} - style={{ - ...style, - justifyContent: isBubbleStyle ? (isAssistantMessage ? 'flex-start' : 'flex-end') : undefined, - flex: isBubbleStyle ? undefined : 1 - }}> + ref={messageContainerRef}> + {isEditing && ( - - -
- -
-
+ )} {!isEditing && ( - - + <> - {showMenubar && !isBubbleStyle && ( - - } - setModel={setModel} - /> - - )} - {showMenubar && isBubbleStyle && ( - + {showMenubar && ( + = ({ /> )} - + )} ) } -const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => { - return isBubbleStyle - ? isAssistantMessage - ? 'var(--chat-background-assistant)' - : 'var(--chat-background-user)' - : undefined -} - const MessageContainer = styled.div` display: flex; + flex-direction: column; width: 100%; position: relative; transition: background-color 0.3s ease; - padding: 0 20px; transform: translateZ(0); will-change: transform; + padding: 10px 10px 0 10px; + border-radius: 10px; &.message-highlight { background-color: var(--color-primary-mute); } @@ -292,11 +217,7 @@ const MessageContainer = styled.div` const MessageContentContainer = styled.div` max-width: 100%; - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; - margin-left: 46px; + padding-left: 46px; margin-top: 5px; overflow-y: auto; ` @@ -306,9 +227,8 @@ const MessageFooter = styled.div` flex-direction: row; justify-content: space-between; align-items: center; - padding: 2px 0; - margin-top: 2px; gap: 20px; + margin-left: 46px; ` const NewContextMessage = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index 3e4773dcd..7b116f849 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -184,7 +184,7 @@ const MessageAnchorLine: FC = ({ messages }) => { else messageItemsRef.current.delete('bottom-anchor') }} style={{ - opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6 + opacity: mouseY ? 0.5 : Math.max(0, 0.6 - (0.3 * Math.abs(0 - messages.length / 2)) / 5) }} onClick={scrollToBottom}> = ({ messages }) => { {messages.map((message, index) => { const opacity = 0.5 + calculateValueByDistance(message.id, 1) - const scale = 1 + calculateValueByDistance(message.id, 1) + const scale = 1 + calculateValueByDistance(message.id, 1.2) const size = 10 + calculateValueByDistance(message.id, 20) const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message)) const username = removeLeadingEmoji(getUserName(message)) @@ -219,15 +219,14 @@ const MessageAnchorLine: FC = ({ messages }) => { {message.role === 'assistant' ? ( - - A - + }} + /> ) : ( <> {isEmoji(avatar) ? ( @@ -241,7 +240,7 @@ const MessageAnchorLine: FC = ({ messages }) => { {avatar} ) : ( - + )} )} @@ -260,17 +259,28 @@ const MessageItemContainer = styled.div` align-items: flex-end; justify-content: space-between; text-align: right; - gap: 4px; + gap: 3px; opacity: 0; transform-origin: right center; + transition: transform cubic-bezier(0.25, 1, 0.5, 1) 150ms; + will-change: transform; +` + +const MessageItemAvatar = styled(Avatar)` + transition: + width, + height, + cubic-bezier(0.25, 1, 0.5, 1) 150ms; + will-change: width, height; ` const MessageLineContainer = styled.div<{ $height: number | null }>` width: 14px; position: fixed; - top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')}; + top: calc(50% - var(--status-bar-height) - 10px); right: 13px; - max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')}; + max-height: ${(props) => + props.$height ? `${props.$height - 20}px` : 'calc(100% - var(--status-bar-height) * 2 - 20px)'}; transform: translateY(-50%); z-index: 0; user-select: none; @@ -280,7 +290,7 @@ const MessageLineContainer = styled.div<{ $height: number | null }>` font-size: 5px; overflow: hidden; &:hover { - width: 440px; + width: 500px; overflow-x: visible; overflow-y: hidden; ${MessageItemContainer} { diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 62636ccd6..36449c8ef 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -308,10 +308,11 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) const EditorContainer = styled.div` padding: 8px 0; - border: 1px solid var(--color-border); + border: 0.5px solid var(--color-border); transition: all 0.2s ease; border-radius: 15px; margin-top: 5px; + margin-bottom: 10px; background-color: var(--color-background-opacity); width: 100%; diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index efc9cd8f5..69b7e7243 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -1,4 +1,3 @@ -import Scrollbar from '@renderer/components/Scrollbar' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' @@ -10,11 +9,10 @@ import type { Message } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' import { Popover } from 'antd' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import styled, { css } from 'styled-components' +import styled from 'styled-components' import MessageItem from './Message' import MessageGroupMenuBar from './MessageGroupMenuBar' -import SelectableMessage from './MessageSelect' interface Props { messages: (Message & { index: number })[] @@ -62,7 +60,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { ) const isGrouped = isMultiSelectMode ? false : messageLength > 1 && messages.every((m) => m.role === 'assistant') - const isHorizontal = multiModelMessageStyle === 'horizontal' const isGrid = multiModelMessageStyle === 'grid' useEffect(() => { @@ -166,25 +163,19 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { isGrouped, message, topic, - index: message.index, - style: { - paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15 - } + index: message.index } const messageContent = ( + className={classNames([ + { + [multiModelMessageStyle]: message.role === 'assistant', + selected: message.id === selectedMessageId + } + ])}> ) @@ -193,47 +184,43 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { return ( + className={classNames([ + 'in-popover', + { + [multiModelMessageStyle]: message.role === 'assistant', + selected: message.id === selectedMessageId + } + ])}> } trigger={gridPopoverTrigger} - styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}> -
{messageContent}
+ styles={{ + root: { maxWidth: '60vw', overflowY: 'auto', zIndex: 1000 }, + body: { padding: 2 } + }}> + {messageContent}
) } - return ( - - {messageContent} - - ) + return messageContent }, - [isGrid, isGrouped, topic, multiModelMessageStyle, isHorizontal, selectedMessageId, gridPopoverTrigger] + [isGrid, isGrouped, topic, multiModelMessageStyle, selectedMessageId, gridPopoverTrigger] ) return ( + id={messages[0].askId ? `message-group-${messages[0].askId}` : undefined} + className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}> + className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}> {messages.map(renderMessage)} {isGrouped && ( @@ -256,73 +243,103 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { ) } -const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>` - padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && 'horizontal' === $layout ? '15px' : '0')}; - &.group-container.horizontal, - &.group-container.grid { - padding: 0 20px; - .message { - padding: 0; - } +const GroupContainer = styled.div` + &.horizontal, + &.grid { + padding: 4px 10px; .group-menu-bar { margin-left: 0; margin-right: 0; } } + &.multi-select-mode { + padding: 5px 10px; + } ` -const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle; $gridColumns: number }>` +const GridContainer = styled.div<{ $count: number; $gridColumns: number }>` width: 100%; display: grid; - gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')}; - grid-template-columns: repeat( - ${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)}, - minmax(480px, 1fr) - ); - @media (max-width: 800px) { - grid-template-columns: repeat( - ${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)}, - minmax(400px, 1fr) - ); + overflow-y: visible; + gap: 16px; + &.horizontal { + padding-bottom: 4px; + grid-template-columns: repeat(${({ $count }) => $count}, minmax(480px, 1fr)); + overflow-x: auto; + } + &.fold, + &.vertical { + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 8px; + } + &.grid { + grid-template-columns: repeat( + ${({ $count, $gridColumns }) => ($count > 1 ? $gridColumns || 2 : 1)}, + minmax(0, 1fr) + ); + grid-template-rows: auto; + } + + &.multi-select-mode { + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 10px; + .message { + border: 0.5px solid var(--color-border); + border-radius: 10px; + padding: 10px; + .message-content-container { + max-height: 200px; + overflow-y: hidden !important; + } + .MessageFooter { + display: none; + } + } } - ${({ $layout }) => - $layout === 'horizontal' && - css` - margin-top: 15px; - `} - ${({ $gridColumns, $layout, $count }) => - $layout === 'grid' && - css` - margin-top: 15px; - grid-template-columns: repeat(${$count > 1 ? $gridColumns || 2 : 1}, minmax(0, 1fr)); - grid-template-rows: auto; - gap: 16px; - `} - ${({ $layout }) => { - return $layout === 'horizontal' - ? css` - overflow-y: auto; - ` - : 'overflow-y: visible;' - }} ` interface MessageWrapperProps { - $layout: 'fold' | 'horizontal' | 'vertical' | 'grid' - // $selected: boolean - $isGrouped: boolean $isInPopover?: boolean } -const MessageWrapper = styled(Scrollbar)` - width: 100%; - display: flex; - +const MessageWrapper = styled.div` &.horizontal { - display: inline-block; + overflow-y: auto; + .message { + border: 0.5px solid var(--color-border); + border-radius: 10px; + } + .message-content-container { + padding-left: 0; + max-height: calc(100vh - 350px); + overflow-y: auto !important; + margin-right: -10px; + } + .MessageFooter { + margin-left: 0; + margin-top: 2px; + margin-bottom: 2px; + } } &.grid { - display: inline-block; + height: 300px; + overflow-y: hidden; + border: 0.5px solid var(--color-border); + border-radius: 10px; + cursor: pointer; + } + &.in-popover { + height: auto; + border: none; + max-height: 50vh; + overflow-y: auto; + cursor: default; + .message-content-container { + padding-left: 0; + } + .MessageFooter { + margin-left: 0; + } } &.fold { display: none; @@ -330,38 +347,6 @@ const MessageWrapper = styled(Scrollbar)` display: inline-block; } } - - ${({ $layout, $isGrouped }) => { - if ($layout === 'horizontal' && $isGrouped) { - return css` - border: 0.5px solid var(--color-border); - padding: 10px; - border-radius: 6px; - max-height: 600px; - margin-bottom: 10px; - ` - } - return '' - }} - - ${({ $layout, $isInPopover, $isGrouped }) => { - // 如果布局是grid,并且是组消息,则设置最大高度和溢出行为(卡片不可滚动,点击展开后可滚动) - // 如果布局是horizontal,则设置溢出行为(卡片可滚动) - // 如果布局是fold、vertical,高度不限制,与正常消息流布局一致,则设置卡片不可滚动(visible) - return $layout === 'grid' && $isGrouped - ? css` - max-height: ${$isInPopover ? '50vh' : '300px'}; - overflow-y: ${$isInPopover ? 'auto' : 'hidden'}; - border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'}; - padding: 10px; - border-radius: 6px; - background-color: var(--color-background); - ` - : css` - overflow-y: ${$layout === 'horizontal' ? 'auto' : 'visible'}; - border-radius: 6px; - ` - }} ` export default memo(MessageGroup) diff --git a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx index bd639eb47..6c2e7766d 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx @@ -59,6 +59,7 @@ const MessageGroupMenuBar: FC = ({ {['fold', 'vertical', 'horizontal', 'grid'].map((layout) => ( ` flex-direction: row; align-items: center; gap: 10px; - margin: 0 20px; - padding: 6px 10px; - border-radius: 6px; - margin-top: 10px; + padding: 8px; + border-radius: 10px; + margin: 8px 10px 16px; justify-content: space-between; overflow: hidden; border: 0.5px solid var(--color-border); height: 40px; - background-color: var(--color-background); ` const LayoutContainer = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx b/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx index 0b52c4168..208a75f95 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx @@ -1,10 +1,11 @@ import { SettingOutlined } from '@ant-design/icons' +import Selector from '@renderer/components/Selector' import { useSettings } from '@renderer/hooks/useSettings' import { SettingDivider } from '@renderer/pages/settings' import { SettingRow } from '@renderer/pages/settings' import { useAppDispatch } from '@renderer/store' import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings' -import { Col, Row, Select, Slider } from 'antd' +import { Col, Row, Slider } from 'antd' import { Popover } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,19 +19,21 @@ const MessageGroupSettings: FC = () => { return ( +
{t('settings.messages.grid_popover_trigger')}
- + options={[ + { label: t('settings.messages.grid_popover_trigger.hover'), value: 'hover' }, + { label: t('settings.messages.grid_popover_trigger.click'), value: 'click' } + ]} + />
diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index 285cfd516..465ca923b 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -4,16 +4,17 @@ import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env' import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelName } from '@renderer/services/ModelService' -import type { Assistant, Model } from '@renderer/types' +import type { Assistant, Model, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { firstLetter, isEmoji, removeLeadingEmoji } from '@renderer/utils' -import { Avatar } from 'antd' +import { Avatar, Checkbox } from 'antd' import dayjs from 'dayjs' -import { CSSProperties, FC, memo, useCallback, useMemo } from 'react' +import { FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -24,6 +25,7 @@ interface Props { assistant: Assistant model?: Model index: number | undefined + topic: Topic } const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { @@ -31,7 +33,7 @@ const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { return modelId ? getModelLogo(modelId) : undefined } -const MessageHeader: FC = memo(({ assistant, model, message, index }) => { +const MessageHeader: FC = memo(({ assistant, model, message, index, topic }) => { const avatar = useAvatar() const { theme } = useTheme() const { userName, sidebarIcons } = useSettings() @@ -39,6 +41,10 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => const { isBubbleStyle } = useMessageStyle() const { openMinappById } = useMinappPopup() + const { isMultiSelectMode, selectedMessageIds, handleSelectMessage } = useChatContext(topic) + + const isSelected = selectedMessageIds?.includes(message.id) + const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message]) const getUserName = useCallback(() => { @@ -67,65 +73,54 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [model?.provider, showMinappIcon]) - const avatarStyle: CSSProperties | undefined = isBubbleStyle - ? { - flexDirection: isAssistantMessage ? 'row' : 'row-reverse', - textAlign: isAssistantMessage ? 'left' : 'right' - } - : undefined - - const containerStyle = isBubbleStyle - ? { - justifyContent: isAssistantMessage ? 'flex-start' : 'flex-end' - } - : undefined - return ( - - - {isAssistantMessage ? ( - - {avatarName} - - ) : ( - <> - {isEmoji(avatar) ? ( - UserPopup.show()} size={35} fontSize={20}> - {avatar} - - ) : ( - UserPopup.show()} - /> - )} - - )} - - - {username} - - - {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} - {showTokens && | } - - - - + + {isAssistantMessage ? ( + + {avatarName} + + ) : ( + <> + {isEmoji(avatar) ? ( + UserPopup.show()} size={35} fontSize={20}> + {avatar} + + ) : ( + UserPopup.show()} + /> + )} + + )} + + + {username} + + + {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} + {showTokens && | } + + + + {isMultiSelectMode && ( + handleSelectMessage(message.id, e.target.checked)} + style={{ position: 'absolute', right: 0, top: 0 }} + /> + )} ) }) @@ -133,23 +128,18 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => MessageHeader.displayName = 'MessageHeader' const Container = styled.div` - display: flex; - flex-direction: row; - align-items: center; - padding-bottom: 4px; -` - -const AvatarWrapper = styled.div` display: flex; flex-direction: row; align-items: center; gap: 10px; + position: relative; ` const UserWrap = styled.div` display: flex; flex-direction: column; justify-content: space-between; + flex: 1; ` const InfoWrap = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index c7e39adf2..50a4fc95e 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -507,8 +507,7 @@ const MessageMenubar: FC = (props) => { e.domEvent.stopPropagation() }} trigger={['click']} - placement="topRight" - arrow> + placement="topRight"> e.stopPropagation()} diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index cae4237ff..1a7d46024 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -1,3 +1,4 @@ +import ContextMenu from '@renderer/components/ContextMenu' import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' @@ -271,7 +272,6 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o id="messages" className="messages-container" ref={scrollContainerRef} - style={{ position: 'relative', paddingTop: showPrompt ? 10 : 0 }} key={assistant.id} onScroll={handleScrollPosition}> @@ -283,22 +283,25 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o scrollableTarget="messages" inverse style={{ overflow: 'visible' }}> - - {groupedMessages.map(([key, groupMessages]) => ( - - ))} - {isLoadingMore && ( - - - - )} - + + + {groupedMessages.map(([key, groupMessages]) => ( + + ))} + {isLoadingMore && ( + + + + )} + + + {showPrompt && } {messageNavigation === 'anchor' && } @@ -361,6 +364,10 @@ const LoaderContainer = styled.div` const ScrollContainer = styled.div` display: flex; flex-direction: column-reverse; + padding: 20px 10px 20px 16px; + .multi-select-mode & { + padding-bottom: 60px; + } ` interface ContainerProps { @@ -370,11 +377,9 @@ interface ContainerProps { const MessagesContainer = styled(Scrollbar)` display: flex; flex-direction: column-reverse; - padding: 10px 0 20px; overflow-x: hidden; - background-color: var(--color-background); z-index: 1; - margin-right: 2px; + position: relative; ` export default Messages diff --git a/src/renderer/src/pages/home/Messages/NarrowLayout.tsx b/src/renderer/src/pages/home/Messages/NarrowLayout.tsx index b1579b4dc..6431bb151 100644 --- a/src/renderer/src/pages/home/Messages/NarrowLayout.tsx +++ b/src/renderer/src/pages/home/Messages/NarrowLayout.tsx @@ -10,7 +10,11 @@ const NarrowLayout: FC = ({ children, ...props }) => { const { narrowMode } = useSettings() if (narrowMode) { - return {children} + return ( + + {children} + + ) } return children diff --git a/src/renderer/src/pages/home/Messages/Prompt.tsx b/src/renderer/src/pages/home/Messages/Prompt.tsx index 1fe67eca4..f0df2a460 100644 --- a/src/renderer/src/pages/home/Messages/Prompt.tsx +++ b/src/renderer/src/pages/home/Messages/Prompt.tsx @@ -30,11 +30,11 @@ const Prompt: FC = ({ assistant, topic }) => { } const Container = styled.div<{ $isDark: boolean }>` - padding: 10px 20px; - margin: 5px 20px 0 20px; + padding: 10px 16px; border-radius: 10px; cursor: pointer; border: 0.5px solid var(--color-border); + margin: 10px 10px 0 10px; ` const Text = styled.div` diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index b3b7a844e..67c3ba7b9 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -1,6 +1,7 @@ -import { CheckOutlined } from '@ant-design/icons' +import EditableNumber from '@renderer/components/EditableNumber' import { HStack } from '@renderer/components/Layout' import Scrollbar from '@renderer/components/Scrollbar' +import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { isOpenAIModel, @@ -38,7 +39,6 @@ import { setPasteLongTextThreshold, setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, - setShowMessageDivider, setShowPrompt, setShowTokens, setShowTranslateConfirm, @@ -54,7 +54,7 @@ import { } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' -import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' +import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd' import { CircleHelp, Settings2 } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -86,7 +86,6 @@ const SettingsTab: FC = (props) => { const { showPrompt, - showMessageDivider, messageFont, showInputEstimatedTokens, sendMessageShortcut, @@ -312,20 +311,6 @@ const SettingsTab: FC = (props) => { dispatch(setShowTokens(checked))} /> - - - {t('settings.messages.divider')} - - - - - dispatch(setShowMessageDivider(checked))} - /> - - {t('settings.messages.use_serif_font')} = (props) => { {t('message.message.style')} - dispatch(setMessageStyle(value as 'plain' | 'bubble'))} - style={{ width: 135 }} - size="small"> - {t('message.message.style.plain')} - {t('message.message.style.bubble')} - + options={[ + { value: 'plain', label: t('message.message.style.plain') }, + { value: 'bubble', label: t('message.message.style.bubble') } + ]} + /> {t('message.message.multi_model_style')} - dispatch(setMultiModelMessageStyle(value as 'fold' | 'vertical' | 'horizontal' | 'grid')) } - style={{ width: 135 }}> - {t('message.message.multi_model_style.fold')} - {t('message.message.multi_model_style.vertical')} - {t('message.message.multi_model_style.horizontal')} - {t('message.message.multi_model_style.grid')} - + options={[ + { value: 'fold', label: t('message.message.multi_model_style.fold') }, + { value: 'vertical', label: t('message.message.multi_model_style.vertical') }, + { value: 'horizontal', label: t('message.message.multi_model_style.horizontal') }, + { value: 'grid', label: t('message.message.multi_model_style.grid') } + ]} + /> {t('settings.messages.navigation')} - dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))} - style={{ width: 135 }}> - {t('settings.messages.navigation.none')} - {t('settings.messages.navigation.buttons')} - {t('settings.messages.navigation.anchor')} - + options={[ + { value: 'none', label: t('settings.messages.navigation.none') }, + { value: 'buttons', label: t('settings.messages.navigation.buttons') }, + { value: 'anchor', label: t('settings.messages.navigation.anchor') } + ]} + /> {t('settings.messages.math_engine')} - dispatch(setMathEngine(value as MathEngine))} - style={{ width: 135 }} - size="small"> - KaTeX - MathJax - {t('settings.messages.math_engine.none')} - + options={[ + { value: 'KaTeX', label: 'KaTeX' }, + { value: 'MathJax', label: 'MathJax' }, + { value: 'none', label: t('settings.messages.math_engine.none') } + ]} + /> @@ -430,17 +415,14 @@ const SettingsTab: FC = (props) => { {t('message.message.code_style')} - onCodeStyleChange(value as CodeStyleVarious)} - style={{ width: 135 }} - size="small"> - {themeNames.map((theme) => ( - - {theme} - - ))} - + options={themeNames.map((theme) => ({ + value: theme, + label: theme + }))} + /> @@ -466,7 +448,7 @@ const SettingsTab: FC = (props) => { - = (props) => { {t('settings.messages.input.paste_long_text_threshold')} - = (props) => { {t('settings.input.target_language')} - } + onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)} options={[ { value: 'chinese', label: t('settings.input.target_language.chinese') }, { value: 'chinese-traditional', label: t('settings.input.target_language.chinese-traditional') }, @@ -653,17 +633,14 @@ const SettingsTab: FC = (props) => { { value: 'japanese', label: t('settings.input.target_language.japanese') }, { value: 'russian', label: t('settings.input.target_language.russian') } ]} - onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)} - style={{ width: 135 }} /> {t('settings.messages.input.send_shortcuts')} - } + onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)} options={[ { value: 'Enter', label: getSendMessageShortcutLabel('Enter') }, { value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') }, @@ -671,8 +648,6 @@ const SettingsTab: FC = (props) => { { value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') }, { value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') } ]} - onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)} - style={{ width: 135 }} /> @@ -704,12 +679,4 @@ const SettingGroup = styled.div<{ theme?: ThemeMode }>` margin-bottom: 10px; ` -const StyledSelect = styled(Select)` - .ant-select-selector { - border-radius: 15px !important; - padding: 4px 10px !important; - height: 26px !important; - } -` - export default SettingsTab diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx index 2aa25c5ff..18b6800a6 100644 --- a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx @@ -1,3 +1,4 @@ +import Selector from '@renderer/components/Selector' import { SettingDivider, SettingRow } from '@renderer/pages/settings' import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup' import { RootState, useAppDispatch } from '@renderer/store' @@ -102,13 +103,11 @@ const OpenAISettingsGroup: FC = ({ - { setServiceTierMode(value as OpenAIServiceTier) }} - size="small" options={serviceTierOptions} /> @@ -135,6 +134,7 @@ const OpenAISettingsGroup: FC = ({ )} + ) } diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx index c59c94441..528f64f41 100644 --- a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx +++ b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx @@ -13,6 +13,7 @@ import { KnowledgeBase, Model } from '@renderer/types' import { getErrorMessage } from '@renderer/utils/error' import { Flex, Form, Input, InputNumber, Modal, Select, Slider, Switch } from 'antd' import { find, sortBy } from 'lodash' +import { ChevronDown } from 'lucide-react' import { nanoid } from 'nanoid' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -116,6 +117,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { const aiProvider = new AiProvider(provider) values.dimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel) } catch (error) { + console.error('Error getting embedding dimensions:', error) window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error)) setLoading(false) return @@ -181,7 +183,12 @@ const PopupContainer: React.FC = ({ title, resolve }) => { label={t('models.embedding_model')} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - } + /> = ({ title, resolve }) => { label={t('models.rerank_model')} tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }} rules={[{ required: false, message: t('message.error.enter.model') }]}> - } + /> {t('models.rerank_model_not_support_provider', { @@ -201,13 +213,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { label={t('knowledge.document_count')} initialValue={DEFAULT_KNOWLEDGE_DOCUMENT_COUNT} // 设置初始值 tooltip={{ title: t('knowledge.document_count_help') }}> - + = ({ base, resolve }) => { const [results, setResults] = useState>([]) const [searchKeyword, setSearchKeyword] = useState('') const { t } = useTranslation() - const searchInputRef = useRef(null) const handleSearch = async (value: string) => { if (!value.trim()) { @@ -84,77 +84,98 @@ const PopupContainer: React.FC = ({ base, resolve }) => { return ( visible && searchInputRef.current?.focus()} - width={800} + width={700} footer={null} centered - transitionName="animation-move-down"> - - + + + + + } + value={searchKeyword} + placeholder={t('knowledge.search')} allowClear - enterButton - size="large" - onSearch={handleSearch} - ref={searchInputRef} + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" + onChange={(e) => setSearchKeyword(e.target.value)} + onPressEnter={() => handleSearch(searchKeyword)} /> - - {loading ? ( - - - - ) : ( - ( - - - - Score: {(item.score * 100).toFixed(1)}% - - handleCopy(item.pageContent)}> - - - - - {highlightText(item.pageContent)} - - - {t('knowledge.source')}:{' '} - {item.file ? ( - - {item.file.origin_name} - - ) : ( - item.metadata.source - )} - - - - - )} - /> - )} - - + + + + + {loading ? ( + + + + ) : ( + ( + + + + + {t('knowledge.source')}:{' '} + {item.file ? ( + + {item.file.origin_name} + + ) : ( + item.metadata.source + )} + + Score: {(item.score * 100).toFixed(1)}% + + + + handleCopy(item.pageContent)}> + + + + + + {highlightText(item.pageContent)} + + + + )} + /> + )} + ) } -const SearchContainer = styled.div` - display: flex; - flex-direction: column; - gap: 20px; -` - const ResultsContainer = styled.div` - max-height: 60vh; + padding: 0 16px; overflow-y: auto; + max-height: 70vh; ` const LoadingContainer = styled.div` @@ -164,21 +185,29 @@ const LoadingContainer = styled.div` height: 200px; ` +const TagContainer = styled.div` + position: absolute; + top: 58px; + right: 16px; + display: flex; + align-items: center; + gap: 8px; + opacity: 0; + transition: opacity 0.2s; +` + const ResultItem = styled.div` width: 100%; position: relative; padding: 16px; background: var(--color-background-soft); border-radius: 8px; -` -const TagContainer = styled.div` - position: absolute; - top: 8px; - right: 8px; - display: flex; - align-items: center; - gap: 8px; + &:hover { + ${TagContainer} { + opacity: 1 !important; + } + } ` const ScoreTag = styled.div` @@ -187,6 +216,7 @@ const ScoreTag = styled.div` color: white; border-radius: 4px; font-size: 12px; + flex-shrink: 0; ` const CopyButton = styled.div` @@ -195,7 +225,7 @@ const CopyButton = styled.div` justify-content: center; width: 24px; height: 24px; - background: var(--color-background); + background: var(--color-background-mute); color: var(--color-text); border-radius: 4px; cursor: pointer; @@ -208,12 +238,35 @@ const CopyButton = styled.div` ` const MetadataContainer = styled.div` - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); user-select: text; ` +const SearchIcon = styled.div` + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + background-color: var(--color-background-soft); + margin-right: 2px; + &.back-icon { + cursor: pointer; + transition: background-color 0.2s; + &:hover { + background-color: var(--color-background-mute); + } + } +` + const TopViewKey = 'KnowledgeSearchPopup' export default class KnowledgeSearchPopup { diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx index d994fa9ee..625ca2c90 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx @@ -1,4 +1,4 @@ -import { DownOutlined, WarningOutlined } from '@ant-design/icons' +import { WarningOutlined } from '@ant-design/icons' import { TopView } from '@renderer/components/TopView' import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant' import { getEmbeddingMaxContext } from '@renderer/config/embedings' @@ -10,11 +10,11 @@ import { useProviders } from '@renderer/hooks/useProvider' import { SettingHelpText } from '@renderer/pages/settings' import { getModelUniqId } from '@renderer/services/ModelService' import { KnowledgeBase } from '@renderer/types' -import { Alert, Form, Input, InputNumber, Modal, Select, Slider } from 'antd' +import { Alert, Button, Form, Input, InputNumber, Modal, Select, Slider } from 'antd' import { sortBy } from 'lodash' +import { ChevronDown } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface ShowParams { base: KnowledgeBase @@ -140,7 +140,13 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) => { initialValue={getModelUniqId(base.model)} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - } + /> = ({ base: _base, resolve }) => { options={rerankSelectOptions} placeholder={t('settings.models.empty')} allowClear + suffixIcon={} /> @@ -166,27 +173,21 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) => { name="documentCount" label={t('knowledge.document_count')} tooltip={{ title: t('knowledge.document_count_help') }}> - + - setShowAdvanced(!showAdvanced)}> - setShowAdvanced(!showAdvanced)}> + {t('common.advanced_settings')} + - {t('common.advanced_settings')} - + -
+
= ({ base: _base, resolve }) => { const TopViewKey = 'KnowledgeSettingsPopup' -const AdvancedSettingsButton = styled.div` - cursor: pointer; - margin-bottom: 16px; - margin-top: -10px; - color: var(--color-primary); - display: flex; - align-items: center; -` - export default class KnowledgeSettingsPopup { static hide() { TopView.hide(TopViewKey) diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx index 169ed3ffd..a593f41cb 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx @@ -3,7 +3,7 @@ import { Box } from '@renderer/components/Layout' import { useAppSelector } from '@renderer/store' import { Assistant, AssistantSettings } from '@renderer/types' import { Row, Segmented, Select, SelectProps, Tooltip } from 'antd' -import { CircleHelp } from 'lucide-react' +import { ChevronDown, CircleHelp } from 'lucide-react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -46,6 +46,7 @@ const AssistantKnowledgeBaseSettings: React.FC = ({ assistant, updateAssi .toLowerCase() .includes(input.toLowerCase()) } + suffixIcon={} /> diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 1a22848ce..31e44abfb 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -1,13 +1,16 @@ import { DeleteOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import EditableNumber from '@renderer/components/EditableNumber' import { HStack } from '@renderer/components/Layout' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' +import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { SettingRow } from '@renderer/pages/settings' import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' import { isNull } from 'lodash' +import { ChevronDown } from 'lucide-react' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -107,9 +110,15 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA ) case 'boolean': return ( - onUpdateCustomParameter(index, 'value', checked)} + { setToolUseMode(value) updateAssistantSettings({ toolUseMode: value }) - }}> - {t('assistants.settings.tool_use_mode.prompt')} - {t('assistants.settings.tool_use_mode.function')} - + }} + size={14} + /> @@ -409,20 +433,26 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(e) => onUpdateCustomParameter(index, 'name', e.target.value)} /> - + - {renderParameterValueInput(param, index)} + {renderParameterValueInput(param, index)} - + {emoji && ( = ({ resolve, tab, ...prop styles={{ content: { padding: 0, - overflow: 'hidden', - background: 'var(--color-background)' + overflow: 'hidden' }, - header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0 } + header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0, borderRadius: 0 }, + body: { + padding: 0 + } }} - width="70vw" + width="min(800px, 70vw)" height="80vh" centered> @@ -145,15 +147,14 @@ const AssistantSettingPopupContainer: React.FC = ({ resolve, tab, ...prop } const LeftMenu = styled.div` - background-color: var(--color-background); height: calc(80vh - 20px); border-right: 0.5px solid var(--color-border); ` const Settings = styled.div` flex: 1; - padding: 10px 20px; - height: calc(80vh - 20px); + padding: 16px 16px; + height: calc(80vh - 16px); overflow-y: scroll; ` @@ -163,6 +164,7 @@ const StyledModal = styled(Modal)` } .ant-modal-close { top: 4px; + right: 4px; } .ant-menu-item { height: 36px; diff --git a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx index eb37f4173..f4e76fadd 100755 --- a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx @@ -39,7 +39,6 @@ const AgentsSubscribeUrlSettings: FC = () => { /> - ) } diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx index 3574c808d..32b314854 100644 --- a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings' -import { Button, Switch, Tooltip } from 'antd' +import { Button, Space, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -106,14 +106,15 @@ const JoplinSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx index 719a2363d..26e8d0872 100644 --- a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx @@ -10,7 +10,7 @@ import { setNotionExportReasoning, setNotionPageNameKey } from '@renderer/store/settings' -import { Button, Switch, Tooltip } from 'antd' +import { Button, Space, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -121,15 +121,16 @@ const NotionSettings: FC = () => { {t('settings.data.notion.api_key')} - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx index 15207200b..108452c13 100644 --- a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, FolderOutlined, LoadingOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import NutstorePathPopup from '@renderer/components/Popups/NutsorePathPopup' +import Selector from '@renderer/components/Selector' import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' @@ -23,7 +24,7 @@ import { } from '@renderer/store/nutstore' import { modalConfirm } from '@renderer/utils' import { NUTSTORE_HOST } from '@shared/config/nutstore' -import { Button, Input, Select, Switch, Tooltip, Typography } from 'antd' +import { Button, Input, Switch, Tooltip, Typography } from 'antd' import dayjs from 'dayjs' import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -279,18 +280,23 @@ const NutstoreSettings: FC = () => { {t('settings.data.webdav.autoSync')} - + {nutstoreAutoSync && syncInterval > 0 && ( <> diff --git a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx index 3ba6673ee..2681f1305 100644 --- a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings' -import { Button, Tooltip } from 'antd' +import { Button, Space, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -108,14 +108,15 @@ const SiyuanSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 8e2a7e5aa..54db33f02 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -1,5 +1,6 @@ import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' +import Selector from '@renderer/components/Selector' import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' @@ -16,7 +17,7 @@ import { setWebdavSyncInterval as _setWebdavSyncInterval, setWebdavUser as _setWebdavUser } from '@renderer/store/settings' -import { Button, Input, Select, Switch, Tooltip } from 'antd' +import { Button, Input, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -173,31 +174,43 @@ const WebDavSettings: FC = () => { {t('settings.data.webdav.autoSync')} - + {t('settings.data.webdav.maxBackups')} - + diff --git a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx index 72a629e55..60a8d6ef7 100644 --- a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings' -import { Button, Tooltip } from 'antd' +import { Button, Space, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -100,14 +100,15 @@ const YuqueSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 48c8dcbe9..56d09bd8d 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -196,7 +196,7 @@ const DisplaySettings: FC = () => { value={userTheme.colorPrimary} onChange={(color) => handleColorPrimaryChange(color.toHexString())} showText - style={{ width: '110px' }} + size="small" presets={[ { label: 'Presets', @@ -222,13 +222,15 @@ const DisplaySettings: FC = () => { {t('settings.zoom.title')} - + {emoji && ( { justifyContent: 'space-between' }}> {t('settings.assistant.model_params')} - @@ -156,7 +156,7 @@ const AssistantSettings: FC = () => { - + { step={0.01} /> - + { - + { step={0.01} /> - + @@ -207,7 +207,7 @@ const AssistantSettings: FC = () => { - + { step={1} /> - + { /> - + @@ -255,7 +255,7 @@ const AssistantSettings: FC = () => { onUpdateAssistantSettings({ enableMaxTokens: enabled }) }} /> - + {enableMaxTokens && ( @@ -307,7 +307,7 @@ const PopupContainer: React.FC = ({ resolve }) => { afterClose={onClose} transitionName="animation-move-down" centered - width={800} + width={500} footer={null}> diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx index cf79088ac..1ff9517f9 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx @@ -121,7 +121,7 @@ const PopupContainer: React.FC = ({ title, provider, resolve }) => { tooltip={t('settings.models.add.group_name.tooltip')}> - + {showMoreSettings && ( -
- - {t('models.type.select')} +
+ + {t('models.type.select')}: {(() => { const defaultTypes = [ ...(isVisionModel(model) ? ['vision'] : []), @@ -235,6 +238,7 @@ const ModelEditContent: FC = ({ model, onUpdateModel, ope } }} dropdownMatchSelectWidth={false} + suffixIcon={} /> @@ -281,32 +285,9 @@ const ModelEditContent: FC = ({ model, onUpdateModel, ope } const TypeTitle = styled.div` - margin-top: 16px; - margin-bottom: 12px; + margin: 12px 0; font-size: 14px; font-weight: 600; ` -const ExpandIcon = styled.div` - font-size: 12px; - color: var(--color-text-3); -` - -const MoreSettingsRow = styled.div` - display: flex; - align-items: center; - gap: 8px; - color: var(--color-text-3); - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - - &:hover { - background-color: var(--color-background-soft); - } -` - export default ModelEditContent diff --git a/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx b/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx index 12f5e2992..7577ed8c0 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx @@ -1,5 +1,5 @@ import { TopView } from '@renderer/components/TopView' -import { Button, Form, FormProps, Input, Modal } from 'antd' +import { Button, Flex, Form, FormProps, Input, Modal } from 'antd' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -66,7 +66,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { centered>
= ({ title, resolve }) => { - + - +
) diff --git a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx index e00eb785e..6fab13d38 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx @@ -1,8 +1,8 @@ +import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' -import { Select } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -37,9 +37,9 @@ const WebSearchSettings: FC = () => { {t('settings.websearch.search_provider')}
- ) }))} + suffixIcon={} /> )} @@ -452,6 +455,7 @@ const TranslatePage: FC = () => { ) }))} + suffixIcon={} /> ) } @@ -551,6 +555,7 @@ const TranslatePage: FC = () => { ) })) ]} + suffixIcon={} />
+
+ + + + {t('models.rerank_model')} + } + /> + + + + {compressionConfig?.method === 'cutoff' && } + {compressionConfig?.method === 'rag' && } + + ) +} + +export default CompressionSettings diff --git a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx index 6fab13d38..ecf2b8375 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' import BasicSettings from './BasicSettings' import BlacklistSettings from './BlacklistSettings' +import CompressionSettings from './CompressionSettings' import WebSearchProviderSetting from './WebSearchProviderSetting' const WebSearchSettings: FC = () => { @@ -56,6 +57,7 @@ const WebSearchSettings: FC = () => { )} + ) diff --git a/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts b/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts index 6d2bc2401..1a3d53d87 100644 --- a/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts @@ -26,15 +26,13 @@ export default class BochaProvider extends BaseWebSearchProvider { Authorization: `Bearer ${this.apiKey}` } - const contentLimit = websearch.contentLimit - const params: BochaSearchParams = { query, count: websearch.maxResults, exclude: websearch.excludeDomains.join(','), freshness: websearch.searchWithTime ? 'oneDay' : 'noLimit', - summary: false, - page: contentLimit ? Math.ceil(contentLimit / websearch.maxResults) : 1 + summary: true, + page: 1 } const response = await fetch(`${this.apiHost}/v1/web-search`, { @@ -58,7 +56,8 @@ export default class BochaProvider extends BaseWebSearchProvider { query: resp.data.queryContext.originalQuery, results: resp.data.webPages.value.map((result) => ({ title: result.name, - content: result.snippet, + // 优先使用 summary(更详细),如果没有则使用 snippet + content: result.summary || result.snippet || '', url: result.url })) } diff --git a/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts b/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts index 8f65449b0..7aee19609 100644 --- a/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts @@ -35,14 +35,9 @@ export default class ExaProvider extends BaseWebSearchProvider { return { query: response.autopromptString, results: response.results.slice(0, websearch.maxResults).map((result) => { - let content = result.text || '' - if (websearch.contentLimit && content.length > websearch.contentLimit) { - content = content.slice(0, websearch.contentLimit) + '...' - } - return { title: result.title || 'No title', - content: content, + content: result.text || '', url: result.url || '' } }) diff --git a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts index 8f171dd3e..8a09b7601 100644 --- a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts @@ -55,11 +55,7 @@ export default class LocalSearchProvider extends BaseWebSearchProvider { // Fetch content for each URL concurrently const fetchPromises = validItems.map(async (item) => { // Logger.log(`Fetching content for ${item.url}...`) - const result = await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser, httpOptions) - if (websearch.contentLimit && result.content.length > websearch.contentLimit) { - result.content = result.content.slice(0, websearch.contentLimit) + '...' - } - return result + return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser, httpOptions) }) // Wait for all fetches to complete diff --git a/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts b/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts index 926da8a24..82b95142f 100644 --- a/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts @@ -122,11 +122,7 @@ export default class SearxngProvider extends BaseWebSearchProvider { // Fetch content for each URL concurrently const fetchPromises = validItems.map(async (item) => { // Logger.log(`Fetching content for ${item.url}...`) - const result = await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser) - if (websearch.contentLimit && result.content.length > websearch.contentLimit) { - result.content = result.content.slice(0, websearch.contentLimit) + '...' - } - return result + return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser) }) // Wait for all fetches to complete diff --git a/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts b/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts index e38b2661d..225bce308 100644 --- a/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts @@ -31,14 +31,9 @@ export default class TavilyProvider extends BaseWebSearchProvider { return { query: result.query, results: result.results.slice(0, websearch.maxResults).map((result) => { - let content = result.content || '' - if (websearch.contentLimit && content.length > websearch.contentLimit) { - content = content.slice(0, websearch.contentLimit) + '...' - } - return { title: result.title || 'No title', - content: content, + content: result.content || '', url: result.url || '' } }) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index fb2c07310..4704a8bfd 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -157,8 +157,13 @@ async function fetchExternalTool( try { // Use the consolidated processWebsearch function WebSearchService.createAbortSignal(lastUserMessage.id) + const webSearchResponse = await WebSearchService.processWebsearch( + webSearchProvider!, + extractResults, + lastUserMessage.id + ) return { - results: await WebSearchService.processWebsearch(webSearchProvider!, extractResults), + results: webSearchResponse, source: WebSearchSource.WEBSEARCH } } catch (error) { diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index da7b93916..707e4df0b 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -130,7 +130,7 @@ export const searchKnowledgeBase = async ( ) } catch (error) { Logger.error(`Error searching knowledge base ${base.name}:`, error) - return [] + throw error } } diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index cf773a751..efb726a0a 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -1,13 +1,38 @@ +import { DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT } from '@renderer/config/constant' import Logger from '@renderer/config/logger' +import i18n from '@renderer/i18n' import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider' import store from '@renderer/store' -import { WebSearchState } from '@renderer/store/websearch' -import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' -import { hasObjectKey } from '@renderer/utils' +import { setWebSearchStatus } from '@renderer/store/runtime' +import { CompressionConfig, WebSearchState } from '@renderer/store/websearch' +import { + KnowledgeBase, + KnowledgeItem, + KnowledgeReference, + WebSearchProvider, + WebSearchProviderResponse, + WebSearchProviderResult, + WebSearchStatus +} from '@renderer/types' +import { hasObjectKey, uuid } from '@renderer/utils' import { addAbortController } from '@renderer/utils/abortController' +import { formatErrorMessage } from '@renderer/utils/error' import { ExtractResults } from '@renderer/utils/extract' import { fetchWebContents } from '@renderer/utils/fetch' +import { consolidateReferencesByUrl, selectReferences } from '@renderer/utils/websearch' import dayjs from 'dayjs' +import { LRUCache } from 'lru-cache' + +import { getKnowledgeBaseParams } from './KnowledgeService' +import { getKnowledgeSourceUrl, searchKnowledgeBase } from './KnowledgeService' + +interface RequestState { + signal: AbortSignal | null + searchBase?: KnowledgeBase + isPaused: boolean + createdAt: number +} + /** * 提供网络搜索相关功能的服务类 */ @@ -19,12 +44,47 @@ class WebSearchService { isPaused = false - createAbortSignal(key: string) { + // 管理不同请求的状态 + private requestStates = new LRUCache({ + max: 5, // 最多5个并发请求 + ttl: 1000 * 60 * 2, // 2分钟过期 + dispose: (requestState: RequestState, requestId: string) => { + if (!requestState.searchBase) return + window.api.knowledgeBase + .delete(requestState.searchBase.id) + .catch((error) => Logger.warn(`[WebSearchService] Failed to cleanup search base for ${requestId}:`, error)) + } + }) + + /** + * 获取或创建单个请求的状态 + * @param requestId 请求 ID(通常是消息 ID) + */ + private getRequestState(requestId: string): RequestState { + let state = this.requestStates.get(requestId) + if (!state) { + state = { + signal: null, + isPaused: false, + createdAt: Date.now() + } + this.requestStates.set(requestId, state) + } + return state + } + + createAbortSignal(requestId: string) { const controller = new AbortController() - this.signal = controller.signal - addAbortController(key, () => { - this.isPaused = true + this.signal = controller.signal // 保持向后兼容 + + const state = this.getRequestState(requestId) + state.signal = controller.signal + + addAbortController(requestId, () => { + this.isPaused = true // 保持向后兼容 + state.isPaused = true this.signal = null + this.requestStates.delete(requestId) controller.abort() }) return controller @@ -137,45 +197,338 @@ class WebSearchService { } } + /** + * 设置网络搜索状态 + */ + private async setWebSearchStatus(requestId: string, status: WebSearchStatus, delayMs?: number) { + store.dispatch(setWebSearchStatus({ requestId, status })) + if (delayMs) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } + + /** + * 确保搜索压缩知识库存在并配置正确 + */ + private async ensureSearchBase( + config: CompressionConfig, + documentCount: number, + requestId: string + ): Promise { + const baseId = `websearch-compression-${requestId}` + const state = this.getRequestState(requestId) + + // 如果已存在且配置未变,直接复用 + if (state.searchBase && this.isConfigMatched(state.searchBase, config)) { + return state.searchBase + } + + // 清理旧的知识库 + if (state.searchBase) { + await window.api.knowledgeBase.delete(state.searchBase.id) + } + + if (!config.embeddingModel) { + throw new Error('Embedding model is required for RAG compression') + } + + // 创建新的知识库 + state.searchBase = { + id: baseId, + name: `WebSearch-RAG-${requestId}`, + model: config.embeddingModel, + rerankModel: config.rerankModel, + dimensions: config.embeddingDimensions, + documentCount, + items: [], + created_at: Date.now(), + updated_at: Date.now(), + version: 1 + } + + // 更新LRU cache + this.requestStates.set(requestId, state) + + // 创建知识库 + const baseParams = getKnowledgeBaseParams(state.searchBase) + await window.api.knowledgeBase.create(baseParams) + + return state.searchBase + } + + /** + * 检查配置是否匹配 + */ + private isConfigMatched(base: KnowledgeBase, config: CompressionConfig): boolean { + return ( + base.model.id === config.embeddingModel?.id && + base.rerankModel?.id === config.rerankModel?.id && + base.dimensions === config.embeddingDimensions + ) + } + + /** + * 对搜索知识库执行多问题查询并按分数排序 + * @param questions 问题列表 + * @param searchBase 搜索知识库 + * @returns 排序后的知识引用列表 + */ + private async querySearchBase(questions: string[], searchBase: KnowledgeBase): Promise { + // 1. 单独搜索每个问题 + const searchPromises = questions.map((question) => searchKnowledgeBase(question, searchBase)) + const allResults = await Promise.all(searchPromises) + + // 2. 合并所有结果并按分数排序 + const flatResults = allResults.flat().sort((a, b) => b.score - a.score) + + // 3. 去重,保留最高分的重复内容 + const seen = new Set() + const uniqueResults = flatResults.filter((item) => { + if (seen.has(item.pageContent)) { + return false + } + seen.add(item.pageContent) + return true + }) + + // 4. 转换为引用格式 + return await Promise.all( + uniqueResults.map(async (result, index) => ({ + id: index + 1, + content: result.pageContent, + sourceUrl: await getKnowledgeSourceUrl(result), + type: 'url' as const + })) + ) + } + + /** + * 使用RAG压缩搜索结果。 + * - 一次性将所有搜索结果添加到知识库 + * - 从知识库中 retrieve 相关结果 + * - 根据 sourceUrl 映射回原始搜索结果 + * + * @param questions 问题列表 + * @param rawResults 原始搜索结果 + * @param config 压缩配置 + * @param requestId 请求ID + * @returns 压缩后的搜索结果 + */ + private async compressWithSearchBase( + questions: string[], + rawResults: WebSearchProviderResult[], + config: CompressionConfig, + requestId: string + ): Promise { + // 根据搜索次数计算所需的文档数量 + const totalDocumentCount = + Math.max(0, rawResults.length) * (config.documentCount ?? DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT) + + const searchBase = await this.ensureSearchBase(config, totalDocumentCount, requestId) + + // 1. 清空知识库 + await window.api.knowledgeBase.reset(getKnowledgeBaseParams(searchBase)) + + // 2. 一次性添加所有搜索结果到知识库 + const addPromises = rawResults.map(async (result) => { + const item: KnowledgeItem & { sourceUrl?: string } = { + id: uuid(), + type: 'note', + content: result.content, + sourceUrl: result.url, // 设置 sourceUrl 用于映射 + created_at: Date.now(), + updated_at: Date.now(), + processingStatus: 'pending' + } + + await window.api.knowledgeBase.add({ + base: getKnowledgeBaseParams(searchBase), + item + }) + }) + + // 等待所有结果添加完成 + await Promise.all(addPromises) + + // 3. 对知识库执行多问题搜索获取压缩结果 + const references = await this.querySearchBase(questions, searchBase) + + // 4. 使用 Round Robin 策略选择引用 + const selectedReferences = selectReferences(rawResults, references, totalDocumentCount) + + Logger.log('[WebSearchService] With RAG, the number of search results:', { + raw: rawResults.length, + retrieved: references.length, + selected: selectedReferences.length + }) + + // 5. 按 sourceUrl 分组并合并同源片段 + return consolidateReferencesByUrl(rawResults, selectedReferences) + } + + /** + * 使用截断方式压缩搜索结果,可以选择单位 char 或 token。 + * + * @param rawResults 原始搜索结果 + * @param config 压缩配置 + * @returns 截断后的搜索结果 + */ + private async compressWithCutoff( + rawResults: WebSearchProviderResult[], + config: CompressionConfig + ): Promise { + if (!config.cutoffLimit) { + Logger.warn('[WebSearchService] Cutoff limit is not set, skipping compression') + return rawResults + } + + const perResultLimit = Math.max(1, Math.floor(config.cutoffLimit / rawResults.length)) + + // 动态导入 tokenx + const { sliceByTokens } = await import('tokenx') + + return rawResults.map((result) => { + if (config.cutoffUnit === 'token') { + // 使用 token 截断 + const slicedContent = sliceByTokens(result.content, 0, perResultLimit) + return { + ...result, + content: slicedContent.length < result.content.length ? slicedContent + '...' : slicedContent + } + } else { + // 使用字符截断(默认行为) + return { + ...result, + content: + result.content.length > perResultLimit ? result.content.slice(0, perResultLimit) + '...' : result.content + } + } + }) + } + + /** + * 处理网络搜索请求的核心方法,处理过程中会设置运行时状态供 UI 使用。 + * + * 该方法执行以下步骤: + * - 验证输入参数并处理边界情况 + * - 处理特殊的summarize请求 + * - 并行执行多个搜索查询 + * - 聚合搜索结果并处理失败情况 + * - 根据配置应用结果压缩(RAG或截断) + * - 返回最终的搜索响应 + * + * @param webSearchProvider - 要使用的网络搜索提供商 + * @param extractResults - 包含搜索问题和链接的提取结果对象 + * @param requestId - 唯一的请求标识符,用于状态跟踪和资源管理 + * + * @returns 包含搜索结果的响应对象 + */ public async processWebsearch( webSearchProvider: WebSearchProvider, - extractResults: ExtractResults + extractResults: ExtractResults, + requestId: string ): Promise { + // 重置状态 + await this.setWebSearchStatus(requestId, { phase: 'default' }) + // 检查 websearch 和 question 是否有效 if (!extractResults.websearch?.question || extractResults.websearch.question.length === 0) { Logger.log('[processWebsearch] No valid question found in extractResults.websearch') return { results: [] } } + // 使用请求特定的signal,如果没有则回退到全局signal + const signal = this.getRequestState(requestId).signal || this.signal + const questions = extractResults.websearch.question const links = extractResults.websearch.links - const firstQuestion = questions[0] - if (firstQuestion === 'summarize' && links && links.length > 0) { - const contents = await fetchWebContents(links, undefined, undefined, { - signal: this.signal - }) - return { - query: 'summaries', - results: contents - } - } - const searchPromises = questions.map((q) => this.search(webSearchProvider, q, { signal: this.signal })) - const searchResults = await Promise.allSettled(searchPromises) - const aggregatedResults: any[] = [] + // 处理 summarize + if (questions[0] === 'summarize' && links && links.length > 0) { + const contents = await fetchWebContents(links, undefined, undefined, { signal }) + return { query: 'summaries', results: contents } + } + + const searchPromises = questions.map((q) => this.search(webSearchProvider, q, { signal })) + const searchResults = await Promise.allSettled(searchPromises) + + // 统计成功完成的搜索数量 + const successfulSearchCount = searchResults.filter((result) => result.status === 'fulfilled').length + if (successfulSearchCount > 1) { + await this.setWebSearchStatus( + requestId, + { + phase: 'fetch_complete', + countAfter: successfulSearchCount + }, + 1000 + ) + } + + let finalResults: WebSearchProviderResult[] = [] searchResults.forEach((result) => { if (result.status === 'fulfilled') { if (result.value.results) { - aggregatedResults.push(...result.value.results) + finalResults.push(...result.value.results) } } if (result.status === 'rejected') { throw result.reason } }) + + // 如果没有搜索结果,直接返回空结果 + if (finalResults.length === 0) { + await this.setWebSearchStatus(requestId, { phase: 'default' }) + return { + query: questions.join(' | '), + results: [] + } + } + + const { compressionConfig } = this.getWebSearchState() + + // RAG压缩处理 + if (compressionConfig?.method === 'rag' && requestId) { + await this.setWebSearchStatus(requestId, { phase: 'rag' }, 500) + + const originalCount = finalResults.length + + try { + finalResults = await this.compressWithSearchBase(questions, finalResults, compressionConfig, requestId) + await this.setWebSearchStatus( + requestId, + { + phase: 'rag_complete', + countBefore: originalCount, + countAfter: finalResults.length + }, + 1000 + ) + } catch (error) { + Logger.warn('[WebSearchService] RAG compression failed, will return empty results:', error) + window.message.error({ + key: 'websearch-rag-failed', + duration: 10, + content: `${i18n.t('settings.websearch.compression.error.rag_failed')}: ${formatErrorMessage(error)}` + }) + + finalResults = [] + await this.setWebSearchStatus(requestId, { phase: 'rag_failed' }, 1000) + } + } + // 截断压缩处理 + else if (compressionConfig?.method === 'cutoff' && compressionConfig.cutoffLimit) { + await this.setWebSearchStatus(requestId, { phase: 'cutoff' }, 500) + finalResults = await this.compressWithCutoff(finalResults, compressionConfig) + } + + // 重置状态 + await this.setWebSearchStatus(requestId, { phase: 'default' }) + return { query: questions.join(' | '), - results: aggregatedResults + results: finalResults } } } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index ae5c52a9f..4a26bf96c 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -50,7 +50,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 115, + version: 116, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 0e9385de0..c8a132180 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1631,6 +1631,31 @@ const migrateConfig = { if (state.settings) { state.settings.upgradeChannel = UpgradeChannel.LATEST } + return state + } catch (error) { + return state + } + }, + '116': (state: RootState) => { + try { + if (state.websearch) { + // migrate contentLimit to cutoffLimit + // @ts-ignore eslint-disable-next-line + if (state.websearch.contentLimit) { + state.websearch.compressionConfig = { + method: 'cutoff', + cutoffUnit: 'char', + // @ts-ignore eslint-disable-next-line + cutoffLimit: state.websearch.contentLimit + } + } else { + state.websearch.compressionConfig = { method: 'none', cutoffUnit: 'char' } + } + + // @ts-ignore eslint-disable-next-line + delete state.websearch.contentLimit + } + return state } catch (error) { return state diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 5c84ab800..d1e3752d1 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppLogo, UserAvatar } from '@renderer/config/env' -import type { MinAppType, Topic } from '@renderer/types' +import type { MinAppType, Topic, WebSearchStatus } from '@renderer/types' import type { UpdateInfo } from 'builder-util-runtime' export interface ChatState { @@ -13,6 +13,10 @@ export interface ChatState { newlyRenamedTopics: string[] } +export interface WebSearchState { + activeSearches: Record +} + export interface UpdateState { info: UpdateInfo | null checking: boolean @@ -39,6 +43,7 @@ export interface RuntimeState { update: UpdateState export: ExportState chat: ChatState + websearch: WebSearchState } export interface ExportState { @@ -72,6 +77,9 @@ const initialState: RuntimeState = { activeTopic: null, renamingTopics: [], newlyRenamedTopics: [] + }, + websearch: { + activeSearches: {} } } @@ -130,6 +138,17 @@ const runtimeSlice = createSlice({ }, setNewlyRenamedTopics: (state, action: PayloadAction) => { state.chat.newlyRenamedTopics = action.payload + }, + // WebSearch related actions + setActiveSearches: (state, action: PayloadAction>) => { + state.websearch.activeSearches = action.payload + }, + setWebSearchStatus: (state, action: PayloadAction<{ requestId: string; status: WebSearchStatus }>) => { + const { requestId, status } = action.payload + if (status.phase === 'default') { + delete state.websearch.activeSearches[requestId] + } + state.websearch.activeSearches[requestId] = status } } }) @@ -151,7 +170,10 @@ export const { setSelectedMessageIds, setActiveTopic, setRenamingTopics, - setNewlyRenamedTopics + setNewlyRenamedTopics, + // WebSearch related actions + setActiveSearches, + setWebSearchStatus } = runtimeSlice.actions export default runtimeSlice.reducer diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index 4f223ccbf..ad6172065 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import type { WebSearchProvider } from '@renderer/types' +import type { Model, WebSearchProvider } from '@renderer/types' export interface SubscribeSource { key: number url: string @@ -7,6 +7,16 @@ export interface SubscribeSource { blacklist?: string[] // 存储从该订阅源获取的黑名单 } +export interface CompressionConfig { + method: 'none' | 'cutoff' | 'rag' + cutoffLimit?: number + cutoffUnit?: 'char' | 'token' + embeddingModel?: Model + embeddingDimensions?: number // undefined表示自动获取 + documentCount?: number // 每个搜索结果的文档数量(只是预期值) + rerankModel?: Model +} + export interface WebSearchState { // 默认搜索提供商的ID /** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */ @@ -24,12 +34,13 @@ export interface WebSearchState { // 是否覆盖服务商搜索 /** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */ overwrite: boolean - contentLimit?: number + // 搜索结果压缩 + compressionConfig?: CompressionConfig // 具体供应商的配置 providerConfig: Record } -const initialState: WebSearchState = { +export const initialState: WebSearchState = { defaultProvider: 'local-bing', providers: [ { @@ -78,6 +89,10 @@ const initialState: WebSearchState = { excludeDomains: [], subscribeSources: [], overwrite: false, + compressionConfig: { + method: 'none', + cutoffUnit: 'char' + }, providerConfig: {} } @@ -150,8 +165,14 @@ const websearchSlice = createSlice({ state.providers.push(action.payload) } }, - setContentLimit: (state, action: PayloadAction) => { - state.contentLimit = action.payload + setCompressionConfig: (state, action: PayloadAction) => { + state.compressionConfig = action.payload + }, + updateCompressionConfig: (state, action: PayloadAction>) => { + state.compressionConfig = { + ...state.compressionConfig, + ...action.payload + } as CompressionConfig }, setProviderConfig: (state, action: PayloadAction>) => { state.providerConfig = action.payload @@ -176,7 +197,8 @@ export const { setSubscribeSources, setOverwrite, addWebSearchProvider, - setContentLimit, + setCompressionConfig, + updateCompressionConfig, setProviderConfig, updateProviderConfig } = websearchSlice.actions diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 340119fa3..3b4cc5cdc 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -500,7 +500,6 @@ export type WebSearchProvider = { url?: string basicAuthUsername?: string basicAuthPassword?: string - contentLimit?: number usingBrowser?: boolean } @@ -542,6 +541,14 @@ export type WebSearchResponse = { source: WebSearchSource } +export type WebSearchPhase = 'default' | 'fetch_complete' | 'rag' | 'rag_complete' | 'rag_failed' | 'cutoff' + +export type WebSearchStatus = { + phase: WebSearchPhase + countBefore?: number + countAfter?: number +} + export type KnowledgeReference = { id: number content: string diff --git a/src/renderer/src/utils/__tests__/websearch.test.ts b/src/renderer/src/utils/__tests__/websearch.test.ts new file mode 100644 index 000000000..2f807d111 --- /dev/null +++ b/src/renderer/src/utils/__tests__/websearch.test.ts @@ -0,0 +1,226 @@ +import { KnowledgeReference, WebSearchProviderResult } from '@renderer/types' +import { describe, expect, it } from 'vitest' + +import { consolidateReferencesByUrl, selectReferences } from '../websearch' + +describe('websearch', () => { + describe('consolidateReferencesByUrl', () => { + const createMockRawResult = (url: string, title: string): WebSearchProviderResult => ({ + title, + url, + content: `Original content for ${title}` + }) + + const createMockReference = (sourceUrl: string, content: string, id: number = 1): KnowledgeReference => ({ + id, + sourceUrl, + content, + type: 'url' + }) + + it('should consolidate single reference to matching raw result', () => { + // 基本功能:单个引用与原始结果匹配 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [createMockReference('https://example.com', 'Retrieved content')] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'Retrieved content' + }) + }) + + it('should consolidate multiple references from same source URL', () => { + // 多个片段合并到同一个URL + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [ + createMockReference('https://example.com', 'First content', 1), + createMockReference('https://example.com', 'Second content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'First content\n\n---\n\nSecond content' + }) + }) + + it('should consolidate references from multiple source URLs', () => { + // 多个不同URL的引用 + const rawResults = [ + createMockRawResult('https://example.com', 'Example Title'), + createMockRawResult('https://test.com', 'Test Title') + ] + const references = [ + createMockReference('https://example.com', 'Example content', 1), + createMockReference('https://test.com', 'Test content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(2) + // 结果顺序可能不确定,使用 toContainEqual + expect(result).toContainEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'Example content' + }) + expect(result).toContainEqual({ + title: 'Test Title', + url: 'https://test.com', + content: 'Test content' + }) + }) + + it('should use custom separator for multiple references', () => { + // 自定义分隔符 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [ + createMockReference('https://example.com', 'First content', 1), + createMockReference('https://example.com', 'Second content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references, ' | ') + + expect(result).toHaveLength(1) + expect(result[0].content).toBe('First content | Second content') + }) + + it('should ignore references with no matching raw result', () => { + // 无匹配的引用 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [ + createMockReference('https://example.com', 'Matching content', 1), + createMockReference('https://nonexistent.com', 'Non-matching content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'Matching content' + }) + }) + + it('should return empty array when no references match raw results', () => { + // 完全无匹配的情况 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [createMockReference('https://nonexistent.com', 'Non-matching content', 1)] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(0) + }) + + it('should handle empty inputs', () => { + // 边界条件:空输入 + expect(consolidateReferencesByUrl([], [])).toEqual([]) + + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + expect(consolidateReferencesByUrl(rawResults, [])).toEqual([]) + + const references = [createMockReference('https://example.com', 'Content', 1)] + expect(consolidateReferencesByUrl([], references)).toEqual([]) + }) + + it('should preserve original result metadata', () => { + // 验证原始结果的元数据保持不变 + const rawResults = [createMockRawResult('https://example.com', 'Complex Title with Special Characters & Symbols')] + const references = [createMockReference('https://example.com', 'New content', 1)] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result[0].title).toBe('Complex Title with Special Characters & Symbols') + expect(result[0].url).toBe('https://example.com') + }) + }) + + describe('selectReferences', () => { + const createMockRawResult = (url: string, title: string): WebSearchProviderResult => ({ + title, + url, + content: `Original content for ${title}` + }) + + const createMockReference = (sourceUrl: string, content: string, id: number = 1): KnowledgeReference => ({ + id, + sourceUrl, + content, + type: 'url' + }) + + it('should select references using round robin strategy', () => { + const rawResults = [ + createMockRawResult('https://a.com', 'A'), + createMockRawResult('https://b.com', 'B'), + createMockRawResult('https://c.com', 'C') + ] + + const references = [ + createMockReference('https://a.com', 'A1', 1), + createMockReference('https://a.com', 'A2', 2), + createMockReference('https://b.com', 'B1', 3), + createMockReference('https://c.com', 'C1', 4), + createMockReference('https://c.com', 'C2', 5) + ] + + const result = selectReferences(rawResults, references, 4) + + expect(result).toHaveLength(4) + // 按照 rawResults 顺序轮询:A1, B1, C1, A2 + expect(result[0].content).toBe('A1') + expect(result[1].content).toBe('B1') + expect(result[2].content).toBe('C1') + expect(result[3].content).toBe('A2') + }) + + it('should handle maxRefs larger than available references', () => { + const rawResults = [createMockRawResult('https://a.com', 'A')] + const references = [createMockReference('https://a.com', 'A1', 1)] + + const result = selectReferences(rawResults, references, 10) + + expect(result).toHaveLength(1) + expect(result[0].content).toBe('A1') + }) + + it('should return empty array for edge cases', () => { + const rawResults = [createMockRawResult('https://a.com', 'A')] + const references = [createMockReference('https://a.com', 'A1', 1)] + + // maxRefs is 0 + expect(selectReferences(rawResults, references, 0)).toEqual([]) + + // empty references + expect(selectReferences(rawResults, [], 5)).toEqual([]) + + // no matching URLs + const nonMatchingRefs = [createMockReference('https://different.com', 'Content', 1)] + expect(selectReferences(rawResults, nonMatchingRefs, 5)).toEqual([]) + }) + + it('should preserve rawResults order in round robin', () => { + // rawResults 的顺序应该影响轮询顺序 + const rawResults = [ + createMockRawResult('https://z.com', 'Z'), // 应该第一个被选择 + createMockRawResult('https://a.com', 'A') // 应该第二个被选择 + ] + + const references = [createMockReference('https://a.com', 'A1', 1), createMockReference('https://z.com', 'Z1', 2)] + + const result = selectReferences(rawResults, references, 2) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe('Z1') // Z 先被选择 + expect(result[1].content).toBe('A1') // A 后被选择 + }) + }) +}) diff --git a/src/renderer/src/utils/websearch.ts b/src/renderer/src/utils/websearch.ts new file mode 100644 index 000000000..05f82861c --- /dev/null +++ b/src/renderer/src/utils/websearch.ts @@ -0,0 +1,116 @@ +import { KnowledgeReference, WebSearchProviderResult } from '@renderer/types' + +/** + * 将检索到的知识片段按源URL整合为搜索结果 + * + * 这个函数接收原始搜索结果和从知识库检索到的相关片段, + * 将同源的片段按URL分组并合并为最终的搜索结果。 + * + * @param rawResults 原始搜索结果,用于提供标题和URL信息 + * @param references 从知识库检索到的相关片段 + * @param separator 合并片段时使用的分隔符,默认为 '\n\n---\n\n' + * @returns 合并后的搜索结果数组 + */ +export function consolidateReferencesByUrl( + rawResults: WebSearchProviderResult[], + references: KnowledgeReference[], + separator: string = '\n\n---\n\n' +): WebSearchProviderResult[] { + // 创建URL到原始结果的映射,用于快速查找 + const urlToOriginalResult = new Map(rawResults.map((result) => [result.url, result])) + + // 使用 reduce 进行分组和内容收集 + const sourceGroups = references.reduce((groups, reference) => { + const originalResult = urlToOriginalResult.get(reference.sourceUrl) + if (!originalResult) return groups + + const existing = groups.get(reference.sourceUrl) + if (existing) { + // 如果已存在该URL的分组,直接添加内容 + existing.contents.push(reference.content) + } else { + // 创建新的分组 + groups.set(reference.sourceUrl, { + originalResult, + contents: [reference.content] + }) + } + return groups + }, new Map()) + + // 转换为最终结果 + return Array.from(sourceGroups.values(), (group) => ({ + title: group.originalResult.title, + url: group.originalResult.url, + content: group.contents.join(separator) + })) +} + +/** + * 使用 Round Robin 策略从引用中选择指定数量的项目 + * 按照原始搜索结果的顺序轮询选择,确保每个源都有机会被选中 + * + * @param rawResults 原始搜索结果,用于确定轮询顺序 + * @param references 所有可选的引用项目 + * @param maxRefs 最大选择数量 + * @returns 按 Round Robin 策略选择的引用数组 + */ +export function selectReferences( + rawResults: WebSearchProviderResult[], + references: KnowledgeReference[], + maxRefs: number +): KnowledgeReference[] { + if (maxRefs <= 0 || references.length === 0) { + return [] + } + + // 建立URL到索引的映射,用于确定轮询顺序 + const urlToIndex = new Map() + rawResults.forEach((result, index) => { + urlToIndex.set(result.url, index) + }) + + // 按sourceUrl分组references,每组内按原顺序保持(已按分数排序) + const groupsByUrl = new Map() + references.forEach((ref) => { + if (!groupsByUrl.has(ref.sourceUrl)) { + groupsByUrl.set(ref.sourceUrl, []) + } + groupsByUrl.get(ref.sourceUrl)!.push(ref) + }) + + // 获取有效的URL列表,按rawResults顺序排序 + const availableUrls = Array.from(groupsByUrl.keys()) + .filter((url) => urlToIndex.has(url)) + .sort((a, b) => urlToIndex.get(a)! - urlToIndex.get(b)!) + + if (availableUrls.length === 0) { + return [] + } + + // Round Robin 选择 + const selected: KnowledgeReference[] = [] + let roundIndex = 0 + + while (selected.length < maxRefs && availableUrls.length > 0) { + const currentUrl = availableUrls[roundIndex] + const group = groupsByUrl.get(currentUrl)! + + if (group.length > 0) { + selected.push(group.shift()!) + } + + // 如果当前组为空,从可用URL列表中移除 + if (group.length === 0) { + availableUrls.splice(roundIndex, 1) + // 调整索引,避免跳过下一个URL + if (roundIndex >= availableUrls.length) { + roundIndex = 0 + } + } else { + roundIndex = (roundIndex + 1) % availableUrls.length + } + } + + return selected +} diff --git a/yarn.lock b/yarn.lock index eefde56f9..2386409f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5745,7 +5745,7 @@ __metadata: styled-components: "npm:^6.1.11" tar: "npm:^7.4.3" tiny-pinyin: "npm:^1.3.2" - tokenx: "npm:^0.4.1" + tokenx: "npm:^1.1.0" turndown: "npm:7.2.0" typescript: "npm:^5.6.2" uuid: "npm:^10.0.0" @@ -17588,10 +17588,10 @@ __metadata: languageName: node linkType: hard -"tokenx@npm:^0.4.1": - version: 0.4.1 - resolution: "tokenx@npm:0.4.1" - checksum: 10c0/377f4e3c31ff9dc57b5b6af0fb1ae821227dee5e1d87b92a3ab1a0ed25454f01185c709d73592002b0d3024de1c904c8f029c46ae1806677816e4659fb8c481e +"tokenx@npm:^1.1.0": + version: 1.1.0 + resolution: "tokenx@npm:1.1.0" + checksum: 10c0/8214bce58b48e130bcf4a27ac1bb5abf486c395310fb0c8f54e31656acacf97da533372afb9e8ac8f7736e6c3f29af86ea9623d4875f1399e66a5203b80609db languageName: node linkType: hard From 98b12fb8009ed1e84454d38455b14e2a3705073a Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Fri, 27 Jun 2025 18:07:17 +0800 Subject: [PATCH 46/56] fix: tei reranker (#7606) fix(tei) --- src/main/reranker/BaseReranker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 9a40dfbdf..83d241fe8 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -86,7 +86,7 @@ export default abstract class BaseReranker { return data.output.results } else if (provider === 'voyageai') { return data.data - } else if (provider === 'mis-tei') { + } else if (provider?.includes('tei')) { return data.map((item: any) => { return { index: item.index, From c7c1cf2552ea96cb8728e333cc12d5c1801e7a1a Mon Sep 17 00:00:00 2001 From: one Date: Fri, 27 Jun 2025 21:53:43 +0800 Subject: [PATCH 47/56] refactor: increase css editor height, fix EditMcpJsonPopup (#7535) * refactor: increase css editor height * fix: lint warnings * refactor: use vh for height * fix: editmcpjsonpopup editor unavailable after deleting all the code --- .../settings/DataSettings/DataSettings.tsx | 2 +- .../DisplaySettings/DisplaySettings.tsx | 4 +- .../settings/MCPSettings/EditMcpJsonPopup.tsx | 42 ++++++++++--------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 93a138f84..fece64a16 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -546,7 +546,7 @@ const DataSettings: FC = () => { } handleDataMigration() - }, []) + }, [t]) const onSkipBackupFilesChange = (value: boolean) => { setSkipBackupFile(value) diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 56d09bd8d..27453ef1c 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -315,9 +315,9 @@ const DisplaySettings: FC = () => { language="css" placeholder={t('settings.display.custom.css.placeholder')} onChange={(value) => dispatch(setCustomCss(value))} - height="350px" + height="60vh" options={{ - collapsible: true, + collapsible: false, wrappable: true, autocompletion: true, lineNumbers: true, diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx index 77ad41fb5..0e505b384 100644 --- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx @@ -3,7 +3,7 @@ import { TopView } from '@renderer/components/TopView' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setMCPServers } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' -import { Modal, Typography } from 'antd' +import { Modal, Spin, Typography } from 'antd' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -16,12 +16,14 @@ const PopupContainer: React.FC = ({ resolve }) => { const [jsonConfig, setJsonConfig] = useState('') const [jsonSaving, setJsonSaving] = useState(false) const [jsonError, setJsonError] = useState('') + const [isLoading, setIsLoading] = useState(true) const mcpServers = useAppSelector((state) => state.mcp.servers) const dispatch = useAppDispatch() const { t } = useTranslation() useEffect(() => { + setIsLoading(true) try { const mcpServersObj: Record = {} @@ -40,6 +42,8 @@ const PopupContainer: React.FC = ({ resolve }) => { } catch (error) { console.error('Failed to format JSON:', error) setJsonError(t('settings.mcp.jsonFormatError')) + } finally { + setIsLoading(false) } }, [mcpServers, t]) @@ -118,24 +122,24 @@ const PopupContainer: React.FC = ({ resolve }) => { {jsonError ? {jsonError} : ''}
- {jsonConfig && ( -
- setJsonConfig(value)} - maxHeight="60vh" - options={{ - lint: true, - collapsible: true, - wrappable: true, - lineNumbers: true, - foldGutter: true, - highlightActiveLine: true, - keymap: true - }} - /> -
+ {isLoading ? ( + + ) : ( + setJsonConfig(value)} + height="60vh" + options={{ + lint: true, + collapsible: false, + wrappable: true, + lineNumbers: true, + foldGutter: true, + highlightActiveLine: true, + keymap: true + }} + /> )} {t('settings.mcp.jsonModeHint')} From 2d3f5baf72c74ca498f265e68631cda7fce1f1f4 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:33:27 +0800 Subject: [PATCH 48/56] feat: Increase the upper limit of web search results (#7439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(WebSearchSettings): 将最大搜索结果限制从20增加到50 * fix(WebSearchSettings): 调整搜索结果滑块宽度并添加50的标记 --- .../src/pages/settings/WebSearchSettings/BasicSettings.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx index 2a85df4b7..f891e2aee 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx @@ -28,11 +28,11 @@ const BasicSettings: FC = () => { {t('settings.websearch.search_max_result')} dispatch(setMaxResult(value))} /> From 14e31018f7b50519da55ca986961db3426131ce8 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 08:36:32 +0800 Subject: [PATCH 49/56] fix: support spell check for mini app (#7602) * feat(IpcChannel): add Webview_SetSpellCheckEnabled channel and implement spell check handling for webviews - Introduced a new IPC channel for enabling/disabling spell check in webviews. - Updated the registerIpc function to handle spell check settings for all webviews. - Enhanced WebviewContainer to set spell check state on DOM ready event. - Refactored context menu setup to accommodate webview context menus. * refactor(ContextMenu): update methods to use Electron.WebContents instead of BrowserWindow - Changed method signatures to accept Electron.WebContents for better context handling. - Updated internal calls to utilize the new WebContents reference for toggling dev tools and managing spell check functionality. * refactor(WebviewContainer): clean up import order and remove unused code - Adjusted the import order in WebviewContainer.tsx for better readability. - Removed redundant import of useSettings to streamline the component. --- packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 15 +++++-- src/main/services/ContextMenu.ts | 16 +++---- src/main/services/WindowService.ts | 7 ++-- src/preload/index.ts | 4 +- .../components/MinApp/WebviewContainer.tsx | 11 +++++ .../src/pages/settings/GeneralSettings.tsx | 42 +++++++++---------- 7 files changed, 59 insertions(+), 37 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 8da9a6742..8782d02f2 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -38,6 +38,7 @@ export enum IpcChannel { Notification_OnClick = 'notification:on-click', Webview_SetOpenLinkExternal = 'webview:set-open-link-external', + Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled', // Open Open_Path = 'open:path', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b2003f8db..32baecac3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -8,7 +8,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Shortcut, ThemeMode } from '@types' -import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron' +import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron' import log from 'electron-log' import { Notification } from 'src/renderer/src/types/notification' @@ -93,9 +93,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // spell check ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => { - const windows = BrowserWindow.getAllWindows() - windows.forEach((window) => { - window.webContents.session.setSpellCheckerEnabled(isEnable) + // disable spell check for all webviews + const webviews = webContents.getAllWebContents() + webviews.forEach((webview) => { + webview.session.setSpellCheckerEnabled(isEnable) }) }) @@ -494,6 +495,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { setOpenLinkExternal(webviewId, isExternal) ) + ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => { + const webview = webContents.fromId(webviewId) + if (!webview) return + webview.session.setSpellCheckerEnabled(isEnable) + }) + // store sync storeSyncService.registerIpcHandler() diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 34ec4b911..411d6e075 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -4,8 +4,8 @@ import { locales } from '../utils/locales' import { configManager } from './ConfigManager' class ContextMenu { - public contextMenu(w: Electron.BrowserWindow) { - w.webContents.on('context-menu', (_event, properties) => { + public contextMenu(w: Electron.WebContents) { + w.on('context-menu', (_event, properties) => { const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties) const filtered = template.filter((item) => item.visible !== false) if (filtered.length > 0) { @@ -26,7 +26,7 @@ class ContextMenu { }) } - private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] { + private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] { const locale = locales[configManager.getLanguage()] const { common } = locale.translation const template: MenuItemConstructorOptions[] = [ @@ -34,7 +34,7 @@ class ContextMenu { id: 'inspect', label: common.inspect, click: () => { - w.webContents.toggleDevTools() + w.toggleDevTools() }, enabled: true } @@ -86,7 +86,7 @@ class ContextMenu { private createSpellCheckMenuItem( properties: Electron.ContextMenuParams, - mainWindow: Electron.BrowserWindow + w: Electron.WebContents ): MenuItemConstructorOptions { const hasText = properties.selectionText.length > 0 @@ -95,14 +95,14 @@ class ContextMenu { label: '&Learn Spelling', visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), click: () => { - mainWindow.webContents.session.addWordToSpellCheckerDictionary(properties.misspelledWord) + w.session.addWordToSpellCheckerDictionary(properties.misspelledWord) } } } private createDictionarySuggestions( properties: Electron.ContextMenuParams, - mainWindow: Electron.BrowserWindow + w: Electron.WebContents ): MenuItemConstructorOptions[] { const hasText = properties.selectionText.length > 0 @@ -126,7 +126,7 @@ class ContextMenu { label: suggestion, visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), click: (menuItem: Electron.MenuItem) => { - mainWindow.webContents.replaceMisspelling(menuItem.label) + w.replaceMisspelling(menuItem.label) } })) } diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 78784120b..ada014f0d 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -143,9 +143,10 @@ export class WindowService { } private setupContextMenu(mainWindow: BrowserWindow) { - contextMenu.contextMenu(mainWindow) - app.on('browser-window-created', (_, win) => { - contextMenu.contextMenu(win) + contextMenu.contextMenu(mainWindow.webContents) + // setup context menu for all webviews like miniapp + app.on('web-contents-created', (_, webContents) => { + contextMenu.contextMenu(webContents) }) // Dangerous API diff --git a/src/preload/index.ts b/src/preload/index.ts index ed2a2042e..7867c6691 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -229,7 +229,9 @@ const api = { }, webview: { setOpenLinkExternal: (webviewId: number, isExternal: boolean) => - ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal) + ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal), + setSpellCheckEnabled: (webviewId: number, isEnable: boolean) => + ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable) }, storeSync: { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index e5f08c350..507de765a 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -1,3 +1,4 @@ +import { useSettings } from '@renderer/hooks/useSettings' import { WebviewTag } from 'electron' import { memo, useEffect, useRef } from 'react' @@ -21,6 +22,7 @@ const WebviewContainer = memo( onNavigateCallback: (appid: string, url: string) => void }) => { const webviewRef = useRef(null) + const { enableSpellCheck } = useSettings() const setRef = (appid: string) => { onSetRefCallback(appid, null) @@ -46,6 +48,14 @@ const WebviewContainer = memo( onNavigateCallback(appid, event.url) } + const handleDomReady = () => { + const webviewId = webviewRef.current?.getWebContentsId() + if (webviewId) { + window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck) + } + } + + webviewRef.current.addEventListener('dom-ready', handleDomReady) webviewRef.current.addEventListener('did-finish-load', handleLoaded) webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate) @@ -55,6 +65,7 @@ const WebviewContainer = memo( return () => { webviewRef.current?.removeEventListener('did-finish-load', handleLoaded) webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate) + webviewRef.current?.removeEventListener('dom-ready', handleDomReady) } // because the appid and url are enough, no need to add onLoadedCallback // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 3c9ebd84d..3f166a1cd 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -172,27 +172,6 @@ const GeneralSettings: FC = () => { /> - - {t('settings.proxy.mode.title')} - - - {storeProxyMode === 'custom' && ( - <> - - - {t('settings.proxy.title')} - setProxyUrl(e.target.value)} - style={{ width: 180 }} - onBlur={() => onSetProxyUrl()} - type="url" - /> - - - )} - {t('settings.general.spell_check')} @@ -223,6 +202,27 @@ const GeneralSettings: FC = () => { )} + + + {t('settings.proxy.mode.title')} + + + {storeProxyMode === 'custom' && ( + <> + + + {t('settings.proxy.title')} + setProxyUrl(e.target.value)} + style={{ width: 180 }} + onBlur={() => onSetProxyUrl()} + type="url" + /> + + + )} {t('settings.notification.title')} From 49653435c25be7c4b4ed68a9dede23b5ef365635 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:10:55 +0800 Subject: [PATCH 50/56] fix(models): Add inference model detection for qwen-plus and qwen-turbo (#7622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(models): 添加对qwen-plus和qwen-turbo模型的推理模型判断 --- src/renderer/src/config/models.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 64b32c669..0172bda93 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2508,9 +2508,11 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { return ( baseName.startsWith('qwen3') || [ + 'qwen-plus', 'qwen-plus-latest', 'qwen-plus-0428', 'qwen-plus-2025-04-28', + 'qwen-turbo', 'qwen-turbo-latest', 'qwen-turbo-0428', 'qwen-turbo-2025-04-28' From cf87a840f74b5bd1b3a52bc556c24469b880b26e Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 16:45:02 +0800 Subject: [PATCH 51/56] fix(FileStorage): remove redundant WordExtractor import (#7625) --- src/main/services/FileStorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 2d8810adc..0c81a454a 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -19,6 +19,7 @@ import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js' import * as path from 'path' import { chdir } from 'process' import { v4 as uuidv4 } from 'uuid' +import WordExtractor from 'word-extractor' class FileStorage { private storageDir = getFilesDir() @@ -228,7 +229,6 @@ class FileStorage { chdir(this.tempDir) if (fileExtension === '.doc') { - const WordExtractor = require('word-extractor') const extractor = new WordExtractor() const extracted = await extractor.extract(filePath) chdir(originalCwd) From 83b95f98309b60033ac6048dbdd40584fd82080b Mon Sep 17 00:00:00 2001 From: happyZYM Date: Sat, 28 Jun 2025 16:45:54 +0800 Subject: [PATCH 52/56] fix: restore strict no-think for Openrouter provider with latest api (#7620) --- src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts | 7 +++---- src/renderer/src/types/sdk.ts | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index a53247c1f..499edfbb5 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -113,6 +113,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (!reasoningEffort) { + if (model.provider === 'openrouter') { + return { reasoning: { enabled: false, exclude: true } } + } if (isSupportedThinkingTokenQwenModel(model)) { return { enable_thinking: false } } @@ -122,10 +125,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (isSupportedThinkingTokenGeminiModel(model)) { - // openrouter没有提供一个不推理的选项,先隐藏 - if (this.provider.id === 'openrouter') { - return { reasoning: { max_tokens: 0, exclude: true } } - } if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { return { reasoning_effort: 'none' } } diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index 559e02eca..6505210b6 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -48,7 +48,7 @@ type OpenAIParamsWithoutReasoningEffort = Omit Date: Sat, 28 Jun 2025 16:51:49 +0800 Subject: [PATCH 53/56] fix: move ContentSearch below Messages in Chat layout (#7628) Reordered the ContentSearch component to render after the Messages component within the Chat page. This change likely improves the UI flow by displaying the search functionality below the chat messages. --- src/renderer/src/pages/home/Chat.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 8d16c5a36..fb623b62a 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -109,13 +109,6 @@ const Chat: FC = (props) => { return (
- } - filter={contentSearchFilter} - includeUser={filterIncludeUser} - onIncludeUserChange={userOutlinedItemClickHandler} - /> = (props) => { onComponentUpdate={messagesComponentUpdateHandler} onFirstUpdate={messagesComponentFirstUpdateHandler} /> + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} + /> {isMultiSelectMode && } From dfcebe97678f31b84874046f2fd6cdaa5b0d2b9b Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 28 Jun 2025 16:58:17 +0800 Subject: [PATCH 54/56] fix(models): update regex patterns for Doubao models and enhance function checks (#7624) - Adjusted regex for visionAllowedModels and DOUBAO_THINKING_MODEL_REGEX to allow for optional suffixes. - Enhanced isFunctionCallingModel and isDoubaoThinkingAutoModel functions to check both model.id and model.name for better matching. --- src/renderer/src/config/models.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 0172bda93..18d24d9ba 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -184,7 +184,7 @@ const visionAllowedModels = [ 'deepseek-vl(?:[\\w-]+)?', 'kimi-latest', 'gemma-3(?:-[\\w-]+)', - 'doubao-seed-1[.-]6(?:-[\\w-]+)' + 'doubao-seed-1[.-]6(?:-[\\w-]+)?' ] const visionExcludedModels = [ @@ -273,6 +273,10 @@ export function isFunctionCallingModel(model: Model): boolean { return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id) } + if (model.provider === 'doubao') { + return FUNCTION_CALLING_REGEX.test(model.id) || FUNCTION_CALLING_REGEX.test(model.name) + } + if (['deepseek', 'anthropic'].includes(model.provider)) { return true } @@ -2525,7 +2529,7 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean { return false } - return DOUBAO_THINKING_MODEL_REGEX.test(model.id) + return DOUBAO_THINKING_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_MODEL_REGEX.test(model.name) } export function isClaudeReasoningModel(model?: Model): boolean { @@ -2857,13 +2861,14 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } | // Doubao 支持思考模式的模型正则 export const DOUBAO_THINKING_MODEL_REGEX = - /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-\d{6})?$/i + /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i // 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx -export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(1-5-thinking-pro-m|seed-1\.6|seed-1-6-[\w-]+)(?:-[\w-]+)*/i +export const DOUBAO_THINKING_AUTO_MODEL_REGEX = + /doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i export function isDoubaoThinkingAutoModel(model: Model): boolean { - return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id) + return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name) } export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$') From 780373d5f74781442d3f26800f3176d57a6ee60d Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 17:17:47 +0800 Subject: [PATCH 55/56] =?UTF-8?q?fix:=20=E6=B5=8B=E8=AF=95=E7=89=88?= =?UTF-8?q?=E6=9C=AC=20(#7590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(AppUpdater): add support for pre-release versions and enhance feed URL logic - Introduced a new FeedUrl for the lowest pre-release version. - Updated AppUpdater to handle early access and upgrade channel settings more effectively. - Enhanced IPC logging for early access and upgrade channel changes. - Refactored feed URL setting logic to streamline update processes. * fix(AppUpdater, ipc): enhance early access and upgrade channel handling - Added checks to prevent unnecessary cancellation of downloads when early access and upgrade channel settings remain unchanged. - Updated IPC handlers to ensure early access is enabled when switching upgrade channels if it was previously disabled. - Improved logging for better traceability of changes in early access and upgrade channel settings. * delete code * delete logs * refactor(AboutSettings): enhance upgrade channel management - Introduced logic to determine the current upgrade channel based on version. - Refactored available test channels to use a more structured approach with tooltips and labels. - Updated the method for retrieving available test channels to improve clarity and maintainability. * feat(IpcChannel, ConfigManager, AppUpdater): implement test plan and channel management - Replaced early access features with test plan and test channel options in IpcChannel and ConfigManager. - Updated IPC handlers to manage test plan and test channel settings, including logging enhancements. - Refactored AppUpdater to support fetching pre-release versions based on the selected test channel. - Modified settings and localization files to reflect the new test plan functionality. - Adjusted AboutSettings and related components to integrate test plan management and improve user experience. * format code * refactor(AppUpdater, AboutSettings): improve test channel logic and localization updates - Refactored the logic in AppUpdater to enhance the handling of test channels, ensuring correct channel retrieval based on the current version. - Updated the AboutSettings component to include useEffect for managing test channel changes and displaying appropriate warnings. - Modified localization files for multiple languages to clarify the behavior of test version switching, aligning with the new logic. --- packages/shared/IpcChannel.ts | 4 +- packages/shared/config/constant.ts | 3 +- src/main/ipc.ts | 18 ++-- src/main/services/AppUpdater.ts | 83 +++++++++++++------ src/main/services/ConfigManager.ts | 20 ++--- src/preload/index.ts | 4 +- src/renderer/src/hooks/useSettings.ts | 16 ++-- src/renderer/src/i18n/locales/en-us.json | 17 ++-- src/renderer/src/i18n/locales/ja-jp.json | 17 ++-- src/renderer/src/i18n/locales/ru-ru.json | 17 ++-- src/renderer/src/i18n/locales/zh-cn.json | 17 ++-- src/renderer/src/i18n/locales/zh-tw.json | 17 ++-- .../src/pages/settings/AboutSettings.tsx | 65 +++++++++------ src/renderer/src/store/migrate.ts | 4 +- src/renderer/src/store/settings.ts | 20 ++--- 15 files changed, 185 insertions(+), 137 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 8782d02f2..daea5dad6 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -15,8 +15,8 @@ export enum IpcChannel { App_SetTrayOnClose = 'app:set-tray-on-close', App_SetTheme = 'app:set-theme', App_SetAutoUpdate = 'app:set-auto-update', - App_SetEnableEarlyAccess = 'app:set-enable-early-access', - App_SetUpgradeChannel = 'app:set-upgrade-channel', + App_SetTestPlan = 'app:set-test-plan', + App_SetTestChannel = 'app:set-test-channel', App_HandleZoomFactor = 'app:handle-zoom-factor', App_Select = 'app:select', App_HasWritePermission = 'app:has-write-permission', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 975767fef..e4545d44c 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -406,7 +406,8 @@ export const defaultLanguage = 'en-US' export enum FeedUrl { PRODUCTION = 'https://releases.cherry-ai.com', - GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download' + GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download', + PRERELEASE_LOWEST = 'https://github.com/CherryHQ/cherry-studio/releases/download/v1.4.0' } export enum UpgradeChannel { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 32baecac3..8c6810bcd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -142,14 +142,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setAutoUpdate(isActive) }) - ipcMain.handle(IpcChannel.App_SetEnableEarlyAccess, async (_, isActive: boolean) => { - appUpdater.cancelDownload() - configManager.setEnableEarlyAccess(isActive) + ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => { + log.info('set test plan', isActive) + if (isActive !== configManager.getTestPlan()) { + appUpdater.cancelDownload() + configManager.setTestPlan(isActive) + } }) - ipcMain.handle(IpcChannel.App_SetUpgradeChannel, async (_, channel: UpgradeChannel) => { - appUpdater.cancelDownload() - configManager.setUpgradeChannel(channel) + ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => { + log.info('set test channel', channel) + if (channel !== configManager.getTestChannel()) { + appUpdater.cancelDownload() + configManager.setTestChannel(channel) + } }) ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index e26a779d5..82165fd71 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -5,7 +5,7 @@ import { IpcChannel } from '@shared/IpcChannel' import { CancellationToken, UpdateInfo } from 'builder-util-runtime' import { app, BrowserWindow, dialog } from 'electron' import logger from 'electron-log' -import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater' +import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater, UpdateCheckResult } from 'electron-updater' import path from 'path' import icon from '../../../build/icon.png?asset' @@ -15,6 +15,7 @@ export default class AppUpdater { autoUpdater: _AppUpdater = autoUpdater private releaseInfo: UpdateInfo | undefined private cancellationToken: CancellationToken = new CancellationToken() + private updateCheckResult: UpdateCheckResult | null = null constructor(mainWindow: BrowserWindow) { logger.transports.file.level = 'info' @@ -65,6 +66,7 @@ export default class AppUpdater { private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) { try { + logger.info('get pre release version from github', channel) const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', { headers: { Accept: 'application/vnd.github+json', @@ -73,11 +75,12 @@ export default class AppUpdater { } }) const data = (await responses.json()) as GithubReleaseInfo[] - logger.debug('github release data', data) const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => { return item.prerelease && item.tag_name.includes(`-${channel}.`) }) + logger.info('release info', release) + if (!release) { return null } @@ -119,31 +122,57 @@ export default class AppUpdater { autoUpdater.autoInstallOnAppQuit = isActive } - private async _setFeedUrl() { - // disable downgrade and differential download - // github and gitcode don't support multiple range download - this.autoUpdater.allowDowngrade = false - this.autoUpdater.disableDifferentialDownload = true + private _getChannelByVersion(version: string) { + if (version.includes(`-${UpgradeChannel.BETA}.`)) { + return UpgradeChannel.BETA + } + if (version.includes(`-${UpgradeChannel.RC}.`)) { + return UpgradeChannel.RC + } + return UpgradeChannel.LATEST + } + + private _getTestChannel() { + const currentChannel = this._getChannelByVersion(app.getVersion()) + const savedChannel = configManager.getTestChannel() + + if (currentChannel === UpgradeChannel.LATEST) { + return savedChannel || UpgradeChannel.RC + } + + if (savedChannel === currentChannel) { + return savedChannel + } + + // if the upgrade channel is not equal to the current channel, use the latest channel + return UpgradeChannel.LATEST + } + + private async _setFeedUrl() { + const testPlan = configManager.getTestPlan() + if (testPlan) { + const channel = this._getTestChannel() - if (configManager.getEnableEarlyAccess()) { - const channel = configManager.getUpgradeChannel() if (channel === UpgradeChannel.LATEST) { - this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) this.autoUpdater.channel = UpgradeChannel.LATEST - return true + this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) + return } const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel) if (preReleaseUrl) { this.autoUpdater.setFeedURL(preReleaseUrl) this.autoUpdater.channel = channel - return true + return } - return false + + // if no prerelease url, use lowest prerelease version to avoid error + this.autoUpdater.setFeedURL(FeedUrl.PRERELEASE_LOWEST) + this.autoUpdater.channel = UpgradeChannel.LATEST + return } - // no early access, use latest version - this.autoUpdater.channel = 'latest' + this.autoUpdater.channel = UpgradeChannel.LATEST this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION) const ipCountry = await this._getIpCountry() @@ -151,12 +180,14 @@ export default class AppUpdater { if (ipCountry.toLowerCase() !== 'cn') { this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) } - return true } public cancelDownload() { this.cancellationToken.cancel() this.cancellationToken = new CancellationToken() + if (this.autoUpdater.autoDownload) { + this.updateCheckResult?.cancellationToken?.cancel() + } } public async checkForUpdates() { @@ -167,17 +198,17 @@ export default class AppUpdater { } } - const isSetFeedUrl = await this._setFeedUrl() - if (!isSetFeedUrl) { - return { - currentVersion: app.getVersion(), - updateInfo: null - } - } + await this._setFeedUrl() + + // disable downgrade after change the channel + this.autoUpdater.allowDowngrade = false + + // github and gitcode don't support multiple range download + this.autoUpdater.disableDifferentialDownload = true try { - const update = await this.autoUpdater.checkForUpdates() - if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) { + this.updateCheckResult = await this.autoUpdater.checkForUpdates() + if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) { // 如果 autoDownload 为 false,则需要再调用下面的函数触发下 // do not use await, because it will block the return of this function logger.info('downloadUpdate manual by check for updates', this.cancellationToken) @@ -186,7 +217,7 @@ export default class AppUpdater { return { currentVersion: this.autoUpdater.currentVersion, - updateInfo: update?.updateInfo + updateInfo: this.updateCheckResult?.updateInfo } } catch (error) { logger.error('Failed to check for update:', error) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 6d33b6e3d..8e4b5d2bf 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -16,8 +16,8 @@ export enum ConfigKeys { ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant', AutoUpdate = 'autoUpdate', - EnableEarlyAccess = 'enableEarlyAccess', - UpgradeChannel = 'upgradeChannel', + TestPlan = 'testPlan', + TestChannel = 'testChannel', EnableDataCollection = 'enableDataCollection', SelectionAssistantEnabled = 'selectionAssistantEnabled', SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode', @@ -143,20 +143,20 @@ export class ConfigManager { this.set(ConfigKeys.AutoUpdate, value) } - getEnableEarlyAccess(): boolean { - return this.get(ConfigKeys.EnableEarlyAccess, false) + getTestPlan(): boolean { + return this.get(ConfigKeys.TestPlan, false) } - setEnableEarlyAccess(value: boolean) { - this.set(ConfigKeys.EnableEarlyAccess, value) + setTestPlan(value: boolean) { + this.set(ConfigKeys.TestPlan, value) } - getUpgradeChannel(): UpgradeChannel { - return this.get(ConfigKeys.UpgradeChannel, UpgradeChannel.LATEST) + getTestChannel(): UpgradeChannel { + return this.get(ConfigKeys.TestChannel) } - setUpgradeChannel(value: UpgradeChannel) { - this.set(ConfigKeys.UpgradeChannel, value) + setTestChannel(value: UpgradeChannel) { + this.set(ConfigKeys.TestChannel, value) } getEnableDataCollection(): boolean { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7867c6691..8412e00bc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -23,8 +23,8 @@ const api = { setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive), setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive), setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive), - setEnableEarlyAccess: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableEarlyAccess, isActive), - setUpgradeChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetUpgradeChannel, channel), + setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive), + setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel), setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme), handleZoomFactor: (delta: number, reset: boolean = false) => ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset), diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 72560706e..dfb75cc79 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -4,7 +4,6 @@ import { SendMessageShortcut, setAssistantIconType, setAutoCheckUpdate as _setAutoCheckUpdate, - setEarlyAccess as _setEarlyAccess, setLaunchOnBoot, setLaunchToTray, setPinTopicsToTop, @@ -12,12 +11,13 @@ import { setShowTokens, setSidebarIcons, setTargetLanguage, + setTestChannel as _setTestChannel, + setTestPlan as _setTestPlan, setTheme, SettingsState, setTopicPosition, setTray as _setTray, setTrayOnClose, - setUpgradeChannel as _setUpgradeChannel, setWindowStyle } from '@renderer/store/settings' import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types' @@ -61,14 +61,14 @@ export function useSettings() { window.api.setAutoUpdate(isAutoUpdate) }, - setEarlyAccess(isEarlyAccess: boolean) { - dispatch(_setEarlyAccess(isEarlyAccess)) - window.api.setEnableEarlyAccess(isEarlyAccess) + setTestPlan(isTestPlan: boolean) { + dispatch(_setTestPlan(isTestPlan)) + window.api.setTestPlan(isTestPlan) }, - setUpgradeChannel(channel: UpgradeChannel) { - dispatch(_setUpgradeChannel(channel)) - window.api.setUpgradeChannel(channel) + setTestChannel(channel: UpgradeChannel) { + dispatch(_setTestChannel(channel)) + window.api.setTestChannel(channel) }, setTheme(theme: ThemeMode) { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index ecb718275..9a5e61122 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1392,15 +1392,14 @@ "general.emoji_picker": "Emoji Picker", "general.image_upload": "Image Upload", "general.auto_check_update.title": "Auto Update", - "general.early_access.title": "Early Access", - "general.early_access.tooltip": "Updating to test versions cannot be downgraded, there is a risk of data loss, please backup your data in advance", - "general.early_access.beta_version": "Beta Version", - "general.early_access.rc_version": "RC Version", - "general.early_access.latest_version": "Latest Version", - "general.early_access.latest_version_tooltip": "github latest version, latest stable version", - "general.early_access.version_options": "Version Options", - "general.early_access.rc_version_tooltip": "More stable, please backup your data", - "general.early_access.beta_version_tooltip": "Latest features but unstable, use with caution", + "general.test_plan.title": "Test Plan", + "general.test_plan.tooltip": "Participate in the test plan to experience the latest features faster, but also brings more risks, please backup your data in advance", + "general.test_plan.beta_version": "Beta Version (Beta)", + "general.test_plan.beta_version_tooltip": "Features may change at any time, bugs are more, upgrade quickly", + "general.test_plan.rc_version": "Preview Version (RC)", + "general.test_plan.rc_version_tooltip": "Close to stable version, features are basically stable, bugs are few", + "general.test_plan.version_options": "Version Options", + "general.test_plan.version_channel_not_match": "Preview and test version switching will take effect after the next stable version is released", "general.reset.button": "Reset", "general.reset.title": "Data Reset", "general.restore.button": "Restore", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 4b0d34eeb..b1b03c4f3 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1863,15 +1863,14 @@ } }, "general.auto_check_update.title": "自動更新", - "general.early_access.title": "早期アクセス", - "general.early_access.tooltip": "更新すると、データが失われる可能性があります。データを事前にバックアップしてください。", - "general.early_access.beta_version": "ベータ版", - "general.early_access.rc_version": "RC版", - "general.early_access.latest_version": "最新版", - "general.early_access.latest_version_tooltip": "github latest バージョン, 最新安定版", - "general.early_access.version_options": "バージョンオプション", - "general.early_access.rc_version_tooltip": "より安定しています。データを事前にバックアップしてください。", - "general.early_access.beta_version_tooltip": "最新の機能ですが、不安定な場合があります。使用には注意してください。", + "general.test_plan.title": "テストプラン", + "general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。", + "general.test_plan.beta_version": "ベータ版(Beta)", + "general.test_plan.beta_version_tooltip": "機能が変更される可能性があります。バグが多く、迅速にアップグレードされます。", + "general.test_plan.rc_version": "プレビュー版(RC)", + "general.test_plan.rc_version_tooltip": "安定版に近い機能ですが、バグが少なく、迅速にアップグレードされます。", + "general.test_plan.version_options": "バージョンオプション", + "general.test_plan.version_channel_not_match": "プレビュー版とテスト版の切り替えは、次の正式版リリース時に有効になります。", "quickPhrase": { "title": "クイックフレーズ", "add": "フレーズを追加", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d7f20e297..46faee867 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1863,15 +1863,14 @@ } }, "general.auto_check_update.title": "Автоматическое обновление", - "general.early_access.title": "Ранний доступ", - "general.early_access.tooltip": "Обновление до тестовых версий не может быть откачено, существует риск потери данных, пожалуйста, сделайте резервную копию данных заранее", - "general.early_access.beta_version": "Бета версия", - "general.early_access.rc_version": "RC версия", - "general.early_access.latest_version": "Стабильная версия", - "general.early_access.latest_version_tooltip": "github latest версия, стабильная версия", - "general.early_access.version_options": "Варианты версии", - "general.early_access.rc_version_tooltip": "Более стабильно, пожалуйста, сделайте резервную копию данных заранее", - "general.early_access.beta_version_tooltip": "Самые последние функции, но нестабильно, используйте с осторожностью", + "general.test_plan.title": "Тестовый план", + "general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее", + "general.test_plan.beta_version": "Тестовая версия (Beta)", + "general.test_plan.beta_version_tooltip": "Функции могут меняться в любое время, ошибки больше, обновление происходит быстрее", + "general.test_plan.rc_version": "Предварительная версия (RC)", + "general.test_plan.rc_version_tooltip": "Похожа на стабильную версию, функции стабильны, ошибки меньше, обновление происходит быстрее", + "general.test_plan.version_options": "Варианты версии", + "general.test_plan.version_channel_not_match": "Предварительная и тестовая версия будут доступны после выхода следующей стабильной версии", "quickPhrase": { "title": "Быстрые фразы", "add": "Добавить фразу", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6b662d38e..828d46d3c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1392,15 +1392,14 @@ "general.emoji_picker": "表情选择器", "general.image_upload": "图片上传", "general.auto_check_update.title": "自动更新", - "general.early_access.title": "抢先体验", - "general.early_access.tooltip": "更新到测试版本不能降级,有数据丢失风险,请务必提前备份数据", - "general.early_access.beta_version": "预览版本", - "general.early_access.rc_version": "公测版本", - "general.early_access.latest_version": "稳定版本", - "general.early_access.version_options": "版本选择", - "general.early_access.rc_version_tooltip": "相对稳定,请备份数据", - "general.early_access.beta_version_tooltip": "功能最新但不稳定,谨慎使用", - "general.early_access.latest_version_tooltip": "github latest 版本, 最新稳定版本", + "general.test_plan.title": "测试计划", + "general.test_plan.tooltip": "参与测试计划,可以更快体验到最新功能,但同时也会带来更多风险,务必提前做好备份", + "general.test_plan.beta_version": "测试版(Beta)", + "general.test_plan.beta_version_tooltip": "功能可能随时变化,bug较多,升级较快", + "general.test_plan.rc_version": "预览版(RC)", + "general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug较少", + "general.test_plan.version_options": "版本选择", + "general.test_plan.version_channel_not_match": "预览版和测试版的切换将在下一个正式版发布时生效", "general.reset.button": "重置", "general.reset.title": "重置数据", "general.restore.button": "恢复", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 44d99d4da..0b833c5b5 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1866,15 +1866,14 @@ } }, "general.auto_check_update.title": "自動更新", - "general.early_access.title": "搶先體驗", - "general.early_access.tooltip": "更新到測試版本不能降級,有數據丟失風險,請務必提前備份數據", - "general.early_access.beta_version": "預覽版本", - "general.early_access.rc_version": "公測版本", - "general.early_access.latest_version": "穩定版本", - "general.early_access.latest_version_tooltip": "github latest 版本, 最新穩定版本", - "general.early_access.version_options": "版本選項", - "general.early_access.rc_version_tooltip": "相對穩定,請務必提前備份數據", - "general.early_access.beta_version_tooltip": "功能最新但不穩定,謹慎使用", + "general.test_plan.title": "測試計畫", + "general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據", + "general.test_plan.beta_version": "測試版本(Beta)", + "general.test_plan.beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快", + "general.test_plan.rc_version": "預覽版本(RC)", + "general.test_plan.rc_version_tooltip": "相對穩定,請務必提前備份數據", + "general.test_plan.version_options": "版本選項", + "general.test_plan.version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效", "quickPhrase": { "title": "快捷短語", "add": "新增短語", diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 9bf65e448..50832da2e 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -26,8 +26,7 @@ const AboutSettings: FC = () => { const [version, setVersion] = useState('') const [isPortable, setIsPortable] = useState(false) const { t } = useTranslation() - const { autoCheckUpdate, setAutoCheckUpdate, earlyAccess, setEarlyAccess, upgradeChannel, setUpgradeChannel } = - useSettings() + const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings() const { theme } = useTheme() const dispatch = useAppDispatch() const { update } = useRuntime() @@ -97,8 +96,20 @@ const AboutSettings: FC = () => { const hasNewVersion = update?.info?.version && version ? compareVersions(update.info.version, version) > 0 : false - const handleUpgradeChannelChange = async (value: UpgradeChannel) => { - setUpgradeChannel(value) + const currentChannelByVersion = + [ + { pattern: `-${UpgradeChannel.BETA}.`, channel: UpgradeChannel.BETA }, + { pattern: `-${UpgradeChannel.RC}.`, channel: UpgradeChannel.RC } + ].find(({ pattern }) => version.includes(pattern))?.channel || UpgradeChannel.LATEST + + useEffect(() => { + if (testPlan && currentChannelByVersion !== UpgradeChannel.LATEST && testChannel !== currentChannelByVersion) { + window.message.warning(t('settings.general.test_plan.version_channel_not_match')) + } + }, [testPlan, testChannel, currentChannelByVersion, t]) + + const handleTestChannelChange = async (value: UpgradeChannel) => { + setTestChannel(value) // Clear update info when switching upgrade channel dispatch( setUpdateState({ @@ -116,25 +127,20 @@ const AboutSettings: FC = () => { const getAvailableTestChannels = () => { return [ { - tooltip: t('settings.general.early_access.latest_version_tooltip'), - label: t('settings.general.early_access.latest_version'), - value: UpgradeChannel.LATEST - }, - { - tooltip: t('settings.general.early_access.rc_version_tooltip'), - label: t('settings.general.early_access.rc_version'), + tooltip: t('settings.general.test_plan.rc_version_tooltip'), + label: t('settings.general.test_plan.rc_version'), value: UpgradeChannel.RC }, { - tooltip: t('settings.general.early_access.beta_version_tooltip'), - label: t('settings.general.early_access.beta_version'), + tooltip: t('settings.general.test_plan.beta_version_tooltip'), + label: t('settings.general.test_plan.beta_version'), value: UpgradeChannel.BETA } ] } - const handlerSetEarlyAccess = (value: boolean) => { - setEarlyAccess(value) + const handleSetTestPlan = (value: boolean) => { + setTestPlan(value) dispatch( setUpdateState({ available: false, @@ -145,7 +151,17 @@ const AboutSettings: FC = () => { downloadProgress: 0 }) ) - if (value === false) setUpgradeChannel(UpgradeChannel.LATEST) + + if (value === true) { + setTestChannel(getTestChannel()) + } + } + + const getTestChannel = () => { + if (testChannel === UpgradeChannel.LATEST) { + return UpgradeChannel.RC + } + return testChannel } useEffect(() => { @@ -155,7 +171,7 @@ const AboutSettings: FC = () => { setIsPortable(appInfo.isPortable) }) setAutoCheckUpdate(autoCheckUpdate) - }, [autoCheckUpdate, setAutoCheckUpdate, setEarlyAccess]) + }, [autoCheckUpdate, setAutoCheckUpdate]) return ( @@ -217,22 +233,21 @@ const AboutSettings: FC = () => { - {t('settings.general.early_access.title')} - - handlerSetEarlyAccess(v)} /> + {t('settings.general.test_plan.title')} + + handleSetTestPlan(v)} /> - {earlyAccess && getAvailableTestChannels().length > 0 && ( + {testPlan && ( <> - {t('settings.general.early_access.version_options')} + {t('settings.general.test_plan.version_options')} handleUpgradeChannelChange(e.target.value)}> + value={getTestChannel()} + onChange={(e) => handleTestChannelChange(e.target.value)}> {getAvailableTestChannels().map((option) => ( {option.label} diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index c8a132180..501c5e483 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1543,7 +1543,7 @@ const migrateConfig = { state.paintings.tokenFluxPaintings = [] } state.settings.showTokens = true - state.settings.earlyAccess = false + state.settings.testPlan = false return state } catch (error) { return state @@ -1629,7 +1629,7 @@ const migrateConfig = { } }) if (state.settings) { - state.settings.upgradeChannel = UpgradeChannel.LATEST + state.settings.testChannel = UpgradeChannel.LATEST } return state } catch (error) { diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index ee991556f..7d8e14ed1 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -68,8 +68,8 @@ export interface SettingsState { pasteLongTextThreshold: number clickAssistantToShowTopic: boolean autoCheckUpdate: boolean - earlyAccess: boolean - upgradeChannel: UpgradeChannel + testPlan: boolean + testChannel: UpgradeChannel renderInputMessageAsMarkdown: boolean // 代码执行 codeExecution: { @@ -222,8 +222,8 @@ export const initialState: SettingsState = { pasteLongTextThreshold: 1500, clickAssistantToShowTopic: true, autoCheckUpdate: true, - earlyAccess: false, - upgradeChannel: UpgradeChannel.LATEST, + testPlan: false, + testChannel: UpgradeChannel.LATEST, renderInputMessageAsMarkdown: false, codeExecution: { enabled: false, @@ -429,11 +429,11 @@ const settingsSlice = createSlice({ setAutoCheckUpdate: (state, action: PayloadAction) => { state.autoCheckUpdate = action.payload }, - setEarlyAccess: (state, action: PayloadAction) => { - state.earlyAccess = action.payload + setTestPlan: (state, action: PayloadAction) => { + state.testPlan = action.payload }, - setUpgradeChannel: (state, action: PayloadAction) => { - state.upgradeChannel = action.payload + setTestChannel: (state, action: PayloadAction) => { + state.testChannel = action.payload }, setRenderInputMessageAsMarkdown: (state, action: PayloadAction) => { state.renderInputMessageAsMarkdown = action.payload @@ -730,8 +730,8 @@ export const { setAssistantIconType, setPasteLongTextAsFile, setAutoCheckUpdate, - setEarlyAccess, - setUpgradeChannel, + setTestPlan, + setTestChannel, setRenderInputMessageAsMarkdown, setClickAssistantToShowTopic, setSkipBackupFile, From ece59cfacfd4146020667378e2bb832127ce6b63 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 17:52:36 +0800 Subject: [PATCH 56/56] fix(migrate): handle state return in migration process and add upgradechannel setting (#7634) * fix(migrate): handle state return in migration process and add upgrade channel setting * fix(migrate): move upgrade channel setting to the correct migration step --- src/renderer/src/store/migrate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 501c5e483..8eea0a34a 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1628,9 +1628,6 @@ const migrateConfig = { } } }) - if (state.settings) { - state.settings.testChannel = UpgradeChannel.LATEST - } return state } catch (error) { return state @@ -1655,6 +1652,9 @@ const migrateConfig = { // @ts-ignore eslint-disable-next-line delete state.websearch.contentLimit } + if (state.settings) { + state.settings.testChannel = UpgradeChannel.LATEST + } return state } catch (error) {