diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index d07a0a5200..0fb9b71896 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -146,5 +146,9 @@ export enum IpcChannel { MiniWindowReload = 'miniwindow-reload', ReduxStateChange = 'redux-state-change', - ReduxStoreReady = 'redux-store-ready' + ReduxStoreReady = 'redux-store-ready', + + // ASR Server + ASR_StartServer = 'start-asr-server', + ASR_StopServer = 'stop-asr-server' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 47c13b0ef7..e0b5a0f733 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +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' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' @@ -28,11 +26,11 @@ import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' import { getResourcePath } from './utils' import { decrypt, encrypt } from './utils/aes' +import { registerASRServerIPC } from './services/ASRServerIPC' 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 +295,6 @@ 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 } - } - }) - - // 停止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处理程序 + registerASRServerIPC(ipcMain, app) } diff --git a/src/main/services/ASRServerIPC.ts b/src/main/services/ASRServerIPC.ts new file mode 100644 index 0000000000..b4c3b85f89 --- /dev/null +++ b/src/main/services/ASRServerIPC.ts @@ -0,0 +1,114 @@ +import fs from 'node:fs' +import { spawn, ChildProcess } from 'node:child_process' +import path from 'node:path' +import { IpcMain, App } from 'electron' +import { IpcChannel } from '@shared/IpcChannel' + +// 存储ASR服务器进程 +let asrServerProcess: ChildProcess | null = null + +/** + * 注册ASR服务器相关的IPC处理程序 + * @param ipcMain IPC主进程对象 + * @param app Electron应用对象 + */ +export function registerASRServerIPC(ipcMain: IpcMain, app: App): void { + // 启动ASR服务器 + ipcMain.handle(IpcChannel.ASR_StartServer, 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 } + } + }) + + // 停止ASR服务器 + ipcMain.handle(IpcChannel.ASR_StopServer, 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 } + } + }) +} diff --git a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts new file mode 100644 index 0000000000..2bb11f7f6f --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts @@ -0,0 +1,132 @@ +import { Readability } from '@mozilla/readability' +import { nanoid } from '@reduxjs/toolkit' +import { WebSearchProvider, WebSearchResponse, WebSearchResult } from '@renderer/types' +import TurndownService from 'turndown' + +import BaseWebSearchProvider from './BaseWebSearchProvider' + +export interface SearchItem { + title: string + url: string +} + +const noContent = 'No content found' + +export default class LocalSearchProvider extends BaseWebSearchProvider { + private turndownService: TurndownService = new TurndownService() + + constructor(provider: WebSearchProvider) { + if (!provider || !provider.url) { + throw new Error('Provider URL is required') + } + super(provider) + } + + public async search( + query: string, + maxResults: number = 15, + excludeDomains: string[] = [] + ): Promise { + const uid = nanoid() + try { + if (!query.trim()) { + throw new Error('Search query cannot be empty') + } + if (!this.provider.url) { + throw new Error('Provider URL is required') + } + + const cleanedQuery = query.split('\r\n')[1] ?? query + const url = this.provider.url.replace('%s', encodeURIComponent(cleanedQuery)) + const content = await window.api.searchService.openUrlInSearchWindow(uid, url) + + // Parse the content to extract URLs and metadata + const searchItems = this.parseValidUrls(content).slice(0, maxResults) + console.log('Total search items:', searchItems) + + const validItems = searchItems + .filter( + (item) => + (item.url.startsWith('http') || item.url.startsWith('https')) && + excludeDomains.includes(new URL(item.url).host) === false + ) + .slice(0, maxResults) + // console.log('Valid search items:', validItems) + + // Fetch content for each URL concurrently + const fetchPromises = validItems.map(async (item) => { + // console.log(`Fetching content for ${item.url}...`) + const result = await this.fetchPageContent(item.url, this.provider.usingBrowser) + if ( + this.provider.contentLimit && + this.provider.contentLimit != -1 && + result.content.length > this.provider.contentLimit + ) { + result.content = result.content.slice(0, this.provider.contentLimit) + '...' + } + return result + }) + + // Wait for all fetches to complete + const results: WebSearchResult[] = await Promise.all(fetchPromises) + + return { + query: query, + results: results.filter((result) => result.content != noContent) + } + } catch (error) { + console.error('Local search failed:', error) + throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + await window.api.searchService.closeSearchWindow(uid) + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected parseValidUrls(_htmlContent: string): SearchItem[] { + throw new Error('Not implemented') + } + + private async fetchPageContent(url: string, usingBrowser: boolean = false): Promise { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout + + let html: string + if (usingBrowser) { + html = await window.api.searchService.openUrlInSearchWindow(`search-window-${nanoid()}`, url) + } else { + const response = await fetch(url, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + }, + signal: controller.signal + }) + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`) + } + html = await response.text() + } + + clearTimeout(timeoutId) // Clear the timeout if fetch completes successfully + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + const article = new Readability(doc).parse() + // console.log('Parsed article:', article) + const markdown = this.turndownService.turndown(article?.content || '') + return { + title: article?.title || url, + url: url, + content: markdown || noContent + } + } catch (e: unknown) { + console.error(`Failed to fetch ${url}`, e) + return { + title: url, + url: url, + content: noContent + } + } + } +} diff --git a/src/renderer/src/services/ASRServerService.ts b/src/renderer/src/services/ASRServerService.ts index 6c8d77757f..6cf0afab37 100644 --- a/src/renderer/src/services/ASRServerService.ts +++ b/src/renderer/src/services/ASRServerService.ts @@ -1,4 +1,5 @@ import i18n from '@renderer/i18n' +import { IpcChannel } from '@shared/IpcChannel' // 使用window.electron而不是直接导入electron模块 // 这样可以避免__dirname不可用的问题 @@ -23,7 +24,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.electron.ipcRenderer.invoke(IpcChannel.ASR_StartServer) if (result.success) { this.isServerRunning = true @@ -65,7 +66,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.electron.ipcRenderer.invoke(IpcChannel.ASR_StopServer, this.serverProcess) if (result.success) { this.isServerRunning = false