mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
冲突6666
This commit is contained in:
commit
eb75884b57
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
113
src/main/ipc.ts
113
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()
|
||||
}
|
||||
|
||||
135
src/main/services/ASRServerService.ts
Normal file
135
src/main/services/ASRServerService.ts
Normal 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()
|
||||
@ -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')
|
||||
}
|
||||
|
||||
82
src/main/services/SearchService.ts
Normal file
82
src/main/services/SearchService.ts
Normal 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()
|
||||
@ -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) {
|
||||
|
||||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1307,7 +1307,9 @@
|
||||
},
|
||||
"title": "ウェブ検索",
|
||||
"overwrite": "サービス検索を上書き",
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する"
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
|
||||
"apikey": "API キー",
|
||||
"free": "無料"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新チェックを有効にする",
|
||||
"quickPhrase": {
|
||||
|
||||
@ -1307,7 +1307,9 @@
|
||||
},
|
||||
"title": "Поиск в Интернете",
|
||||
"overwrite": "Переопределить поставщика поиска",
|
||||
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM"
|
||||
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
|
||||
"apikey": "Ключ API",
|
||||
"free": "Бесплатно"
|
||||
},
|
||||
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
|
||||
"quickPhrase": {
|
||||
|
||||
@ -1308,7 +1308,9 @@
|
||||
"description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力",
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "网络搜索"
|
||||
"title": "网络搜索",
|
||||
"apikey": "API 密钥",
|
||||
"free": "免费"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
|
||||
@ -1307,7 +1307,9 @@
|
||||
},
|
||||
"title": "網路搜尋",
|
||||
"overwrite": "覆蓋搜尋服務商",
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋"
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
|
||||
"apikey": "API 金鑰",
|
||||
"free": "免費"
|
||||
},
|
||||
"general.auto_check_update.title": "啟用自動更新檢查",
|
||||
"quickPhrase": {
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -31,6 +31,10 @@ class WebSearchService {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider.id.startsWith('local-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (hasObjectKey(provider, 'apiKey')) {
|
||||
return provider.apiKey !== ''
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 94,
|
||||
version: 95,
|
||||
blacklist: ['runtime', 'messages'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
12
src/renderer/src/types/asr.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@ -336,6 +336,9 @@ export type WebSearchProvider = {
|
||||
apiKey?: string
|
||||
apiHost?: string
|
||||
engines?: string[]
|
||||
url?: string
|
||||
contentLimit?: number
|
||||
usingBrowser?: boolean
|
||||
}
|
||||
|
||||
export type WebSearchResponse = {
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user