冲突6666

This commit is contained in:
1600822305 2025-04-10 16:17:46 +08:00
commit eb75884b57
51 changed files with 1373 additions and 541 deletions

View File

@ -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
}
}
})

View File

@ -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",

View File

@ -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'
}

View File

@ -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')

View File

@ -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)
})
}

View File

@ -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()
}

View File

@ -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()

View File

@ -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')
}

View File

@ -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<string, BrowserWindow> = {}
public static getInstance(): SearchService {
if (!SearchService.instance) {
SearchService.instance = new SearchService()
}
return SearchService.instance
}
constructor() {
// Initialize the service
}
private async createNewSearchWindow(uid: string): Promise<BrowserWindow> {
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<void> {
await this.createNewSearchWindow(uid)
}
public async closeSearchWindow(uid: string): Promise<void> {
const window = this.searchWindows[uid]
if (window) {
window.close()
delete this.searchWindows[uid]
}
}
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
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<void>((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()

View File

@ -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) {

View File

@ -175,6 +175,11 @@ declare global {
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
getDirectoryContents: (token: string, path: string) => Promise<any>
}
searchService: {
openSearchWindow: (uid: string) => Promise<string>
closeSearchWindow: (uid: string) => Promise<string>
openUrlInSearchWindow: (uid: string, url: string) => Promise<string>
}
}
}
}

View File

@ -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)
}
}

View File

@ -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')

View File

@ -105,7 +105,14 @@ const ASRButton: FC<Props> = ({ onTranscribed, disabled = false, style }) => {
}
return (
<Tooltip title={isRecording ? t('settings.asr.stop') : isCountingDown ? `${t('settings.asr.preparing')} (${countdown})` : t('settings.asr.start')}>
<Tooltip
title={
isRecording
? t('settings.asr.stop')
: isCountingDown
? `${t('settings.asr.preparing')} (${countdown})`
: t('settings.asr.start')
}>
<ButtonWrapper>
<StyledButton
type={isRecording || isCountingDown ? 'primary' : 'default'}
@ -114,11 +121,8 @@ const ASRButton: FC<Props> = ({ onTranscribed, disabled = false, style }) => {
onDoubleClick={handleCancel}
disabled={disabled || isProcessing || (isCountingDown && countdown > 0)}
style={style}
className={isCountingDown ? 'counting-down' : ''}
>
{isCountingDown && (
<CountdownNumber>{countdown}</CountdownNumber>
)}
className={isCountingDown ? 'counting-down' : ''}>
{isCountingDown && <CountdownNumber>{countdown}</CountdownNumber>}
</StyledButton>
{isCountingDown && (
<CountdownIndicator>
@ -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);
}
}
`

View File

@ -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'

View File

@ -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<TTSButtonProps> = ({ message, className }) => {
setIsSpeaking(true)
try {
await TTSService.speakFromMessage(message)
// 监听播放结束
const checkPlayingStatus = () => {
if (!TTSService.isCurrentlyPlaying()) {
@ -32,9 +32,9 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
clearInterval(checkInterval)
}
}
const checkInterval = setInterval(checkPlayingStatus, 500)
// 安全机制,确保即使出错也会重置状态
setTimeout(() => {
if (isSpeaking) {

View File

@ -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<string, Model[]> = {
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<string, Model[]> = {
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 = [

View File

@ -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'
}
}
}

View File

@ -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`)
}

View File

@ -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",

View File

@ -1307,7 +1307,9 @@
},
"title": "ウェブ検索",
"overwrite": "サービス検索を上書き",
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する"
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
"apikey": "API キー",
"free": "無料"
},
"general.auto_check_update.title": "自動更新チェックを有効にする",
"quickPhrase": {

View File

@ -1307,7 +1307,9 @@
},
"title": "Поиск в Интернете",
"overwrite": "Переопределить поставщика поиска",
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM"
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
"apikey": "Ключ API",
"free": "Бесплатно"
},
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
"quickPhrase": {

View File

@ -1308,7 +1308,9 @@
"description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力",
"title": "Tavily"
},
"title": "网络搜索"
"title": "网络搜索",
"apikey": "API 密钥",
"free": "免费"
},
"quickPhrase": {
"title": "快捷短语",

View File

@ -1307,7 +1307,9 @@
},
"title": "網路搜尋",
"overwrite": "覆蓋搜尋服務商",
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋"
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
"apikey": "API 金鑰",
"free": "免費"
},
"general.auto_check_update.title": "啟用自動更新檢查",
"quickPhrase": {

View File

@ -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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
</ToolbarMenu>
<ToolbarMenu>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
<ASRButton onTranscribed={(transcribedText) => {
// 如果是空字符串,不做任何处理
if (!transcribedText) return
<ASRButton
onTranscribed={(transcribedText) => {
// 如果是空字符串,不做任何处理
if (!transcribedText) return
// 将识别的文本添加到当前输入框
setText((prevText) => {
// 如果当前有文本,添加空格后再添加识别的文本
if (prevText.trim()) {
return prevText + ' ' + transcribedText
}
return transcribedText
})
}} />
// 将识别的文本添加到当前输入框
setText((prevText) => {
// 如果当前有文本,添加空格后再添加识别的文本
if (prevText.trim()) {
return prevText + ' ' + transcribedText
}
return transcribedText
})
}}
/>
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>

View File

@ -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> = (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])

