diff --git a/package.json b/package.json index 2cbbf43f39..36002abd87 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@electron/notarize": "^2.5.0", "@google/generative-ai": "^0.24.0", "@langchain/community": "^0.3.36", + "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@strongtz/win32-arm64-msvc": "^0.4.7", "@tryfabric/martian": "^1.2.4", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index d07a0a5200..0946491bb6 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -18,6 +18,10 @@ export enum IpcChannel { App_InstallUvBinary = 'app:install-uv-binary', App_InstallBunBinary = 'app:install-bun-binary', + // ASR Server + Asr_StartServer = 'start-asr-server', + Asr_StopServer = 'stop-asr-server', + // Open Open_Path = 'open:path', Open_Website = 'open:website', @@ -146,5 +150,10 @@ export enum IpcChannel { MiniWindowReload = 'miniwindow-reload', ReduxStateChange = 'redux-state-change', - ReduxStoreReady = 'redux-store-ready' + ReduxStoreReady = 'redux-store-ready', + + // Search Window + SearchWindow_Open = 'search-window:open', + SearchWindow_Close = 'search-window:close', + SearchWindow_OpenUrl = 'search-window:open-url' } diff --git a/scripts/after-pack.js b/scripts/after-pack.js index e4b20db5d8..b226e8a232 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -18,35 +18,48 @@ exports.default = async function (context) { 'node_modules' ) - removeDifferentArchNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64']) + keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64']) } if (platform === 'linux') { const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl'] - removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch) + keepPackageNodeFiles(node_modules_path, '@libsql', _arch) } if (platform === 'windows') { const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') if (arch === Arch.arm64) { - removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc']) - removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc']) + keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc']) + keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc']) } if (arch === Arch.x64) { - removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc']) - removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) + keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc']) + keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) } } } -function removeDifferentArchNodeFiles(nodeModulesPath, packageName, arch) { +/** + * 使用指定架构的 node_modules 文件 + * @param {*} nodeModulesPath + * @param {*} packageName + * @param {*} arch + * @returns + */ +function keepPackageNodeFiles(nodeModulesPath, packageName, arch) { const modulePath = path.join(nodeModulesPath, packageName) + + if (!fs.existsSync(modulePath)) { + console.log(`[After Pack] Directory does not exist: ${modulePath}`) + return + } + const dirs = fs.readdirSync(modulePath) dirs .filter((dir) => !arch.includes(dir)) .forEach((dir) => { fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true }) - console.log(`Removed dir: ${dir}`, arch) + console.log(`[After Pack] Removed dir: ${dir}`, arch) }) } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 752e2e5510..271b7730e9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -23,6 +23,7 @@ import mcpService from './services/MCPService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' +import { searchService } from './services/SearchService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' @@ -297,8 +298,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { NutstoreService.getDirectoryContents(token, path) ) + // search window + ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => { + await searchService.openSearchWindow(uid) + }) + ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => { + await searchService.closeSearchWindow(uid) + }) + ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => { + return await searchService.openUrlInSearchWindow(uid, url) + }) + // 启动ASR服务器 - ipcMain.handle('start-asr-server', async () => { + ipcMain.handle(IpcChannel.Asr_StartServer, async () => { try { if (asrServerProcess) { return { success: true, pid: asrServerProcess.pid } @@ -371,7 +383,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { }) // 停止ASR服务器 - ipcMain.handle('stop-asr-server', async (_event, pid) => { + ipcMain.handle(IpcChannel.Asr_StopServer, async (_event, pid) => { try { if (!asrServerProcess) { return { success: true } diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index aef46e9ba0..ecfa14a83c 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -147,8 +147,12 @@ class McpService { ...getDefaultEnvironment(), PATH: this.getEnhancedPath(process.env.PATH || ''), ...server.env - } + }, + stderr: 'pipe' }) + transport.stderr?.on('data', (data) => + Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString()) + ) } else { throw new Error('Either baseUrl or command must be provided') } diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts new file mode 100644 index 0000000000..327bf6e7ff --- /dev/null +++ b/src/main/services/SearchService.ts @@ -0,0 +1,82 @@ +import { is } from '@electron-toolkit/utils' +import { BrowserWindow } from 'electron' + +export class SearchService { + private static instance: SearchService | null = null + private searchWindows: Record = {} + public static getInstance(): SearchService { + if (!SearchService.instance) { + SearchService.instance = new SearchService() + } + return SearchService.instance + } + + constructor() { + // Initialize the service + } + + private async createNewSearchWindow(uid: string): Promise { + const newWindow = new BrowserWindow({ + width: 800, + height: 600, + show: false, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + devTools: is.dev + } + }) + newWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => { + const headers = { + ...details.requestHeaders, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + callback({ requestHeaders: headers }) + }) + this.searchWindows[uid] = newWindow + newWindow.on('closed', () => { + delete this.searchWindows[uid] + }) + return newWindow + } + + public async openSearchWindow(uid: string): Promise { + await this.createNewSearchWindow(uid) + } + + public async closeSearchWindow(uid: string): Promise { + const window = this.searchWindows[uid] + if (window) { + window.close() + delete this.searchWindows[uid] + } + } + + public async openUrlInSearchWindow(uid: string, url: string): Promise { + let window = this.searchWindows[uid] + if (window) { + await window.loadURL(url) + } else { + window = await this.createNewSearchWindow(uid) + await window.loadURL(url) + } + + // Get the page content after loading the URL + // Wait for the page to fully load before getting the content + await new Promise((resolve) => { + const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout + window.webContents.once('did-finish-load', () => { + clearTimeout(loadTimeout) + // Small delay to ensure JavaScript has executed + setTimeout(resolve, 500) + }) + }) + + // Get the page content after ensuring it's fully loaded + const content = await window.webContents.executeJavaScript('document.documentElement.outerHTML') + return content + } +} + +export const searchService = SearchService.getInstance() diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 89c9650348..5dea34b91e 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -17,7 +17,6 @@ export class WindowService { private mainWindow: BrowserWindow | null = null private miniWindow: BrowserWindow | null = null private isPinnedMiniWindow: boolean = false - private wasFullScreen: boolean = false //hacky-fix: store the focused status of mainWindow before miniWindow shows //to restore the focus status when miniWindow hides private wasMainWindowFocused: boolean = false @@ -41,7 +40,8 @@ export class WindowService { const mainWindowState = windowStateKeeper({ defaultWidth: 1080, - defaultHeight: 670 + defaultHeight: 670, + fullScreen: false }) const theme = configManager.getTheme() @@ -53,7 +53,7 @@ export class WindowService { height: mainWindowState.height, minWidth: 1080, minHeight: 600, - show: false, // 初始不显示 + show: false, autoHideMenuBar: true, transparent: isMac, vibrancy: 'sidebar', @@ -138,12 +138,10 @@ export class WindowService { // 处理全屏相关事件 mainWindow.on('enter-full-screen', () => { - this.wasFullScreen = true mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true) }) mainWindow.on('leave-full-screen', () => { - this.wasFullScreen = false mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false) }) @@ -275,16 +273,6 @@ export class WindowService { } //上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况 - // 如果是Windows或Linux,且处于全屏状态,则退出应用 - if (this.wasFullScreen) { - if (isWin || isLinux) { - return app.quit() - } else { - event.preventDefault() - mainWindow.setFullScreen(false) - return - } - } event.preventDefault() mainWindow.hide() @@ -316,16 +304,29 @@ export class WindowService { this.mainWindow.restore() return } - //[macOS] Known Issue - // setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows) - // AppleScript may be a solution, but it's not worth - // [Linux] Known Issue - // setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态 - // 因此在 Linux 环境下不执行这两行代码 + /** + * About setVisibleOnAllWorkspaces + * + * [macOS] Known Issue + * setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows) + * AppleScript may be a solution, but it's not worth + * + * [Linux] Known Issue + * setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态 + * 因此在 Linux 环境下不执行这两行代码 + */ if (!isLinux) { this.mainWindow.setVisibleOnAllWorkspaces(true) } + + //[macOS] After being closed in fullscreen, the fullscreen behavior will become strange when window shows again + // So we need to set it to FALSE explicitly. + // althougle other platforms don't have the issue, but it's a good practice to do so + if (this.mainWindow.isFullScreen()) { + this.mainWindow.setFullScreen(false) + } + this.mainWindow.show() this.mainWindow.focus() if (!isLinux) { @@ -338,7 +339,9 @@ export class WindowService { public toggleMainWindow() { // should not toggle main window when in full screen - if (this.wasFullScreen) { + // but if the main window is close to tray when it's in full screen, we can show it again + // (it's a bug in macos, because we can close the window when it's in full screen, and the state will be remained) + if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) { return } @@ -392,7 +395,8 @@ export class WindowService { //miniWindow should show in current desktop this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) //make miniWindow always on top of fullscreen apps with level set - this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1) + //[mac] level higher than 'floating' will cover the pinyin input method + this.miniWindow.setAlwaysOnTop(true, 'floating') this.miniWindow.on('ready-to-show', () => { if (isPreload) { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 9e63498cf4..a1cd1c0c98 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -175,6 +175,11 @@ declare global { decryptToken: (token: string) => Promise<{ username: string; access_token: string }> getDirectoryContents: (token: string, path: string) => Promise } + searchService: { + openSearchWindow: (uid: string) => Promise + closeSearchWindow: (uid: string) => Promise + openUrlInSearchWindow: (uid: string, url: string) => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 33db085287..a8dcf9d71f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -169,6 +169,11 @@ const api = { decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token), getDirectoryContents: (token: string, path: string) => ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path) + }, + searchService: { + openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid), + closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid), + openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url) } } diff --git a/src/renderer/src/components/Popups/UserPopup.tsx b/src/renderer/src/components/Popups/UserPopup.tsx index 0ad2195760..9bc0d6167f 100644 --- a/src/renderer/src/components/Popups/UserPopup.tsx +++ b/src/renderer/src/components/Popups/UserPopup.tsx @@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime' import { setUserName } from '@renderer/store/settings' import { compressImage, isEmoji } from '@renderer/utils' import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd' -import { useState } from 'react' +import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 8f2170e567..7291a37b5b 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -234,6 +234,10 @@ export function isFunctionCallingModel(model: Model): boolean { return false } + if (model.provider === 'qiniu') { + return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id) + } + if (['deepseek', 'anthropic'].includes(model.provider)) { return true } @@ -500,12 +504,6 @@ export const SYSTEM_MODELS: Record = { name: 'text-embedding-3-small', group: '嵌入模型' }, - { - id: 'text-embedding-3-small', - provider: 'o3', - name: 'text-embedding-3-small', - group: '嵌入模型' - }, { id: 'text-embedding-ada-002', provider: 'o3', @@ -2015,7 +2013,56 @@ export const SYSTEM_MODELS: Record = { group: 'Voyage Rerank V2' } ], - qiniu: [] + qiniu: [ + { + id: 'deepseek-r1', + provider: 'qiniu', + name: 'DeepSeek R1', + group: 'DeepSeek' + }, + { + id: 'deepseek-r1-search', + provider: 'qiniu', + name: 'DeepSeek R1 Search', + group: 'DeepSeek' + }, + { + id: 'deepseek-r1-32b', + provider: 'qiniu', + name: 'DeepSeek R1 32B', + group: 'DeepSeek' + }, + { + id: 'deepseek-v3', + provider: 'qiniu', + name: 'DeepSeek V3', + group: 'DeepSeek' + }, + { + id: 'deepseek-v3-search', + provider: 'qiniu', + name: 'DeepSeek V3 Search', + group: 'DeepSeek' + }, + { + id: 'deepseek-v3-tool', + provider: 'qiniu', + name: 'DeepSeek V3 Tool', + group: 'DeepSeek' + }, + { + id: 'qwq-32b', + provider: 'qiniu', + name: 'QWQ 32B', + group: 'Qwen' + }, + { + id: 'qwen2.5-72b-instruct', + provider: 'qiniu', + name: 'Qwen2.5 72B Instruct', + group: 'Qwen' + } + ] } export const TEXT_TO_IMAGES_MODELS = [ diff --git a/src/renderer/src/config/webSearchProviders.ts b/src/renderer/src/config/webSearchProviders.ts index 7901adee7a..1fc9638169 100644 --- a/src/renderer/src/config/webSearchProviders.ts +++ b/src/renderer/src/config/webSearchProviders.ts @@ -31,5 +31,20 @@ export const WEB_SEARCH_PROVIDER_CONFIG = { official: 'https://exa.ai', apiKey: 'https://dashboard.exa.ai/api-keys' } + }, + 'local-google': { + websites: { + official: 'https://www.google.com' + } + }, + 'local-bing': { + websites: { + official: 'https://www.bing.com' + } + }, + 'local-baidu': { + websites: { + official: 'https://www.baidu.com' + } } } diff --git a/src/renderer/src/hooks/useWebSearchProviders.ts b/src/renderer/src/hooks/useWebSearchProviders.ts index e2f0673f89..dc9c0ee084 100644 --- a/src/renderer/src/hooks/useWebSearchProviders.ts +++ b/src/renderer/src/hooks/useWebSearchProviders.ts @@ -25,11 +25,20 @@ export const useDefaultWebSearchProvider = () => { export const useWebSearchProviders = () => { const providers = useAppSelector((state) => state.websearch.providers) + const dispatch = useAppDispatch() return { providers, - updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers)) + updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers)), + addWebSearchProvider: (provider: WebSearchProvider) => { + // Check if provider exists + const exists = providers.some((p) => p.id === provider.id) + if (!exists) { + // Use the existing update action to add the new provider + dispatch(updateWebSearchProviders([...providers, provider])) + } + } } } @@ -37,6 +46,7 @@ export const useWebSearchProvider = (id: string) => { const providers = useAppSelector((state) => state.websearch.providers) const provider = providers.find((provider) => provider.id === id) const dispatch = useAppDispatch() + if (!provider) { throw new Error(`Web search provider with id ${id} not found`) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c642079e20..2103c98429 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1308,7 +1308,9 @@ }, "title": "Web Search", "overwrite": "Override search service", - "overwrite_tooltip": "Force use search service instead of LLM" + "overwrite_tooltip": "Force use search service instead of LLM", + "apikey": "API key", + "free": "Free" }, "quickPhrase": { "title": "Quick Phrases", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 02e025e1ee..17488f64b5 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1307,7 +1307,9 @@ }, "title": "ウェブ検索", "overwrite": "サービス検索を上書き", - "overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する" + "overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する", + "apikey": "API キー", + "free": "無料" }, "general.auto_check_update.title": "自動更新チェックを有効にする", "quickPhrase": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 9c9419bab2..ee67104483 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1307,7 +1307,9 @@ }, "title": "Поиск в Интернете", "overwrite": "Переопределить поставщика поиска", - "overwrite_tooltip": "Использовать поставщика поиска вместо LLM" + "overwrite_tooltip": "Использовать поставщика поиска вместо LLM", + "apikey": "Ключ API", + "free": "Бесплатно" }, "general.auto_check_update.title": "Включить автоматическую проверку обновлений", "quickPhrase": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b9a9f6fe98..160dcc55c9 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1308,7 +1308,9 @@ "description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力", "title": "Tavily" }, - "title": "网络搜索" + "title": "网络搜索", + "apikey": "API 密钥", + "free": "免费" }, "quickPhrase": { "title": "快捷短语", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index b50f72e3f6..bfc75a11ab 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1307,7 +1307,9 @@ }, "title": "網路搜尋", "overwrite": "覆蓋搜尋服務商", - "overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋" + "overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋", + "apikey": "API 金鑰", + "free": "免費" }, "general.auto_check_update.title": "啟用自動更新檢查", "quickPhrase": { diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index 07fc8c5f2f..7ab60fb466 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -1,23 +1,53 @@ +import { Center, VStack } from '@renderer/components/Layout' import { TopView } from '@renderer/components/TopView' +import ImageStorage from '@renderer/services/ImageStorage' import { Provider, ProviderType } from '@renderer/types' -import { Divider, Form, Input, Modal, Select } from 'antd' -import { useState } from 'react' +import { compressImage } from '@renderer/utils' +import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' interface Props { provider?: Provider - resolve: (result: { name: string; type: ProviderType }) => void + resolve: (result: { name: string; type: ProviderType; logo?: string; logoFile?: File }) => void } const PopupContainer: React.FC = ({ provider, resolve }) => { const [open, setOpen] = useState(true) const [name, setName] = useState(provider?.name || '') const [type, setType] = useState(provider?.type || 'openai') + const [logo, setLogo] = useState(null) + const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() - const onOk = () => { + useEffect(() => { + if (provider?.id) { + const loadLogo = async () => { + try { + const logoData = await ImageStorage.get(`provider-${provider.id}`) + if (logoData) { + setLogo(logoData) + } + } catch (error) { + console.error('Failed to load logo', error) + } + } + loadLogo() + } + }, [provider]) + + const onOk = async () => { setOpen(false) - resolve({ name, type }) + + // 返回结果,但不包含文件对象,因为文件已经直接保存到 ImageStorage + const result = { + name, + type, + logo: logo || undefined + } + + resolve(result) } const onCancel = () => { @@ -26,11 +56,94 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { } const onClose = () => { - resolve({ name, type }) + resolve({ name, type, logo: logo || undefined }) } const buttonDisabled = name.length === 0 + const handleReset = async () => { + try { + setLogo(null) + + if (provider?.id) { + await ImageStorage.set(`provider-${provider.id}`, '') + } + + setDropdownOpen(false) + } catch (error: any) { + window.message.error(error.message) + } + } + + const getInitials = () => { + return name.charAt(0).toUpperCase() || 'P' + } + + const items = [ + { + key: 'upload', + label: ( +
+ {}} + accept="image/png, image/jpeg, image/gif" + itemRender={() => null} + maxCount={1} + onChange={async ({ file }) => { + try { + const _file = file.originFileObj as File + let logoData: string | Blob + + if (_file.type === 'image/gif') { + logoData = _file + } else { + logoData = await compressImage(_file) + } + + if (provider?.id) { + if (logoData instanceof Blob && !(logoData instanceof File)) { + const fileFromBlob = new File([logoData], 'logo.png', { type: logoData.type }) + await ImageStorage.set(`provider-${provider.id}`, fileFromBlob) + } else { + await ImageStorage.set(`provider-${provider.id}`, logoData) + } + const savedLogo = await ImageStorage.get(`provider-${provider.id}`) + setLogo(savedLogo) + } else { + // 临时保存在内存中,等创建 provider 后会在调用方保存 + const tempUrl = await new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.readAsDataURL(logoData) + }) + setLogo(tempUrl) + } + + setDropdownOpen(false) + } catch (error: any) { + window.message.error(error.message) + } + }}> + {t('settings.general.image_upload')} + +
+ ) + }, + { + key: 'reset', + label: ( +
{ + e.stopPropagation() + handleReset() + }}> + {t('settings.general.avatar.reset')} +
+ ) + } + ] + return ( = ({ provider, resolve }) => { title={t('settings.provider.add.title')} okButtonProps={{ disabled: buttonDisabled }}> + +
+ + { + setDropdownOpen(visible) + }}> + {logo ? : {getInitials()}} + + +
+
= ({ provider, resolve }) => { ) } +const ProviderLogo = styled.img` + cursor: pointer; + width: 60px; + height: 60px; + border-radius: 12px; + object-fit: contain; + transition: opacity 0.3s ease; + background-color: var(--color-background-soft); + padding: 5px; + border: 0.5px solid var(--color-border); + &:hover { + opacity: 0.8; + } +` + +const ProviderInitialsLogo = styled.div` + cursor: pointer; + width: 60px; + height: 60px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + font-weight: 500; + transition: opacity 0.3s ease; + background-color: var(--color-background-soft); + border: 0.5px solid var(--color-border); + &:hover { + opacity: 0.8; + } +` + export default class AddProviderPopup { static topviewId = 0 static hide() { TopView.hide('AddProviderPopup') } static show(provider?: Provider) { - return new Promise<{ name: string; type: ProviderType }>((resolve) => { + return new Promise<{ name: string; type: ProviderType; logo?: string; logoFile?: File }>((resolve) => { TopView.show( = ({ providerId, modelStatuses = [], s const isChecking = modelStatus?.checking === true return ( - {model?.name?.[0]?.toUpperCase()}, - name: ( - - - {model.id} - + + + + {model?.name?.[0]?.toUpperCase()} + + + - {model.name} - - - - ), - ext: '.model', - actions: ( - - {renderLatencyText(modelStatus)} - {renderStatusIndicator(modelStatus)} -