完善小黑盒搜索功能,将小黑盒操作作为工具给大模型
This commit is contained in:
+370
-21
@@ -1,7 +1,7 @@
|
||||
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { chromium, BrowserContext } from 'playwright'
|
||||
import { existsSync, rmSync } from 'fs'
|
||||
import { existsSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
||||
import { ScraperFactory } from './scrapers'
|
||||
import { XiaoheiheScrap } from './scrapers/xiaoheihe'
|
||||
import { GenericScraper } from './scrapers/generic'
|
||||
@@ -11,6 +11,7 @@ import { XiaoheiheService } from './platforms/xiaoheihe'
|
||||
let floatingWindow: BrowserWindow | null = null
|
||||
let settingsWindow: BrowserWindow | null = null
|
||||
let chatWindow: BrowserWindow | null = null
|
||||
let toolsPanelWindow: BrowserWindow | null = null
|
||||
|
||||
// Initialize scraper factory
|
||||
const scraperFactory = new ScraperFactory()
|
||||
@@ -25,14 +26,139 @@ platformServiceFactory.register(new XiaoheiheService())
|
||||
let persistentContext: BrowserContext | null = null
|
||||
const userDataDir = join(app.getPath('userData'), 'browser-data')
|
||||
|
||||
// Settings file path
|
||||
const settingsDir = join(app.getPath('userData'), 'settings')
|
||||
const settingsFilePath = join(settingsDir, 'config.json')
|
||||
const loginInfoFilePath = join(settingsDir, 'login-info.json')
|
||||
|
||||
// Rate limiter for search operations
|
||||
class SearchRateLimiter {
|
||||
private lastSearchTime: number = 0
|
||||
private searchCount: number = 0
|
||||
private readonly minInterval: number = 3000 // 最小间隔 3 秒
|
||||
private readonly maxSearchPerMinute: number = 10 // 每分钟最多 10 次
|
||||
private readonly resetInterval: number = 60000 // 1 分钟重置计数
|
||||
|
||||
canSearch(): { allowed: boolean; waitTime?: number; reason?: string } {
|
||||
const now = Date.now()
|
||||
const timeSinceLastSearch = now - this.lastSearchTime
|
||||
|
||||
// 检查是否需要重置计数器
|
||||
if (timeSinceLastSearch > this.resetInterval) {
|
||||
this.searchCount = 0
|
||||
}
|
||||
|
||||
// 检查是否超过频率限制
|
||||
if (this.searchCount >= this.maxSearchPerMinute) {
|
||||
const waitTime = this.resetInterval - timeSinceLastSearch
|
||||
return {
|
||||
allowed: false,
|
||||
waitTime: Math.ceil(waitTime / 1000),
|
||||
reason: '搜索过于频繁,请稍后再试'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最小间隔
|
||||
if (timeSinceLastSearch < this.minInterval) {
|
||||
const waitTime = this.minInterval - timeSinceLastSearch
|
||||
return {
|
||||
allowed: false,
|
||||
waitTime: Math.ceil(waitTime / 1000),
|
||||
reason: '请求过快,请稍后再试'
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
recordSearch(): void {
|
||||
this.lastSearchTime = Date.now()
|
||||
this.searchCount++
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.searchCount = 0
|
||||
this.lastSearchTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
const searchRateLimiter = new SearchRateLimiter()
|
||||
|
||||
// Ensure settings directory exists
|
||||
function ensureSettingsDir(): void {
|
||||
if (!existsSync(settingsDir)) {
|
||||
mkdirSync(settingsDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Read settings from file
|
||||
function readSettings(): any {
|
||||
try {
|
||||
ensureSettingsDir()
|
||||
if (existsSync(settingsFilePath)) {
|
||||
const data = readFileSync(settingsFilePath, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
return {}
|
||||
} catch (error) {
|
||||
console.error('Failed to read settings:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Write settings to file
|
||||
function writeSettings(settings: any): { success: boolean; error?: string } {
|
||||
try {
|
||||
ensureSettingsDir()
|
||||
writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2), 'utf-8')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to write settings:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '保存设置失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read login info from file
|
||||
function readLoginInfo(): any {
|
||||
try {
|
||||
ensureSettingsDir()
|
||||
if (existsSync(loginInfoFilePath)) {
|
||||
const data = readFileSync(loginInfoFilePath, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
return {}
|
||||
} catch (error) {
|
||||
console.error('Failed to read login info:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Write login info to file
|
||||
function writeLoginInfo(loginInfo: any): { success: boolean; error?: string } {
|
||||
try {
|
||||
ensureSettingsDir()
|
||||
writeFileSync(loginInfoFilePath, JSON.stringify(loginInfo, null, 2), 'utf-8')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to write login info:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '保存登录信息失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createFloatingWindow(): void {
|
||||
const { width } = screen.getPrimaryDisplay().workAreaSize
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
floatingWindow = new BrowserWindow({
|
||||
width: 260,
|
||||
height: 160,
|
||||
x: width - 100,
|
||||
y: 20,
|
||||
width: 160,
|
||||
height: 200,
|
||||
x: width - 160,
|
||||
y: Math.floor((height - 200) / 2),
|
||||
frame: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
@@ -92,7 +218,8 @@ function createSettingsWindow(): void {
|
||||
}
|
||||
|
||||
function createChatWindow(initialText?: string): void {
|
||||
console.log('createChatWindow called with initialText:', initialText)
|
||||
const startTime = Date.now()
|
||||
console.log('[PERF] createChatWindow called at:', startTime)
|
||||
|
||||
// If chat window already exists, focus it and send new text if provided
|
||||
if (chatWindow && !chatWindow.isDestroyed()) {
|
||||
@@ -108,16 +235,18 @@ function createChatWindow(initialText?: string): void {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Creating new chat window')
|
||||
console.log('[PERF] Creating new chat window')
|
||||
chatWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
title: 'AI 对话',
|
||||
show: false, // Hide window during load for better perceived performance
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
contextIsolation: true,
|
||||
spellcheck: false // Disable spell check to avoid conflicts with IME
|
||||
}
|
||||
})
|
||||
|
||||
@@ -128,12 +257,20 @@ function createChatWindow(initialText?: string): void {
|
||||
chatWindow.loadFile(join(__dirname, '../renderer/chat.html'))
|
||||
}
|
||||
|
||||
// Show window when ready to avoid showing loading state
|
||||
chatWindow.once('ready-to-show', () => {
|
||||
const readyTime = Date.now()
|
||||
console.log('[PERF] Chat window ready-to-show at:', readyTime)
|
||||
console.log('[PERF] Time from create to ready:', readyTime - startTime, 'ms')
|
||||
chatWindow?.show()
|
||||
})
|
||||
|
||||
// Send initial text after page loads
|
||||
if (initialText) {
|
||||
console.log('Setting up did-finish-load listener for initial text')
|
||||
chatWindow.webContents.once('did-finish-load', () => {
|
||||
console.log('Chat window did-finish-load event fired')
|
||||
// Add a small delay to ensure React components are mounted
|
||||
// Add a small delay to ensure Vue components are mounted
|
||||
setTimeout(() => {
|
||||
if (chatWindow && !chatWindow.isDestroyed()) {
|
||||
console.log('Sending initial text to new window:', initialText)
|
||||
@@ -148,6 +285,49 @@ function createChatWindow(initialText?: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
// Create tools panel window
|
||||
function createToolsPanelWindow(): void {
|
||||
console.log('Creating tools panel window')
|
||||
|
||||
// If tools panel already exists, focus it
|
||||
if (toolsPanelWindow && !toolsPanelWindow.isDestroyed()) {
|
||||
console.log('Tools panel window already exists, focusing')
|
||||
toolsPanelWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
toolsPanelWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 700,
|
||||
title: '工具箱',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
spellcheck: false
|
||||
}
|
||||
})
|
||||
|
||||
// Load tools panel page
|
||||
if (process.env['ELECTRON_RENDERER_URL']) {
|
||||
toolsPanelWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/tools.html`)
|
||||
} else {
|
||||
toolsPanelWindow.loadFile(join(__dirname, '../renderer/tools.html'))
|
||||
}
|
||||
|
||||
// Show window when ready
|
||||
toolsPanelWindow.once('ready-to-show', () => {
|
||||
console.log('Tools panel window ready-to-show')
|
||||
toolsPanelWindow?.show()
|
||||
})
|
||||
|
||||
toolsPanelWindow.on('closed', () => {
|
||||
toolsPanelWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch article content using Playwright with factory pattern
|
||||
async function fetchArticleContent(url: string): Promise<{
|
||||
title: string
|
||||
@@ -169,8 +349,10 @@ async function fetchArticleContent(url: string): Promise<{
|
||||
hotScore: number
|
||||
}
|
||||
}> {
|
||||
console.log('fetchArticleContent: Starting to fetch article from URL:', url)
|
||||
let browser
|
||||
try {
|
||||
console.log('fetchArticleContent: Launching browser...')
|
||||
browser = await chromium.launch({ headless: true })
|
||||
const context = await browser.newContext({
|
||||
userAgent:
|
||||
@@ -178,25 +360,35 @@ async function fetchArticleContent(url: string): Promise<{
|
||||
})
|
||||
const page = await context.newPage()
|
||||
|
||||
console.log('fetchArticleContent: Navigating to URL...')
|
||||
// Navigate to the URL
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
|
||||
|
||||
// Get appropriate scraper for this URL
|
||||
console.log('fetchArticleContent: Getting scraper for URL...')
|
||||
const scraper = scraperFactory.getScraper(url)
|
||||
if (!scraper) {
|
||||
throw new Error('No suitable scraper found for this URL')
|
||||
}
|
||||
|
||||
console.log('fetchArticleContent: Using scraper:', scraper.constructor.name)
|
||||
// Use scraper to extract content
|
||||
const result = await scraper.scrape(page)
|
||||
|
||||
await browser.close()
|
||||
console.log('fetchArticleContent: Scraping completed, article title:', result.title)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (browser) {
|
||||
await browser.close()
|
||||
}
|
||||
console.error('fetchArticleContent: Error occurred:', error)
|
||||
throw error
|
||||
} finally {
|
||||
// Always close browser in finally block to ensure cleanup
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close()
|
||||
console.log('fetchArticleContent: Browser closed')
|
||||
} catch (closeError) {
|
||||
console.error('Failed to close browser:', closeError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +436,20 @@ async function waitForQrCodeLogin(): Promise<{
|
||||
return { success: false, error: '浏览器上下文未初始化' }
|
||||
}
|
||||
const service = new XiaoheiheService()
|
||||
return await service.waitForQrCodeLogin(persistentContext)
|
||||
const result = await service.waitForQrCodeLogin(persistentContext)
|
||||
|
||||
// Save login info if successful
|
||||
if (result.success && result.username) {
|
||||
const savedLoginInfo = readLoginInfo()
|
||||
savedLoginInfo['www.xiaoheihe.cn'] = {
|
||||
username: result.username,
|
||||
lastUpdate: new Date().toISOString()
|
||||
}
|
||||
writeLoginInfo(savedLoginInfo)
|
||||
console.log('Login info saved for user:', result.username)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Wait for QR code login error:', error)
|
||||
return {
|
||||
@@ -270,6 +475,26 @@ async function checkPlatformLoginFast(url: string): Promise<{
|
||||
const context = await getPersistentContext()
|
||||
const loginStatus = await service.checkLoginStatusFast(context)
|
||||
|
||||
// 如果已登录,尝试从本地文件读取用户名(如果 service 没有返回用户名)
|
||||
if (loginStatus.isLoggedIn && !loginStatus.username) {
|
||||
const savedLoginInfo = readLoginInfo()
|
||||
const platformKey = url.replace(/https?:\/\//, '').split('/')[0]
|
||||
if (savedLoginInfo[platformKey]?.username) {
|
||||
loginStatus.username = savedLoginInfo[platformKey].username
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已登录且有用户名,保存到本地文件
|
||||
if (loginStatus.isLoggedIn && loginStatus.username) {
|
||||
const savedLoginInfo = readLoginInfo()
|
||||
const platformKey = url.replace(/https?:\/\//, '').split('/')[0]
|
||||
savedLoginInfo[platformKey] = {
|
||||
username: loginStatus.username,
|
||||
lastUpdate: new Date().toISOString()
|
||||
}
|
||||
writeLoginInfo(savedLoginInfo)
|
||||
}
|
||||
|
||||
return { success: true, ...loginStatus }
|
||||
} catch (error) {
|
||||
console.error('Check platform login fast error:', error)
|
||||
@@ -288,6 +513,7 @@ async function checkPlatformLogin(url: string): Promise<{
|
||||
username?: string
|
||||
error?: string
|
||||
}> {
|
||||
let page: Awaited<ReturnType<BrowserContext['newPage']>> | undefined
|
||||
try {
|
||||
const service = platformServiceFactory.getService(url)
|
||||
if (!service) {
|
||||
@@ -295,11 +521,10 @@ async function checkPlatformLogin(url: string): Promise<{
|
||||
}
|
||||
|
||||
const context = await getPersistentContext()
|
||||
const page = await context.newPage()
|
||||
page = await context.newPage()
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
|
||||
|
||||
const loginStatus = await service.checkLoginStatus(page)
|
||||
await page.close()
|
||||
|
||||
return { success: true, ...loginStatus }
|
||||
} catch (error) {
|
||||
@@ -309,6 +534,15 @@ async function checkPlatformLogin(url: string): Promise<{
|
||||
isLoggedIn: false,
|
||||
error: error instanceof Error ? error.message : '检查登录状态失败'
|
||||
}
|
||||
} finally {
|
||||
// Always close page in finally block to ensure cleanup
|
||||
if (page) {
|
||||
try {
|
||||
await page.close()
|
||||
} catch (closeError) {
|
||||
console.error('Failed to close page:', closeError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +582,72 @@ async function postCommentToPlatform(
|
||||
}
|
||||
}
|
||||
|
||||
// Search platform content
|
||||
async function searchPlatform(
|
||||
platform: string,
|
||||
query: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
results?: Array<{
|
||||
title: string
|
||||
url: string
|
||||
author?: string
|
||||
publishTime?: string
|
||||
summary?: string
|
||||
commentCount?: number
|
||||
likeCount?: number
|
||||
}>
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
console.log('searchPlatform: Starting search on platform:', platform)
|
||||
console.log('searchPlatform: Query:', query)
|
||||
|
||||
// 检查搜索频率限制
|
||||
const rateLimitCheck = searchRateLimiter.canSearch()
|
||||
if (!rateLimitCheck.allowed) {
|
||||
console.log('searchPlatform: Rate limit exceeded')
|
||||
return {
|
||||
success: false,
|
||||
error: 'RATE_LIMIT_EXCEEDED',
|
||||
results: []
|
||||
}
|
||||
}
|
||||
|
||||
// 根据平台名称构造一个URL来获取对应的服务
|
||||
const platformUrls: Record<string, string> = {
|
||||
xiaoheihe: 'https://www.xiaoheihe.cn'
|
||||
}
|
||||
|
||||
const platformUrl = platformUrls[platform]
|
||||
if (!platformUrl) {
|
||||
return { success: false, error: '不支持的平台' }
|
||||
}
|
||||
|
||||
const service = platformServiceFactory.getService(platformUrl)
|
||||
if (!service) {
|
||||
return { success: false, error: '未找到平台服务' }
|
||||
}
|
||||
|
||||
console.log('searchPlatform: Getting persistent context...')
|
||||
const context = await getPersistentContext()
|
||||
console.log('searchPlatform: Context obtained, calling service.search...')
|
||||
|
||||
// 记录本次搜索
|
||||
searchRateLimiter.recordSearch()
|
||||
|
||||
const result = await service.search(context, query)
|
||||
console.log('searchPlatform: Search completed, found', result.results?.length || 0, 'results')
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('searchPlatform: Exception occurred:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '搜索失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerGlobalShortcuts(): void {
|
||||
// Register Command+K (Mac) or Ctrl+K (Windows/Linux)
|
||||
const shortcut = process.platform === 'darwin' ? 'Command+K' : 'Control+K'
|
||||
@@ -391,6 +691,18 @@ app.whenReady().then(() => {
|
||||
createSettingsWindow()
|
||||
})
|
||||
|
||||
// Handle open tools panel from renderer
|
||||
ipcMain.on('open-tools-panel', () => {
|
||||
createToolsPanelWindow()
|
||||
})
|
||||
|
||||
// Handle close tools panel from renderer
|
||||
ipcMain.on('close-tools-panel', () => {
|
||||
if (toolsPanelWindow && !toolsPanelWindow.isDestroyed()) {
|
||||
toolsPanelWindow.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle open chat window from renderer
|
||||
ipcMain.on('open-chat', (_, selectedText?: string) => {
|
||||
console.log('open-chat event received, selectedText:', selectedText)
|
||||
@@ -437,9 +749,22 @@ app.whenReady().then(() => {
|
||||
return await checkPlatformLoginFast(url)
|
||||
})
|
||||
|
||||
// Handle check platform login status
|
||||
ipcMain.handle('check-platform-login', async (_, url: string) => {
|
||||
return await checkPlatformLogin(url)
|
||||
// Handle check platform login status (accepts platform name or URL)
|
||||
ipcMain.handle('check-platform-login', async (_, arg: string | { platform: string }) => {
|
||||
// Support both old format (URL string) and new format (object with platform)
|
||||
let url: string
|
||||
if (typeof arg === 'string') {
|
||||
url = arg
|
||||
} else {
|
||||
const platformUrls: Record<string, string> = {
|
||||
xiaoheihe: 'https://www.xiaoheihe.cn'
|
||||
}
|
||||
url = platformUrls[arg.platform]
|
||||
if (!url) {
|
||||
return { success: false, isLoggedIn: false, error: '不支持的平台' }
|
||||
}
|
||||
}
|
||||
return await checkPlatformLoginFast(url)
|
||||
})
|
||||
|
||||
// Handle post comment to platform
|
||||
@@ -474,6 +799,12 @@ app.whenReady().then(() => {
|
||||
console.log('User data directory deleted successfully')
|
||||
}
|
||||
|
||||
// Clear saved login info from file
|
||||
const savedLoginInfo = readLoginInfo()
|
||||
delete savedLoginInfo['www.xiaoheihe.cn']
|
||||
writeLoginInfo(savedLoginInfo)
|
||||
console.log('Login info cleared for xiaoheihe')
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: '不支持的平台' }
|
||||
@@ -486,6 +817,24 @@ app.whenReady().then(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Handle search platform
|
||||
ipcMain.handle(
|
||||
'search-platform',
|
||||
async (_, { platform, query }: { platform: string; query: string }) => {
|
||||
return await searchPlatform(platform, query)
|
||||
}
|
||||
)
|
||||
|
||||
// Handle read settings
|
||||
ipcMain.handle('read-settings', () => {
|
||||
return readSettings()
|
||||
})
|
||||
|
||||
// Handle write settings
|
||||
ipcMain.handle('write-settings', (_, settings: any) => {
|
||||
return writeSettings(settings)
|
||||
})
|
||||
|
||||
createFloatingWindow()
|
||||
registerGlobalShortcuts()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user