View File

@ -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'

View File

@ -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()) {

View File

@ -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<Props> = ({ provider, resolve }) => {
const [open, setOpen] = useState(true)
const [name, setName] = useState(provider?.name || '')
const [type, setType] = useState<ProviderType>(provider?.type || 'openai')
const [logo, setLogo] = useState<string | null>(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<Props> = ({ 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: (
<div style={{ width: '100%', textAlign: 'center' }}>
<Upload
customRequest={() => {}}
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<string>((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')}
</Upload>
</div>
)
},
{
key: 'reset',
label: (
<div
style={{ width: '100%', textAlign: 'center' }}
onClick={(e) => {
e.stopPropagation()
handleReset()
}}>
{t('settings.general.avatar.reset')}
</div>
)
}
]
return (
<Modal
open={open}
@ -43,6 +156,23 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
title={t('settings.provider.add.title')}
okButtonProps={{ disabled: buttonDisabled }}>
<Divider style={{ margin: '8px 0' }} />
<Center mt="10px" mb="20px">
<VStack alignItems="center" gap="10px">
<Dropdown
menu={{ items }}
trigger={['click']}
open={dropdownOpen}
align={{ offset: [0, 4] }}
placement="bottom"
onOpenChange={(visible) => {
setDropdownOpen(visible)
}}>
{logo ? <ProviderLogo src={logo} /> : <ProviderInitialsLogo>{getInitials()}</ProviderInitialsLogo>}
</Dropdown>
</VStack>
</Center>
<Form layout="vertical" style={{ gap: 8 }}>
<Form.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}>
<Input
@ -70,13 +200,46 @@ const PopupContainer: React.FC<Props> = ({ 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(
<PopupContainer
provider={provider}

View File

@ -10,12 +10,12 @@ import {
SettingOutlined
} from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse'
import { HStack } from '@renderer/components/Layout'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { getModelLogo } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
import FileItem from '@renderer/pages/files/FileItem'
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
@ -270,52 +270,48 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
const isChecking = modelStatus?.checking === true
return (
<FileItem
key={model.id}
fileInfo={{
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
name: (
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
<ListItem key={model.id}>
<HStack alignItems="center" gap={10} style={{ flex: 1 }}>
<Avatar src={getModelLogo(model.id)} style={{ width: 30, height: 30 }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
placement="top">
<span>{model.name}</span>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} />
</ListItemName>
),
ext: '.model',
actions: (
<Flex gap={4} align="center">
{renderLatencyText(modelStatus)}
{renderStatusIndicator(modelStatus)}
<Button
type="text"
onClick={() => !isChecking && onEditModel(model)}
disabled={isChecking}
icon={<SettingOutlined />}
/>
<Button
type="text"
onClick={() => !isChecking && removeModel(model)}
disabled={isChecking}
icon={<MinusOutlined />}
/>
</Flex>
)
}}
/>
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
placement="top">
<span>{model.name}</span>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} />
</ListItemName>
</HStack>
<Flex gap={4} align="center">
{renderLatencyText(modelStatus)}
{renderStatusIndicator(modelStatus)}
<Button
type="text"
onClick={() => !isChecking && onEditModel(model)}
disabled={isChecking}
icon={<SettingOutlined />}
/>
<Button
type="text"
onClick={() => !isChecking && removeModel(model)}
disabled={isChecking}
icon={<MinusOutlined />}
/>
</Flex>
</ListItem>
)
})}
</Flex>
@ -357,6 +353,16 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
)
}
const ListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
`
const ListItemName = styled.div`
display: flex;
flex-direction: row;

View File

@ -3,10 +3,11 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea
import Scrollbar from '@renderer/components/Scrollbar'
import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import ImageStorage from '@renderer/services/ImageStorage'
import { Provider } from '@renderer/types'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
import { FC, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -20,6 +21,28 @@ const ProvidersList: FC = () => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState<string>('')
const [dragging, setDragging] = useState(false)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
useEffect(() => {
const loadAllLogos = async () => {
const logos: Record<string, string> = {}
for (const provider of providers) {
if (provider.id) {
try {
const logoData = await ImageStorage.get(`provider-${provider.id}`)
if (logoData) {
logos[provider.id] = logoData
}
} catch (error) {
console.error(`Failed to load logo for provider ${provider.id}`, error)
}
}
}
setProviderLogos(logos)
}
loadAllLogos()
}, [providers])
const onDragEnd = (result: DropResult) => {
setDragging(false)
@ -32,15 +55,15 @@ const ProvidersList: FC = () => {
}
const onAddProvider = async () => {
const { name: prividerName, type } = await AddProviderPopup.show()
const { name: providerName, type, logo } = await AddProviderPopup.show()
if (!prividerName.trim()) {
if (!providerName.trim()) {
return
}
const provider = {
id: uuid(),
name: prividerName.trim(),
name: providerName.trim(),
type,
apiKey: '',
apiHost: '',
@ -49,6 +72,21 @@ const ProvidersList: FC = () => {
isSystem: false
} as Provider
let updatedLogos = { ...providerLogos }
if (logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
updatedLogos = {
...updatedLogos,
[provider.id]: logo
}
setProviderLogos(updatedLogos)
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('保存Provider Logo失败')
}
}
addProvider(provider)
setSelectedProvider(provider)
}
@ -60,8 +98,36 @@ const ProvidersList: FC = () => {
key: 'edit',
icon: <EditOutlined />,
async onClick() {
const { name, type } = await AddProviderPopup.show(provider)
name && updateProvider({ ...provider, name, type })
const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
if (name) {
updateProvider({ ...provider, name, type })
if (provider.id) {
if (logoFile && logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
setProviderLogos((prev) => ({
...prev,
[provider.id]: logo
}))
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('更新Provider Logo失败')
}
} else if (logo === undefined && logoFile === undefined) {
try {
await ImageStorage.set(`provider-${provider.id}`, '')
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to reset logo', error)
}
}
}
}
}
},
{
@ -76,7 +142,21 @@ const ProvidersList: FC = () => {
okButtonProps: { danger: true },
okText: t('common.delete'),
centered: true,
onOk: () => {
onOk: async () => {
// 删除provider前先清理其logo
if (provider.id) {
try {
await ImageStorage.remove(`provider-${provider.id}`)
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to delete logo', error)
}
}
setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider)
}
@ -96,17 +176,33 @@ const ProvidersList: FC = () => {
return menus
}
//will match the providers and the models that provider provides
const getProviderAvatar = (provider: Provider) => {
if (provider.isSystem) {
return <ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
}
const customLogo = providerLogos[provider.id]
if (customLogo) {
return <ProviderLogo shape="square" src={customLogo} size={25} />
}
return (
<ProviderLogo
size={25}
shape="square"
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
{getFirstCharacter(provider.name)}
</ProviderLogo>
)
}
const filteredProviders = providers.filter((provider) => {
// 获取 provider 的名称
const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
// 检查 provider 的 id 和 name 是否匹配搜索条件
const isProviderMatch =
provider.id.toLowerCase().includes(searchText.toLowerCase()) ||
providerName.toLowerCase().includes(searchText.toLowerCase())
// 检查 provider.models 中是否有 model 的 id 或 name 匹配搜索条件
const isModelMatch = provider.models.some((model) => {
return (
model.id.toLowerCase().includes(searchText.toLowerCase()) ||
@ -114,7 +210,6 @@ const ProvidersList: FC = () => {
)
})
// 如果 provider 或 model 匹配,则保留该 provider
return isProviderMatch || isModelMatch
})
@ -161,17 +256,7 @@ const ProvidersList: FC = () => {
key={JSON.stringify(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}>
{provider.isSystem && (
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
)}
{!provider.isSystem && (
<ProviderLogo
size={25}
shape="square"
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
{getFirstCharacter(provider.name)}
</ProviderLogo>
)}
{getProviderAvatar(provider)}
<ProviderItemName className="text-nowrap">
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
</ProviderItemName>

View File

@ -1,24 +1,16 @@
import { InfoCircleOutlined, GlobalOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import ASRService from '@renderer/services/ASRService'
import { GlobalOutlined, InfoCircleOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons'
import ASRServerService from '@renderer/services/ASRServerService'
import ASRService from '@renderer/services/ASRService'
import { useAppDispatch } from '@renderer/store'
import {
setAsrApiKey,
setAsrApiUrl,
setAsrEnabled,
setAsrModel,
setAsrServiceType
} from '@renderer/store/settings'
import { setAsrApiKey, setAsrApiUrl, setAsrEnabled, setAsrModel, setAsrServiceType } from '@renderer/store/settings'
import { Button, Form, Input, Select, Space, Switch } from 'antd'
import { FC, useState, useEffect } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
const ASRSettings: FC = () => {
const { t } = useTranslation()
const { isDark } = useTheme()
const dispatch = useAppDispatch()
// 服务器状态
@ -47,9 +39,7 @@ const ASRSettings: FC = () => {
]
// 模型选项
const modelOptions = [
{ label: 'whisper-1', value: 'whisper-1' }
]
const modelOptions = [{ label: 'whisper-1', value: 'whisper-1' }]
return (
<Container>
@ -137,8 +127,7 @@ const ASRSettings: FC = () => {
setIsServerRunning(true)
}
}}
disabled={!asrEnabled || isServerRunning}
>
disabled={!asrEnabled || isServerRunning}>
{t('settings.asr.server.start')}
</Button>
<Button
@ -150,8 +139,7 @@ const ASRSettings: FC = () => {
setIsServerRunning(false)
}
}}
disabled={!asrEnabled || !isServerRunning}
>
disabled={!asrEnabled || !isServerRunning}>
{t('settings.asr.server.stop')}
</Button>
</Space>
@ -160,27 +148,33 @@ const ASRSettings: FC = () => {
type="primary"
icon={<GlobalOutlined />}
onClick={() => ASRServerService.openServerPage()}
disabled={!asrEnabled || !isServerRunning}
>
disabled={!asrEnabled || !isServerRunning}>
{t('settings.asr.open_browser')}
</Button>
<Button
onClick={() => {
// 尝试连接到WebSocket服务器
ASRService.connectToWebSocketServer?.().then(connected => {
if (connected) {
window.message.success({ content: t('settings.asr.local.connection_success'), key: 'ws-connect' })
} else {
ASRService.connectToWebSocketServer?.()
.then((connected) => {
if (connected) {
window.message.success({
content: t('settings.asr.local.connection_success'),
key: 'ws-connect'
})
} else {
window.message.error({
content: t('settings.asr.local.connection_failed'),
key: 'ws-connect'
})
}
})
.catch((error) => {
console.error('Failed to connect to WebSocket server:', error)
window.message.error({ content: t('settings.asr.local.connection_failed'), key: 'ws-connect' })
}
}).catch(error => {
console.error('Failed to connect to WebSocket server:', error)
window.message.error({ content: t('settings.asr.local.connection_failed'), key: 'ws-connect' })
})
})
}}
disabled={!asrEnabled || !isServerRunning}
>
disabled={!asrEnabled || !isServerRunning}>
{t('settings.asr.local.test_connection')}
</Button>
@ -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`

View File

@ -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')}
/>
</VoiceSelectContainer>
{availableVoices.length === 0 && <LoadingText>{t('settings.tts.edge_voice.loading')}</LoadingText>}
{availableVoices.length === 0 && (
<LoadingText>{t('settings.tts.edge_voice.loading')}</LoadingText>
)}
</Form.Item>
)}

View File

@ -117,7 +117,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
<SettingTitle>
<Flex align="center" gap={8}>
<ProviderLogo shape="square" src={getWebSearchProviderLogo(provider.id)} size={16} />
<ProviderName> {provider.name}</ProviderName>
{officialWebsite && webSearchProviderConfig?.websites && (
<Link target="_blank" href={webSearchProviderConfig.websites.official}>
@ -156,7 +155,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
</SettingHelpTextRow>
</>
)}
{hasObjectKey(provider, 'apiHost') && (
<>
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>

View File

@ -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<WebSearchProvider | undefined>(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')})`
}))}
/>
</div>
</SettingRow>
</SettingGroup>
<SettingGroup theme={themeMode}>
{selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />}
</SettingGroup>
{!isLocalProvider && (
<SettingGroup theme={themeMode}>
{selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />}
</SettingGroup>
)}
<BasicSettings />
<BlacklistSettings />
</SettingContainer>

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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<void> {
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
}
}
}

View File

@ -15,7 +15,8 @@ class TTSService {
*/
speak = async (text: string): Promise<void> => {
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<void>(resolve => setTimeout(resolve, 500))
await new Promise<void>((resolve) => setTimeout(resolve, 500))
mediaRecorder.stop()
// 等待录制完成
await new Promise<void>(resolve => {
await new Promise<void>((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('<think>') ||
text.includes('<thinking>') ||
text.includes('[THINKING]') ||
text.includes('```thinking');
const hasThinkingMarkers =
text.includes('<think>') ||
text.includes('<thinking>') ||
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)
}
}
// 直接检查是否以<think>开头
const trimmedText = text.trim();
console.log('检查是否以<think>开头:', trimmedText.startsWith('<think>'));
const trimmedText = text.trim()
console.log('检查是否以<think>开头:', trimmedText.startsWith('<think>'))
if (trimmedText.startsWith('<think>')) {
// 如果文本以<think>开头,则尝试找到对应的</think>结尾标签
const endTagIndex = text.indexOf('</think>');
console.log('结束标签位置:', endTagIndex);
const endTagIndex = text.indexOf('</think>')
console.log('结束标签位置:', endTagIndex)
if (endTagIndex !== -1) {
// 找到结束标签,去除<think>...</think>部分
const thinkContent = text.substring(0, endTagIndex + 9); // 思考过程部分
const afterThinkTag = text.substring(endTagIndex + 9).trim(); // 9是</think>的长度
const thinkContent = text.substring(0, endTagIndex + 9) // 思考过程部分
const afterThinkTag = text.substring(endTagIndex + 9).trim() // 9是</think>的长度
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('找到<think>标签,已移除思考过程');
return afterThinkTag;
console.log('找到<think>标签,已移除思考过程')
return afterThinkTag
} else {
// 如果思考过程后没有内容,则尝试提取思考过程中的有用信息
console.log('思考过程后没有内容,尝试提取思考过程中的有用信息');
console.log('思考过程后没有内容,尝试提取思考过程中的有用信息')
// 提取<think>和</think>之间的内容
const thinkContentText = text.substring(text.indexOf('<think>') + 7, endTagIndex).trim();
const thinkContentText = text.substring(text.indexOf('<think>') + 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 {
// 先处理<think>标签
if (text.includes('<think>')) {
const startIndex = text.indexOf('<think>');
const endIndex = text.indexOf('</think>');
const startIndex = text.indexOf('<think>')
const endIndex = text.indexOf('</think>')
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
console.log('找到<think>标签,起始位置:', startIndex, '结束位置:', endIndex);
console.log('找到<think>标签,起始位置:', startIndex, '结束位置:', endIndex)
// 提取<think>和</think>之间的内容
const thinkContent = text.substring(startIndex + 7, endIndex);
const thinkContent = text.substring(startIndex + 7, endIndex)
// 提取</think>后面的内容
const afterThinkContent = text.substring(endIndex + 9).trim(); // 9是</think>的长度
const afterThinkContent = text.substring(endIndex + 9).trim() // 9是</think>的长度
console.log('<think>内容长度:', thinkContent.length);
console.log('</think>后内容长度:', afterThinkContent.length);
console.log('<think>内容长度:', thinkContent.length)
console.log('</think>后内容长度:', afterThinkContent.length)
if (afterThinkContent) {
// 如果</think>后面有内容,则使用该内容
console.log('使用</think>后面的内容');
return afterThinkContent;
console.log('使用</think>后面的内容')
return afterThinkContent
} else {
// 如果</think>后面没有内容,则使用思考过程中的内容
console.log('使用<think>标签中的内容');
return thinkContent;
console.log('使用<think>标签中的内容')
return thinkContent
}
}
}
// 如果没有<think>标签或处理失败,则移除其他思考过程标记
let processedText = text
const processedText = text
// 移除HTML标记的思考过程
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
.replace(/<think>[\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 {

View File

@ -31,6 +31,10 @@ class WebSearchService {
return false
}
if (provider.id.startsWith('local-')) {
return true
}
if (hasObjectKey(provider, 'apiKey')) {
return provider.apiKey !== ''
}

View File

@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 94,
version: 95,
blacklist: ['runtime', 'messages'],
migrate
},

View File

@ -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
}
}
}

View File

@ -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<boolean>) => {
state.overwrite = action.payload
},
addWebSearchProvider: (state, action: PayloadAction<WebSearchProvider>) => {
// 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

12
src/renderer/src/types/asr.d.ts vendored Normal file
View File

@ -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
}
}

View File

@ -336,6 +336,9 @@ export type WebSearchProvider = {
apiKey?: string
apiHost?: string
engines?: string[]
url?: string
contentLimit?: number
usingBrowser?: boolean
}
export type WebSearchResponse = {

View File

@ -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"