From 25c166cb8ecdf067113a7aace178e36fdad82f0e Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Fri, 21 Mar 2025 16:45:42 +0800 Subject: [PATCH 01/27] feat: Launch on boot, Minimize to tray on launch & on close / fix: Mac: don't show dock when close to tray (#2871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * launch/tray feature enhance stashed * feature: Issue #2754. launch on boot(win&mac, linux not supported now), min to tray when launch(not only boot), min to tray when close bug-fix: Issue #2576. In Mac, if tray-on-close is set, MainWindow will not show on the dock when closed bug-fix: MiniWindow will hide MainWindow when it shows first time and won't hide MainWindow later. The user will not open the MainWindow again if the tray is set to not show. The bug fixed by not hiding the MainWindow anytime the MiniWindow showed. * migration version fix * fix: enable universal shortcuts when launch to tray * ✨ feat: add Model Context Protocol (MCP) support (#2809) * ✨ feat: add Model Context Protocol (MCP) server configuration (main) - Added `@modelcontextprotocol/sdk` dependency for MCP integration. - Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities. - Created `useMCPServers` hook to manage MCP server state and actions. - Added i18n support for MCP settings with translation keys. - Integrated MCP settings into the application's settings navigation and routing. - Implemented Redux state management for MCP servers. - Updated `yarn.lock` with new dependencies and their resolutions. * 🌟 feat: implement mcp service and integrate with ipc handlers - Added `MCPService` class to manage Model Context Protocol servers. - Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers. - Integrated MCP related types into existing type declarations for consistency across the application. - Updated `preload` to expose new MCP related APIs to the renderer process. - Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states. - Introduced selectors in the MCP Redux slice for fetching active and all servers from the store. - Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application. * feat: enhance MCPService initialization to prevent recursive calls and improve error handling * feat: enhance MCP integration by adding MCPTool type and updating related methods * feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing * fix: finish_reason undefined --------- Co-authored-by: LiuVaayne <10231735+vaayne@users.noreply.github.com> Co-authored-by: kangfenmao --- src/main/index.ts | 7 ++ src/main/ipc.ts | 28 ++++++++ src/main/services/ConfigManager.ts | 16 +++++ src/main/services/ShortcutService.ts | 20 +++++- src/main/services/WindowService.ts | 35 ++++++---- src/preload/index.d.ts | 3 + src/preload/index.ts | 3 + src/renderer/src/hooks/useSettings.ts | 31 +++++++-- src/renderer/src/i18n/locales/en-us.json | 7 +- src/renderer/src/i18n/locales/ja-jp.json | 7 +- src/renderer/src/i18n/locales/ru-ru.json | 7 +- src/renderer/src/i18n/locales/zh-cn.json | 7 +- src/renderer/src/i18n/locales/zh-tw.json | 7 +- .../src/pages/settings/GeneralSettings.tsx | 67 +++++++++++++++++-- src/renderer/src/store/migrate.ts | 3 + src/renderer/src/store/settings.ts | 18 +++++ 16 files changed, 237 insertions(+), 29 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 907ef2b64..970dbaf59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ import { app, ipcMain } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' +import { configManager } from './services/ConfigManager' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' @@ -21,6 +22,12 @@ if (!app.requestSingleInstanceLock()) { // Set app user model id for windows electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') + // Mac: Hide dock icon before window creation when launch to tray is set + const isLaunchToTray = configManager.getLaunchToTray() + if (isLaunchToTray) { + app.dock?.hide() + } + const mainWindow = windowService.createMainWindow() new TrayService() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 10255e563..db2eb0eab 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' +import { isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { MCPServer, Shortcut, ThemeMode } from '@types' import { BrowserWindow, ipcMain, session, shell } from 'electron' @@ -68,11 +69,38 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setLanguage(language) }) + // launch on boot + ipcMain.handle('app:set-launch-on-boot', (_, isActive: boolean) => { + // Set login item settings for windows and mac + // linux is not supported because it requires more file operations + if (isWin || isMac) { + if (isActive) { + app.setLoginItemSettings({ + openAtLogin: true + }) + } else { + app.setLoginItemSettings({ + openAtLogin: false + }) + } + } + }) + + // launch to tray + ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => { + configManager.setLaunchToTray(isActive) + }) + // tray ipcMain.handle('app:set-tray', (_, isActive: boolean) => { configManager.setTray(isActive) }) + // to tray on close + ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => { + configManager.setTrayOnClose(isActive) + }) + ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray()) ipcMain.handle('config:set', (_, key: string, value: any) => { diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 719d089e9..da20bc414 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -30,6 +30,14 @@ export class ConfigManager { this.store.set('theme', theme) } + getLaunchToTray(): boolean { + return !!this.store.get('launchToTray', false) + } + + setLaunchToTray(value: boolean) { + this.store.set('launchToTray', value) + } + getTray(): boolean { return !!this.store.get('tray', true) } @@ -39,6 +47,14 @@ export class ConfigManager { this.notifySubscribers('tray', value) } + getTrayOnClose(): boolean { + return !!this.store.get('trayOnClose', true) + } + + setTrayOnClose(value: boolean) { + this.store.set('trayOnClose', value) + } + getZoomFactor(): number { return this.store.get('zoomFactor', 1) as number } diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 0738a6b92..10d46730d 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -115,7 +115,20 @@ const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutForm } export function registerShortcuts(window: BrowserWindow) { - const register = () => { + window.once('ready-to-show', () => { + if (configManager.getLaunchToTray()) { + registerOnlyUniversalShortcuts() + } + }) + + //only for clearer code + const registerOnlyUniversalShortcuts = () => { + register(true) + } + + //onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window + //onlyUniversalShortcuts is needed when we launch to tray + const register = (onlyUniversalShortcuts: boolean = false) => { if (window.isDestroyed()) return const shortcuts = configManager.getShortcuts() @@ -131,6 +144,11 @@ export function registerShortcuts(window: BrowserWindow) { if (!shortcut.enabled) { return } + + // only register universal shortcuts when needed + if (onlyUniversalShortcuts && !['show_app', 'mini_window'].includes(shortcut.key)) { + return + } const handler = getShortcutHandler(shortcut) if (!handler) { diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 14bdef4e1..9ae1c1dcd 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -1,5 +1,5 @@ import { is } from '@electron-toolkit/utils' -import { isDev, isLinux, isWin } from '@main/constant' +import { isDev, isLinux, isMac, isWin } from '@main/constant' import { getFilesDir } from '@main/utils/file' import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron' import Logger from 'electron-log' @@ -39,8 +39,6 @@ export class WindowService { }) const theme = configManager.getTheme() - const isMac = process.platform === 'darwin' - const isLinux = process.platform === 'linux' this.mainWindow = new BrowserWindow({ x: mainWindowState.x, @@ -146,7 +144,12 @@ export class WindowService { private setupWindowEvents(mainWindow: BrowserWindow) { mainWindow.once('ready-to-show', () => { mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) - mainWindow.show() + + // show window only when laucn to tray not set + const isLaunchToTray = configManager.getLaunchToTray() + if (!isLaunchToTray) { + mainWindow.show() + } }) // 处理全屏相关事件 @@ -255,11 +258,18 @@ export class WindowService { return app.quit() } - // 没有开启托盘,且是Windows或Linux系统,直接退出 - const notInTray = !configManager.getTray() - if ((isWin || isLinux) && notInTray) { - return app.quit() + // 托盘及关闭行为设置 + const isShowTray = configManager.getTray() + const isTrayOnClose = configManager.getTrayOnClose() + // 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出 + if (!isShowTray || (isShowTray && !isTrayOnClose)) { + // 如果是Windows或Linux,直接退出 + // mac按照系统默认行为,不退出 + if (isWin || isLinux) { + return app.quit() + } } + //上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况 // 如果是Windows或Linux,且处于全屏状态,则退出应用 if (this.wasFullScreen) { @@ -273,6 +283,7 @@ export class WindowService { } event.preventDefault() mainWindow.hide() + app.dock?.hide() //for mac to hide to tray }) mainWindow.on('closed', () => { @@ -301,6 +312,8 @@ export class WindowService { this.mainWindow = this.createMainWindow() this.mainWindow.focus() } + //for mac users, when window is shown, should show dock icon (dock may be set to hide when launch) + app.dock?.show() } public showMiniWindow() { @@ -310,9 +323,6 @@ export class WindowService { return } - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.hide() - } if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) { this.selectionMenuWindow.hide() } @@ -327,8 +337,6 @@ export class WindowService { return } - const isMac = process.platform === 'darwin' - this.miniWindow = new BrowserWindow({ width: 500, height: 520, @@ -403,7 +411,6 @@ export class WindowService { } const theme = configManager.getTheme() - const isMac = process.platform === 'darwin' this.selectionMenuWindow = new BrowserWindow({ width: 280, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4d0b88048..7384816bc 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -23,7 +23,10 @@ declare global { openWebsite: (url: string) => void setProxy: (proxy: string | undefined) => void setLanguage: (theme: LanguageVarious) => void + setLaunchOnBoot: (isActive: boolean) => void + setLaunchToTray: (isActive: boolean) => void setTray: (isActive: boolean) => void + setTrayOnClose: (isActive: boolean) => void restartTray: () => void setTheme: (theme: 'light' | 'dark') => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 831b59a7f..0a1ea8c51 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,7 +11,10 @@ const api = { checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'), showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'), setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang), + setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-on-boot', isActive), + setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-to-tray', isActive), setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive), + setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke('app:set-tray-on-close', isActive), restartTray: () => ipcRenderer.invoke('app:restart-tray'), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme), openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index abe673c26..0a05a162f 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -1,6 +1,8 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { SendMessageShortcut, + setLaunchOnBoot, + setLaunchToTray, setSendMessageShortcut as _setSendMessageShortcut, setShowAssistantIcon, setSidebarIcons, @@ -8,7 +10,8 @@ import { setTheme, SettingsState, setTopicPosition, - setTray, + setTray as _setTray, + setTrayOnClose, setWindowStyle } from '@renderer/store/settings' import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types' @@ -22,10 +25,30 @@ export function useSettings() { setSendMessageShortcut(shortcut: SendMessageShortcut) { dispatch(_setSendMessageShortcut(shortcut)) }, - setTray(isActive: boolean) { - dispatch(setTray(isActive)) - window.api.setTray(isActive) + + setLaunch(isLaunchOnBoot: boolean | undefined, isLaunchToTray: boolean | undefined = undefined) { + if (isLaunchOnBoot !== undefined) { + dispatch(setLaunchOnBoot(isLaunchOnBoot)) + window.api.setLaunchOnBoot(isLaunchOnBoot) + } + + if (isLaunchToTray !== undefined) { + dispatch(setLaunchToTray(isLaunchToTray)) + window.api.setLaunchToTray(isLaunchToTray) + } }, + + setTray(isShowTray: boolean | undefined, isTrayOnClose: boolean | undefined = undefined) { + if (isShowTray !== undefined) { + dispatch(_setTray(isShowTray)) + window.api.setTray(isShowTray) + } + if (isTrayOnClose !== undefined) { + dispatch(setTrayOnClose(isTrayOnClose)) + window.api.setTrayOnClose(isTrayOnClose) + } + }, + setTheme(theme: ThemeMode) { dispatch(setTheme(theme)) }, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 8c68ccdbe..5223dc5f6 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1095,7 +1095,12 @@ "topic.position.left": "Left", "topic.position.right": "Right", "topic.show.time": "Show topic time", - "tray.title": "Enable System Tray Icon", + "tray.title": "Tray", + "tray.show": "Show Tray Icon", + "tray.onclose": "Minimize to Tray on Close", + "launch.title": "Launch", + "launch.onboot": "Start Automatically on Boot", + "launch.totray": "Minimize to Tray on Launch", "websearch": { "blacklist": "Blacklist", "blacklist_description": "Results from the following websites will not appear in search results", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 68066152d..165d242da 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1095,7 +1095,12 @@ "topic.position.left": "左", "topic.position.right": "右", "topic.show.time": "トピックの時間を表示", - "tray.title": "システムトレイアイコンを有効にする", + "tray.title": "トレイ", + "tray.show": "トレイアイコンを表示", + "tray.onclose": "閉じるときにトレイに最小化", + "launch.title": "起動", + "launch.onboot": "起動時に自動で開始", + "launch.totray": "起動時にトレイに最小化", "websearch": { "blacklist": "ブラックリスト", "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index b1b32680e..46286ff50 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1095,7 +1095,12 @@ "topic.position.left": "Слева", "topic.position.right": "Справа", "topic.show.time": "Показывать время топика", - "tray.title": "Включить значок системного трея", + "tray.title": "Трей", + "tray.show": "Показать значок в трее", + "tray.onclose": "Свернуть в трей при закрытии", + "launch.title": "Запуск", + "launch.onboot": "Автозапуск при включении", + "launch.totray": "Свернуть в трей при запуске", "websearch": { "blacklist": "Черный список", "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b08379f02..af39e01e7 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1095,7 +1095,12 @@ "topic.position.left": "左侧", "topic.position.right": "右侧", "topic.show.time": "显示话题时间", - "tray.title": "启用系统托盘图标", + "tray.title": "托盘", + "tray.show": "显示托盘图标", + "tray.onclose": "关闭时最小化到托盘", + "launch.title": "启动", + "launch.onboot": "开机自动启动", + "launch.totray": "启动时最小化到托盘", "websearch": { "blacklist": "黑名单", "blacklist_description": "在搜索结果中不会出现以下网站的结果", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 12d92d300..937b1d52c 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1095,7 +1095,12 @@ "topic.position.left": "左側", "topic.position.right": "右側", "topic.show.time": "顯示話題時間", - "tray.title": "啟用系統工具列圖示", + "tray.title": "系统匣", + "tray.show": "顯示系统匣圖示", + "tray.onclose": "關閉時最小化到系统匣", + "launch.title": "啟動", + "launch.onboot": "開機自動啟動", + "launch.totray": "啟動時最小化到系统匣", "websearch": { "blacklist": "黑名單", "blacklist_description": "以下網站不會出現在搜尋結果中", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index e73e33824..eed439d85 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -13,13 +13,47 @@ import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '.' const GeneralSettings: FC = () => { - const { language, proxyUrl: storeProxyUrl, theme, setTray, tray, proxyMode: storeProxyMode } = useSettings() + const { + language, + proxyUrl: storeProxyUrl, + theme, + setLaunch, + setTray, + launchOnBoot, + launchToTray, + trayOnClose, + tray, + proxyMode: storeProxyMode + } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) const { theme: themeMode } = useTheme() - const updateTray = (value: boolean) => { - setTray(value) - window.api.setTray(value) + const updateTray = (isShowTray: boolean) => { + setTray(isShowTray) + //only set tray on close/launch to tray when tray is enabled + if (!isShowTray) { + updateTrayOnClose(false) + updateLaunchToTray(false) + } + } + + const updateTrayOnClose = (isTrayOnClose: boolean) => { + setTray(undefined, isTrayOnClose) + //in case tray is not enabled, enable it + if (isTrayOnClose && !tray) { + updateTray(true) + } + } + + const updateLaunchOnBoot = (isLaunchOnBoot: boolean) => { + setLaunch(isLaunchOnBoot) + } + + const updateLaunchToTray = (isLaunchToTray: boolean) => { + setLaunch(undefined, isLaunchToTray) + if (isLaunchToTray && !tray) { + updateTray(true) + } } const dispatch = useAppDispatch() @@ -52,8 +86,10 @@ const GeneralSettings: FC = () => { dispatch(setProxyMode(mode)) if (mode === 'system') { window.api.setProxy('system') + dispatch(_setProxyUrl(undefined)) } else if (mode === 'none') { window.api.setProxy(undefined) + dispatch(_setProxyUrl(undefined)) } } @@ -111,11 +147,32 @@ const GeneralSettings: FC = () => { )} + + + {t('settings.launch.title')} - {t('settings.tray.title')} + {t('settings.launch.onboot')} + updateLaunchOnBoot(checked)} /> + + + + {t('settings.launch.totray')} + updateLaunchToTray(checked)} /> + + + + {t('settings.tray.title')} + + + {t('settings.tray.show')} updateTray(checked)} /> + + + {t('settings.tray.onclose')} + updateTrayOnClose(checked)} disabled={!tray} /> + ) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f0330d58d..a781101a3 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -791,6 +791,9 @@ const migrateConfig = { }, '83': (state: RootState) => { state.settings.messageNavigation = 'buttons' + state.settings.launchOnBoot = false + state.settings.launchToTray = false + state.settings.trayOnClose = true return state } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index b532c8e67..85999c543 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -28,6 +28,9 @@ export interface SettingsState { showMessageDivider: boolean messageFont: 'system' | 'serif' showInputEstimatedTokens: boolean + launchOnBoot: boolean + launchToTray: boolean + trayOnClose: boolean tray: boolean theme: ThemeMode windowStyle: 'transparent' | 'opaque' @@ -103,6 +106,9 @@ const initialState: SettingsState = { showMessageDivider: true, messageFont: 'system', showInputEstimatedTokens: false, + launchOnBoot: false, + launchToTray: false, + trayOnClose: true, tray: true, theme: ThemeMode.auto, windowStyle: 'transparent', @@ -205,9 +211,18 @@ const settingsSlice = createSlice({ setShowInputEstimatedTokens: (state, action: PayloadAction) => { state.showInputEstimatedTokens = action.payload }, + setLaunchOnBoot: (state, action: PayloadAction) => { + state.launchOnBoot = action.payload + }, + setLaunchToTray: (state, action: PayloadAction) => { + state.launchToTray = action.payload + }, setTray: (state, action: PayloadAction) => { state.tray = action.payload }, + setTrayOnClose: (state, action: PayloadAction) => { + state.trayOnClose = action.payload + }, setTheme: (state, action: PayloadAction) => { state.theme = action.payload }, @@ -386,6 +401,9 @@ export const { setShowMessageDivider, setMessageFont, setShowInputEstimatedTokens, + setLaunchOnBoot, + setLaunchToTray, + setTrayOnClose, setTray, setTheme, setFontSize, From a0ccc4e661d9b2aed96aacf299c0255e0541d7ae Mon Sep 17 00:00:00 2001 From: one Date: Fri, 21 Mar 2025 19:32:05 +0800 Subject: [PATCH 02/27] fix: use messagesRef to avoid empty new branch --- src/renderer/src/pages/home/Messages/Messages.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 47ffa60d7..39d6f6cca 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -139,7 +139,8 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) EventEmitter.on(EVENT_NAMES.NEW_BRANCH, async (index: number) => { const newTopic = getDefaultTopic(assistant.id) newTopic.name = topic.name - const branchMessages = take(messages, messages.length - index) + const currentMessages = messagesRef.current + const branchMessages = take(currentMessages, currentMessages.length - index) // 将分支的消息放入数据库 await db.topics.add({ id: newTopic.id, messages: branchMessages }) From bddec814025df558ee320622ef2dfb833af7afdc Mon Sep 17 00:00:00 2001 From: eeee0717 Date: Fri, 21 Mar 2025 20:16:01 +0800 Subject: [PATCH 03/27] fix --- src/main/services/KnowledgeService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 49aec13b1..f9fedb78b 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -131,7 +131,8 @@ class KnowledgeService { model, apiKey, dimensions, - batchSize + batchSize, + configuration: { baseURL } }) ) .setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) })) From 117cf548fe08fb1b63ec8d676c5e0dfb4405d174 Mon Sep 17 00:00:00 2001 From: suyao Date: Sat, 22 Mar 2025 00:11:02 +0800 Subject: [PATCH 04/27] chore(ProxyManager): remove unnecessary console log --- src/main/services/ProxyManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index 573d0a9f3..24c741b5c 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -73,7 +73,6 @@ export class ProxyManager { await this.setSessionsProxy({ mode: 'system' }) const proxyString = await session.defaultSession.resolveProxy('https://dummy.com') const [protocol, address] = proxyString.split(';')[0].split(' ') - console.log('protocol', protocol) const url = protocol === 'PROXY' ? `http://${address}` : null if (url && url !== this.config.url) { this.config.url = url.toLowerCase() From b7ee0ea7b3473c6cc9669d2c6f095ba5a37dcab2 Mon Sep 17 00:00:00 2001 From: one Date: Fri, 21 Mar 2025 23:47:43 +0800 Subject: [PATCH 05/27] fix: take messages with empty tool_calls as normal messages --- src/renderer/src/providers/OpenAIProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/providers/OpenAIProvider.ts b/src/renderer/src/providers/OpenAIProvider.ts index 8764f4991..b776aec5b 100644 --- a/src/renderer/src/providers/OpenAIProvider.ts +++ b/src/renderer/src/providers/OpenAIProvider.ts @@ -474,7 +474,7 @@ export default class OpenAIProvider extends BaseProvider { const finishReason = chunk.choices[0]?.finish_reason - if (delta?.tool_calls) { + if (delta?.tool_calls?.length) { const chunkToolCalls = delta.tool_calls for (const t of chunkToolCalls) { const { index, id, function: fn, type } = t From 183f1310e537ae5e6075cb3d28b895059aedef5e Mon Sep 17 00:00:00 2001 From: one Date: Sat, 22 Mar 2025 02:50:43 +0800 Subject: [PATCH 06/27] fix: reset topicId for branched messages --- src/renderer/src/pages/home/Messages/Messages.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 39d6f6cca..2c7eb7593 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -140,7 +140,12 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) const newTopic = getDefaultTopic(assistant.id) newTopic.name = topic.name const currentMessages = messagesRef.current - const branchMessages = take(currentMessages, currentMessages.length - index) + + // 复制消息并且更新 topicId + const branchMessages = take(currentMessages, currentMessages.length - index).map((msg) => ({ + ...msg, + topicId: newTopic.id + })) // 将分支的消息放入数据库 await db.topics.add({ id: newTopic.id, messages: branchMessages }) From 2e2ed664d0b79bf3f1122c859c26416690fae3f1 Mon Sep 17 00:00:00 2001 From: ousugo Date: Sat, 22 Mar 2025 00:32:55 +0800 Subject: [PATCH 07/27] fix: update REASONING_REGEX to include 'hunyuan-t1' model --- src/renderer/src/config/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 2b35d9943..254ab41ba 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -172,7 +172,7 @@ export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview| // Reasoning models export const REASONING_REGEX = - /^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*)$/i + /^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*)$/i // Embedding models export const EMBEDDING_REGEX = /(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings)/i From 56207d5617ad89c3e11b3de94cc1d738544df7ba Mon Sep 17 00:00:00 2001 From: ousugo Date: Fri, 21 Mar 2025 17:20:56 +0800 Subject: [PATCH 08/27] feat: add search input focus handling in EditModelsPopup --- .../settings/ProviderSettings/EditModelsPopup.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx index a0f0283d1..6f459ee39 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx @@ -17,7 +17,7 @@ import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/ut import { Avatar, Button, Empty, Flex, Modal, Popover, Radio, Tooltip } from 'antd' import Search from 'antd/es/input/Search' import { groupBy, isEmpty, uniqBy } from 'lodash' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -44,6 +44,7 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const [searchText, setSearchText] = useState('') const [filterType, setFilterType] = useState('all') const { t, i18n } = useTranslation() + const searchInputRef = useRef(null) const systemModels = SYSTEM_MODELS[_provider.id] || [] const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id') @@ -127,6 +128,14 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + if (open && searchInputRef.current) { + setTimeout(() => { + searchInputRef.current?.focus() + }, 100) + } + }, [open]) + const ModalHeader = () => { return ( @@ -167,6 +176,7 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { setSearchText(e.target.value)} From c9d640770a8061751616dc6ebf523b5feb18a5d1 Mon Sep 17 00:00:00 2001 From: Xiangfang Chen <565499699@qq.com> Date: Fri, 21 Mar 2025 18:01:20 +0800 Subject: [PATCH 09/27] add GLM-4V-Flash Models. --- src/renderer/src/config/models.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 254ab41ba..6ef1bac84 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -1102,6 +1102,12 @@ export const SYSTEM_MODELS: Record = { name: 'GLM 4V', group: 'GLM-4v' }, + { + id: 'glm-4v-flash', + provider: 'zhipu', + name: 'GLM-4V-Flash', + group: 'GLM-4v' + }, { id: 'glm-4v-plus', provider: 'zhipu', From e0f1768c4f2fa567953d46bb4cff34f0085a7bc5 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Fri, 21 Mar 2025 18:57:25 +0800 Subject: [PATCH 10/27] refactor(NpxSearch): improve type safety and error handling - Changed import of MCPServer to type import for better clarity. - Enhanced error handling in async operations to manage unknown error types. - Updated Table component to use ellipsis for long text in description and npm link. - Adjusted column width for actions and ensured consistent styling in the component. --- .../pages/settings/MCPSettings/NpxSearch.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx index c751ebc8c..4eb8895b9 100644 --- a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx @@ -1,9 +1,9 @@ import { SearchOutlined } from '@ant-design/icons' import { useTheme } from '@renderer/context/ThemeProvider' -import { MCPServer } from '@renderer/types' +import type { MCPServer } from '@renderer/types' import { Button, Input, Space, Spin, Table, Typography } from 'antd' import { npxFinder } from 'npx-scope-finder' -import { FC, useState } from 'react' +import { type FC, useState } from 'react' import { useTranslation } from 'react-i18next' import { SettingDivider, SettingGroup, SettingTitle } from '..' @@ -21,7 +21,7 @@ interface SearchResult { const NpxSearch: FC = () => { const { theme } = useTheme() const { t } = useTranslation() - const { Paragraph, Text } = Typography + const { Paragraph, Text, Link } = Typography // Add new state variables for npm scope search const [npmScope, setNpmScope] = useState('@modelcontextprotocol') @@ -59,8 +59,12 @@ const NpxSearch: FC = () => { if (formattedResults.length === 0) { window.message.info(t('settings.mcp.npx_list.no_packages')) } - } catch (error: any) { - window.message.error(`${t('settings.mcp.npx_list.search_error')}: ${error.message}`) + } catch (error: unknown) { + if (error instanceof Error) { + window.message.error(`${t('settings.mcp.npx_list.search_error')}: ${error.message}`) + } else { + window.message.error(t('settings.mcp.npx_list.search_error')) + } } finally { setSearchLoading(false) } @@ -92,7 +96,7 @@ const NpxSearch: FC = () => { ) : searchResults.length > 0 ? ( - + { { title: t('settings.mcp.npx_list.description'), key: 'description', + ellipsis: true, render: (_, record: SearchResult) => ( - - {record.description} - + + {record.description} + {t('settings.mcp.npx_list.usage')}: {record.usage} - - {record.npmLink} - + + + {record.npmLink} + + ) }, @@ -125,7 +132,7 @@ const NpxSearch: FC = () => { { title: t('settings.mcp.npx_list.actions'), key: 'actions', - width: '100px', + width: '120px', render: (_, record: SearchResult) => ( diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 85999c543..0a302596e 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -86,8 +86,10 @@ export interface SettingsState { yuqueToken: string | null yuqueUrl: string | null yuqueRepoId: string | null - obsidianApiKey: string | null - obsidianUrl: string | null + //obsidian settings obsidianVault, obisidanFolder + obsidianValut: string | null + obsidianFolder: string | null + obsidianTages: string | null joplinToken: string | null joplinUrl: string | null } @@ -161,10 +163,12 @@ const initialState: SettingsState = { yuqueToken: '', yuqueUrl: '', yuqueRepoId: '', - obsidianApiKey: '', - obsidianUrl: '', + obsidianValut: '', + obsidianFolder: '', + obsidianTages: '', joplinToken: '', joplinUrl: '' + } const settingsSlice = createSlice({ @@ -369,11 +373,14 @@ const settingsSlice = createSlice({ setYuqueUrl: (state, action: PayloadAction) => { state.yuqueUrl = action.payload }, - setObsidianApiKey: (state, action: PayloadAction) => { - state.obsidianApiKey = action.payload + setObsidianValut: (state, action: PayloadAction) => { + state.obsidianValut = action.payload }, - setObsidianUrl: (state, action: PayloadAction) => { - state.obsidianUrl = action.payload + setObsidianFolder: (state, action: PayloadAction) => { + state.obsidianFolder = action.payload + }, + setObsidianTages: (state, action: PayloadAction) => { + state.obsidianTages = action.payload }, setJoplinToken: (state, action: PayloadAction) => { state.joplinToken = action.payload @@ -452,8 +459,9 @@ export const { setYuqueToken, setYuqueRepoId, setYuqueUrl, - setObsidianApiKey, - setObsidianUrl, + setObsidianValut, + setObsidianFolder, + setObsidianTages, setJoplinToken, setJoplinUrl, setMessageNavigation diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index b483e0e27..f8d74d040 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -320,58 +320,46 @@ export const exportMarkdownToYuque = async (title: string, content: string) => { /** * 导出Markdown到Obsidian + * @param attributes 文档属性 + * @param attributes.title 标题 + * @param attributes.created 创建时间 + * @param attributes.source 来源 + * @param attributes.tags 标签 + * @param attributes.processingMethod 处理方式 */ -export const exportMarkdownToObsidian = async ( - fileName: string, - markdown: string, - selectedPath: string, - isMdFile: boolean = false -) => { +export const exportMarkdownToObsidian = async (attributes: any) => { try { - const obsidianUrl = store.getState().settings.obsidianUrl - const obsidianApiKey = store.getState().settings.obsidianApiKey + const obsidianValut = store.getState().settings.obsidianValut + const obsidianFolder = store.getState().settings.obsidianFolder - if (!obsidianUrl || !obsidianApiKey) { + if (!obsidianValut || !obsidianFolder) { window.message.error(i18n.t('chat.topics.export.obsidian_not_configured')) return } + let path = '' - // 如果是md文件,直接将内容追加到该文件 - if (isMdFile) { - const response = await fetch(`${obsidianUrl}vault${selectedPath}`, { - method: 'POST', - headers: { - 'Content-Type': 'text/markdown', - Authorization: `Bearer ${obsidianApiKey}` - }, - body: `\n\n${markdown}` // 添加两个换行后追加内容 - }) - - if (!response.ok) { - window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) - return - } - } else { - // 创建新文件 - const sanitizedFileName = removeSpecialCharactersForFileName(fileName) - const path = selectedPath === '/' ? '' : selectedPath - const fullPath = path.endsWith('/') ? `${path}${sanitizedFileName}.md` : `${path}/${sanitizedFileName}.md` - - const response = await fetch(`${obsidianUrl}vault${fullPath}`, { - method: 'PUT', - headers: { - 'Content-Type': 'text/markdown', - Authorization: `Bearer ${obsidianApiKey}` - }, - body: markdown - }) - - if (!response.ok) { - window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) - return - } + if (!attributes.title) { + window.message.error(i18n.t('chat.topics.export.obsidian_title_required')) + return } + //构建保存路径添加以 / 结尾 + if (!obsidianFolder.endsWith('/')) { + path = obsidianFolder + '/' + } + //构建文件名 + const fileName = transformObsidianFileName(attributes.title) + + let obsidianUrl = `obsidian://new?file=${encodeURIComponent(path + fileName)}&vault=${encodeURIComponent(obsidianValut)}&clipboard` + + if (attributes.processingMethod === '3') { + obsidianUrl += '&overwrite=true' + } else if (attributes.processingMethod === '2') { + obsidianUrl += '&prepend=true' + } else if (attributes.processingMethod === '1') { + obsidianUrl += '&append=true' + } + window.open(obsidianUrl) window.message.success(i18n.t('chat.topics.export.obsidian_export_success')) } catch (error) { console.error('导出到Obsidian失败:', error) @@ -379,6 +367,51 @@ export const exportMarkdownToObsidian = async ( } } +/** + * 生成Obsidian文件名,源自 Obsidian Web Clipper 官方实现,修改了一些细节 + * @param fileName + * @returns + */ + +function transformObsidianFileName(fileName: string): string { + const platform = window.navigator.userAgent + const isWindows = /win/i.test(platform) + const isMac = /mac/i.test(platform) + + // 删除Obsidian 全平台无效字符 + let sanitized = fileName.replace(/[#|\\^\\[\]]/g, '') + + if (isWindows) { + // Windows 的清理 + sanitized = sanitized + .replace(/[<>:"\\/\\|?*]/g, '') // 移除无效字符 + .replace(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i, '_$1$2') // 避免保留名称 + .replace(/[\s.]+$/, '') // 移除结尾的空格和句点 + } else if (isMac) { + // Mac 的清理 + sanitized = sanitized + .replace(/[/:\u0020-\u007E]/g, '') // 移除无效字符 + .replace(/^\./, '_') // 避免以句点开头 + } else { + // Linux 或其他系统 + sanitized = sanitized + .replace(/[<>:"\\/\\|?*]/g, '') // 移除无效字符 + .replace(/^\./, '_') // 避免以句点开头 + } + + // 所有平台的通用操作 + sanitized = sanitized + .replace(/^\.+/, '') // 移除开头的句点 + .trim() // 移除前后空格 + .slice(0, 245) // 截断为 245 个字符,留出空间以追加 ' 1.md' + + // 确保文件名不为空 + if (sanitized.length === 0) { + sanitized = 'Untitled' + } + + return sanitized +} export const exportMarkdownToJoplin = async (title: string, content: string) => { const { joplinUrl, joplinToken } = store.getState().settings From ed731db56a1184f84eb302e8030d953f6ec32882 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 22 Mar 2025 10:08:32 +0800 Subject: [PATCH 12/27] fix: remove unnecessary dependency from useEffect in MessageAnchorLine component --- src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index 2b314014e..be3c64faa 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -135,7 +135,7 @@ const MessageAnchorLine: FC = ({ messages }) => { messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, - [messages, setSelectedMessage] + [setSelectedMessage] ) if (messages.length === 0) return null From 404ec095d4ba9d710fa8cfc9ff59c6dab8cf43ab Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 22 Mar 2025 16:30:54 +0800 Subject: [PATCH 13/27] refactor: update rerank model support and configuration - Changed provider configuration in dev-app-update.yml to use GitHub. - Added SUPPORTED_REANK_PROVIDERS constant to filter available rerank models. - Updated tooltip messages in localization files to indicate supported providers. - Enhanced AddKnowledgePopup and KnowledgeSettingsPopup components to display supported providers in the UI. --- dev-app-update.yml | 10 +++---- src/main/reranker/DefaultReranker.ts | 1 + src/renderer/src/config/providers.ts | 2 ++ src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/ja-jp.json | 3 +- src/renderer/src/i18n/locales/ru-ru.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 29 ++++++++++--------- src/renderer/src/i18n/locales/zh-tw.json | 5 ++-- .../components/AddKnowledgePopup.tsx | 11 +++++++ .../components/KnowledgeSettingsPopup.tsx | 10 ++++++- 10 files changed, 52 insertions(+), 25 deletions(-) diff --git a/dev-app-update.yml b/dev-app-update.yml index 12788dcfd..6c9cb28c9 100644 --- a/dev-app-update.yml +++ b/dev-app-update.yml @@ -1,8 +1,8 @@ # provider: generic # url: http://127.0.0.1:8080 # updaterCacheDirName: cherry-studio-updater -# provider: github -# repo: cherry-studio -# owner: kangfenmao -provider: generic -url: https://cherrystudio.ocool.online +provider: github +repo: cherry-studio +owner: kangfenmao +# provider: generic +# url: https://cherrystudio.ocool.online diff --git a/src/main/reranker/DefaultReranker.ts b/src/main/reranker/DefaultReranker.ts index 0bb074560..aac0c65e8 100644 --- a/src/main/reranker/DefaultReranker.ts +++ b/src/main/reranker/DefaultReranker.ts @@ -7,6 +7,7 @@ export default class DefaultReranker extends BaseReranker { constructor(base: KnowledgeBaseParams) { super(base) } + async rerank(): Promise { throw new Error('Method not implemented.') } diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index e371df0d2..7e01f5f86 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -93,6 +93,8 @@ export function getProviderLogo(providerId: string) { return PROVIDER_LOGO_MAP[providerId as keyof typeof PROVIDER_LOGO_MAP] } +export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina'] + export const PROVIDER_CONFIG = { openai: { api: { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 2cfda2a6b..53c91ab76 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -563,7 +563,8 @@ "vision": "Vision", "websearch": "WebSearch", "rerank_model": "Reordering Model", - "rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add." + "rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add.", + "rerank_model_support_provider": "Currently, the reordering model only supports some providers ({{provider}})" }, "navbar": { "expand": "Expand Dialog", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 8ee358da7..1b2294638 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -563,7 +563,8 @@ "vision": "画像", "websearch": "ウェブ検索", "rerank_model": "再順序付けモデル", - "rerank_model_tooltip": "設定->モデルサービスに移動し、管理ボタンをクリックして追加します。" + "rerank_model_tooltip": "設定->モデルサービスに移動し、管理ボタンをクリックして追加します。", + "rerank_model_support_provider": "現在の再順序付けモデルは、{{provider}} のみサポートしています" }, "navbar": { "expand": "ダイアログを展開", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index abcaef27e..267a36549 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -569,7 +569,8 @@ "vision": "Визуальные", "websearch": "Веб-поисковые", "rerank_model": "Модель переупорядочивания", - "rerank_model_tooltip": "В настройках -> Служба модели нажмите кнопку \"Управление\", чтобы добавить." + "rerank_model_tooltip": "В настройках -> Служба модели нажмите кнопку \"Управление\", чтобы добавить.", + "rerank_model_support_provider": "Текущая модель переупорядочивания поддерживается только некоторыми поставщиками ({{provider}})" }, "navbar": { "expand": "Развернуть диалоговое окно", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b26656130..d01c3ef24 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -164,20 +164,20 @@ "topics.export.yuque": "导出到语雀", "topics.export.obsidian": "导出到 Obsidian", "topics.export.obsidian_not_configured": "Obsidian 未配置", - "topics.export.obsidian_title":"标题", - "topics.export.obsidian_title_placeholder":"请输入标题", - "topics.export.obsidian_title_required":"标题不能为空", - "topics.export.obsidian_tags":"标签", - "topics.export.obsidian_tags_placeholder":"请输入标签,多个标签用英文逗号分隔,Obsidian不可用纯数字", - "topics.export.obsidian_created":"创建时间", - "topics.export.obsidian_created_placeholder":"请选择创建时间", - "topics.export.obsidian_source":"来源", - "topics.export.obsidian_source_placeholder":"请输入来源", - "topics.export.obsidian_operate":"处理方式", - "topics.export.obsidian_operate_placeholder":"请选择处理方式", - "topics.export.obsidian_operate_append":"追加", - "topics.export.obsidian_operate_prepend":"前置", - "topics.export.obsidian_operate_new_or_overwrite":"新建(如果存在就覆盖)", + "topics.export.obsidian_title": "标题", + "topics.export.obsidian_title_placeholder": "请输入标题", + "topics.export.obsidian_title_required": "标题不能为空", + "topics.export.obsidian_tags": "标签", + "topics.export.obsidian_tags_placeholder": "请输入标签,多个标签用英文逗号分隔,Obsidian不可用纯数字", + "topics.export.obsidian_created": "创建时间", + "topics.export.obsidian_created_placeholder": "请选择创建时间", + "topics.export.obsidian_source": "来源", + "topics.export.obsidian_source_placeholder": "请输入来源", + "topics.export.obsidian_operate": "处理方式", + "topics.export.obsidian_operate_placeholder": "请选择处理方式", + "topics.export.obsidian_operate_append": "追加", + "topics.export.obsidian_operate_prepend": "前置", + "topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆盖)", "topics.export.obsidian_atributes": "配置笔记属性", "topics.export.obsidian_btn": "确定", "topics.export.obsidian_export_success": "导出成功", @@ -540,6 +540,7 @@ "embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加", "rerank_model": "重排序模型", "rerank_model_tooltip": "在设置->模型服务中点击管理按钮添加", + "rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})", "free": "免费", "no_matches": "无可用模型", "parameter_name": "参数名称", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 81dbd945b..4910a4450 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -164,7 +164,7 @@ "topics.export.yuque": "匯出到語雀", "topics.export.obsidian": "匯出到 Obsidian", "topics.export.obsidian_not_configured": "Obsidian 未配置", - "topics.export.obsidian_title":"標題", + "topics.export.obsidian_title": "標題", "topics.export.obsidian_title_placeholder": "請輸入標題", "topics.export.obsidian_title_required": "標題不能為空", "topics.export.obsidian_tags": "標籤", @@ -566,7 +566,8 @@ "vision": "視覺", "websearch": "網路搜尋", "rerank_model": "重排序模型", - "rerank_model_tooltip": "在設定->模型服務中點擊管理按鈕添加" + "rerank_model_tooltip": "在設定->模型服務中點擊管理按鈕添加", + "rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})" }, "navbar": { "expand": "伸縮對話框", diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx index c1cfe279b..ae7f45a49 100644 --- a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx +++ b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx @@ -1,7 +1,9 @@ import { TopView } from '@renderer/components/TopView' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useProviders } from '@renderer/hooks/useProvider' +import { SettingHelpText } from '@renderer/pages/settings' import AiProvider from '@renderer/providers/AiProvider' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -34,14 +36,17 @@ const PopupContainer: React.FC = ({ title, resolve }) => { const { t } = useTranslation() const { providers } = useProviders() const { addKnowledgeBase } = useKnowledgeBases() + const allModels = providers .map((p) => p.models) .flat() .filter((model) => isEmbeddingModel(model)) + const rerankModels = providers .map((p) => p.models) .flat() .filter((model) => isRerankModel(model)) + const nameInputRef = useRef(null) const selectOptions = providers @@ -60,6 +65,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { const rerankSelectOptions = providers .filter((p) => p.models.length > 0) + .filter((p) => SUPPORTED_REANK_PROVIDERS.includes(p.id)) .map((p) => ({ label: p.isSystem ? t(`provider.${p.id}`) : p.name, title: p.name, @@ -164,6 +170,11 @@ const PopupContainer: React.FC = ({ title, resolve }) => { tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }} rules={[{ required: false, message: t('message.error.enter.model') }]}> } + onChange={(e) => handleTextChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + handleTextChange('') + if (!searchText) setSearchVisible(false) + } + }} + onBlur={() => { + if (!searchText) setSearchVisible(false) + }} + autoFocus + allowClear + onClear={handleClear} + /> + ) : ( + + setSearchVisible(true)} /> + + ) +} + +export default ModelListSearchBar diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 792fc0227..a2566d360 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -12,7 +12,7 @@ import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/servic import { Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' import { providerCharge } from '@renderer/utils/oauth' -import { Button, Divider, Flex, Input, Space, Switch } from 'antd' +import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { isEmpty } from 'lodash' import { FC, useEffect, useState } from 'react' @@ -34,6 +34,7 @@ import GraphRAGSettings from './GraphRAGSettings' import HealthCheckPopup from './HealthCheckPopup' import LMStudioSettings from './LMStudioSettings' import ModelList, { ModelStatus } from './ModelList' +import ModelListSearchBar from './ModelListSearchBar' import OllamSettings from './OllamaSettings' import ProviderSettingsPopup from './ProviderSettingsPopup' import SelectProviderModelPopup from './SelectProviderModelPopup' @@ -49,6 +50,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [apiVersion, setApiVersion] = useState(provider.apiVersion) const [apiValid, setApiValid] = useState(false) const [apiChecking, setApiChecking] = useState(false) + const [searchText, setSearchText] = useState('') const { updateProvider, models } = useProvider(provider.id) const { t } = useTranslation() const { theme } = useTheme() @@ -361,22 +363,25 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} {provider.id === 'copilot' && } - - {t('common.models')} + - {!isEmpty(models) && ( + {t('common.models')} + {!isEmpty(models) && } + + {!isEmpty(models) && ( + - )} - - + /> + + )} + - + ) } From 6c6af2a12bffadc191fa21f4de784bbc90af432d Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Sat, 22 Mar 2025 21:50:45 +0800 Subject: [PATCH 18/27] feat(provider): gemini-2.0-flash-exp image (#3421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: finish basic gemini-2.0-flash-exp generate image * feat: support edit image * chore * fix: package https-proxy-agent-v5 version * feat: throw finish message and add history messages * feat: update generate image models * chore --------- Co-authored-by: 亢奋猫 --- package.json | 1 + src/renderer/src/config/models.ts | 24 + src/renderer/src/i18n/locales/en-us.json | 13 +- src/renderer/src/i18n/locales/ja-jp.json | 13 +- src/renderer/src/i18n/locales/ru-ru.json | 13 +- src/renderer/src/i18n/locales/zh-cn.json | 4 +- src/renderer/src/i18n/locales/zh-tw.json | 13 +- .../src/pages/home/Inputbar/Inputbar.tsx | 25 +- .../pages/home/Messages/MessageContent.tsx | 2 + .../src/pages/home/Messages/MessageImage.tsx | 29 + src/renderer/src/providers/GeminiProvider.ts | 507 ++++++++++++------ src/renderer/src/providers/index.d.ts | 1 + src/renderer/src/services/ApiService.ts | 9 +- src/renderer/src/types/index.ts | 7 + yarn.lock | 143 ++++- 15 files changed, 623 insertions(+), 181 deletions(-) create mode 100644 src/renderer/src/pages/home/Messages/MessageImage.tsx diff --git a/package.json b/package.json index 067c2ed79..1e3bef94b 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@electron-toolkit/utils": "^3.0.0", "@electron/notarize": "^2.5.0", "@emotion/is-prop-valid": "^1.3.1", + "@google/genai": "^0.4.0", "@google/generative-ai": "^0.21.0", "@llm-tools/embedjs": "patch:@llm-tools/embedjs@npm%3A0.1.28#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.28-8e4393fa2d.patch", "@llm-tools/embedjs-libsql": "^0.1.28", diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index a5920d145..597fbcbf6 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -1878,6 +1878,8 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [ 'stabilityai/stable-diffusion-xl-base-1.0' ] +export const GENERATE_IMAGE_MODELS = ['gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp'] + export function isTextToImageModel(model: Model): boolean { return TEXT_TO_IMAGE_REGEX.test(model.id) } @@ -2009,6 +2011,28 @@ export function isWebSearchModel(model: Model): boolean { return false } +export function isGenerateImageModel(model: Model): boolean { + if (!model) { + return false + } + + const provider = getProviderByModel(model) + + if (!provider) { + return false + } + + const isEmbedding = isEmbeddingModel(model) + + if (isEmbedding) { + return false + } + if (GENERATE_IMAGE_MODELS.includes(model.id)) { + return true + } + return false +} + export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Record { if (isWebSearchModel(model)) { if (assistant.enableWebSearch) { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 73d653a0c..8d7b5de28 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -198,7 +198,16 @@ "topics.prompt.tips": "Topic Prompts: Additional supplementary prompts provided for the current topic", "topics.title": "Topics", "topics.unpinned": "Unpinned Topics", - "translate": "Translate" + "topics.new": "New Topic", + "translate": "Translate", + "navigation": { + "prev": "Previous Message", + "next": "Next Message", + "first": "Already at the first message", + "last": "Already at the last message" + }, + "input.generate_image": "Generate image", + "input.generate_image_not_supported": "The model does not support generating images." }, "code_block": { "collapse": "Collapse", @@ -1168,4 +1177,4 @@ "visualization": "Visualization" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 371623546..fe0c1d727 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -198,7 +198,16 @@ "topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供", "topics.title": "トピック", "topics.unpinned": "固定解除", - "translate": "翻訳" + "topics.new": "新しいトピック", + "translate": "翻訳", + "navigation": { + "prev": "前のメッセージ", + "next": "次のメッセージ", + "first": "最初のメッセージです", + "last": "最後のメッセージです" + }, + "input.generate_image": "画像を生成する", + "input.generate_image_not_supported": "モデルは画像の生成をサポートしていません。" }, "code_block": { "collapse": "折りたたむ", @@ -1168,4 +1177,4 @@ "visualization": "可視化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 3b4583f6a..7e6485678 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -198,7 +198,16 @@ "topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы", "topics.title": "Топики", "topics.unpinned": "Открепленные темы", - "translate": "Перевести" + "topics.new": "Новый топик", + "translate": "Перевести", + "navigation": { + "prev": "Предыдущее сообщение", + "next": "Следующее сообщение", + "first": "Уже первое сообщение", + "last": "Уже последнее сообщение" + }, + "input.generate_image": "Сгенерировать изображение", + "input.generate_image_not_supported": "Модель не поддерживает генерацию изображений." }, "code_block": { "collapse": "Свернуть", @@ -1168,4 +1177,4 @@ "visualization": "Визуализация" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index e5ed1435b..1eaab35b8 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -116,6 +116,8 @@ "input.translate": "翻译成{{target_language}}", "input.upload": "上传图片或文档", "input.upload.document": "上传文档(模型不支持图片)", + "input.generate_image": "生成图片", + "input.generate_image_not_supported": "模型不支持生成图片", "input.web_search": "开启网络搜索", "input.web_search.button.ok": "去设置", "input.web_search.enable": "开启网络搜索", @@ -1168,4 +1170,4 @@ "visualization": "可视化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index f5e3aeaec..11ad3b849 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -200,7 +200,16 @@ "topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞", "topics.title": "話題", "topics.unpinned": "取消固定", - "translate": "翻譯" + "topics.new": "開始新對話", + "translate": "翻譯", + "navigation": { + "prev": "上一條訊息", + "next": "下一條訊息", + "first": "已經是第一條訊息", + "last": "已經是最後一條訊息" + }, + "input.generate_image": "生成圖片", + "input.generate_image_not_supported": "模型不支援生成圖片" }, "code_block": { "collapse": "折疊", @@ -1170,4 +1179,4 @@ "visualization": "視覺化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index dce62038a..d60ccb490 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -7,10 +7,11 @@ import { GlobalOutlined, HolderOutlined, PauseCircleOutlined, + PictureOutlined, QuestionCircleOutlined } from '@ant-design/icons' import TranslateButton from '@renderer/components/TranslateButton' -import { isFunctionCallingModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' +import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' @@ -626,7 +627,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } const onEnableWebSearch = () => { - console.log(assistant) if (!isWebSearchModel(model)) { if (!WebSearchService.isWebSearchEnabled()) { window.modal.confirm({ @@ -645,10 +645,17 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = updateAssistant({ ...assistant, enableWebSearch: !assistant.enableWebSearch }) } + const onEnableGenerateImage = () => { + updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage }) + } + useEffect(() => { if (!isWebSearchModel(model) && !WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch) { updateAssistant({ ...assistant, enableWebSearch: false }) } + if (!isGenerateImageModel(model) && assistant.enableGenerateImage) { + updateAssistant({ ...assistant, enableGenerateImage: false }) + } }, [assistant, model, updateAssistant]) const resetHeight = () => { @@ -738,6 +745,20 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = ToolbarButton={ToolbarButton} /> )} + + + + + onMentionModel(model, mentionFromKeyboard)} diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 6d5f7b69f..eea785032 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -16,6 +16,7 @@ import styled from 'styled-components' import Markdown from '../Markdown/Markdown' import MessageAttachments from './MessageAttachments' import MessageError from './MessageError' +import MessageImage from './MessageImage' import MessageSearchResults from './MessageSearchResults' import MessageThought from './MessageThought' import MessageTools from './MessageTools' @@ -150,6 +151,7 @@ const MessageContent: React.FC = ({ message: _message, model }) => { + {message.metadata?.generateImage && } {message.translatedContent && ( diff --git a/src/renderer/src/pages/home/Messages/MessageImage.tsx b/src/renderer/src/pages/home/Messages/MessageImage.tsx new file mode 100644 index 000000000..0f24eded3 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageImage.tsx @@ -0,0 +1,29 @@ +import { Message } from '@renderer/types' +import { Image as AntdImage } from 'antd' +import { FC } from 'react' +import styled from 'styled-components' + +interface Props { + message: Message +} + +const MessageImage: FC = ({ message }) => { + return ( + + {message.metadata?.generateImage!.images.map((image, index) => ( + + ))} + + ) +} +const Container = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + margin-top: 8px; +` +const Image = styled(AntdImage)` + border-radius: 10px; +` + +export default MessageImage diff --git a/src/renderer/src/providers/GeminiProvider.ts b/src/renderer/src/providers/GeminiProvider.ts index 653877e35..a21385fb7 100644 --- a/src/renderer/src/providers/GeminiProvider.ts +++ b/src/renderer/src/providers/GeminiProvider.ts @@ -1,3 +1,10 @@ +import { + ContentListUnion, + createPartFromBase64, + FinishReason, + GenerateContentResponse, + GoogleGenAI +} from '@google/genai' import { Content, FileDataPart, @@ -35,16 +42,19 @@ import axios from 'axios' import { isEmpty, takeRight } from 'lodash' import OpenAI from 'openai' -import { CompletionsParams } from '.' +import { ChunkCallbackData, CompletionsParams } from '.' import BaseProvider from './BaseProvider' export default class GeminiProvider extends BaseProvider { private sdk: GoogleGenerativeAI private requestOptions: RequestOptions + private imageSdk: GoogleGenAI constructor(provider: Provider) { super(provider) this.sdk = new GoogleGenerativeAI(this.apiKey) + /// this sdk is experimental + this.imageSdk = new GoogleGenAI({ apiKey: this.apiKey }) this.requestOptions = { baseUrl: this.getBaseURL() } @@ -105,6 +115,25 @@ export default class GeminiProvider extends BaseProvider { const role = message.role === 'user' ? 'user' : 'model' const parts: Part[] = [{ text: await this.getMessageContent(message) }] + // Add any generated images from previous responses + if (message.metadata?.generateImage?.images && message.metadata.generateImage.images.length > 0) { + for (const imageUrl of message.metadata.generateImage.images) { + if (imageUrl && imageUrl.startsWith('data:')) { + // Extract base64 data and mime type from the data URL + const matches = imageUrl.match(/^data:(.+);base64,(.*)$/) + if (matches && matches.length === 3) { + const mimeType = matches[1] + const base64Data = matches[2] + parts.push({ + inlineData: { + data: base64Data, + mimeType: mimeType + } + } as InlineDataPart) + } + } + } + } for (const file of message.files || []) { if (file.type === FileTypes.IMAGE) { @@ -179,180 +208,184 @@ export default class GeminiProvider extends BaseProvider { * @param onFilterMessages - The onFilterMessages callback */ public async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams) { - const defaultModel = getDefaultModel() - const model = assistant.model || defaultModel - const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant) + if (assistant.enableGenerateImage) { + await this.generateImageExp({ messages, assistant, onFilterMessages, onChunk }) + } else { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant) - const userMessages = filterUserRoleStartMessages( - filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 2))) - ) - onFilterMessages(userMessages) + const userMessages = filterUserRoleStartMessages( + filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 2))) + ) + onFilterMessages(userMessages) - const userLastMessage = userMessages.pop() + const userLastMessage = userMessages.pop() - const history: Content[] = [] + const history: Content[] = [] - for (const message of userMessages) { - history.push(await this.getMessageContents(message)) - } - - const tools = mcpToolsToGeminiTools(mcpTools) - const toolResponses: MCPToolResponse[] = [] - - if (assistant.enableWebSearch && isWebSearchModel(model)) { - tools.push({ - // @ts-ignore googleSearch is not a valid tool for Gemini - googleSearch: {} - }) - } - - const geminiModel = this.sdk.getGenerativeModel( - { - model: model.id, - ...(isGemmaModel(model) ? {} : { systemInstruction: assistant.prompt }), - safetySettings: this.getSafetySettings(model.id), - tools: tools, - generationConfig: { - maxOutputTokens: maxTokens, - temperature: assistant?.settings?.temperature, - topP: assistant?.settings?.topP, - ...this.getCustomParameters(assistant) - } - }, - this.requestOptions - ) - - const chat = geminiModel.startChat({ history }) - const messageContents = await this.getMessageContents(userLastMessage!) - - if (isGemmaModel(model) && assistant.prompt) { - const isFirstMessage = history.length === 0 - if (isFirstMessage) { - const systemMessage = { - role: 'user', - parts: [ - { - text: - 'user\n' + - assistant.prompt + - '\n' + - 'user\n' + - messageContents.parts[0].text + - '' - } - ] - } - messageContents.parts = systemMessage.parts + for (const message of userMessages) { + history.push(await this.getMessageContents(message)) } - } - const start_time_millsec = new Date().getTime() - const { abortController, cleanup } = this.createAbortController(userLastMessage?.id) - const { signal } = abortController + const tools = mcpToolsToGeminiTools(mcpTools) + const toolResponses: MCPToolResponse[] = [] - if (!streamOutput) { - const { response } = await chat.sendMessage(messageContents.parts, { signal }) - const time_completion_millsec = new Date().getTime() - start_time_millsec - onChunk({ - text: response.candidates?.[0].content.parts[0].text, - usage: { - prompt_tokens: response.usageMetadata?.promptTokenCount || 0, - completion_tokens: response.usageMetadata?.candidatesTokenCount || 0, - total_tokens: response.usageMetadata?.totalTokenCount || 0 - }, - metrics: { - completion_tokens: response.usageMetadata?.candidatesTokenCount, - time_completion_millsec, - time_first_token_millsec: 0 - }, - search: response.candidates?.[0]?.groundingMetadata - }) - return - } - - const userMessagesStream = await chat.sendMessageStream(messageContents.parts, { signal }) - let time_first_token_millsec = 0 - - const processStream = async (stream: GenerateContentStreamResult, idx: number) => { - for await (const chunk of stream.stream) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break - - if (time_first_token_millsec == 0) { - time_first_token_millsec = new Date().getTime() - start_time_millsec - } - - const time_completion_millsec = new Date().getTime() - start_time_millsec - - const functionCalls = chunk.functionCalls() - - if (functionCalls) { - const fcallParts: FunctionCallPart[] = [] - const fcRespParts: FunctionResponsePart[] = [] - for (const call of functionCalls) { - console.log('Function call:', call) - fcallParts.push({ functionCall: call } as FunctionCallPart) - const mcpTool = geminiFunctionCallToMcpTool(mcpTools, call) - if (mcpTool) { - upsertMCPToolResponse( - toolResponses, - { - tool: mcpTool, - status: 'invoking', - id: `${call.name}-${idx}` - }, - onChunk - ) - const toolCallResponse = await callMCPTool(mcpTool) - fcRespParts.push({ - functionResponse: { - name: mcpTool.id, - response: toolCallResponse - } - }) - upsertMCPToolResponse( - toolResponses, - { - tool: mcpTool, - status: 'done', - response: toolCallResponse, - id: `${call.name}-${idx}` - }, - onChunk - ) - } - } - - if (fcRespParts) { - history.push(messageContents) - history.push({ - role: 'model', - parts: fcallParts - }) - const newChat = geminiModel.startChat({ history }) - const newStream = await newChat.sendMessageStream(fcRespParts, { signal }) - await processStream(newStream, idx + 1) - } - } - - onChunk({ - text: chunk.text(), - usage: { - prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0, - completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0, - total_tokens: chunk.usageMetadata?.totalTokenCount || 0 - }, - metrics: { - completion_tokens: chunk.usageMetadata?.candidatesTokenCount, - time_completion_millsec, - time_first_token_millsec - }, - search: chunk.candidates?.[0]?.groundingMetadata, - mcpToolResponse: toolResponses + if (assistant.enableWebSearch && isWebSearchModel(model)) { + tools.push({ + // @ts-ignore googleSearch is not a valid tool for Gemini + googleSearch: {} }) } - } - await processStream(userMessagesStream, 0).finally(cleanup) + const geminiModel = this.sdk.getGenerativeModel( + { + model: model.id, + ...(isGemmaModel(model) ? {} : { systemInstruction: assistant.prompt }), + safetySettings: this.getSafetySettings(model.id), + tools: tools, + generationConfig: { + maxOutputTokens: maxTokens, + temperature: assistant?.settings?.temperature, + topP: assistant?.settings?.topP, + ...this.getCustomParameters(assistant) + } + }, + this.requestOptions + ) + + const chat = geminiModel.startChat({ history }) + const messageContents = await this.getMessageContents(userLastMessage!) + + if (isGemmaModel(model) && assistant.prompt) { + const isFirstMessage = history.length === 0 + if (isFirstMessage) { + const systemMessage = { + role: 'user', + parts: [ + { + text: + 'user\n' + + assistant.prompt + + '\n' + + 'user\n' + + messageContents.parts[0].text + + '' + } + ] + } + messageContents.parts = systemMessage.parts + } + } + + const start_time_millsec = new Date().getTime() + const { abortController, cleanup } = this.createAbortController(userLastMessage?.id) + const { signal } = abortController + + if (!streamOutput) { + const { response } = await chat.sendMessage(messageContents.parts, { signal }) + const time_completion_millsec = new Date().getTime() - start_time_millsec + onChunk({ + text: response.candidates?.[0].content.parts[0].text, + usage: { + prompt_tokens: response.usageMetadata?.promptTokenCount || 0, + completion_tokens: response.usageMetadata?.candidatesTokenCount || 0, + total_tokens: response.usageMetadata?.totalTokenCount || 0 + }, + metrics: { + completion_tokens: response.usageMetadata?.candidatesTokenCount, + time_completion_millsec, + time_first_token_millsec: 0 + }, + search: response.candidates?.[0]?.groundingMetadata + }) + return + } + + const userMessagesStream = await chat.sendMessageStream(messageContents.parts, { signal }) + let time_first_token_millsec = 0 + + const processStream = async (stream: GenerateContentStreamResult, idx: number) => { + for await (const chunk of stream.stream) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break + + if (time_first_token_millsec == 0) { + time_first_token_millsec = new Date().getTime() - start_time_millsec + } + + const time_completion_millsec = new Date().getTime() - start_time_millsec + + const functionCalls = chunk.functionCalls() + + if (functionCalls) { + const fcallParts: FunctionCallPart[] = [] + const fcRespParts: FunctionResponsePart[] = [] + for (const call of functionCalls) { + console.log('Function call:', call) + fcallParts.push({ functionCall: call } as FunctionCallPart) + const mcpTool = geminiFunctionCallToMcpTool(mcpTools, call) + if (mcpTool) { + upsertMCPToolResponse( + toolResponses, + { + tool: mcpTool, + status: 'invoking', + id: `${call.name}-${idx}` + }, + onChunk + ) + const toolCallResponse = await callMCPTool(mcpTool) + fcRespParts.push({ + functionResponse: { + name: mcpTool.id, + response: toolCallResponse + } + }) + upsertMCPToolResponse( + toolResponses, + { + tool: mcpTool, + status: 'done', + response: toolCallResponse, + id: `${call.name}-${idx}` + }, + onChunk + ) + } + } + + if (fcRespParts) { + history.push(messageContents) + history.push({ + role: 'model', + parts: fcallParts + }) + const newChat = geminiModel.startChat({ history }) + const newStream = await newChat.sendMessageStream(fcRespParts, { signal }) + await processStream(newStream, idx + 1) + } + } + + onChunk({ + text: chunk.text(), + usage: { + prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0, + completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0, + total_tokens: chunk.usageMetadata?.totalTokenCount || 0 + }, + metrics: { + completion_tokens: chunk.usageMetadata?.candidatesTokenCount, + time_completion_millsec, + time_first_token_millsec + }, + search: chunk.candidates?.[0]?.groundingMetadata, + mcpToolResponse: toolResponses + }) + } + } + + await processStream(userMessagesStream, 0).finally(cleanup) + } } /** @@ -536,6 +569,150 @@ export default class GeminiProvider extends BaseProvider { return [] } + /** + * 生成图像 + * @param messages - 消息列表 + * @param assistant - 助手配置 + * @param onChunk - 处理生成块的回调 + * @param onFilterMessages - 过滤消息的回调 + * @returns Promise + */ + private async generateImageExp({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + const { contextCount } = getAssistantSettings(assistant) + + const userMessages = filterUserRoleStartMessages(filterContextMessages(takeRight(messages, contextCount + 2))) + onFilterMessages(userMessages) + + const userLastMessage = userMessages.pop() + if (!userLastMessage) { + throw new Error('No user message found') + } + + const history: Content[] = [] + + for (const message of userMessages) { + history.push(await this.getMessageContents(message)) + } + + const userLastMessageContent = await this.getMessageContents(userLastMessage) + const allContents = [...history, userLastMessageContent] + + let contents: ContentListUnion = allContents.length > 0 ? (allContents as ContentListUnion) : [] + + contents = await this.addImageFileToContents(userLastMessage, contents) + + const response = await this.callGeminiGenerateContent(model.id, contents) + + console.log('response', response) + + const { isValid, message } = this.isValidGeminiResponse(response) + if (!isValid) { + throw new Error(`Gemini API error: ${message}`) + } + + this.processGeminiImageResponse(response, onChunk) + } + + /** + * 添加图片文件到内容列表 + * @param message - 用户消息 + * @param contents - 内容列表 + * @returns 更新后的内容列表 + */ + private async addImageFileToContents(message: Message, contents: ContentListUnion): Promise { + if (message.files && message.files.length > 0) { + const file = message.files[0] + const fileContent = await window.api.file.base64Image(file.id + file.ext) + + if (fileContent && fileContent.base64) { + const contentsArray = Array.isArray(contents) ? contents : [contents] + return [...contentsArray, createPartFromBase64(fileContent.base64, fileContent.mime)] + } + } + return contents + } + + /** + * 调用Gemini API生成内容 + * @param modelId - 模型ID + * @param contents - 内容列表 + * @returns 生成结果 + */ + private async callGeminiGenerateContent( + modelId: string, + contents: ContentListUnion + ): Promise { + try { + return await this.imageSdk.models.generateContent({ + model: modelId, + contents: contents, + config: { + responseModalities: ['Text', 'Image'], + responseMimeType: 'text/plain' + } + }) + } catch (error) { + console.error('Gemini API error:', error) + throw error + } + } + + /** + * 检查Gemini响应是否有效 + * @param response - Gemini响应 + * @returns 是否有效 + */ + private isValidGeminiResponse(response: GenerateContentResponse): { isValid: boolean; message: string } { + return { + isValid: response?.candidates?.[0]?.finishReason === FinishReason.STOP ? true : false, + message: response?.candidates?.[0]?.finishReason || '' + } + } + + /** + * 处理Gemini图像响应 + * @param response - Gemini响应 + * @param onChunk - 处理生成块的回调 + */ + private processGeminiImageResponse(response: any, onChunk: (chunk: ChunkCallbackData) => void): void { + const parts = response.candidates[0].content.parts + + // 提取图像数据 + const images = parts + .filter((part: Part) => part.inlineData) + .map((part: Part) => { + if (!part.inlineData) { + return null + } + const dataPrefix = `data:${part.inlineData.mimeType || 'image/png'};base64,` + return part.inlineData.data.startsWith('data:') ? part.inlineData.data : dataPrefix + part.inlineData.data + }) + + // 提取文本数据 + const text = parts + .filter((part: Part) => part.text !== undefined) + .map((part: Part) => part.text) + .join('') + + // 返回结果 + onChunk({ + text, + generateImage: { + images + }, + usage: { + prompt_tokens: response.usageMetadata?.promptTokenCount || 0, + completion_tokens: response.usageMetadata?.candidatesTokenCount || 0, + total_tokens: response.usageMetadata?.totalTokenCount || 0 + }, + metrics: { + completion_tokens: response.usageMetadata?.candidatesTokenCount + } + }) + } + /** * Check if the model is valid * @param model - The model diff --git a/src/renderer/src/providers/index.d.ts b/src/renderer/src/providers/index.d.ts index 40b9f6803..f21880b4e 100644 --- a/src/renderer/src/providers/index.d.ts +++ b/src/renderer/src/providers/index.d.ts @@ -9,6 +9,7 @@ interface ChunkCallbackData { search?: GroundingMetadata citations?: string[] mcpToolResponse?: MCPToolResponse[] + generateImage?: GenerateImageResponse } interface CompletionsParams { diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index a8e2e3c2e..492909126 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -111,7 +111,7 @@ export async function fetchChatCompletion({ messages: filterUsefulMessages(messages), assistant, onFilterMessages: (messages) => (_messages = messages), - onChunk: ({ text, reasoning_content, usage, metrics, search, citations, mcpToolResponse }) => { + onChunk: ({ text, reasoning_content, usage, metrics, search, citations, mcpToolResponse, generateImage }) => { message.content = message.content + text || '' message.usage = usage message.metrics = metrics @@ -127,6 +127,12 @@ export async function fetchChatCompletion({ if (mcpToolResponse) { message.metadata = { ...message.metadata, mcpTools: cloneDeep(mcpToolResponse) } } + if (generateImage) { + message.metadata = { + ...message.metadata, + generateImage: generateImage + } + } // Handle citations from Perplexity API if (isFirstChunk && citations) { @@ -162,6 +168,7 @@ export async function fetchChatCompletion({ } } } + console.log('message', message) } catch (error: any) { if (isAbortError(error)) { message.status = 'paused' diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 1e3c37353..10e981c87 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -16,6 +16,7 @@ export type Assistant = { settings?: Partial messages?: AssistantMessage[] enableWebSearch?: boolean + enableGenerateImage?: boolean } export type AssistantMessage = { @@ -77,6 +78,8 @@ export type Message = { webSearch?: WebSearchResponse // MCP Tools mcpTools?: MCPToolResponse[] + // Generate Image + generateImage?: GenerateImageResponse } // 多模型消息样式 multiModelMessageStyle?: 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -295,6 +298,10 @@ export type GenerateImageParams = { promptEnhancement?: boolean } +export type GenerateImageResponse = { + images: string[] +} + export interface TranslateHistory { id: string sourceText: string diff --git a/yarn.lock b/yarn.lock index 14207c212..4d47dc607 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1021,6 +1021,16 @@ __metadata: languageName: node linkType: hard +"@google/genai@npm:^0.4.0": + version: 0.4.0 + resolution: "@google/genai@npm:0.4.0" + dependencies: + google-auth-library: "npm:^9.14.2" + ws: "npm:^8.18.0" + checksum: 10c0/4feb837b373cdbe60a5388b880b2384b116ffa369ae17ec2562c4e9da0f90e315d5e30c413ee3a620b6d147c55e1e9165f0e143aba6d945f1dfbe61fa584fefc + languageName: node + linkType: hard + "@google/generative-ai@npm:^0.21.0": version: 0.21.0 resolution: "@google/generative-ai@npm:0.21.0" @@ -3336,6 +3346,7 @@ __metadata: "@emotion/is-prop-valid": "npm:^1.3.1" "@eslint-react/eslint-plugin": "npm:^1.36.1" "@eslint/js": "npm:^9.22.0" + "@google/genai": "npm:^0.4.0" "@google/generative-ai": "npm:^0.21.0" "@hello-pangea/dnd": "npm:^16.6.0" "@kangfenmao/keyv-storage": "npm:^0.1.0" @@ -4012,7 +4023,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": +"base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -4035,6 +4046,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^9.0.0": + version: 9.1.2 + resolution: "bignumber.js@npm:9.1.2" + checksum: 10c0/e17786545433f3110b868725c449fa9625366a6e675cd70eb39b60938d6adbd0158cb4b3ad4f306ce817165d37e63f4aa3098ba4110db1d9a3b9f66abfbaf10d + languageName: node + linkType: hard + "bindings@npm:^1.5.0": version: 1.5.0 resolution: "bindings@npm:1.5.0" @@ -4201,6 +4219,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e + languageName: node + linkType: hard + "buffer-equal@npm:0.0.1": version: 0.0.1 resolution: "buffer-equal@npm:0.0.1" @@ -5648,6 +5673,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -6663,7 +6697,7 @@ __metadata: languageName: node linkType: hard -"extend@npm:^3.0.0, extend@npm:~3.0.2": +"extend@npm:^3.0.0, extend@npm:^3.0.2, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" checksum: 10c0/73bf6e27406e80aa3e85b0d1c4fd987261e628064e170ca781125c0b635a3dabad5e05adbf07595ea0cf1e6c5396cacb214af933da7cbaf24fe75ff14818e8f9 @@ -7249,6 +7283,30 @@ __metadata: languageName: node linkType: hard +"gaxios@npm:^6.0.0, gaxios@npm:^6.1.1": + version: 6.7.1 + resolution: "gaxios@npm:6.7.1" + dependencies: + extend: "npm:^3.0.2" + https-proxy-agent: "npm:^7.0.1" + is-stream: "npm:^2.0.0" + node-fetch: "npm:^2.6.9" + uuid: "npm:^9.0.1" + checksum: 10c0/53e92088470661c5bc493a1de29d05aff58b1f0009ec5e7903f730f892c3642a93e264e61904383741ccbab1ce6e519f12a985bba91e13527678b32ee6d7d3fd + languageName: node + linkType: hard + +"gcp-metadata@npm:^6.1.0": + version: 6.1.1 + resolution: "gcp-metadata@npm:6.1.1" + dependencies: + gaxios: "npm:^6.1.1" + google-logging-utils: "npm:^0.0.2" + json-bigint: "npm:^1.0.0" + checksum: 10c0/71f6ad4800aa622c246ceec3955014c0c78cdcfe025971f9558b9379f4019f5e65772763428ee8c3244fa81b8631977316eaa71a823493f82e5c44d7259ffac8 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -7492,6 +7550,27 @@ __metadata: languageName: node linkType: hard +"google-auth-library@npm:^9.14.2": + version: 9.15.1 + resolution: "google-auth-library@npm:9.15.1" + dependencies: + base64-js: "npm:^1.3.0" + ecdsa-sig-formatter: "npm:^1.0.11" + gaxios: "npm:^6.1.1" + gcp-metadata: "npm:^6.1.0" + gtoken: "npm:^7.0.0" + jws: "npm:^4.0.0" + checksum: 10c0/6eef36d9a9cb7decd11e920ee892579261c6390104b3b24d3e0f3889096673189fe2ed0ee43fd563710e2560de98e63ad5aa4967b91e7f4e69074a422d5f7b65 + languageName: node + linkType: hard + +"google-logging-utils@npm:^0.0.2": + version: 0.0.2 + resolution: "google-logging-utils@npm:0.0.2" + checksum: 10c0/9a4bbd470dd101c77405e450fffca8592d1d7114f245a121288d04a957aca08c9dea2dd1a871effe71e41540d1bb0494731a0b0f6fea4358e77f06645e4268c1 + languageName: node + linkType: hard + "gopd@npm:^1.0.1, gopd@npm:^1.2.0": version: 1.2.0 resolution: "gopd@npm:1.2.0" @@ -7551,6 +7630,16 @@ __metadata: languageName: node linkType: hard +"gtoken@npm:^7.0.0": + version: 7.1.0 + resolution: "gtoken@npm:7.1.0" + dependencies: + gaxios: "npm:^6.0.0" + jws: "npm:^4.0.0" + checksum: 10c0/0a3dcacb1a3c4578abe1ee01c7d0bf20bffe8ded3ee73fc58885d53c00f6eb43b4e1372ff179f0da3ed5cfebd5b7c6ab8ae2776f1787e90d943691b4fe57c716 + languageName: node + linkType: hard + "har-schema@npm:^2.0.0": version: 2.0.0 resolution: "har-schema@npm:2.0.0" @@ -8486,6 +8575,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^2.0.0": + version: 2.0.1 + resolution: "is-stream@npm:2.0.1" + checksum: 10c0/7c284241313fc6efc329b8d7f08e16c0efeb6baab1b4cd0ba579eb78e5af1aa5da11e68559896a2067cd6c526bd29241dda4eb1225e627d5aa1a89a76d4635a5 + languageName: node + linkType: hard + "is-stream@npm:^3.0.0": version: 3.0.0 resolution: "is-stream@npm:3.0.0" @@ -8662,6 +8758,15 @@ __metadata: languageName: node linkType: hard +"json-bigint@npm:^1.0.0": + version: 1.0.0 + resolution: "json-bigint@npm:1.0.0" + dependencies: + bignumber.js: "npm:^9.0.0" + checksum: 10c0/e3f34e43be3284b573ea150a3890c92f06d54d8ded72894556357946aeed9877fd795f62f37fe16509af189fd314ab1104d0fd0f163746ad231b9f378f5b33f4 + languageName: node + linkType: hard + "json-bignum@npm:^0.0.3": version: 0.0.3 resolution: "json-bignum@npm:0.0.3" @@ -8813,6 +8918,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^2.0.0": + version: 2.0.0 + resolution: "jwa@npm:2.0.0" + dependencies: + buffer-equal-constant-time: "npm:1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/6baab823b93c038ba1d2a9e531984dcadbc04e9eb98d171f4901b7a40d2be15961a359335de1671d78cb6d987f07cbe5d350d8143255977a889160c4d90fcc3c + languageName: node + linkType: hard + +"jws@npm:^4.0.0": + version: 4.0.0 + resolution: "jws@npm:4.0.0" + dependencies: + jwa: "npm:^2.0.0" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/f1ca77ea5451e8dc5ee219cb7053b8a4f1254a79cb22417a2e1043c1eb8a569ae118c68f24d72a589e8a3dd1824697f47d6bd4fb4bebb93a3bdf53545e721661 + languageName: node + linkType: hard + "katex@npm:^0.12.0": version: 0.12.0 resolution: "katex@npm:0.12.0" @@ -10803,7 +10929,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7": +"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -15259,6 +15385,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^9.0.1": + version: 9.0.1 + resolution: "uuid@npm:9.0.1" + bin: + uuid: dist/bin/uuid + checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b + languageName: node + linkType: hard + "uzip@npm:0.20201231.0": version: 0.20201231.0 resolution: "uzip@npm:0.20201231.0" @@ -15587,7 +15722,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.13.0": +"ws@npm:^8.13.0, ws@npm:^8.18.0": version: 8.18.1 resolution: "ws@npm:8.18.1" peerDependencies: From d56774fd5996ad199c5cba8ffa82c9b8d37ced7d Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Sat, 22 Mar 2025 22:14:25 +0800 Subject: [PATCH 19/27] fix(knowledge): show more info (#3790) * feat: remove rerank model from embedded model * feat: add rerank model info * fix: embedding and rerank model end without `/v1` bug --- src/main/reranker/JinaReranker.ts | 14 ++-- src/main/reranker/SiliconFlowReranker.ts | 14 ++-- .../src/pages/knowledge/KnowledgeContent.tsx | 64 ++++++++++++++++--- .../components/AddKnowledgePopup.tsx | 15 +++-- .../components/KnowledgeSettingsPopup.tsx | 2 +- 5 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/main/reranker/JinaReranker.ts b/src/main/reranker/JinaReranker.ts index dbee063cc..3dfede195 100644 --- a/src/main/reranker/JinaReranker.ts +++ b/src/main/reranker/JinaReranker.ts @@ -10,9 +10,15 @@ export default class JinaReranker extends BaseReranker { } public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const baseURL = this.base?.rerankBaseURL?.endsWith('/') + let baseURL = this.base?.rerankBaseURL?.endsWith('/') ? this.base.rerankBaseURL.slice(0, -1) : this.base.rerankBaseURL + + // 必须携带/v1,否则会404 + if (baseURL && !baseURL.endsWith('/v1')) { + baseURL = `${baseURL}/v1` + } + const url = `${baseURL}/rerank` const requestBody = { @@ -40,9 +46,9 @@ export default class JinaReranker extends BaseReranker { }) .filter((doc): doc is ExtractChunkData => doc !== undefined) .sort((a, b) => b.score - a.score) - } catch (error) { - console.error('Jina Reranker API 错误:', error) - throw error + } catch (error: any) { + console.error('Jina Reranker API 错误:', error.status) + throw new Error(`${error} - BaseUrl: ${baseURL}`) } } } diff --git a/src/main/reranker/SiliconFlowReranker.ts b/src/main/reranker/SiliconFlowReranker.ts index ee82362e2..1e47cca9c 100644 --- a/src/main/reranker/SiliconFlowReranker.ts +++ b/src/main/reranker/SiliconFlowReranker.ts @@ -10,9 +10,15 @@ export default class SiliconFlowReranker extends BaseReranker { } public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const baseURL = this.base?.rerankBaseURL?.endsWith('/') + let baseURL = this.base?.rerankBaseURL?.endsWith('/') ? this.base.rerankBaseURL.slice(0, -1) : this.base.rerankBaseURL + + // 必须携带/v1,否则会404 + if (baseURL && !baseURL.endsWith('/v1')) { + baseURL = `${baseURL}/v1` + } + const url = `${baseURL}/rerank` const requestBody = { @@ -42,9 +48,9 @@ export default class SiliconFlowReranker extends BaseReranker { }) .filter((doc): doc is ExtractChunkData => doc !== undefined) .sort((a, b) => b.score - a.score) - } catch (error) { - console.error('SiliconFlow Reranker API 错误:', error) - throw error + } catch (error: any) { + console.error('SiliconFlow Reranker API 错误:', error.status) + throw new Error(`${error} - BaseUrl: ${baseURL}`) } } } diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index 7fa16c276..a632fe86e 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -61,6 +61,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { } = useKnowledge(selectedBase.id || '') const providerName = getProviderName(base?.model.provider || '') + const rerankModelProviderName = getProviderName(base?.rerankModel?.provider || '') const disabled = !base?.version || !providerName if (!base) { @@ -445,13 +446,34 @@ const KnowledgeContent: FC = ({ selectedBase }) => { - - - {base.model.name} - {t('models.dimensions', { dimensions: base.dimensions || 0 })} - {providerName && {providerName}} -