diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 3a52c4ddcb..2d81d04582 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -80,13 +80,13 @@ export default defineConfig({ build: { rollupOptions: { input: { - index: resolve('src/renderer/index.html'), - }, + index: resolve('src/renderer/index.html') + } }, // 复制ASR服务器文件 assetsInlineLimit: 0, // 确保复制assets目录下的所有文件 - copyPublicDir: true, + copyPublicDir: true } } }) 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/public/asr-server/server.js b/public/asr-server/server.js index b9e4bec168..c09927780e 100644 --- a/public/asr-server/server.js +++ b/public/asr-server/server.js @@ -39,8 +39,8 @@ wss.on('connection', (ws) => { console.log('[Server] Browser identified and connected') // Notify Electron that the browser is ready if (electronConnection && electronConnection.readyState === WebSocket.OPEN) { - electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })); - console.log('[Server] Sent browser_ready status to Electron'); + electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })) + console.log('[Server] Sent browser_ready status to Electron') } // Notify Electron if it's already connected if (electronConnection) { @@ -66,8 +66,8 @@ wss.on('connection', (ws) => { console.log('[Server] Electron identified and connected') // If browser is already connected when Electron connects, notify Electron immediately if (browserConnection && browserConnection.readyState === WebSocket.OPEN) { - electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })); - console.log('[Server] Sent initial browser_ready status to Electron'); + electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })) + console.log('[Server] Sent initial browser_ready status to Electron') } ws.on('close', () => { console.log('[Server] Electron disconnected') 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 47c13b0ef7..7da159df35 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,4 @@ import fs from 'node:fs' -import { spawn, ChildProcess } from 'node:child_process' import path from 'node:path' import { isMac, isWin } from '@main/constant' @@ -23,6 +22,8 @@ import mcpService from './services/MCPService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' +import { asrServerService } from './services/ASRServerService' +import { searchService } from './services/SearchService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' @@ -31,8 +32,7 @@ import { decrypt, encrypt } from './utils/aes' import { getConfigDir, getFilesDir } from './utils/file' import { compress, decompress } from './utils/zip' -// 存储ASR服务器进程 -let asrServerProcess: ChildProcess | null = null + const fileManager = new FileStorage() const backupManager = new BackupManager() @@ -297,102 +297,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { NutstoreService.getDirectoryContents(token, path) ) - // 启动ASR服务器 - ipcMain.handle('start-asr-server', async () => { - try { - if (asrServerProcess) { - return { success: true, pid: asrServerProcess.pid } - } - - // 获取服务器文件路径 - console.log('App path:', app.getAppPath()) - // 在开发环境和生产环境中使用不同的路径 - let serverPath = '' - let isExeFile = false - - // 首先检查是否有打包后的exe文件 - const exePath = path.join(app.getAppPath(), 'resources', 'cherry-asr-server.exe') - if (fs.existsSync(exePath)) { - serverPath = exePath - isExeFile = true - console.log('检测到打包后的exe文件:', serverPath) - } else if (process.env.NODE_ENV === 'development') { - // 开发环境 - serverPath = path.join(app.getAppPath(), 'src', 'renderer', 'src', 'assets', 'asr-server', 'server.js') - } else { - // 生产环境 - serverPath = path.join(app.getAppPath(), 'public', 'asr-server', 'server.js') - } - console.log('ASR服务器路径:', serverPath) - - // 检查文件是否存在 - if (!fs.existsSync(serverPath)) { - return { success: false, error: '服务器文件不存在' } - } - - // 启动服务器进程 - if (isExeFile) { - // 如果是exe文件,直接启动 - asrServerProcess = spawn(serverPath, [], { - stdio: 'pipe', - detached: false - }) - } else { - // 如果是js文件,使用node启动 - asrServerProcess = spawn('node', [serverPath], { - stdio: 'pipe', - detached: false - }) - } - - // 处理服务器输出 - asrServerProcess.stdout?.on('data', (data) => { - console.log(`[ASR Server] ${data.toString()}`) - }) - - asrServerProcess.stderr?.on('data', (data) => { - console.error(`[ASR Server Error] ${data.toString()}`) - }) - - // 处理服务器退出 - asrServerProcess.on('close', (code) => { - console.log(`[ASR Server] 进程退出,退出码: ${code}`) - asrServerProcess = null - }) - - // 等待一段时间确保服务器启动 - await new Promise(resolve => setTimeout(resolve, 1000)) - - return { success: true, pid: asrServerProcess.pid } - } catch (error) { - console.error('启动ASR服务器失败:', error) - return { success: false, error: (error as Error).message } - } + // 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('stop-asr-server', async (_event, pid) => { - try { - if (!asrServerProcess) { - return { success: true } - } - - // 检查PID是否匹配 - if (asrServerProcess.pid !== pid) { - console.warn(`请求停止的PID (${pid}) 与当前运行的ASR服务器PID (${asrServerProcess.pid}) 不匹配`) - } - - // 杀死进程 - asrServerProcess.kill() - - // 等待一段时间确保进程已经退出 - await new Promise(resolve => setTimeout(resolve, 500)) - - asrServerProcess = null - return { success: true } - } catch (error) { - console.error('停止ASR服务器失败:', error) - return { success: false, error: (error as Error).message } - } - }) + // 注册ASR服务器IPC处理程序 + asrServerService.registerIpcHandlers() } diff --git a/src/main/services/ASRServerService.ts b/src/main/services/ASRServerService.ts new file mode 100644 index 0000000000..b9ecd0c505 --- /dev/null +++ b/src/main/services/ASRServerService.ts @@ -0,0 +1,135 @@ +import { ChildProcess, spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +import { IpcChannel } from '@shared/IpcChannel' +import { app, ipcMain } from 'electron' +import log from 'electron-log' + +/** + * ASR服务器服务,用于管理ASR服务器进程 + */ +class ASRServerService { + private asrServerProcess: ChildProcess | null = null + + /** + * 注册IPC处理程序 + */ + public registerIpcHandlers(): void { + // 启动ASR服务器 + ipcMain.handle(IpcChannel.Asr_StartServer, this.startServer.bind(this)) + + // 停止ASR服务器 + ipcMain.handle(IpcChannel.Asr_StopServer, this.stopServer.bind(this)) + } + + /** + * 启动ASR服务器 + * @returns Promise<{success: boolean, pid?: number, error?: string}> + */ + private async startServer(): Promise<{success: boolean, pid?: number, error?: string}> { + try { + if (this.asrServerProcess) { + return { success: true, pid: this.asrServerProcess.pid } + } + + // 获取服务器文件路径 + log.info('App path:', app.getAppPath()) + // 在开发环境和生产环境中使用不同的路径 + let serverPath = '' + let isExeFile = false + + // 首先检查是否有打包后的exe文件 + const exePath = path.join(app.getAppPath(), 'resources', 'cherry-asr-server.exe') + if (fs.existsSync(exePath)) { + serverPath = exePath + isExeFile = true + log.info('检测到打包后的exe文件:', serverPath) + } else if (process.env.NODE_ENV === 'development') { + // 开发环境 + serverPath = path.join(app.getAppPath(), 'src', 'renderer', 'src', 'assets', 'asr-server', 'server.js') + } else { + // 生产环境 + serverPath = path.join(app.getAppPath(), 'public', 'asr-server', 'server.js') + } + log.info('ASR服务器路径:', serverPath) + + // 检查文件是否存在 + if (!fs.existsSync(serverPath)) { + return { success: false, error: '服务器文件不存在' } + } + + // 启动服务器进程 + if (isExeFile) { + // 如果是exe文件,直接启动 + this.asrServerProcess = spawn(serverPath, [], { + stdio: 'pipe', + detached: false + }) + } else { + // 如果是js文件,使用node启动 + this.asrServerProcess = spawn('node', [serverPath], { + stdio: 'pipe', + detached: false + }) + } + + // 处理服务器输出 + this.asrServerProcess.stdout?.on('data', (data) => { + log.info(`[ASR Server] ${data.toString()}`) + }) + + this.asrServerProcess.stderr?.on('data', (data) => { + log.error(`[ASR Server Error] ${data.toString()}`) + }) + + // 处理服务器退出 + this.asrServerProcess.on('close', (code) => { + log.info(`[ASR Server] 进程退出,退出码: ${code}`) + this.asrServerProcess = null + }) + + // 等待一段时间确保服务器启动 + await new Promise(resolve => setTimeout(resolve, 1000)) + + return { success: true, pid: this.asrServerProcess.pid } + } catch (error) { + log.error('启动ASR服务器失败:', error) + return { success: false, error: (error as Error).message } + } + } + + /** + * 停止ASR服务器 + * @param _event IPC事件 + * @param pid 进程ID + * @returns Promise<{success: boolean, error?: string}> + */ + private async stopServer(_event: Electron.IpcMainInvokeEvent, pid?: number): Promise<{success: boolean, error?: string}> { + try { + if (!this.asrServerProcess) { + return { success: true } + } + + // 检查PID是否匹配 + if (pid && this.asrServerProcess.pid !== pid) { + log.warn(`请求停止的PID (${pid}) 与当前运行的ASR服务器PID (${this.asrServerProcess.pid}) 不匹配`) + } + + // 杀死进程 + this.asrServerProcess.kill() + + // 等待一段时间确保进程已经退出 + await new Promise(resolve => setTimeout(resolve, 500)) + + this.asrServerProcess = null + return { success: true } + } catch (error) { + log.error('停止ASR服务器失败:', error) + return { success: false, error: (error as Error).message } + } + } +} + +// 导出单例实例 +export const asrServerService = new ASRServerService() 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..b2b72f5b71 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -169,6 +169,15 @@ 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) + }, + asrServer: { + startServer: () => ipcRenderer.invoke(IpcChannel.Asr_StartServer), + stopServer: (pid: number) => ipcRenderer.invoke(IpcChannel.Asr_StopServer, pid) } } diff --git a/src/renderer/src/assets/asr-server/server.js b/src/renderer/src/assets/asr-server/server.js index 19479cfbbb..7b3d77d9f2 100644 --- a/src/renderer/src/assets/asr-server/server.js +++ b/src/renderer/src/assets/asr-server/server.js @@ -9,33 +9,33 @@ const port = 8080 // Define the port // 获取index.html文件的路径 function getIndexHtmlPath() { // 在开发环境中,直接使用相对路径 - const devPath = path.join(__dirname, 'index.html'); + const devPath = path.join(__dirname, 'index.html') // 在pkg打包后,文件会被包含在可执行文件中 // 使用process.pkg检测是否是打包环境 if (process.pkg) { // 在打包环境中,使用绝对路径 - return path.join(path.dirname(process.execPath), 'index.html'); + return path.join(path.dirname(process.execPath), 'index.html') } // 如果文件存在,返回开发路径 try { if (require('fs').existsSync(devPath)) { - return devPath; + return devPath } } catch (e) { - console.error('Error checking file existence:', e); + console.error('Error checking file existence:', e) } // 如果都不存在,尝试使用当前目录 - return path.join(process.cwd(), 'index.html'); + return path.join(process.cwd(), 'index.html') } // 提供网页给浏览器 app.get('/', (req, res) => { - const indexPath = getIndexHtmlPath(); - console.log(`Serving index.html from: ${indexPath}`); - res.sendFile(indexPath); + const indexPath = getIndexHtmlPath() + console.log(`Serving index.html from: ${indexPath}`) + res.sendFile(indexPath) }) const server = http.createServer(app) @@ -65,8 +65,8 @@ wss.on('connection', (ws) => { console.log('[Server] Browser identified and connected') // Notify Electron that the browser is ready if (electronConnection && electronConnection.readyState === WebSocket.OPEN) { - electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })); - console.log('[Server] Sent browser_ready status to Electron'); + electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })) + console.log('[Server] Sent browser_ready status to Electron') } // Notify Electron if it's already connected if (electronConnection) { @@ -92,8 +92,8 @@ wss.on('connection', (ws) => { console.log('[Server] Electron identified and connected') // If browser is already connected when Electron connects, notify Electron immediately if (browserConnection && browserConnection.readyState === WebSocket.OPEN) { - electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })); - console.log('[Server] Sent initial browser_ready status to Electron'); + electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' })) + console.log('[Server] Sent initial browser_ready status to Electron') } ws.on('close', () => { console.log('[Server] Electron disconnected') diff --git a/src/renderer/src/components/ASRButton.tsx b/src/renderer/src/components/ASRButton.tsx index 67813a9af1..89ad0347d9 100644 --- a/src/renderer/src/components/ASRButton.tsx +++ b/src/renderer/src/components/ASRButton.tsx @@ -105,7 +105,14 @@ const ASRButton: FC = ({ onTranscribed, disabled = false, style }) => { } return ( - + = ({ onTranscribed, disabled = false, style }) => { onDoubleClick={handleCancel} disabled={disabled || isProcessing || (isCountingDown && countdown > 0)} style={style} - className={isCountingDown ? 'counting-down' : ''} - > - {isCountingDown && ( - {countdown} - )} + className={isCountingDown ? 'counting-down' : ''}> + {isCountingDown && {countdown}} {isCountingDown && ( @@ -151,9 +155,15 @@ const CountdownIndicator = styled.div` z-index: 10; @keyframes pulse { - 0% { opacity: 0.7; } - 50% { opacity: 1; } - 100% { opacity: 0.7; } + 0% { + opacity: 0.7; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.7; + } } &:after { @@ -176,9 +186,15 @@ const CountdownNumber = styled.span` animation: zoom 1s infinite; @keyframes zoom { - 0% { transform: scale(0.8); } - 50% { transform: scale(1.2); } - 100% { transform: scale(0.8); } + 0% { + transform: scale(0.8); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(0.8); + } } ` 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/components/TTSButton.tsx b/src/renderer/src/components/TTSButton.tsx index 36b856a434..58e4c2039a 100644 --- a/src/renderer/src/components/TTSButton.tsx +++ b/src/renderer/src/components/TTSButton.tsx @@ -1,9 +1,9 @@ import { SoundOutlined } from '@ant-design/icons' +import TTSService from '@renderer/services/TTSService' +import { Message } from '@renderer/types' import { Button, Tooltip } from 'antd' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import TTSService from '@renderer/services/TTSService' -import { Message } from '@renderer/types' interface TTSButtonProps { message: Message @@ -24,7 +24,7 @@ const TTSButton: React.FC = ({ message, className }) => { setIsSpeaking(true) try { await TTSService.speakFromMessage(message) - + // 监听播放结束 const checkPlayingStatus = () => { if (!TTSService.isCurrentlyPlaying()) { @@ -32,9 +32,9 @@ const TTSButton: React.FC = ({ message, className }) => { clearInterval(checkInterval) } } - + const checkInterval = setInterval(checkPlayingStatus, 500) - + // 安全机制,确保即使出错也会重置状态 setTimeout(() => { if (isSpeaking) { 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/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index a6890bac0c..460e24eb66 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -13,8 +13,8 @@ import { ThunderboltOutlined, TranslationOutlined } from '@ant-design/icons' -import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import ASRButton from '@renderer/components/ASRButton' +import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import TranslateButton from '@renderer/components/TranslateButton' import { isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' @@ -1009,19 +1009,21 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = - { - // 如果是空字符串,不做任何处理 - if (!transcribedText) return + { + // 如果是空字符串,不做任何处理 + if (!transcribedText) return - // 将识别的文本添加到当前输入框 - setText((prevText) => { - // 如果当前有文本,添加空格后再添加识别的文本 - if (prevText.trim()) { - return prevText + ' ' + transcribedText - } - return transcribedText - }) - }} /> + // 将识别的文本添加到当前输入框 + setText((prevText) => { + // 如果当前有文本,添加空格后再添加识别的文本 + if (prevText.trim()) { + return prevText + ' ' + transcribedText + } + return transcribedText + }) + }} + /> {loading && ( diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 0acbe80234..e5189732da 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -22,8 +22,8 @@ import { TranslateLanguageOptions } from '@renderer/config/translate' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService' -import TTSService from '@renderer/services/TTSService' import { translateText } from '@renderer/services/TranslateService' +import TTSService from '@renderer/services/TTSService' import { RootState } from '@renderer/store' import type { Message, Model } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types' @@ -165,24 +165,31 @@ const MessageMenubar: FC = (props) => { content: content.trim(), metadata: { ...message.metadata, - generateImage: imageUrls.length > 0 ? { - type: 'url', - images: imageUrls - } : undefined + generateImage: + imageUrls.length > 0 + ? { + type: 'url', + images: imageUrls + } + : undefined } }) - resendMessage && handleResendUserMessage({ - ...message, - content: content.trim(), - metadata: { - ...message.metadata, - generateImage: imageUrls.length > 0 ? { - type: 'url', - images: imageUrls - } : undefined - } - }) + resendMessage && + handleResendUserMessage({ + ...message, + content: content.trim(), + metadata: { + ...message.metadata, + generateImage: + imageUrls.length > 0 + ? { + type: 'url', + images: imageUrls + } + : undefined + } + }) } }, [message, editMessage, handleResendUserMessage, t]) diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 4924023913..9bd50cacd9 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -3,7 +3,6 @@ import { LOAD_MORE_COUNT } from '@renderer/config/constant' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMessageOperations, useTopicLoading, useTopicMessages } from '@renderer/hooks/useMessageOperations' - import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic' diff --git a/src/renderer/src/pages/home/Messages/TTSStopButton.tsx b/src/renderer/src/pages/home/Messages/TTSStopButton.tsx index 3b586f7e75..82b6f921eb 100644 --- a/src/renderer/src/pages/home/Messages/TTSStopButton.tsx +++ b/src/renderer/src/pages/home/Messages/TTSStopButton.tsx @@ -1,9 +1,9 @@ -import { useTranslation } from 'react-i18next' import { SoundOutlined } from '@ant-design/icons' -import { Tooltip } from 'antd' -import styled from 'styled-components' -import { useCallback, useEffect, useState } from 'react' import TTSService from '@renderer/services/TTSService' +import { Tooltip } from 'antd' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' const TTSStopButton: React.FC = () => { const { t } = useTranslation() @@ -27,7 +27,7 @@ const TTSStopButton: React.FC = () => { TTSService.stop() // 等待一下,确保播放已经完全停止 - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) // 再次检查并停止,确保强制停止 if (TTSService.isCurrentlyPlaying()) { 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)} - @@ -160,27 +148,33 @@ const ASRSettings: FC = () => { type="primary" icon={} onClick={() => ASRServerService.openServerPage()} - disabled={!asrEnabled || !isServerRunning} - > + disabled={!asrEnabled || !isServerRunning}> {t('settings.asr.open_browser')} @@ -239,27 +233,27 @@ const Alert = styled.div<{ type: 'info' | 'warning' | 'error' | 'success' }>` props.type === 'info' ? 'var(--color-info-bg)' : props.type === 'warning' - ? 'var(--color-warning-bg)' - : props.type === 'error' - ? 'var(--color-error-bg)' - : 'var(--color-success-bg)'}; + ? 'var(--color-warning-bg)' + : props.type === 'error' + ? 'var(--color-error-bg)' + : 'var(--color-success-bg)'}; border: 1px solid ${(props) => props.type === 'info' ? 'var(--color-info-border)' : props.type === 'warning' - ? 'var(--color-warning-border)' - : props.type === 'error' - ? 'var(--color-error-border)' - : 'var(--color-success-border)'}; + ? 'var(--color-warning-border)' + : props.type === 'error' + ? 'var(--color-error-border)' + : 'var(--color-success-border)'}; color: ${(props) => props.type === 'info' ? 'var(--color-info-text)' : props.type === 'warning' - ? 'var(--color-warning-text)' - : props.type === 'error' - ? 'var(--color-error-text)' - : 'var(--color-success-text)'}; + ? 'var(--color-warning-text)' + : props.type === 'error' + ? 'var(--color-error-text)' + : 'var(--color-success-text)'}; ` const BrowserTip = styled.div` diff --git a/src/renderer/src/pages/settings/TTSSettings/TTSSettings.tsx b/src/renderer/src/pages/settings/TTSSettings/TTSSettings.tsx index 29b01c12f0..76458971be 100644 --- a/src/renderer/src/pages/settings/TTSSettings/TTSSettings.tsx +++ b/src/renderer/src/pages/settings/TTSSettings/TTSSettings.tsx @@ -17,7 +17,7 @@ import { setTtsServiceType, setTtsVoice } from '@renderer/store/settings' -import { Button, Form, Input, message, Select, Space, Switch, Tag, Tabs } from 'antd' +import { Button, Form, Input, message, Select, Space, Switch, Tabs, Tag } from 'antd' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -32,7 +32,6 @@ import { SettingRowTitle, SettingTitle } from '..' - import ASRSettings from './ASRSettings' const CustomVoiceInput = styled.div` @@ -462,7 +461,9 @@ const TTSSettings: FC = () => { console.log('强制刷新TTS服务类型:', currentType) dispatch(setTtsServiceType(currentType)) window.message.success({ - content: t('settings.tts.service_type.refreshed', { defaultValue: '已刷新TTS服务类型设置' }), + content: t('settings.tts.service_type.refreshed', { + defaultValue: '已刷新TTS服务类型设置' + }), key: 'tts-refresh' }) }} @@ -528,7 +529,9 @@ const TTSSettings: FC = () => { title={t('settings.tts.edge_voice.refresh')} /> - {availableVoices.length === 0 && {t('settings.tts.edge_voice.loading')}} + {availableVoices.length === 0 && ( + {t('settings.tts.edge_voice.loading')} + )} )} diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index eb82344a3d..1937a464ae 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -117,7 +117,6 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { - {provider.name} {officialWebsite && webSearchProviderConfig?.websites && ( @@ -156,7 +155,6 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { )} - {hasObjectKey(provider, 'apiHost') && ( <> diff --git a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx index 4c5b8f613b..e00eb785e7 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx @@ -1,6 +1,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import { WebSearchProvider } from '@renderer/types' +import { hasObjectKey } from '@renderer/utils' import { Select } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -17,6 +18,8 @@ const WebSearchSettings: FC = () => { const [selectedProvider, setSelectedProvider] = useState(defaultProvider) const { theme: themeMode } = useTheme() + const isLocalProvider = selectedProvider?.id.startsWith('local') + function updateSelectedWebSearchProvider(providerId: string) { const provider = providers.find((p) => p.id === providerId) if (!provider) { @@ -39,14 +42,19 @@ const WebSearchSettings: FC = () => { style={{ width: '200px' }} onChange={(value: string) => updateSelectedWebSearchProvider(value)} placeholder={t('settings.websearch.search_provider_placeholder')} - options={providers.map((p) => ({ value: p.id, label: p.name }))} + options={providers.map((p) => ({ + value: p.id, + label: `${p.name} (${hasObjectKey(p, 'apiKey') ? t('settings.websearch.apikey') : t('settings.websearch.free')})` + }))} /> - - {selectedProvider && } - + {!isLocalProvider && ( + + {selectedProvider && } + + )} diff --git a/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts index 8e1c311a8d..8359e037b5 100644 --- a/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/BaseWebSearchProvider.ts @@ -2,7 +2,7 @@ import { WebSearchProvider, WebSearchResponse } from '@renderer/types' export default abstract class BaseWebSearchProvider { // @ts-ignore this - private provider: WebSearchProvider + protected provider: WebSearchProvider protected apiKey: string constructor(provider: WebSearchProvider) { diff --git a/src/renderer/src/providers/WebSearchProvider/LocalBaiduProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalBaiduProvider.ts new file mode 100644 index 0000000000..cd93c500e3 --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/LocalBaiduProvider.ts @@ -0,0 +1,28 @@ +import LocalSearchProvider, { SearchItem } from './LocalSearchProvider' + +export default class LocalBaiduProvider extends LocalSearchProvider { + protected parseValidUrls(htmlContent: string): SearchItem[] { + const results: SearchItem[] = [] + + try { + // Parse HTML string into a DOM document + const parser = new DOMParser() + const doc = parser.parseFromString(htmlContent, 'text/html') + + const items = doc.querySelectorAll('#content_left .result h3') + items.forEach((item) => { + const node = item.querySelector('a') + if (node) { + results.push({ + title: node.textContent || '', + url: node.href + }) + } + }) + } catch (error) { + console.error('Failed to parse Baidu search HTML:', error) + } + console.log('Parsed Baidu search results:', results) + return results + } +} diff --git a/src/renderer/src/providers/WebSearchProvider/LocalBingProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalBingProvider.ts new file mode 100644 index 0000000000..fe233c469e --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/LocalBingProvider.ts @@ -0,0 +1,27 @@ +import LocalSearchProvider, { SearchItem } from './LocalSearchProvider' + +export default class LocalBingProvider extends LocalSearchProvider { + protected parseValidUrls(htmlContent: string): SearchItem[] { + const results: SearchItem[] = [] + + try { + // Parse HTML string into a DOM document + const parser = new DOMParser() + const doc = parser.parseFromString(htmlContent, 'text/html') + + const items = doc.querySelectorAll('#b_results h2') + items.forEach((item) => { + const node = item.querySelector('a') + if (node) { + results.push({ + title: node.textContent || '', + url: node.href + }) + } + }) + } catch (error) { + console.error('Failed to parse Bing search HTML:', error) + } + return results + } +} diff --git a/src/renderer/src/providers/WebSearchProvider/LocalGoogleProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalGoogleProvider.ts new file mode 100644 index 0000000000..60b3f9aa9c --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/LocalGoogleProvider.ts @@ -0,0 +1,28 @@ +import LocalSearchProvider, { SearchItem } from './LocalSearchProvider' + +export default class LocalGoogleProvider extends LocalSearchProvider { + protected parseValidUrls(htmlContent: string): SearchItem[] { + const results: SearchItem[] = [] + + try { + // Parse HTML string into a DOM document + const parser = new DOMParser() + const doc = parser.parseFromString(htmlContent, 'text/html') + + const items = doc.querySelectorAll('#search .MjjYud') + items.forEach((item) => { + const title = item.querySelector('h3') + const link = item.querySelector('a') + if (title && link) { + results.push({ + title: title.textContent || '', + url: link.href + }) + } + }) + } catch (error) { + console.error('Failed to parse Google search HTML:', error) + } + return results + } +} diff --git a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts index 6c57f34beb..df5c08b989 100644 --- a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts +++ b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts @@ -3,6 +3,9 @@ import { WebSearchProvider } from '@renderer/types' import BaseWebSearchProvider from './BaseWebSearchProvider' import DefaultProvider from './DefaultProvider' import ExaProvider from './ExaProvider' +import LocalBaiduProvider from './LocalBaiduProvider' +import LocalBingProvider from './LocalBingProvider' +import LocalGoogleProvider from './LocalGoogleProvider' import SearxngProvider from './SearxngProvider' import TavilyProvider from './TavilyProvider' @@ -15,7 +18,12 @@ export default class WebSearchProviderFactory { return new SearxngProvider(provider) case 'exa': return new ExaProvider(provider) - + case 'local-google': + return new LocalGoogleProvider(provider) + case 'local-baidu': + return new LocalBaiduProvider(provider) + case 'local-bing': + return new LocalBingProvider(provider) default: return new DefaultProvider(provider) } diff --git a/src/renderer/src/services/ASRServerService.ts b/src/renderer/src/services/ASRServerService.ts index 6c8d77757f..506715a4e8 100644 --- a/src/renderer/src/services/ASRServerService.ts +++ b/src/renderer/src/services/ASRServerService.ts @@ -23,7 +23,7 @@ class ASRServerService { window.message.loading({ content: i18n.t('settings.asr.server.starting'), key: 'asr-server' }) // 使用IPC调用主进程启动服务器 - const result = await window.electron.ipcRenderer.invoke('start-asr-server') + const result = await window.api.asrServer.startServer() if (result.success) { this.isServerRunning = true @@ -65,7 +65,7 @@ class ASRServerService { window.message.loading({ content: i18n.t('settings.asr.server.stopping'), key: 'asr-server' }) // 使用IPC调用主进程停止服务器 - const result = await window.electron.ipcRenderer.invoke('stop-asr-server', this.serverProcess) + const result = await window.api.asrServer.stopServer(this.serverProcess) if (result.success) { this.isServerRunning = false diff --git a/src/renderer/src/services/ASRService.ts b/src/renderer/src/services/ASRService.ts index 260725fdf6..252ecd9b1d 100644 --- a/src/renderer/src/services/ASRService.ts +++ b/src/renderer/src/services/ASRService.ts @@ -153,7 +153,10 @@ class ASRService { } } else if (data.type === 'error') { console.error('[ASRService] 收到错误消息:', data.message || data.data) - window.message.error({ content: `语音识别错误: ${data.message || data.data?.error || '未知错误'}`, key: 'asr-error' }) + window.message.error({ + content: `语音识别错误: ${data.message || data.data?.error || '未知错误'}`, + key: 'asr-error' + }) } } catch (error) { console.error('[ASRService] 解析WebSocket消息失败:', error, event.data) @@ -175,7 +178,9 @@ class ASRService { } const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000) - console.log(`[ASRService] 将在 ${delay}ms 后尝试重连 (尝试 ${this.reconnectAttempt + 1}/${this.maxReconnectAttempts})`) + console.log( + `[ASRService] 将在 ${delay}ms 后尝试重连 (尝试 ${this.reconnectAttempt + 1}/${this.maxReconnectAttempts})` + ) this.reconnectTimeout = setTimeout(() => { this.reconnectAttempt++ @@ -222,7 +227,7 @@ class ASRService { }) // 等待一秒 - await new Promise(resolve => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 1000)) waitAttempts++ } @@ -355,7 +360,7 @@ class ASRService { // 停止所有轨道 if (this.stream) { - this.stream.getTracks().forEach(track => track.stop()) + this.stream.getTracks().forEach((track) => track.stop()) this.stream = null } @@ -391,7 +396,7 @@ class ASRService { this.isRecording = false this.mediaRecorder = null if (this.stream) { - this.stream.getTracks().forEach(track => track.stop()) + this.stream.getTracks().forEach((track) => track.stop()) this.stream = null } } @@ -420,7 +425,7 @@ class ASRService { const response = await fetch(asrApiUrl, { method: 'POST', headers: { - 'Authorization': `Bearer ${asrApiKey}` + Authorization: `Bearer ${asrApiKey}` }, body: formData }) @@ -510,7 +515,7 @@ class ASRService { // 停止所有轨道 if (this.stream) { - this.stream.getTracks().forEach(track => track.stop()) + this.stream.getTracks().forEach((track) => track.stop()) this.stream = null } diff --git a/src/renderer/src/services/ImageStorage.ts b/src/renderer/src/services/ImageStorage.ts index bdc4c723ac..b976c6b431 100644 --- a/src/renderer/src/services/ImageStorage.ts +++ b/src/renderer/src/services/ImageStorage.ts @@ -34,4 +34,17 @@ export default class ImageStorage { const id = IMAGE_PREFIX + key return (await db.settings.get(id))?.value } + + static async remove(key: string): Promise { + const id = IMAGE_PREFIX + key + try { + const record = await db.settings.get(id) + if (record) { + await db.settings.delete(id) + } + } catch (error) { + console.error('Error removing the image', error) + throw error + } + } } diff --git a/src/renderer/src/services/TTSService.ts b/src/renderer/src/services/TTSService.ts index 52d101fd0d..29e0914f2b 100644 --- a/src/renderer/src/services/TTSService.ts +++ b/src/renderer/src/services/TTSService.ts @@ -15,7 +15,8 @@ class TTSService { */ speak = async (text: string): Promise => { try { - const { ttsEnabled, ttsServiceType, ttsApiKey, ttsApiUrl, ttsVoice, ttsModel, ttsEdgeVoice } = store.getState().settings + const { ttsEnabled, ttsServiceType, ttsApiKey, ttsApiUrl, ttsVoice, ttsModel, ttsEdgeVoice } = + store.getState().settings if (!ttsEnabled) { window.message.error({ content: i18n.t('settings.tts.error.not_enabled'), key: 'tts-error' }) @@ -26,7 +27,10 @@ class TTSService { this.stop() // 显示加载提示 - window.message.loading({ content: i18n.t('settings.tts.processing', { defaultValue: '正在生成语音...' }), key: 'tts-loading' }) + window.message.loading({ + content: i18n.t('settings.tts.processing', { defaultValue: '正在生成语音...' }), + key: 'tts-loading' + }) // 初始化为空的Blob,防止类型错误 let audioBlob: Blob = new Blob([], { type: 'audio/wav' }) @@ -125,7 +129,7 @@ class TTSService { const utterance = new SpeechSynthesisUtterance(text) // 获取可用的语音合成声音 - let voices = window.speechSynthesis.getVoices() + const voices = window.speechSynthesis.getVoices() console.log('初始可用的语音合成声音:', voices) // 如果没有可用的声音,等待声音加载 @@ -204,9 +208,8 @@ class TTSService { // 遍历映射表中的候选音色 for (const candidateVoice of voiceMapping[ttsEdgeVoice]) { // 尝试找到匹配的音色 - const matchedVoice = updatedVoices.find(voice => - voice.name.includes(candidateVoice) || - voice.voiceURI.includes(candidateVoice) + const matchedVoice = updatedVoices.find( + (voice) => voice.name.includes(candidateVoice) || voice.voiceURI.includes(candidateVoice) ) if (matchedVoice) { @@ -219,7 +222,7 @@ class TTSService { // 如果映射表没有找到匹配,尝试精确匹配名称 if (!selectedVoice) { - selectedVoice = updatedVoices.find(voice => voice.name === ttsEdgeVoice) + selectedVoice = updatedVoices.find((voice) => voice.name === ttsEdgeVoice) if (selectedVoice) { console.log('找到精确匹配的语音:', selectedVoice.name) } @@ -234,15 +237,16 @@ class TTSService { console.log('检测到Neural音色值,提取语言代码:', langCode) // 先尝试匹配包含语言代码的语音 - selectedVoice = updatedVoices.find(voice => - voice.lang.startsWith(langCode) && - (voice.name.includes(langParts[2]) || // 匹配人名部分,如Xiaoxiao - voice.name.toLowerCase().includes(langParts[2].toLowerCase())) + selectedVoice = updatedVoices.find( + (voice) => + voice.lang.startsWith(langCode) && + (voice.name.includes(langParts[2]) || // 匹配人名部分,如Xiaoxiao + voice.name.toLowerCase().includes(langParts[2].toLowerCase())) ) // 如果没有找到,就匹配该语言的任何语音 if (!selectedVoice) { - selectedVoice = updatedVoices.find(voice => voice.lang.startsWith(langCode)) + selectedVoice = updatedVoices.find((voice) => voice.lang.startsWith(langCode)) if (selectedVoice) { console.log('找到匹配语言的语音:', selectedVoice.name) } @@ -255,9 +259,10 @@ class TTSService { console.log('尝试模糊匹配语音:', ttsEdgeVoice) // 尝试匹配名称中包含的部分 - selectedVoice = updatedVoices.find(voice => - voice.name.toLowerCase().includes(ttsEdgeVoice.toLowerCase()) || - ttsEdgeVoice.toLowerCase().includes(voice.name.toLowerCase()) + selectedVoice = updatedVoices.find( + (voice) => + voice.name.toLowerCase().includes(ttsEdgeVoice.toLowerCase()) || + ttsEdgeVoice.toLowerCase().includes(voice.name.toLowerCase()) ) if (selectedVoice) { @@ -282,7 +287,7 @@ class TTSService { if (langCode) { console.log('尝试根据语言代码匹配语音:', langCode) - selectedVoice = updatedVoices.find(voice => voice.lang.startsWith(langCode)) + selectedVoice = updatedVoices.find((voice) => voice.lang.startsWith(langCode)) if (selectedVoice) { console.log('找到匹配语言代码的语音:', selectedVoice.name) @@ -293,7 +298,7 @@ class TTSService { // 如果还是没有找到,使用默认语音或第一个可用的语音 if (!selectedVoice) { // 先尝试使用默认语音 - selectedVoice = updatedVoices.find(voice => voice.default) + selectedVoice = updatedVoices.find((voice) => voice.default) // 如果没有默认语音,使用第一个可用的语音 if (!selectedVoice && updatedVoices.length > 0) { @@ -310,7 +315,7 @@ class TTSService { } // 设置语音合成参数 - utterance.rate = 1.0 // 语速(0.1-10) + utterance.rate = 1.0 // 语速(0.1-10) utterance.pitch = 1.0 // 音调(0-2) utterance.volume = 1.0 // 音量(0-1) @@ -332,7 +337,7 @@ class TTSService { console.log('文本过长,分段处理以确保完整播放') // 将文本按句子分段 - const sentences = text.split(/[.!?\u3002\uff01\uff1f]/).filter(s => s.trim().length > 0) + const sentences = text.split(/[.!?\u3002\uff01\uff1f]/).filter((s) => s.trim().length > 0) console.log(`将文本分为 ${sentences.length} 个句子进行播放`) // 创建多个语音合成器实例 @@ -359,33 +364,67 @@ class TTSService { // 创建一个有效的音频文件作为占位符 // 这是一个最小的有效WAV文件头 const wavHeader = new Uint8Array([ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // 文件大小 - 0x57, 0x41, 0x56, 0x45, // "WAVE" - 0x66, 0x6d, 0x74, 0x20, // "fmt " - 0x10, 0x00, 0x00, 0x00, // fmt块大小 - 0x01, 0x00, // 格式类型 - 0x01, 0x00, // 通道数 - 0x44, 0xac, 0x00, 0x00, // 采样率 - 0x88, 0x58, 0x01, 0x00, // 字节率 - 0x02, 0x00, // 块对齐 - 0x10, 0x00, // 位深度 - 0x64, 0x61, 0x74, 0x61, // "data" - 0x10, 0x00, 0x00, 0x00 // 数据大小 (16 bytes) - ]); + 0x52, + 0x49, + 0x46, + 0x46, // "RIFF" + 0x24, + 0x00, + 0x00, + 0x00, // 文件大小 + 0x57, + 0x41, + 0x56, + 0x45, // "WAVE" + 0x66, + 0x6d, + 0x74, + 0x20, // "fmt " + 0x10, + 0x00, + 0x00, + 0x00, // fmt块大小 + 0x01, + 0x00, // 格式类型 + 0x01, + 0x00, // 通道数 + 0x44, + 0xac, + 0x00, + 0x00, // 采样率 + 0x88, + 0x58, + 0x01, + 0x00, // 字节率 + 0x02, + 0x00, // 块对齐 + 0x10, + 0x00, // 位深度 + 0x64, + 0x61, + 0x74, + 0x61, // "data" + 0x10, + 0x00, + 0x00, + 0x00 // 数据大小 (16 bytes) + ]) // 添加一些样本数据 - const dummyAudio = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); - const combinedArray = new Uint8Array(wavHeader.length + dummyAudio.length); - combinedArray.set(wavHeader); - combinedArray.set(dummyAudio, wavHeader.length); + const dummyAudio = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) + const combinedArray = new Uint8Array(wavHeader.length + dummyAudio.length) + combinedArray.set(wavHeader) + combinedArray.set(dummyAudio, wavHeader.length) // 创建一个有效的WAV文件 let localAudioBlob = new Blob([combinedArray], { type: 'audio/wav' }) console.log('创建了有效WAV文件,大小:', localAudioBlob.size, 'bytes') // 显示成功消息 - window.message.success({ content: i18n.t('settings.tts.playing', { defaultValue: '语音播放中...' }), key: 'tts-loading' }) + window.message.success({ + content: i18n.t('settings.tts.playing', { defaultValue: '语音播放中...' }), + key: 'tts-loading' + }) // 在Edge TTS模式下,我们不需要播放音频元素,因为浏览器已经在播放语音 // 我们只需要创建一个有效的音频Blob作为占位符 @@ -459,26 +498,57 @@ class TTSService { // 创建一个有效的音频数据 // 这是一个最小的有效WAV文件头 const wavHeader = new Uint8Array([ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // 文件大小 - 0x57, 0x41, 0x56, 0x45, // "WAVE" - 0x66, 0x6d, 0x74, 0x20, // "fmt " - 0x10, 0x00, 0x00, 0x00, // fmt块大小 - 0x01, 0x00, // 格式类型 - 0x01, 0x00, // 通道数 - 0x44, 0xac, 0x00, 0x00, // 采样率 - 0x88, 0x58, 0x01, 0x00, // 字节率 - 0x02, 0x00, // 块对齐 - 0x10, 0x00, // 位深度 - 0x64, 0x61, 0x74, 0x61, // "data" - 0x00, 0x00, 0x00, 0x00 // 数据大小 - ]); + 0x52, + 0x49, + 0x46, + 0x46, // "RIFF" + 0x24, + 0x00, + 0x00, + 0x00, // 文件大小 + 0x57, + 0x41, + 0x56, + 0x45, // "WAVE" + 0x66, + 0x6d, + 0x74, + 0x20, // "fmt " + 0x10, + 0x00, + 0x00, + 0x00, // fmt块大小 + 0x01, + 0x00, // 格式类型 + 0x01, + 0x00, // 通道数 + 0x44, + 0xac, + 0x00, + 0x00, // 采样率 + 0x88, + 0x58, + 0x01, + 0x00, // 字节率 + 0x02, + 0x00, // 块对齐 + 0x10, + 0x00, // 位深度 + 0x64, + 0x61, + 0x74, + 0x61, // "data" + 0x00, + 0x00, + 0x00, + 0x00 // 数据大小 + ]) // 添加一些样本数据 - const dummyAudio = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); - const combinedArray = new Uint8Array(wavHeader.length + dummyAudio.length); - combinedArray.set(wavHeader); - combinedArray.set(dummyAudio, wavHeader.length); + const dummyAudio = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) + const combinedArray = new Uint8Array(wavHeader.length + dummyAudio.length) + combinedArray.set(wavHeader) + combinedArray.set(dummyAudio, wavHeader.length) localAudioBlob = new Blob([combinedArray], { type: 'audio/wav' }) console.log('创建了有效WAV文件,大小:', localAudioBlob.size, 'bytes') @@ -527,12 +597,12 @@ class TTSService { mediaRecorder.start() // 录制500毫秒 - await new Promise(resolve => setTimeout(resolve, 500)) + await new Promise((resolve) => setTimeout(resolve, 500)) mediaRecorder.stop() // 等待录制完成 - await new Promise(resolve => { + await new Promise((resolve) => { mediaRecorder.onstop = () => { fallbackAudioBlob = new Blob(fallbackAudioChunks, { type: 'audio/wav' }) oscillator.stop() @@ -611,26 +681,57 @@ class TTSService { // 创建一个有效的音频文件作为占位符 // 这是一个最小的有效WAV文件头 const wavHeader = new Uint8Array([ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // 文件大小 - 0x57, 0x41, 0x56, 0x45, // "WAVE" - 0x66, 0x6d, 0x74, 0x20, // "fmt " - 0x10, 0x00, 0x00, 0x00, // fmt块大小 - 0x01, 0x00, // 格式类型 - 0x01, 0x00, // 通道数 - 0x44, 0xac, 0x00, 0x00, // 采样率 - 0x88, 0x58, 0x01, 0x00, // 字节率 - 0x02, 0x00, // 块对齐 - 0x10, 0x00, // 位深度 - 0x64, 0x61, 0x74, 0x61, // "data" - 0x10, 0x00, 0x00, 0x00 // 数据大小 (16 bytes) - ]); + 0x52, + 0x49, + 0x46, + 0x46, // "RIFF" + 0x24, + 0x00, + 0x00, + 0x00, // 文件大小 + 0x57, + 0x41, + 0x56, + 0x45, // "WAVE" + 0x66, + 0x6d, + 0x74, + 0x20, // "fmt " + 0x10, + 0x00, + 0x00, + 0x00, // fmt块大小 + 0x01, + 0x00, // 格式类型 + 0x01, + 0x00, // 通道数 + 0x44, + 0xac, + 0x00, + 0x00, // 采样率 + 0x88, + 0x58, + 0x01, + 0x00, // 字节率 + 0x02, + 0x00, // 块对齐 + 0x10, + 0x00, // 位深度 + 0x64, + 0x61, + 0x74, + 0x61, // "data" + 0x10, + 0x00, + 0x00, + 0x00 // 数据大小 (16 bytes) + ]) // 添加一些样本数据 - const dummyAudio = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); - const combinedArray = new Uint8Array(wavHeader.length + dummyAudio.length); - combinedArray.set(wavHeader); - combinedArray.set(dummyAudio, wavHeader.length); + const dummyAudio = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) + const combinedArray = new Uint8Array(wavHeader.length + dummyAudio.length) + combinedArray.set(wavHeader) + combinedArray.set(dummyAudio, wavHeader.length) audioBlob = new Blob([combinedArray], { type: 'audio/wav' }) console.log('创建了有效WAV文件,大小:', audioBlob.size, 'bytes') @@ -689,17 +790,20 @@ class TTSService { */ private cleanTextForSpeech(text: string): string { // 获取最新的TTS设置 - const { ttsFilterOptions = { - filterThinkingProcess: true, - filterMarkdown: true, - filterCodeBlocks: true, - filterHtmlTags: true, - maxTextLength: 4000 - }, ttsServiceType } = store.getState().settings; + const { + ttsFilterOptions = { + filterThinkingProcess: true, + filterMarkdown: true, + filterCodeBlocks: true, + filterHtmlTags: true, + maxTextLength: 4000 + }, + ttsServiceType + } = store.getState().settings // 输出当前的TTS服务类型,便于调试 console.log('清理文本时使用的TTS服务类型:', ttsServiceType || 'openai') - let cleanedText = text; + let cleanedText = text // 根据过滤选项进行处理 @@ -708,23 +812,23 @@ class TTSService { cleanedText = cleanedText // 移除加粗和斜体标记 .replace(/\*\*([^*]+)\*\*/g, '$1') // **bold** -> bold - .replace(/\*([^*]+)\*/g, '$1') // *italic* -> italic - .replace(/__([^_]+)__/g, '$1') // __bold__ -> bold - .replace(/_([^_]+)_/g, '$1') // _italic_ -> italic + .replace(/\*([^*]+)\*/g, '$1') // *italic* -> italic + .replace(/__([^_]+)__/g, '$1') // __bold__ -> bold + .replace(/_([^_]+)_/g, '$1') // _italic_ -> italic // 移除链接格式,只保留链接文本 - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // [text](url) -> text + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) -> text } // 移除代码块 if (ttsFilterOptions.filterCodeBlocks) { cleanedText = cleanedText - .replace(/```[\s\S]*?```/g, '') // 移除代码块 - .replace(/`([^`]+)`/g, '$1'); // `code` -> code + .replace(/```[\s\S]*?```/g, '') // 移除代码块 + .replace(/`([^`]+)`/g, '$1') // `code` -> code } // 移除HTML标签 if (ttsFilterOptions.filterHtmlTags) { - cleanedText = cleanedText.replace(/<[^>]*>/g, ''); + cleanedText = cleanedText.replace(/<[^>]*>/g, '') } // 基本清理(始终执行) @@ -734,9 +838,9 @@ class TTSService { // 将多个连续的换行替换为单个换行 .replace(/\n+/g, '\n') // 移除行首和行尾的空白字符 - .trim(); + .trim() - return cleanedText; + return cleanedText } /** @@ -746,95 +850,99 @@ class TTSService { */ private removeThinkingProcess(text: string): string { // 获取最新的TTS设置 - const { ttsFilterOptions = { - filterThinkingProcess: true, - filterMarkdown: true, - filterCodeBlocks: true, - filterHtmlTags: true, - maxTextLength: 4000 - }, ttsServiceType } = store.getState().settings; + const { + ttsFilterOptions = { + filterThinkingProcess: true, + filterMarkdown: true, + filterCodeBlocks: true, + filterHtmlTags: true, + maxTextLength: 4000 + }, + ttsServiceType + } = store.getState().settings // 输出当前的TTS服务类型,便于调试 console.log('移除思考过程时使用的TTS服务类型:', ttsServiceType || 'openai') // 如果不需要过滤思考过程,直接返回原文本 if (!ttsFilterOptions.filterThinkingProcess) { - return text; + return text } // 如果整个文本都是{'text': '...'}格式,则不处理 // 这种情况可能是伪思考过程,实际上是整个回答 - const isFullTextJson = text.trim().startsWith('{') && - text.includes('"text":') && - text.trim().endsWith('}') && - !text.includes('\n\n'); + const isFullTextJson = + text.trim().startsWith('{') && text.includes('"text":') && text.trim().endsWith('}') && !text.includes('\n\n') // 如果文本中包含多个段落或明显的思考过程标记,则处理 - const hasThinkingMarkers = text.includes('') || - text.includes('') || - text.includes('[THINKING]') || - text.includes('```thinking'); + const hasThinkingMarkers = + text.includes('') || + text.includes('') || + text.includes('[THINKING]') || + text.includes('```thinking') // 如果文本以JSON格式开头,且不是整个文本都是JSON,或者包含思考过程标记 if ((text.trim().startsWith('{') && text.includes('"text":') && !isFullTextJson) || hasThinkingMarkers) { // 尝试提取JSON中的text字段 try { - const match = text.match(/"text":\s*"([^"]+)"/); + const match = text.match(/"text":\s*"([^"]+)"/) if (match && match[1]) { // 只返回text字段的内容 - return match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"'); + return match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"') } } catch (e) { - console.error('解析JSON失败:', e); + console.error('解析JSON失败:', e) } } // 直接检查是否以开头 - const trimmedText = text.trim(); - console.log('检查是否以开头:', trimmedText.startsWith('')); + const trimmedText = text.trim() + console.log('检查是否以开头:', trimmedText.startsWith('')) if (trimmedText.startsWith('')) { // 如果文本以开头,则尝试找到对应的结尾标签 - const endTagIndex = text.indexOf(''); - console.log('结束标签位置:', endTagIndex); + const endTagIndex = text.indexOf('') + console.log('结束标签位置:', endTagIndex) if (endTagIndex !== -1) { // 找到结束标签,去除...部分 - const thinkContent = text.substring(0, endTagIndex + 9); // 思考过程部分 - const afterThinkTag = text.substring(endTagIndex + 9).trim(); // 9是的长度 + const thinkContent = text.substring(0, endTagIndex + 9) // 思考过程部分 + const afterThinkTag = text.substring(endTagIndex + 9).trim() // 9是的长度 - console.log('思考过程内容长度:', thinkContent.length); - console.log('思考过程后的内容长度:', afterThinkTag.length); - console.log('思考过程后的内容开头:', afterThinkTag.substring(0, 50)); + console.log('思考过程内容长度:', thinkContent.length) + console.log('思考过程后的内容长度:', afterThinkTag.length) + console.log('思考过程后的内容开头:', afterThinkTag.substring(0, 50)) if (afterThinkTag) { - console.log('找到标签,已移除思考过程'); - return afterThinkTag; + console.log('找到标签,已移除思考过程') + return afterThinkTag } else { // 如果思考过程后没有内容,则尝试提取思考过程中的有用信息 - console.log('思考过程后没有内容,尝试提取思考过程中的有用信息'); + console.log('思考过程后没有内容,尝试提取思考过程中的有用信息') // 提取之间的内容 - const thinkContentText = text.substring(text.indexOf('') + 7, endTagIndex).trim(); + const thinkContentText = text.substring(text.indexOf('') + 7, endTagIndex).trim() // 如果思考过程中包含“这是”或“This is”等关键词,可能是有用的信息 - if (thinkContentText.includes('这是') || - thinkContentText.includes('This is') || - thinkContentText.includes('The error') || - thinkContentText.includes('错误')) { - + if ( + thinkContentText.includes('这是') || + thinkContentText.includes('This is') || + thinkContentText.includes('The error') || + thinkContentText.includes('错误') + ) { // 尝试找到最后一个段落,可能包含总结信息 - const paragraphs = thinkContentText.split(/\n\s*\n/); + const paragraphs = thinkContentText.split(/\n\s*\n/) if (paragraphs.length > 0) { - const lastParagraph = paragraphs[paragraphs.length - 1].trim(); - if (lastParagraph.length > 50) { // 确保段落足够长 - console.log('从思考过程中提取了最后一个段落'); - return lastParagraph; + const lastParagraph = paragraphs[paragraphs.length - 1].trim() + if (lastParagraph.length > 50) { + // 确保段落足够长 + console.log('从思考过程中提取了最后一个段落') + return lastParagraph } } // 如果没有找到合适的段落,返回整个思考过程 - console.log('返回整个思考过程内容'); - return thinkContentText; + console.log('返回整个思考过程内容') + return thinkContentText } } } @@ -842,35 +950,35 @@ class TTSService { // 先处理标签 if (text.includes('')) { - const startIndex = text.indexOf(''); - const endIndex = text.indexOf(''); + const startIndex = text.indexOf('') + const endIndex = text.indexOf('') if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) { - console.log('找到标签,起始位置:', startIndex, '结束位置:', endIndex); + console.log('找到标签,起始位置:', startIndex, '结束位置:', endIndex) // 提取之间的内容 - const thinkContent = text.substring(startIndex + 7, endIndex); + const thinkContent = text.substring(startIndex + 7, endIndex) // 提取后面的内容 - const afterThinkContent = text.substring(endIndex + 9).trim(); // 9是的长度 + const afterThinkContent = text.substring(endIndex + 9).trim() // 9是的长度 - console.log('内容长度:', thinkContent.length); - console.log('后内容长度:', afterThinkContent.length); + console.log('内容长度:', thinkContent.length) + console.log('后内容长度:', afterThinkContent.length) if (afterThinkContent) { // 如果后面有内容,则使用该内容 - console.log('使用后面的内容'); - return afterThinkContent; + console.log('使用后面的内容') + return afterThinkContent } else { // 如果后面没有内容,则使用思考过程中的内容 - console.log('使用标签中的内容'); - return thinkContent; + console.log('使用标签中的内容') + return thinkContent } } } // 如果没有标签或处理失败,则移除其他思考过程标记 - let processedText = text + const processedText = text // 移除HTML标记的思考过程 .replace(/[\s\S]*?<\/thinking>/gi, '') .replace(/[\s\S]*?<\/think>/gi, '') @@ -881,31 +989,38 @@ class TTSService { .replace(/```thinking[\s\S]*?```/gi, '') .replace(/```think[\s\S]*?```/gi, '') // 移除开头的“我先思考一下”类似的句子 - .replace(/^(\s*)(\S+\s+)?(\S+\s+)?(\S+\s+)?(我|让我|让我们|我们|我先|我来)(思考|分析|理解|看一下|想一想)[^\n]*\n/i, '') + .replace( + /^(\s*)(\S+\s+)?(\S+\s+)?(\S+\s+)?(我|让我|让我们|我们|我先|我来)(思考|分析|理解|看一下|想一想)[^\n]*\n/i, + '' + ) // 移除开头的“Let me think”类似的句子 - .replace(/^(\s*)(\S+\s+)?(\S+\s+)?(\S+\s+)?(Let me|I'll|I will|I need to|Let's|I'm going to)\s+(think|analyze|understand|consider|break down)[^\n]*\n/i, '') + .replace( + /^(\s*)(\S+\s+)?(\S+\s+)?(\S+\s+)?(Let me|I'll|I will|I need to|Let's|I'm going to)\s+(think|analyze|understand|consider|break down)[^\n]*\n/i, + '' + ) // 移除开头的“To answer this question”类似的句子 - .replace(/^(\s*)(\S+\s+)?(\S+\s+)?(\S+\s+)?(To answer this|To solve this|To address this|To respond to this)[^\n]*\n/i, '') + .replace( + /^(\s*)(\S+\s+)?(\S+\s+)?(\S+\s+)?(To answer this|To solve this|To address this|To respond to this)[^\n]*\n/i, + '' + ) // 如果文本中包含“我的回答是”或“我的答案是”,只保留这之后的内容 const answerMarkers = [ /[\n\r]+(\s*)(我的|最终|最终的|正确的|完整的)?(回答|答案|结论|解决方案)(是|如下|就是|就是如下)[\s::]*/i, /[\n\r]+(\s*)(My|The|Final|Complete|Correct)\s+(answer|response|solution|conclusion)\s+(is|would be|follows)[\s:]*/i - ]; + ] for (const marker of answerMarkers) { - const parts = processedText.split(marker); + const parts = processedText.split(marker) if (parts.length > 1) { // 取最后一个匹配后的内容 - return parts[parts.length - 1].trim(); + return parts[parts.length - 1].trim() } } - return processedText; + return processedText } - - /** * 从消息中提取文本并转换为语音 * @param message 消息对象 @@ -923,25 +1038,25 @@ class TTSService { console.log('原始文本开头:', text.substring(0, 100)) // 先移除思考过程 - const processedText = this.removeThinkingProcess(text); - console.log('移除思考过程后文本长度:', processedText.length); - console.log('处理后文本开头:', processedText.substring(0, 100)); - text = processedText; + const processedText = this.removeThinkingProcess(text) + console.log('移除思考过程后文本长度:', processedText.length) + console.log('处理后文本开头:', processedText.substring(0, 100)) + text = processedText // 清理文本,移除不需要的标点符号 text = this.cleanTextForSpeech(text) console.log('清理标点符号后文本长度:', text.length) // 获取最新的TTS设置 - const latestSettings = store.getState().settings; + const latestSettings = store.getState().settings const ttsFilterOptions = latestSettings.ttsFilterOptions || { filterThinkingProcess: true, filterMarkdown: true, filterCodeBlocks: true, filterHtmlTags: true, maxTextLength: 4000 - }; - const ttsServiceType = latestSettings.ttsServiceType; + } + const ttsServiceType = latestSettings.ttsServiceType // 输出当前的TTS服务类型,便于调试 console.log('当前消息播放使用的TTS服务类型:', ttsServiceType || 'openai') @@ -956,7 +1071,7 @@ class TTSService { // 如果消息过长,可能会导致TTS API超时或失败 // 根据设置的最大文本长度进行截断 - const maxLength = ttsFilterOptions.maxTextLength || 4000; // 默认为4000 + const maxLength = ttsFilterOptions.maxTextLength || 4000 // 默认为4000 if (text.length > maxLength) { text = text.substring(0, maxLength) + '...' console.log(`文本过长,已截断为${maxLength}个字符`) @@ -1006,7 +1121,7 @@ class TTSService { const fadeOut = () => { if (currentStep < fadeOutSteps && this.audio) { - this.audio.volume = Math.max(0, originalVolume - (fadeStep * currentStep)) + this.audio.volume = Math.max(0, originalVolume - fadeStep * currentStep) currentStep++ setTimeout(fadeOut, fadeOutInterval) } else { diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index c4a06845b3..3c05a0fe22 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -31,6 +31,10 @@ class WebSearchService { return false } + if (provider.id.startsWith('local-')) { + return true + } + if (hasObjectKey(provider, 'apiKey')) { return provider.apiKey !== '' } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8c330f6549..7da3f88904 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -42,7 +42,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 94, + version: 95, blacklist: ['runtime', 'messages'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index d6127e23c3..5b0aa17fc9 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -14,6 +14,7 @@ import { RootState } from '.' import { INITIAL_PROVIDERS, moveProvider } from './llm' import { mcpSlice } from './mcp' import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings' +import { defaultWebSearchProviders } from './websearch' // remove logo base64 data to reduce the size of the state function removeMiniAppIconsFromState(state: RootState) { @@ -52,6 +53,17 @@ function addProvider(state: RootState, id: string) { } } +function addWebSearchProvider(state: RootState, id: string) { + if (state.websearch && state.websearch.providers) { + if (!state.websearch.providers.find((p) => p.id === id)) { + const provider = defaultWebSearchProviders.find((p) => p.id === id) + if (provider) { + state.websearch.providers.push(provider) + } + } + } +} + const migrateConfig = { '2': (state: RootState) => { try { @@ -985,21 +997,9 @@ const migrateConfig = { }, '77': (state: RootState) => { try { + addWebSearchProvider(state, 'searxng') + addWebSearchProvider(state, 'exa') if (state.websearch) { - if (!state.websearch.providers.find((p) => p.id === 'searxng')) { - state.websearch.providers.push( - { - id: 'searxng', - name: 'Searxng', - apiHost: '' - }, - { - id: 'exa', - name: 'Exa', - apiKey: '' - } - ) - } state.websearch.providers.forEach((p) => { // @ts-ignore eslint-disable-next-line delete p.enabled @@ -1192,6 +1192,20 @@ const migrateConfig = { } catch (error) { return state } + }, + '95': (state: RootState) => { + try { + addWebSearchProvider(state, 'local-google') + addWebSearchProvider(state, 'local-bing') + addWebSearchProvider(state, 'local-baidu') + const qiniuProvider = state.llm.providers.find((provider) => provider.id === 'qiniu') + if (qiniuProvider && isEmpty(qiniuProvider.models)) { + qiniuProvider.models = SYSTEM_MODELS.qiniu + } + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index aa63edbc0d..48a06d8477 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -35,6 +35,21 @@ const initialState: WebSearchState = { id: 'exa', name: 'Exa', apiKey: '' + }, + { + id: 'local-google', + name: 'Google', + url: 'https://www.google.com/search?q=%s' + }, + { + id: 'local-bing', + name: 'Bing', + url: 'https://cn.bing.com/search?q=%s&ensearch=1' + }, + { + id: 'local-baidu', + name: 'Baidu', + url: 'https://www.baidu.com/s?wd=%s' } ], searchWithTime: true, @@ -44,6 +59,8 @@ const initialState: WebSearchState = { overwrite: false } +export const defaultWebSearchProviders = initialState.providers + const websearchSlice = createSlice({ name: 'websearch', initialState, @@ -77,6 +94,15 @@ const websearchSlice = createSlice({ }, setOverwrite: (state, action: PayloadAction) => { state.overwrite = action.payload + }, + addWebSearchProvider: (state, action: PayloadAction) => { + // Check if provider with same ID already exists + const exists = state.providers.some((provider) => provider.id === action.payload.id) + + if (!exists) { + // Add the new provider to the array + state.providers.push(action.payload) + } } } }) @@ -90,7 +116,8 @@ export const { setExcludeDomains, setMaxResult, setEnhanceMode, - setOverwrite + setOverwrite, + addWebSearchProvider } = websearchSlice.actions export default websearchSlice.reducer diff --git a/src/renderer/src/types/asr.d.ts b/src/renderer/src/types/asr.d.ts new file mode 100644 index 0000000000..982ca195d3 --- /dev/null +++ b/src/renderer/src/types/asr.d.ts @@ -0,0 +1,12 @@ +interface ASRServerAPI { + startServer: () => Promise<{ success: boolean; pid?: number; error?: string }> + stopServer: (pid: number) => Promise<{ success: boolean; error?: string }> +} + +interface Window { + api: { + asrServer: ASRServerAPI + // 其他API... + [key: string]: any + } +} diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 0b16cff16e..923699afaa 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -336,6 +336,9 @@ export type WebSearchProvider = { apiKey?: string apiHost?: string engines?: string[] + url?: string + contentLimit?: number + usingBrowser?: boolean } export type WebSearchResponse = { diff --git a/yarn.lock b/yarn.lock index 9356b5a1f5..45976d17c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2527,6 +2527,13 @@ __metadata: languageName: node linkType: hard +"@mozilla/readability@npm:^0.6.0": + version: 0.6.0 + resolution: "@mozilla/readability@npm:0.6.0" + checksum: 10c0/05c0fdb837f6bddd307b9dbc396538cdf17ab6a0d3bec96971c2dfc079737fb7ab830a0797c5fad3d66db71cb9c305d8e05fe68d9b7eb0be951d4b802e43e588 + languageName: node + linkType: hard + "@neon-rs/load@npm:^0.0.4": version: 0.0.4 resolution: "@neon-rs/load@npm:0.0.4" @@ -3911,6 +3918,7 @@ __metadata: "@kangfenmao/keyv-storage": "npm:^0.1.0" "@langchain/community": "npm:^0.3.36" "@modelcontextprotocol/sdk": "npm:^1.9.0" + "@mozilla/readability": "npm:^0.6.0" "@notionhq/client": "npm:^2.2.15" "@reduxjs/toolkit": "npm:^2.2.5" "@strongtz/win32-arm64-msvc": "npm:^0.4.7"