新增小黑盒内容爬取,AI消息自动发送评论
This commit is contained in:
Generated
+46
-1
@@ -13,7 +13,8 @@
|
|||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"antd": "^5.28.0",
|
"antd": "^5.28.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"electron-updater": "^6.3.9"
|
"electron-updater": "^6.3.9",
|
||||||
|
"playwright": "^1.56.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||||
@@ -8435,6 +8436,50 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||||
|
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.56.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||||
|
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/plist": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,8 @@
|
|||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"antd": "^5.28.0",
|
"antd": "^5.28.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"electron-updater": "^6.3.9"
|
"electron-updater": "^6.3.9",
|
||||||
|
"playwright": "^1.56.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||||
|
|||||||
+318
-72
@@ -1,19 +1,37 @@
|
|||||||
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
|
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { chromium, BrowserContext } from 'playwright'
|
||||||
|
import { ScraperFactory } from './scrapers'
|
||||||
|
import { XiaoheiheScrap } from './scrapers/xiaoheihe'
|
||||||
|
import { GenericScraper } from './scrapers/generic'
|
||||||
|
import { PlatformServiceFactory } from './platforms'
|
||||||
|
import { XiaoheiheService } from './platforms/xiaoheihe'
|
||||||
|
|
||||||
let floatingWindow: BrowserWindow | null = null
|
let floatingWindow: BrowserWindow | null = null
|
||||||
let settingsWindow: BrowserWindow | null = null
|
let settingsWindow: BrowserWindow | null = null
|
||||||
let floatingWindowReady = false
|
let chatWindow: BrowserWindow | null = null
|
||||||
let tooltipOpenCache = false
|
|
||||||
|
// Initialize scraper factory
|
||||||
|
const scraperFactory = new ScraperFactory()
|
||||||
|
scraperFactory.register(new XiaoheiheScrap())
|
||||||
|
scraperFactory.register(new GenericScraper()) // Generic must be last (catch-all)
|
||||||
|
|
||||||
|
// Initialize platform service factory
|
||||||
|
const platformServiceFactory = new PlatformServiceFactory()
|
||||||
|
platformServiceFactory.register(new XiaoheiheService())
|
||||||
|
|
||||||
|
// Persistent browser context for maintaining login state
|
||||||
|
let persistentContext: BrowserContext | null = null
|
||||||
|
const userDataDir = join(app.getPath('userData'), 'browser-data')
|
||||||
|
|
||||||
function createFloatingWindow(): void {
|
function createFloatingWindow(): void {
|
||||||
const { width } = screen.getPrimaryDisplay().workAreaSize
|
const { width } = screen.getPrimaryDisplay().workAreaSize
|
||||||
|
|
||||||
floatingWindow = new BrowserWindow({
|
floatingWindow = new BrowserWindow({
|
||||||
width: 400,
|
width: 240,
|
||||||
height: 400,
|
height: 210,
|
||||||
x: width - 420,
|
x: width - 100,
|
||||||
y: 60,
|
y: 20,
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
@@ -32,63 +50,12 @@ function createFloatingWindow(): void {
|
|||||||
floatingWindow.setAlwaysOnTop(true, 'floating')
|
floatingWindow.setAlwaysOnTop(true, 'floating')
|
||||||
floatingWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
floatingWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||||
|
|
||||||
// Mark window as ready when content is loaded
|
|
||||||
floatingWindow.webContents.on('did-finish-load', () => {
|
|
||||||
floatingWindowReady = true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle mouse enter/leave events to control click-through
|
|
||||||
ipcMain.on('set-ignore-mouse-events', (_, ignore: boolean, options?: { forward: boolean }) => {
|
|
||||||
if (floatingWindow) {
|
|
||||||
floatingWindow.setIgnoreMouseEvents(ignore, options)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle open settings from renderer
|
|
||||||
ipcMain.on('open-settings', () => {
|
|
||||||
createSettingsWindow()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle quit app from renderer
|
|
||||||
ipcMain.on('quit-app', () => {
|
|
||||||
app.quit()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load the floating window HTML
|
// Load the floating window HTML
|
||||||
if (process.env['ELECTRON_RENDERER_URL']) {
|
if (process.env['ELECTRON_RENDERER_URL']) {
|
||||||
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
|
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
|
||||||
} else {
|
} else {
|
||||||
floatingWindow.loadFile(join(__dirname, '../renderer/floating.html'))
|
floatingWindow.loadFile(join(__dirname, '../renderer/floating.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle window drag
|
|
||||||
ipcMain.on('floating-window-move', (_, { x, y }) => {
|
|
||||||
if (floatingWindow) {
|
|
||||||
floatingWindow.setPosition(x, y)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle get window bounds
|
|
||||||
ipcMain.handle('get-window-bounds', () => {
|
|
||||||
if (floatingWindow) {
|
|
||||||
const bounds = floatingWindow.getBounds()
|
|
||||||
return { x: bounds.x, y: bounds.y }
|
|
||||||
}
|
|
||||||
return { x: 0, y: 0 }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Listen for tooltip state changes from renderer to update cache
|
|
||||||
ipcMain.on('tooltip-state-changed', (_, isOpen: boolean) => {
|
|
||||||
tooltipOpenCache = isOpen
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle check if tooltip is open
|
|
||||||
ipcMain.handle('is-tooltip-open', () => {
|
|
||||||
if (floatingWindow) {
|
|
||||||
return floatingWindow.webContents.executeJavaScript('window.__tooltipOpen || false')
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSettingsWindow(): void {
|
function createSettingsWindow(): void {
|
||||||
@@ -123,28 +90,234 @@ function createSettingsWindow(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createChatWindow(initialText?: string): void {
|
||||||
|
// If chat window already exists, focus it and send new text if provided
|
||||||
|
if (chatWindow && !chatWindow.isDestroyed()) {
|
||||||
|
chatWindow.focus()
|
||||||
|
if (initialText) {
|
||||||
|
chatWindow.webContents.send('set-initial-text', initialText)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chatWindow = new BrowserWindow({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
title: 'AI 对话',
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load chat page
|
||||||
|
if (process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
chatWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/chat.html`)
|
||||||
|
} else {
|
||||||
|
chatWindow.loadFile(join(__dirname, '../renderer/chat.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial text after page loads
|
||||||
|
if (initialText) {
|
||||||
|
chatWindow.webContents.once('did-finish-load', () => {
|
||||||
|
chatWindow?.webContents.send('set-initial-text', initialText)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chatWindow.on('closed', () => {
|
||||||
|
chatWindow = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch article content using Playwright with factory pattern
|
||||||
|
async function fetchArticleContent(url: string): Promise<{
|
||||||
|
title: string
|
||||||
|
author?: string
|
||||||
|
authorIp?: string
|
||||||
|
publishTime?: string
|
||||||
|
content: string
|
||||||
|
tags?: string[]
|
||||||
|
comments: Array<{
|
||||||
|
author: string
|
||||||
|
content: string
|
||||||
|
time?: string
|
||||||
|
replies?: Array<{ author: string; content: string; time?: string }>
|
||||||
|
}>
|
||||||
|
stats?: {
|
||||||
|
likes: number
|
||||||
|
favorites: number
|
||||||
|
commentCount: number
|
||||||
|
hotScore: number
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
let browser
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ headless: true })
|
||||||
|
const context = await browser.newContext({
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
|
||||||
|
// Navigate to the URL
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
|
||||||
|
|
||||||
|
// Get appropriate scraper for this URL
|
||||||
|
const scraper = scraperFactory.getScraper(url)
|
||||||
|
if (!scraper) {
|
||||||
|
throw new Error('No suitable scraper found for this URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use scraper to extract content
|
||||||
|
const result = await scraper.scrape(page)
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
if (browser) {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create persistent browser context
|
||||||
|
async function getPersistentContext(headless = true): Promise<BrowserContext> {
|
||||||
|
if (!persistentContext) {
|
||||||
|
persistentContext = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
headless, // 根据参数决定是否无头模式
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return persistentContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get login QR code
|
||||||
|
async function getLoginQrCode(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
qrCodeDataUrl?: string
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// 使用无头模式
|
||||||
|
const context = await getPersistentContext(true)
|
||||||
|
const service = new XiaoheiheService()
|
||||||
|
return await service.getLoginQrCode(context)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get login QR code error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '获取二维码失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for QR code login
|
||||||
|
async function waitForQrCodeLogin(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
username?: string
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
if (!persistentContext) {
|
||||||
|
return { success: false, error: '浏览器上下文未初始化' }
|
||||||
|
}
|
||||||
|
const service = new XiaoheiheService()
|
||||||
|
return await service.waitForQrCodeLogin(persistentContext)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wait for QR code login error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '等待登录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check login status for a platform
|
||||||
|
async function checkPlatformLogin(url: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
isLoggedIn: boolean
|
||||||
|
username?: string
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const service = platformServiceFactory.getService(url)
|
||||||
|
if (!service) {
|
||||||
|
return { success: false, isLoggedIn: false, error: '不支持的平台' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await getPersistentContext()
|
||||||
|
const 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) {
|
||||||
|
console.error('Check platform login error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
error: error instanceof Error ? error.message : '检查登录状态失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post comment to platform
|
||||||
|
async function postCommentToPlatform(
|
||||||
|
url: string,
|
||||||
|
comment: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
console.log('postCommentToPlatform: Starting with URL:', url)
|
||||||
|
console.log('postCommentToPlatform: Comment length:', comment?.length)
|
||||||
|
|
||||||
|
const service = platformServiceFactory.getService(url)
|
||||||
|
if (!service) {
|
||||||
|
console.error('postCommentToPlatform: No service found for URL')
|
||||||
|
return { success: false, error: '不支持的平台' }
|
||||||
|
}
|
||||||
|
console.log('postCommentToPlatform: Service found')
|
||||||
|
|
||||||
|
console.log('postCommentToPlatform: Getting persistent context...')
|
||||||
|
const context = await getPersistentContext()
|
||||||
|
console.log('postCommentToPlatform: Context obtained, calling service.postComment...')
|
||||||
|
|
||||||
|
const result = await service.postComment(context, url, comment)
|
||||||
|
console.log('postCommentToPlatform: Result from service.postComment:', JSON.stringify(result))
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('postCommentToPlatform: Exception occurred:', error)
|
||||||
|
console.error(
|
||||||
|
'postCommentToPlatform: Error stack:',
|
||||||
|
error instanceof Error ? error.stack : 'No stack trace'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '发送评论失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function registerGlobalShortcuts(): void {
|
function registerGlobalShortcuts(): void {
|
||||||
// Register Cmd+K (Mac) or Ctrl+K (Windows/Linux)
|
// Register Command+K (Mac) or Ctrl+K (Windows/Linux)
|
||||||
const shortcut = process.platform === 'darwin' ? 'Command+K' : 'Control+K'
|
const shortcut = process.platform === 'darwin' ? 'Command+K' : 'Control+K'
|
||||||
|
|
||||||
const registered = globalShortcut.register(shortcut, () => {
|
const registered = globalShortcut.register(shortcut, () => {
|
||||||
if (floatingWindow && !floatingWindow.isDestroyed() && floatingWindowReady) {
|
if (floatingWindow && !floatingWindow.isDestroyed()) {
|
||||||
// Get clipboard text immediately for faster response
|
// Get clipboard text for selected text
|
||||||
const selectedText = clipboard.readText('selection')
|
const selectedText = clipboard.readText('selection')
|
||||||
const text = selectedText || clipboard.readText()
|
const text = selectedText || clipboard.readText()
|
||||||
|
|
||||||
// Use cached state for instant response
|
if (text && text.trim()) {
|
||||||
if (tooltipOpenCache) {
|
// Send event to renderer to show prompt
|
||||||
// If tooltip is open, close it
|
floatingWindow.webContents.send('show-text-prompt', text.trim())
|
||||||
tooltipOpenCache = false
|
floatingWindow.focus()
|
||||||
floatingWindow.webContents.send('close-tooltip')
|
|
||||||
} else {
|
|
||||||
// If tooltip is closed, open it with selected text
|
|
||||||
if (text && text.trim()) {
|
|
||||||
tooltipOpenCache = true
|
|
||||||
floatingWindow.webContents.send('show-text-action-prompt', text)
|
|
||||||
floatingWindow.focus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -161,6 +334,79 @@ app.whenReady().then(() => {
|
|||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
|
||||||
|
// Handle mouse enter/leave events to control click-through
|
||||||
|
ipcMain.on('set-ignore-mouse-events', (_, ignore: boolean, options?: { forward: boolean }) => {
|
||||||
|
if (floatingWindow) {
|
||||||
|
floatingWindow.setIgnoreMouseEvents(ignore, options)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle open settings from renderer
|
||||||
|
ipcMain.on('open-settings', () => {
|
||||||
|
createSettingsWindow()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle open chat window from renderer
|
||||||
|
ipcMain.on('open-chat', (_, selectedText?: string) => {
|
||||||
|
console.log('open-chat event received, selectedText:', selectedText)
|
||||||
|
createChatWindow(selectedText)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle quit app from renderer
|
||||||
|
ipcMain.on('quit-app', () => {
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle window drag
|
||||||
|
ipcMain.on('floating-window-move', (_, { x, y }) => {
|
||||||
|
if (floatingWindow) {
|
||||||
|
floatingWindow.setPosition(x, y)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle get window bounds
|
||||||
|
ipcMain.handle('get-window-bounds', () => {
|
||||||
|
if (floatingWindow) {
|
||||||
|
const bounds = floatingWindow.getBounds()
|
||||||
|
return { x: bounds.x, y: bounds.y }
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle fetch article content
|
||||||
|
ipcMain.handle('fetch-article', async (_, url: string) => {
|
||||||
|
try {
|
||||||
|
const result = await fetchArticleContent(url)
|
||||||
|
return { success: true, ...result }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch article:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '抓取文章失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle check platform login status
|
||||||
|
ipcMain.handle('check-platform-login', async (_, url: string) => {
|
||||||
|
return await checkPlatformLogin(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle post comment to platform
|
||||||
|
ipcMain.handle('post-comment', async (_, { url, comment }: { url: string; comment: string }) => {
|
||||||
|
return await postCommentToPlatform(url, comment)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle get login QR code
|
||||||
|
ipcMain.handle('get-login-qrcode', async () => {
|
||||||
|
return await getLoginQrCode()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle wait for QR code login
|
||||||
|
ipcMain.handle('wait-qrcode-login', async () => {
|
||||||
|
return await waitForQrCodeLogin()
|
||||||
|
})
|
||||||
|
|
||||||
createFloatingWindow()
|
createFloatingWindow()
|
||||||
registerGlobalShortcuts()
|
registerGlobalShortcuts()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { BrowserContext, Page } from 'playwright'
|
||||||
|
|
||||||
|
// 平台服务接口
|
||||||
|
export interface PlatformService {
|
||||||
|
// 平台标识
|
||||||
|
canHandle(url: string): boolean
|
||||||
|
|
||||||
|
// 检查登录状态
|
||||||
|
checkLoginStatus(page: Page): Promise<{
|
||||||
|
isLoggedIn: boolean
|
||||||
|
username?: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
// 发送评论
|
||||||
|
postComment(
|
||||||
|
context: BrowserContext,
|
||||||
|
url: string,
|
||||||
|
comment: string
|
||||||
|
): Promise<{ success: boolean; error?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台服务工厂
|
||||||
|
export class PlatformServiceFactory {
|
||||||
|
private services: PlatformService[] = []
|
||||||
|
|
||||||
|
register(service: PlatformService): void {
|
||||||
|
this.services.push(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
getService(url: string): PlatformService | null {
|
||||||
|
return this.services.find((service) => service.canHandle(url)) || null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { BrowserContext, Page } from 'playwright'
|
||||||
|
import { PlatformService } from './index'
|
||||||
|
|
||||||
|
export class XiaoheiheService implements PlatformService {
|
||||||
|
canHandle(url: string): boolean {
|
||||||
|
return url.includes('xiaoheihe.cn')
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkLoginStatus(page: Page): Promise<{
|
||||||
|
isLoggedIn: boolean
|
||||||
|
username?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// 检查是否存在登录按钮(未登录状态)
|
||||||
|
const loginButton = await page.locator('.user-box__login').count()
|
||||||
|
if (loginButton > 0) {
|
||||||
|
return { isLoggedIn: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否存在用户名(已登录状态)
|
||||||
|
const usernameElement = await page.locator('.user-box__username').first()
|
||||||
|
const usernameCount = await usernameElement.count()
|
||||||
|
|
||||||
|
if (usernameCount > 0) {
|
||||||
|
const username = await usernameElement.textContent()
|
||||||
|
return {
|
||||||
|
isLoggedIn: true,
|
||||||
|
username: username?.trim() || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isLoggedIn: false }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check login status error:', error)
|
||||||
|
return { isLoggedIn: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取登录二维码
|
||||||
|
async getLoginQrCode(
|
||||||
|
context: BrowserContext
|
||||||
|
): Promise<{ success: boolean; qrCodeDataUrl?: string; error?: string }> {
|
||||||
|
let page: Page | null = null
|
||||||
|
try {
|
||||||
|
page = await context.newPage()
|
||||||
|
await page.goto('https://www.xiaoheihe.cn/app/bbs/home', {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
|
||||||
|
// 等待页面加载
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
// 检查是否已登录
|
||||||
|
const loginStatus = await this.checkLoginStatus(page)
|
||||||
|
if (loginStatus.isLoggedIn) {
|
||||||
|
await page.close()
|
||||||
|
return { success: false, error: '用户已登录' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击登录按钮
|
||||||
|
const loginButton = page.locator('.user-box__login')
|
||||||
|
await loginButton.waitFor({ state: 'visible', timeout: 10000 })
|
||||||
|
await loginButton.click()
|
||||||
|
|
||||||
|
// 等待登录弹窗和二维码出现
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
// 等待 canvas 元素出现
|
||||||
|
const qrCanvas = page.locator('#login-qrcode')
|
||||||
|
await qrCanvas.waitFor({ state: 'visible', timeout: 10000 })
|
||||||
|
|
||||||
|
// 获取二维码的 dataURL
|
||||||
|
const qrCodeDataUrl = await page.evaluate(() => {
|
||||||
|
const canvas = document.querySelector('#login-qrcode') as HTMLCanvasElement
|
||||||
|
if (canvas) {
|
||||||
|
return canvas.toDataURL('image/png')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!qrCodeDataUrl) {
|
||||||
|
await page.close()
|
||||||
|
return { success: false, error: '无法获取二维码' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不关闭页面,保持登录状态检测
|
||||||
|
// 将页面保存起来用于轮询
|
||||||
|
return { success: true, qrCodeDataUrl }
|
||||||
|
} catch (error) {
|
||||||
|
if (page) {
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
console.error('Get login QR code error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '获取二维码失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待用户扫码登录完成
|
||||||
|
async waitForQrCodeLogin(
|
||||||
|
context: BrowserContext,
|
||||||
|
maxWaitTime = 120000 // 2分钟超时
|
||||||
|
): Promise<{ success: boolean; username?: string; error?: string }> {
|
||||||
|
let page: Page | null = null
|
||||||
|
try {
|
||||||
|
console.log('waitForQrCodeLogin: Starting...')
|
||||||
|
// 使用现有的第一个页面(即显示二维码的页面)
|
||||||
|
const pages = context.pages()
|
||||||
|
console.log('waitForQrCodeLogin: Found', pages.length, 'pages')
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
return { success: false, error: '未找到登录页面' }
|
||||||
|
}
|
||||||
|
page = pages[0]
|
||||||
|
console.log('waitForQrCodeLogin: Using first page, URL:', page.url())
|
||||||
|
|
||||||
|
// 尝试关闭登录弹窗(如果存在)
|
||||||
|
try {
|
||||||
|
console.log('waitForQrCodeLogin: Attempting to close modal with Escape key')
|
||||||
|
// 点击弹窗的关闭按钮或按 ESC 键
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
console.log('waitForQrCodeLogin: Modal close attempt completed')
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误,继续执行
|
||||||
|
console.log('waitForQrCodeLogin: Close modal error (ignored):', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
let checkCount = 0
|
||||||
|
|
||||||
|
// 轮询检测登录状态
|
||||||
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
|
checkCount++
|
||||||
|
console.log(`waitForQrCodeLogin: Check #${checkCount} - Checking login status...`)
|
||||||
|
|
||||||
|
const loginStatus = await this.checkLoginStatus(page)
|
||||||
|
console.log(`waitForQrCodeLogin: Check #${checkCount} - isLoggedIn:`, loginStatus.isLoggedIn, 'username:', loginStatus.username)
|
||||||
|
|
||||||
|
if (loginStatus.isLoggedIn) {
|
||||||
|
console.log('waitForQrCodeLogin: Login detected! Closing page and returning success')
|
||||||
|
await page.close()
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
username: loginStatus.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`waitForQrCodeLogin: Check #${checkCount} - Not logged in yet, waiting 2 seconds...`)
|
||||||
|
await page.waitForTimeout(2000) // 每2秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('waitForQrCodeLogin: Timeout reached, closing page')
|
||||||
|
await page.close()
|
||||||
|
return { success: false, error: '登录超时' }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('waitForQrCodeLogin: Exception occurred:', error)
|
||||||
|
if (page) {
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '等待登录失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postComment(
|
||||||
|
context: BrowserContext,
|
||||||
|
url: string,
|
||||||
|
comment: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
let page: Page | null = null
|
||||||
|
try {
|
||||||
|
console.log('postComment: Starting...')
|
||||||
|
console.log('postComment: URL:', url)
|
||||||
|
console.log('postComment: Comment length:', comment?.length)
|
||||||
|
console.log('postComment: Comment content:', comment)
|
||||||
|
|
||||||
|
if (!url || !comment) {
|
||||||
|
console.error('postComment: Missing required parameters')
|
||||||
|
return { success: false, error: '缺少必要的参数' }
|
||||||
|
}
|
||||||
|
|
||||||
|
page = await context.newPage()
|
||||||
|
console.log('postComment: New page created')
|
||||||
|
|
||||||
|
console.log('postComment: Navigating to URL...')
|
||||||
|
// 使用 domcontentloaded 而不是 networkidle,因为 networkidle 可能永远不会触发
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||||
|
console.log('postComment: Page loaded (DOM ready)')
|
||||||
|
|
||||||
|
// 等待页面内容加载
|
||||||
|
await page.waitForTimeout(3000)
|
||||||
|
console.log('postComment: Waited for page to settle')
|
||||||
|
|
||||||
|
// 检查登录状态
|
||||||
|
console.log('postComment: Checking login status...')
|
||||||
|
const loginStatus = await this.checkLoginStatus(page)
|
||||||
|
console.log('postComment: Login status:', JSON.stringify(loginStatus))
|
||||||
|
|
||||||
|
if (!loginStatus.isLoggedIn) {
|
||||||
|
console.error('postComment: User not logged in')
|
||||||
|
await page.close()
|
||||||
|
return { success: false, error: '用户未登录,请先登录小黑盒' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定位评论输入框(contenteditable)
|
||||||
|
const editorSelector = '.link-reply__editor .ProseMirror[contenteditable="true"]'
|
||||||
|
console.log('postComment: Looking for editor with selector:', editorSelector)
|
||||||
|
const editor = page.locator(editorSelector).first()
|
||||||
|
|
||||||
|
// 等待编辑器出现
|
||||||
|
console.log('postComment: Waiting for editor to be visible...')
|
||||||
|
await editor.waitFor({ state: 'visible', timeout: 10000 })
|
||||||
|
console.log('postComment: Editor is visible')
|
||||||
|
|
||||||
|
// 点击编辑器以聚焦
|
||||||
|
console.log('postComment: Clicking editor...')
|
||||||
|
await editor.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
console.log('postComment: Editor clicked')
|
||||||
|
|
||||||
|
// 填充评论内容(使用 fill 或 type)
|
||||||
|
// 对于 contenteditable,需要直接设置 innerHTML
|
||||||
|
console.log('postComment: Filling comment content...')
|
||||||
|
const fillResult = await page.evaluate(
|
||||||
|
({ selector, text }) => {
|
||||||
|
const element = document.querySelector(selector) as HTMLElement
|
||||||
|
if (element) {
|
||||||
|
// 创建一个文本节点或段落
|
||||||
|
element.innerHTML = `<p>${text.replace(/\n/g, '<br>')}</p>`
|
||||||
|
// 触发 input 事件
|
||||||
|
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
return { success: true, hasContent: element.innerHTML.length > 0 }
|
||||||
|
}
|
||||||
|
return { success: false, hasContent: false }
|
||||||
|
},
|
||||||
|
{ selector: editorSelector, text: comment }
|
||||||
|
)
|
||||||
|
console.log('postComment: Comment content filled, result:', JSON.stringify(fillResult))
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
// 点击发送按钮
|
||||||
|
const sendButtonSelector = '.link-reply__menu-btn.hb-color__btn--confirm'
|
||||||
|
console.log('postComment: Looking for send button with selector:', sendButtonSelector)
|
||||||
|
const sendButton = page.locator(sendButtonSelector).first()
|
||||||
|
|
||||||
|
console.log('postComment: Waiting for send button to be visible...')
|
||||||
|
await sendButton.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
console.log('postComment: Send button is visible')
|
||||||
|
|
||||||
|
console.log('postComment: Clicking send button...')
|
||||||
|
await sendButton.click()
|
||||||
|
console.log('postComment: Send button clicked successfully')
|
||||||
|
|
||||||
|
// 等待评论提交完成
|
||||||
|
console.log('postComment: Waiting for comment submission...')
|
||||||
|
await page.waitForTimeout(3000)
|
||||||
|
console.log('postComment: Waited 3 seconds after sending')
|
||||||
|
|
||||||
|
// 验证评论是否发送成功(可以检查是否有成功提示或评论出现)
|
||||||
|
// 这里简单等待,实际可以检查页面变化
|
||||||
|
await page.close()
|
||||||
|
console.log('postComment: Page closed, returning success')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('postComment: Exception occurred:', error)
|
||||||
|
console.error(
|
||||||
|
'postComment: Error stack:',
|
||||||
|
error instanceof Error ? error.stack : 'No stack trace'
|
||||||
|
)
|
||||||
|
if (page) {
|
||||||
|
try {
|
||||||
|
await page.close()
|
||||||
|
console.log('postComment: Page closed after error')
|
||||||
|
} catch (closeError) {
|
||||||
|
console.error('postComment: Error closing page:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '发送评论失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Page } from 'playwright'
|
||||||
|
import { Scraper, ScraperResult } from './index'
|
||||||
|
|
||||||
|
export class GenericScraper implements Scraper {
|
||||||
|
canHandle(url: string): boolean {
|
||||||
|
// Generic scraper can handle any URL
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async scrape(page: Page): Promise<ScraperResult> {
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
// Remove unwanted elements
|
||||||
|
const selectorsToRemove = [
|
||||||
|
'script',
|
||||||
|
'style',
|
||||||
|
'nav',
|
||||||
|
'header',
|
||||||
|
'footer',
|
||||||
|
'aside',
|
||||||
|
'.ad',
|
||||||
|
'.advertisement',
|
||||||
|
'.sidebar',
|
||||||
|
'.navigation',
|
||||||
|
'.menu'
|
||||||
|
]
|
||||||
|
|
||||||
|
selectorsToRemove.forEach((selector) => {
|
||||||
|
const elements = document.querySelectorAll(selector)
|
||||||
|
elements.forEach((el) => el.remove())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extract title
|
||||||
|
const titleElement = document.querySelector('h1') || document.querySelector('title')
|
||||||
|
const title = titleElement?.textContent?.trim() || ''
|
||||||
|
|
||||||
|
// Try to find main content area
|
||||||
|
const mainContent =
|
||||||
|
document.querySelector('article') ||
|
||||||
|
document.querySelector('[role="main"]') ||
|
||||||
|
document.querySelector('.article-content') ||
|
||||||
|
document.querySelector('.post-content') ||
|
||||||
|
document.querySelector('.content') ||
|
||||||
|
document.querySelector('main') ||
|
||||||
|
document.body
|
||||||
|
|
||||||
|
// Get text content
|
||||||
|
let content = mainContent?.textContent || ''
|
||||||
|
|
||||||
|
// Clean up whitespace
|
||||||
|
content = content
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\n\s*\n/g, '\n')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
comments: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Page } from 'playwright'
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
author: string
|
||||||
|
content: string
|
||||||
|
time?: string
|
||||||
|
replies?: Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScraperResult {
|
||||||
|
title: string
|
||||||
|
author?: string
|
||||||
|
authorIp?: string
|
||||||
|
publishTime?: string
|
||||||
|
content: string
|
||||||
|
tags?: string[]
|
||||||
|
comments: Comment[]
|
||||||
|
stats?: {
|
||||||
|
likes: number
|
||||||
|
favorites: number
|
||||||
|
commentCount: number
|
||||||
|
hotScore: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scraper {
|
||||||
|
canHandle(url: string): boolean
|
||||||
|
scrape(page: Page): Promise<ScraperResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScraperFactory {
|
||||||
|
private scrapers: Scraper[] = []
|
||||||
|
|
||||||
|
register(scraper: Scraper): void {
|
||||||
|
this.scrapers.push(scraper)
|
||||||
|
}
|
||||||
|
|
||||||
|
getScraper(url: string): Scraper | null {
|
||||||
|
return this.scrapers.find((scraper) => scraper.canHandle(url)) || null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { Page } from 'playwright'
|
||||||
|
import { Scraper, ScraperResult, Comment } from './index'
|
||||||
|
|
||||||
|
export class XiaoheiheScrap implements Scraper {
|
||||||
|
canHandle(url: string): boolean {
|
||||||
|
return url.includes('xiaoheihe.cn')
|
||||||
|
}
|
||||||
|
|
||||||
|
async scrape(page: Page): Promise<ScraperResult> {
|
||||||
|
// Wait for the main content to load - specifically for xiaoheihe
|
||||||
|
await page.waitForSelector('.image-text__content, [class*="content"]', {
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait a bit more for dynamic content
|
||||||
|
await page.waitForTimeout(3000)
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
// Helper function to convert relative time to absolute time
|
||||||
|
const parseRelativeTime = (relativeTime: string): string => {
|
||||||
|
const now = new Date()
|
||||||
|
const match = relativeTime.match(/(\d+)\s*(分钟|小时|天|月|年)前/)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const value = parseInt(match[1])
|
||||||
|
const unit = match[2]
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case '分钟':
|
||||||
|
now.setMinutes(now.getMinutes() - value)
|
||||||
|
break
|
||||||
|
case '小时':
|
||||||
|
now.setHours(now.getHours() - value)
|
||||||
|
break
|
||||||
|
case '天':
|
||||||
|
now.setDate(now.getDate() - value)
|
||||||
|
break
|
||||||
|
case '月':
|
||||||
|
now.setMonth(now.getMonth() - value)
|
||||||
|
break
|
||||||
|
case '年':
|
||||||
|
now.setFullYear(now.getFullYear() - value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format as Chinese datetime
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
// 只有分钟和小时显示具体时分秒,天/月/年只显示日期
|
||||||
|
if (unit === '分钟' || unit === '小时') {
|
||||||
|
const hour = String(now.getHours()).padStart(2, '0')
|
||||||
|
const minute = String(now.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||||
|
} else {
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativeTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title
|
||||||
|
const titleElement =
|
||||||
|
document.querySelector('h1') ||
|
||||||
|
document.querySelector('.title') ||
|
||||||
|
document.querySelector('[class*="title"]')
|
||||||
|
const title = titleElement?.textContent?.trim() || ''
|
||||||
|
|
||||||
|
// Extract author info from .link-user__info
|
||||||
|
const authorElement = document.querySelector('.link-user__username')
|
||||||
|
const author = authorElement?.textContent?.trim() || ''
|
||||||
|
|
||||||
|
// Extract author IP
|
||||||
|
const authorIpElement = document.querySelector('.link-data__ip')
|
||||||
|
const authorIp = authorIpElement?.textContent?.trim().replace('·', '').trim() || ''
|
||||||
|
|
||||||
|
// Extract publish time
|
||||||
|
const publishTimeElement = document.querySelector('.link-data__time')
|
||||||
|
const rawPublishTime = publishTimeElement?.textContent?.trim() || ''
|
||||||
|
const publishTime = parseRelativeTime(rawPublishTime)
|
||||||
|
|
||||||
|
// Extract main content - specifically for xiaoheihe
|
||||||
|
// Try multiple selectors for content
|
||||||
|
const contentElement =
|
||||||
|
document.querySelector('.post__content') ||
|
||||||
|
document.querySelector('.image-text__content')
|
||||||
|
|
||||||
|
let content = ''
|
||||||
|
if (contentElement) {
|
||||||
|
// Clone the element to avoid modifying the original DOM
|
||||||
|
const clonedElement = contentElement.cloneNode(true) as HTMLElement
|
||||||
|
|
||||||
|
// Remove unwanted elements
|
||||||
|
const unwantedElements = clonedElement.querySelectorAll(
|
||||||
|
'script, style, .com-img, .com-origin-source, svg'
|
||||||
|
)
|
||||||
|
unwantedElements.forEach((el) => el.remove())
|
||||||
|
|
||||||
|
// Get all text paragraphs
|
||||||
|
const textParagraphs = clonedElement.querySelectorAll('.com-text')
|
||||||
|
if (textParagraphs.length > 0) {
|
||||||
|
// Extract text from each paragraph
|
||||||
|
const paragraphTexts: string[] = []
|
||||||
|
textParagraphs.forEach((p) => {
|
||||||
|
const text = p.textContent?.trim()
|
||||||
|
// Skip empty paragraphs
|
||||||
|
if (text && !p.classList.contains('empty')) {
|
||||||
|
paragraphTexts.push(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
content = paragraphTexts.join('\n\n')
|
||||||
|
} else {
|
||||||
|
// Fallback to textContent if no .com-text found
|
||||||
|
content = clonedElement.textContent?.trim() || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tags
|
||||||
|
const tags: string[] = []
|
||||||
|
const tagElements = document.querySelectorAll('.link-section-tags .content-tag-text')
|
||||||
|
tagElements.forEach((tagEl) => {
|
||||||
|
const tagText = tagEl.textContent?.trim()
|
||||||
|
if (tagText) {
|
||||||
|
tags.push(tagText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extract statistics (likes, favorites, comments count)
|
||||||
|
const operationItems = document.querySelectorAll('.link-reply__operation-item')
|
||||||
|
let likes = 0
|
||||||
|
let favorites = 0
|
||||||
|
let commentCount = 0
|
||||||
|
|
||||||
|
operationItems.forEach((item) => {
|
||||||
|
const icon = item.querySelector('i')
|
||||||
|
const countText = item.querySelector('.link-reply__operation-desc')?.textContent?.trim()
|
||||||
|
const count = parseInt(countText || '0')
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
const iconClass = icon.className
|
||||||
|
if (iconClass.includes('thumbs-up')) {
|
||||||
|
likes = count
|
||||||
|
} else if (iconClass.includes('star')) {
|
||||||
|
favorites = count
|
||||||
|
} else if (iconClass.includes('comment')) {
|
||||||
|
commentCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate hot score (1-100)
|
||||||
|
// 评分算法设计:
|
||||||
|
// - 点赞权重: 40%
|
||||||
|
// - 评论权重: 35% (评论互动性更高)
|
||||||
|
// - 收藏权重: 25%
|
||||||
|
//
|
||||||
|
// 分数段设计(基于小黑盒实际情况):
|
||||||
|
// 1-20分: 冷门文章 (点赞<10, 评论<5)
|
||||||
|
// 21-40分: 普通文章 (点赞10-50, 评论5-20)
|
||||||
|
// 41-60分: 有一定热度 (点赞50-200, 评论20-80)
|
||||||
|
// 61-80分: 热门文章 (点赞200-800, 评论80-300)
|
||||||
|
// 81-100分: 爆款文章 (点赞>800, 评论>300)
|
||||||
|
const calculateHotScore = (likes: number, favorites: number, comments: number): number => {
|
||||||
|
// 使用对数函数来平滑分数分布,避免极端值
|
||||||
|
const likesScore = Math.min(40, (Math.log10(likes + 1) / Math.log10(1000)) * 40)
|
||||||
|
const commentsScore = Math.min(35, (Math.log10(comments + 1) / Math.log10(500)) * 35)
|
||||||
|
const favoritesScore = Math.min(25, (Math.log10(favorites + 1) / Math.log10(500)) * 25)
|
||||||
|
|
||||||
|
const rawScore = likesScore + commentsScore + favoritesScore
|
||||||
|
// 确保分数在1-100之间,至少为1分
|
||||||
|
return Math.max(1, Math.min(100, Math.round(rawScore)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotScore = calculateHotScore(likes, favorites, commentCount)
|
||||||
|
|
||||||
|
// Extract comments with replies
|
||||||
|
const comments: Comment[] = []
|
||||||
|
const commentItems = document.querySelectorAll(
|
||||||
|
'.link-comment__list > .link-comment__comment-item'
|
||||||
|
)
|
||||||
|
|
||||||
|
commentItems.forEach((commentEl) => {
|
||||||
|
// Get main comment author
|
||||||
|
const authorEl = commentEl.querySelector('.info-box__username')
|
||||||
|
const commentAuthor = authorEl?.textContent?.trim() || '匿名'
|
||||||
|
|
||||||
|
// Get main comment content
|
||||||
|
const contentEl = commentEl.querySelector('.comment-item__content')
|
||||||
|
const commentContent = contentEl?.textContent?.trim() || ''
|
||||||
|
|
||||||
|
// Get time and location
|
||||||
|
const timeEl = commentEl.querySelector('.info-box__create-time')
|
||||||
|
const ipEl = commentEl.querySelector('.info-box__ip')
|
||||||
|
const rawTime = timeEl?.textContent?.trim() || ''
|
||||||
|
const time = parseRelativeTime(rawTime)
|
||||||
|
const ip = ipEl?.textContent?.trim()
|
||||||
|
const timeInfo = [time, ip].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
// Get replies (子评论)
|
||||||
|
const replies: Comment[] = []
|
||||||
|
const replyItems = commentEl.querySelectorAll('.comment-children-item')
|
||||||
|
|
||||||
|
replyItems.forEach((replyEl) => {
|
||||||
|
const replyAuthorEl = replyEl.querySelector('.children-item__comment-creator')
|
||||||
|
const replyAuthor = replyAuthorEl?.textContent?.trim() || '匿名'
|
||||||
|
|
||||||
|
const replyContentEl = replyEl.querySelector('.children-item__comment-content')
|
||||||
|
const replyContent = replyContentEl?.textContent?.trim() || ''
|
||||||
|
|
||||||
|
// Get reply target (回复谁)
|
||||||
|
const replyToEl = replyEl.querySelector('.children-item__reply-to')
|
||||||
|
let replyToText = replyToEl?.textContent?.trim() || ''
|
||||||
|
// Remove trailing colon
|
||||||
|
replyToText = replyToText.replace(/:\s*$/, '').trim()
|
||||||
|
|
||||||
|
const replyTimeEl = replyEl.querySelector('.children-item__create-time')
|
||||||
|
const replyIpEl = replyEl.querySelector('.children-item__ip')
|
||||||
|
const rawReplyTime = replyTimeEl?.textContent?.trim() || ''
|
||||||
|
const replyTime = parseRelativeTime(rawReplyTime)
|
||||||
|
const replyIp = replyIpEl?.textContent?.trim()
|
||||||
|
const replyTimeInfo = [replyTime, replyIp].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
if (replyContent) {
|
||||||
|
// Build reply content with context
|
||||||
|
let fullReplyContent = replyContent
|
||||||
|
if (replyToText && replyToText !== ':') {
|
||||||
|
fullReplyContent = `${replyToText} ${replyContent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
replies.push({
|
||||||
|
author: replyAuthor,
|
||||||
|
content: fullReplyContent,
|
||||||
|
time: replyTimeInfo
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (commentContent) {
|
||||||
|
comments.push({
|
||||||
|
author: commentAuthor,
|
||||||
|
content: commentContent,
|
||||||
|
time: timeInfo,
|
||||||
|
replies: replies.length > 0 ? replies : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
authorIp,
|
||||||
|
publishTime,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
comments,
|
||||||
|
stats: {
|
||||||
|
likes,
|
||||||
|
favorites,
|
||||||
|
commentCount,
|
||||||
|
hotScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up content
|
||||||
|
result.content = result.content
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\n\s*\n/g, '\n')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>AI 对话</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/chat.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import Chat from './components/Chat'
|
||||||
|
import { ConfigProvider, theme } from 'antd'
|
||||||
|
|
||||||
|
export const ChatApp: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.defaultAlgorithm
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chat />
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ChatApp />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Input, Button, Typography, Space, Modal, message } from 'antd'
|
||||||
|
import { SendOutlined, CommentOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentData {
|
||||||
|
author: string
|
||||||
|
content: string
|
||||||
|
time?: string
|
||||||
|
replies?: CommentData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const Chat: React.FC = () => {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [currentArticleUrl, setCurrentArticleUrl] = useState<string>('')
|
||||||
|
const [lastAiResponse, setLastAiResponse] = useState<string>('')
|
||||||
|
const [isPostingComment, setIsPostingComment] = useState(false)
|
||||||
|
const [isQrModalVisible, setIsQrModalVisible] = useState(false)
|
||||||
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('')
|
||||||
|
const [qrCodeError, setQrCodeError] = useState<string>('')
|
||||||
|
const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false)
|
||||||
|
const [confirmUsername, setConfirmUsername] = useState<string>('')
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const shouldAutoScrollRef = useRef(true)
|
||||||
|
const isArticleRequestRef = useRef(false) // 使用 ref 而不是 state 以避免异步问题
|
||||||
|
|
||||||
|
// Listen for initial text from main process
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.ipcRenderer.on(
|
||||||
|
'set-initial-text',
|
||||||
|
(_: unknown, text: string) => {
|
||||||
|
setInputValue(text)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Check if user is near bottom and update auto-scroll flag
|
||||||
|
useEffect(() => {
|
||||||
|
const container = messagesContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const handleScroll = (): void => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = container
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
// If user is within 100px of bottom, enable auto-scroll
|
||||||
|
shouldAutoScrollRef.current = distanceFromBottom < 100
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll)
|
||||||
|
return () => container.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto scroll to bottom when new messages arrive or content updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldAutoScrollRef.current) {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: isLoading ? 'auto' : 'smooth' })
|
||||||
|
}
|
||||||
|
}, [messages, isLoading])
|
||||||
|
|
||||||
|
const handleSend = async (): Promise<void> => {
|
||||||
|
if (!inputValue.trim() || isLoading) return
|
||||||
|
|
||||||
|
const input = inputValue.trim()
|
||||||
|
|
||||||
|
// Detect if input contains URL
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||||
|
const urls = input.match(urlRegex)
|
||||||
|
|
||||||
|
let finalContent = input
|
||||||
|
|
||||||
|
// If URL detected, try to fetch article content using Playwright
|
||||||
|
if (urls && urls.length > 0) {
|
||||||
|
const url = urls[0]
|
||||||
|
setCurrentArticleUrl(url) // 保存文章 URL
|
||||||
|
setLastAiResponse('') // 清空之前的 AI 回复
|
||||||
|
isArticleRequestRef.current = true // 标记这是文章请求
|
||||||
|
|
||||||
|
// Show fetching status
|
||||||
|
const fetchingMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: '正在抓取文章内容...',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
setMessages((prev) => [...prev, fetchingMessage])
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call main process to fetch article using Playwright
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('fetch-article', url)
|
||||||
|
|
||||||
|
// Remove fetching message
|
||||||
|
setMessages((prev) => prev.filter((msg) => msg.id !== fetchingMessage.id))
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Build formatted content with title, author, tags, article, and comments
|
||||||
|
// 将文章内容作为附加信息添加到用户的原始输入后面
|
||||||
|
let articleInfo = `\n\n--- 以下是文章详细信息 ---\n\n文章链接:${url}\n\n`
|
||||||
|
|
||||||
|
// Add title if available
|
||||||
|
if (result.title) {
|
||||||
|
articleInfo += `标题:${result.title}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add author info if available
|
||||||
|
if (result.author) {
|
||||||
|
let authorInfo = `作者:${result.author}`
|
||||||
|
if (result.authorIp) {
|
||||||
|
authorInfo += ` (${result.authorIp})`
|
||||||
|
}
|
||||||
|
if (result.publishTime) {
|
||||||
|
authorInfo += ` - ${result.publishTime}`
|
||||||
|
}
|
||||||
|
articleInfo += `${authorInfo}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tags if available
|
||||||
|
if (result.tags && result.tags.length > 0) {
|
||||||
|
articleInfo += `标签:${result.tags.join(', ')}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add statistics if available
|
||||||
|
if (result.stats) {
|
||||||
|
const { likes, favorites, commentCount, hotScore } = result.stats
|
||||||
|
articleInfo += `数据统计:👍 ${likes} | ⭐ ${favorites} | 💬 ${commentCount} | 🔥 热度 ${hotScore}/100\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
articleInfo += '\n'
|
||||||
|
|
||||||
|
// Add main content
|
||||||
|
const articleContent = result.content || ''
|
||||||
|
const contentLimit = 5000
|
||||||
|
articleInfo += `正文内容:\n${articleContent.substring(0, contentLimit)}`
|
||||||
|
|
||||||
|
if (articleContent.length > contentLimit) {
|
||||||
|
articleInfo += '\n\n(正文过长,已截取部分内容)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add comments if available
|
||||||
|
if (result.comments && result.comments.length > 0) {
|
||||||
|
articleInfo += '\n\n评论区:\n'
|
||||||
|
const commentLimit = 15
|
||||||
|
const commentsToShow = result.comments.slice(0, commentLimit)
|
||||||
|
|
||||||
|
commentsToShow.forEach((comment: CommentData, index: number) => {
|
||||||
|
articleInfo += `\n${index + 1}. ${comment.author}:${comment.content}`
|
||||||
|
if (comment.time) {
|
||||||
|
articleInfo += ` (${comment.time})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add replies if available
|
||||||
|
if (comment.replies && comment.replies.length > 0) {
|
||||||
|
comment.replies.forEach((reply: CommentData) => {
|
||||||
|
articleInfo += `\n └─ ${reply.author}:${reply.content}`
|
||||||
|
if (reply.time) {
|
||||||
|
articleInfo += ` (${reply.time})`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.comments.length > commentLimit) {
|
||||||
|
articleInfo += `\n\n(共${result.comments.length}条评论,已显示前${commentLimit}条)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将文章信息附加到用户原始输入后面
|
||||||
|
finalContent = input + articleInfo
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || '抓取失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Remove fetching message and show error
|
||||||
|
setMessages((prev) => prev.filter((msg) => msg.id !== fetchingMessage.id))
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: `${error instanceof Error ? error.message : '抓取失败'},将直接使用您的输入`,
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
// Reset article request flag if fetching fails
|
||||||
|
isArticleRequestRef.current = false
|
||||||
|
// Wait a bit before continuing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不是文章请求,确保标记为 false
|
||||||
|
isArticleRequestRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: finalContent,
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMessage])
|
||||||
|
setInputValue('')
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
// Create assistant message placeholder for streaming
|
||||||
|
const assistantId = (Date.now() + 1).toString()
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: assistantId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
setMessages((prev) => [...prev, assistantMessage])
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get active model config from localStorage
|
||||||
|
const savedConfigs = localStorage.getItem('ai-model-configs')
|
||||||
|
const savedActiveId = localStorage.getItem('ai-active-model-id')
|
||||||
|
|
||||||
|
if (!savedConfigs || !savedActiveId) {
|
||||||
|
throw new Error('请先在设置中配置 AI 模型')
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs: ModelConfig[] = JSON.parse(savedConfigs)
|
||||||
|
const activeConfig = configs.find((c) => c.id === savedActiveId)
|
||||||
|
|
||||||
|
if (!activeConfig) {
|
||||||
|
throw new Error('未找到活跃的模型配置')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare conversation history for API
|
||||||
|
const conversationHistory = [...messages, userMessage].map((msg) => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Call AI API with streaming
|
||||||
|
const response = await fetch(`${activeConfig.baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${activeConfig.apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: activeConfig.model,
|
||||||
|
messages: conversationHistory,
|
||||||
|
stream: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API 请求失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('无法读取响应流')
|
||||||
|
}
|
||||||
|
|
||||||
|
let accumulatedContent = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
const lines = chunk.split('\n').filter((line) => line.trim() !== '')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6)
|
||||||
|
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
const content = parsed.choices[0]?.delta?.content
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
accumulatedContent += content
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === assistantId ? { ...msg, content: accumulatedContent } : msg
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Throttled auto scroll during streaming (every 100ms)
|
||||||
|
if (scrollTimerRef.current) {
|
||||||
|
clearTimeout(scrollTimerRef.current)
|
||||||
|
}
|
||||||
|
scrollTimerRef.current = setTimeout(() => {
|
||||||
|
if (shouldAutoScrollRef.current) {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing SSE data:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accumulatedContent) {
|
||||||
|
throw new Error('没有收到任何回复内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在处理文章请求时才保存 AI 回复内容用于发送评论
|
||||||
|
if (isArticleRequestRef.current) {
|
||||||
|
console.log('Saving AI response for article request:', accumulatedContent.substring(0, 100))
|
||||||
|
setLastAiResponse(accumulatedContent)
|
||||||
|
isArticleRequestRef.current = false // 重置标记
|
||||||
|
} else {
|
||||||
|
console.log('Not an article request, skipping save')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final scroll to bottom after streaming completes
|
||||||
|
if (scrollTimerRef.current) {
|
||||||
|
clearTimeout(scrollTimerRef.current)
|
||||||
|
}
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI API Error:', error)
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === assistantId
|
||||||
|
? { ...msg, content: `错误: ${error instanceof Error ? error.message : '未知错误'}` }
|
||||||
|
: msg
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showQrCodeLogin = async (): Promise<void> => {
|
||||||
|
console.log('showQrCodeLogin called')
|
||||||
|
|
||||||
|
// 打开 Modal
|
||||||
|
setIsQrModalVisible(true)
|
||||||
|
setQrCodeError('')
|
||||||
|
setQrCodeDataUrl('')
|
||||||
|
|
||||||
|
// 获取二维码
|
||||||
|
try {
|
||||||
|
console.log('Fetching QR code...')
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('get-login-qrcode')
|
||||||
|
console.log('QR code fetch result:', result)
|
||||||
|
|
||||||
|
if (result.success && result.qrCodeDataUrl) {
|
||||||
|
console.log('QR code fetched successfully')
|
||||||
|
setQrCodeDataUrl(result.qrCodeDataUrl)
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch QR code:', result.error)
|
||||||
|
setQrCodeError(result.error || '获取二维码失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception while fetching QR code:', error)
|
||||||
|
setQrCodeError(error instanceof Error ? error.message : '未知错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQrModalOk = async (): Promise<void> => {
|
||||||
|
console.log('handleQrModalOk called')
|
||||||
|
message.loading({ content: '正在验证登录状态...', key: 'verify-login', duration: 0 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Invoking wait-qrcode-login...')
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('wait-qrcode-login')
|
||||||
|
console.log('wait-qrcode-login result:', result)
|
||||||
|
message.destroy('verify-login')
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('Login successful, username:', result.username)
|
||||||
|
message.success(`登录成功!欢迎 ${result.username}`)
|
||||||
|
setIsQrModalVisible(false)
|
||||||
|
|
||||||
|
// 登录成功后,直接显示确认发送对话框
|
||||||
|
console.log('Showing confirm dialog with username:', result.username)
|
||||||
|
showConfirmDialog(result.username)
|
||||||
|
} else {
|
||||||
|
console.log('Login failed:', result.error)
|
||||||
|
message.error(result.error || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception in handleQrModalOk:', error)
|
||||||
|
message.destroy('verify-login')
|
||||||
|
message.error(error instanceof Error ? error.message : '验证登录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示确认发送对话框的独立函数
|
||||||
|
const showConfirmDialog = (username?: string): void => {
|
||||||
|
console.log('showConfirmDialog called with username:', username)
|
||||||
|
setConfirmUsername(username || '当前用户')
|
||||||
|
setIsConfirmModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理确认发送评论
|
||||||
|
const handleConfirmOk = async (): Promise<void> => {
|
||||||
|
console.log('handleConfirmOk called')
|
||||||
|
console.log('currentArticleUrl:', currentArticleUrl)
|
||||||
|
console.log('lastAiResponse length:', lastAiResponse?.length)
|
||||||
|
|
||||||
|
setIsConfirmModalVisible(false)
|
||||||
|
setIsPostingComment(true)
|
||||||
|
message.loading({ content: '正在发送评论...', key: 'posting', duration: 0 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Invoking post-comment...')
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('post-comment', {
|
||||||
|
url: currentArticleUrl,
|
||||||
|
comment: lastAiResponse
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('post-comment result:', result)
|
||||||
|
message.destroy('posting')
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('Comment posted successfully')
|
||||||
|
message.success('评论发送成功!')
|
||||||
|
} else {
|
||||||
|
console.error('Comment posting failed:', result.error)
|
||||||
|
message.error(result.error || '评论发送失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception in handleConfirmOk:', error)
|
||||||
|
message.destroy('posting')
|
||||||
|
message.error(error instanceof Error ? error.message : '发送评论时出错')
|
||||||
|
} finally {
|
||||||
|
console.log('handleConfirmOk finally block')
|
||||||
|
setIsPostingComment(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmCancel = (): void => {
|
||||||
|
setIsConfirmModalVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQrModalCancel = (): void => {
|
||||||
|
setIsQrModalVisible(false)
|
||||||
|
setQrCodeDataUrl('')
|
||||||
|
setQrCodeError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostComment = async (): Promise<void> => {
|
||||||
|
console.log('handlePostComment called')
|
||||||
|
console.log('currentArticleUrl:', currentArticleUrl)
|
||||||
|
console.log('lastAiResponse:', lastAiResponse)
|
||||||
|
|
||||||
|
if (!currentArticleUrl || !lastAiResponse) {
|
||||||
|
message.error('没有可发送的内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Checking login status...')
|
||||||
|
// 先检查登录状态
|
||||||
|
const loginStatus = await window.electron.ipcRenderer.invoke(
|
||||||
|
'check-platform-login',
|
||||||
|
currentArticleUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Login status result:', loginStatus)
|
||||||
|
|
||||||
|
if (!loginStatus.success) {
|
||||||
|
message.error(loginStatus.error || '检查登录状态失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginStatus.isLoggedIn) {
|
||||||
|
// 显示扫码登录对话框
|
||||||
|
showQrCodeLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录,显示确认对话框
|
||||||
|
showConfirmDialog(loginStatus.username)
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
background: '#f5f5f5'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: '#fff',
|
||||||
|
borderBottom: '1px solid #e8e8e8',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: '16px' }}>
|
||||||
|
AI 对话
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages List */}
|
||||||
|
<div
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '24px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100%',
|
||||||
|
color: '#999'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary">开始新的对话...</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '70%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: message.role === 'user' ? '#1890ff' : '#fff',
|
||||||
|
color: message.role === 'user' ? '#fff' : '#000',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: message.role === 'user' ? '#fff' : '#000' }}>
|
||||||
|
{message.content}
|
||||||
|
{message.role === 'assistant' && !message.content && isLoading && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '8px',
|
||||||
|
height: '16px',
|
||||||
|
background: '#1890ff',
|
||||||
|
marginLeft: '2px',
|
||||||
|
animation: 'blink 1s infinite'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
opacity: 0.7
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.timestamp.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: '#fff',
|
||||||
|
borderTop: '1px solid #e8e8e8',
|
||||||
|
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.06)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<TextArea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
|
||||||
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!inputValue.trim() || isLoading}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
|
||||||
|
{/* 发送评论按钮 */}
|
||||||
|
{currentArticleUrl && lastAiResponse && (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
icon={<CommentOutlined />}
|
||||||
|
onClick={handlePostComment}
|
||||||
|
disabled={isPostingComment}
|
||||||
|
loading={isPostingComment}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
发送 AI 总结到评论区
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code Login Modal */}
|
||||||
|
<Modal
|
||||||
|
title="扫码登录小黑盒"
|
||||||
|
open={isQrModalVisible}
|
||||||
|
onOk={handleQrModalOk}
|
||||||
|
onCancel={handleQrModalCancel}
|
||||||
|
okText="已完成登录"
|
||||||
|
cancelText="取消"
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
|
<p style={{ marginBottom: 16, color: '#666' }}>请使用小黑盒 APP 扫描二维码登录</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 200
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!qrCodeDataUrl && !qrCodeError && <span>正在获取二维码...</span>}
|
||||||
|
{qrCodeError && <span style={{ color: '#ff4d4f' }}>{qrCodeError}</span>}
|
||||||
|
{qrCodeDataUrl && (
|
||||||
|
<img
|
||||||
|
src={qrCodeDataUrl}
|
||||||
|
alt="登录二维码"
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: 8
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Confirm Send Comment Modal */}
|
||||||
|
<Modal
|
||||||
|
title="确认发送评论"
|
||||||
|
open={isConfirmModalVisible}
|
||||||
|
onOk={handleConfirmOk}
|
||||||
|
onCancel={handleConfirmCancel}
|
||||||
|
okText="确认发送"
|
||||||
|
cancelText="取消"
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
将以用户 <strong>{confirmUsername}</strong> 的身份发送评论到:
|
||||||
|
</p>
|
||||||
|
<p style={{ wordBreak: 'break-all', color: '#666' }}>{currentArticleUrl}</p>
|
||||||
|
<p style={{ marginTop: 16 }}>评论内容:</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: 8,
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 4,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: 12
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lastAiResponse}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Chat
|
||||||
@@ -1,105 +1,29 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react'
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
import { streamChat } from '../services/aiService'
|
|
||||||
import ContextMenu from './ContextMenu'
|
import ContextMenu from './ContextMenu'
|
||||||
|
|
||||||
const FloatingBall: React.FC = () => {
|
const FloatingBall: React.FC = () => {
|
||||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false)
|
|
||||||
const [isBlinking, setIsBlinking] = useState(false)
|
const [isBlinking, setIsBlinking] = useState(false)
|
||||||
const [selectedText, setSelectedText] = useState<string>('')
|
|
||||||
const [inputValue, setInputValue] = useState<string>('')
|
|
||||||
const [aiResponse, setAiResponse] = useState<string>('')
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||||
const [isMouseOverBall, setIsMouseOverBall] = useState(false)
|
const [isMouseOverBall, setIsMouseOverBall] = useState(false)
|
||||||
|
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false)
|
||||||
|
const [showTextPrompt, setShowTextPrompt] = useState(false)
|
||||||
|
const [selectedText, setSelectedText] = useState('')
|
||||||
const isDraggingRef = useRef(false)
|
const isDraggingRef = useRef(false)
|
||||||
const startPosRef = useRef({ x: 0, y: 0 })
|
const startPosRef = useRef({ x: 0, y: 0 })
|
||||||
const windowStartRef = useRef({ x: 0, y: 0 })
|
const windowStartRef = useRef({ x: 0, y: 0 })
|
||||||
const blinkTimerRef = useRef<NodeJS.Timeout | null>(null)
|
const blinkTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const responseRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Initialize and expose tooltip state to main process via window object immediately
|
// Blinking animation - blink every 3-5 seconds
|
||||||
useEffect(() => {
|
|
||||||
// Initialize immediately on mount
|
|
||||||
;(window as Window & { __tooltipOpen?: boolean }).__tooltipOpen = false
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Update tooltip state whenever it changes
|
|
||||||
useEffect(() => {
|
|
||||||
;(window as Window & { __tooltipOpen?: boolean }).__tooltipOpen = isTooltipOpen
|
|
||||||
// Notify main process to update cache for faster shortcut response
|
|
||||||
window.electron.ipcRenderer.send('tooltip-state-changed', isTooltipOpen)
|
|
||||||
}, [isTooltipOpen])
|
|
||||||
|
|
||||||
// Handle ESC key to close tooltip
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEscapeKey = (e: KeyboardEvent): void => {
|
|
||||||
if (e.key === 'Escape' && isTooltipOpen) {
|
|
||||||
setIsTooltipOpen(false)
|
|
||||||
setSelectedText('')
|
|
||||||
setInputValue('')
|
|
||||||
setAiResponse('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleEscapeKey)
|
|
||||||
return (): void => {
|
|
||||||
document.removeEventListener('keydown', handleEscapeKey)
|
|
||||||
}
|
|
||||||
}, [isTooltipOpen])
|
|
||||||
|
|
||||||
// Listen for global shortcut trigger from main process
|
|
||||||
useEffect(() => {
|
|
||||||
const handleTextActionPrompt = (_event: unknown, text: string): void => {
|
|
||||||
setSelectedText(text)
|
|
||||||
setIsTooltipOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseTooltip = (): void => {
|
|
||||||
setIsTooltipOpen(false)
|
|
||||||
setSelectedText('')
|
|
||||||
setInputValue('')
|
|
||||||
setAiResponse('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribe1 = window.electron.ipcRenderer.on(
|
|
||||||
'show-text-action-prompt',
|
|
||||||
handleTextActionPrompt
|
|
||||||
)
|
|
||||||
const unsubscribe2 = window.electron.ipcRenderer.on('close-tooltip', handleCloseTooltip)
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
if (unsubscribe1) {
|
|
||||||
unsubscribe1()
|
|
||||||
}
|
|
||||||
if (unsubscribe2) {
|
|
||||||
unsubscribe2()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when AI response updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (responseRef.current) {
|
|
||||||
responseRef.current.scrollTop = responseRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
}, [aiResponse])
|
|
||||||
|
|
||||||
// Blinking animation - blink every 3-5 seconds when not active
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scheduleNextBlink = (): void => {
|
const scheduleNextBlink = (): void => {
|
||||||
const delay = Math.random() * 2000 + 3000 // Random delay between 3-5 seconds
|
const delay = Math.random() * 2000 + 3000 // Random delay between 3-5 seconds
|
||||||
blinkTimerRef.current = setTimeout(() => {
|
blinkTimerRef.current = setTimeout(() => {
|
||||||
if (!isTooltipOpen) {
|
setIsBlinking(true)
|
||||||
setIsBlinking(true)
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
setIsBlinking(false)
|
||||||
setIsBlinking(false)
|
|
||||||
scheduleNextBlink()
|
|
||||||
}, 200) // Blink duration: 200ms
|
|
||||||
} else {
|
|
||||||
scheduleNextBlink()
|
scheduleNextBlink()
|
||||||
}
|
}, 200) // Blink duration: 200ms
|
||||||
}, delay)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +34,25 @@ const FloatingBall: React.FC = () => {
|
|||||||
clearTimeout(blinkTimerRef.current)
|
clearTimeout(blinkTimerRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isTooltipOpen])
|
}, [])
|
||||||
|
|
||||||
|
// Handle Command+K shortcut from main process
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.ipcRenderer.on(
|
||||||
|
'show-text-prompt',
|
||||||
|
(_: unknown, text: string) => {
|
||||||
|
setSelectedText(text)
|
||||||
|
setShowTextPrompt(true)
|
||||||
|
setIsActionMenuOpen(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleMouseEnterBall = (): void => {
|
const handleMouseEnterBall = (): void => {
|
||||||
setIsMouseOverBall(true)
|
setIsMouseOverBall(true)
|
||||||
@@ -121,22 +63,11 @@ const FloatingBall: React.FC = () => {
|
|||||||
const handleMouseLeaveBall = (): void => {
|
const handleMouseLeaveBall = (): void => {
|
||||||
setIsMouseOverBall(false)
|
setIsMouseOverBall(false)
|
||||||
// When mouse leaves the ball area, always restore click-through
|
// When mouse leaves the ball area, always restore click-through
|
||||||
// If mouse enters tooltip, the tooltip's onMouseEnter will disable click-through again
|
|
||||||
if (!isContextMenuOpen) {
|
if (!isContextMenuOpen) {
|
||||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseEnterTooltip = (): void => {
|
|
||||||
// When mouse enters tooltip, stop ignoring mouse events
|
|
||||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseLeaveTooltip = (): void => {
|
|
||||||
// When mouse leaves tooltip, restore click-through
|
|
||||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent): void => {
|
const handleContextMenu = (e: React.MouseEvent): void => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// Show custom context menu at cursor position
|
// Show custom context menu at cursor position
|
||||||
@@ -149,10 +80,8 @@ const FloatingBall: React.FC = () => {
|
|||||||
const handleCloseContextMenu = (): void => {
|
const handleCloseContextMenu = (): void => {
|
||||||
setIsContextMenuOpen(false)
|
setIsContextMenuOpen(false)
|
||||||
// Re-enable mouse events pass-through when menu closes, but only if mouse is not over the ball
|
// Re-enable mouse events pass-through when menu closes, but only if mouse is not over the ball
|
||||||
// Use setTimeout to ensure state update completes first
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Check if mouse is still over the ball or tooltip
|
if (!isMouseOverBall) {
|
||||||
if (!isMouseOverBall && !isTooltipOpen) {
|
|
||||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||||
}
|
}
|
||||||
}, 50)
|
}, 50)
|
||||||
@@ -168,42 +97,6 @@ const FloatingBall: React.FC = () => {
|
|||||||
window.electron.ipcRenderer.send('quit-app')
|
window.electron.ipcRenderer.send('quit-app')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendMessage = async (): Promise<void> => {
|
|
||||||
const message = inputValue.trim()
|
|
||||||
if (!message) return
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
setAiResponse('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await streamChat(message, {
|
|
||||||
onStart: () => {
|
|
||||||
setAiResponse('')
|
|
||||||
},
|
|
||||||
onToken: (token: string) => {
|
|
||||||
setAiResponse((prev) => prev + token)
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
|
||||||
setIsLoading(false)
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
setIsLoading(false)
|
|
||||||
setAiResponse(`错误: ${error.message}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
setIsLoading(false)
|
|
||||||
setAiResponse(`错误: ${error instanceof Error ? error.message : '未知错误'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSendMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseDown = async (e: React.MouseEvent): Promise<void> => {
|
const handleMouseDown = async (e: React.MouseEvent): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -220,7 +113,7 @@ const FloatingBall: React.FC = () => {
|
|||||||
const bounds = await window.electron.ipcRenderer.invoke('get-window-bounds')
|
const bounds = await window.electron.ipcRenderer.invoke('get-window-bounds')
|
||||||
windowStartRef.current = { x: bounds.x, y: bounds.y }
|
windowStartRef.current = { x: bounds.x, y: bounds.y }
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent): void => {
|
||||||
const deltaX = moveEvent.screenX - startPosRef.current.x
|
const deltaX = moveEvent.screenX - startPosRef.current.x
|
||||||
const deltaY = moveEvent.screenY - startPosRef.current.y
|
const deltaY = moveEvent.screenY - startPosRef.current.y
|
||||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||||
@@ -237,24 +130,13 @@ const FloatingBall: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = (): void => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
|
||||||
// If not dragged, treat as a click - toggle tooltip
|
// If not dragged, treat as a click - toggle action menu
|
||||||
if (!isDraggingRef.current) {
|
if (!isDraggingRef.current) {
|
||||||
// Use setTimeout to avoid state update conflicts
|
setIsActionMenuOpen((prev) => !prev)
|
||||||
setTimeout(() => {
|
|
||||||
setIsTooltipOpen((prev) => {
|
|
||||||
if (prev) {
|
|
||||||
// Closing tooltip, clear all states
|
|
||||||
setSelectedText('')
|
|
||||||
setInputValue('')
|
|
||||||
setAiResponse('')
|
|
||||||
}
|
|
||||||
return !prev
|
|
||||||
})
|
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isDraggingRef.current = false
|
isDraggingRef.current = false
|
||||||
@@ -269,6 +151,51 @@ const FloatingBall: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideIn1 {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(30px, 30px) scale(0.3);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn2 {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px) scale(0.3);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn3 {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(30px, -30px) scale(0.3);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
isOpen={isContextMenuOpen}
|
isOpen={isContextMenuOpen}
|
||||||
position={contextMenuPosition}
|
position={contextMenuPosition}
|
||||||
@@ -281,27 +208,11 @@ const FloatingBall: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
// When mouse leaves menu, restore click-through
|
// When mouse leaves menu, restore click-through
|
||||||
if (!isMouseOverBall && !isTooltipOpen) {
|
if (!isMouseOverBall) {
|
||||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<style>{`
|
|
||||||
.ai-response-container::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
.ai-response-container::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.ai-response-container::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(25, 118, 210, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.ai-response-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(25, 118, 210, 0.5);
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -313,186 +224,204 @@ const FloatingBall: React.FC = () => {
|
|||||||
pointerEvents: 'none'
|
pointerEvents: 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Tooltip - Separate from ball container for proper click-through */}
|
{/* Text Prompt */}
|
||||||
{isTooltipOpen && (
|
{showTextPrompt && selectedText && (
|
||||||
<div
|
<div
|
||||||
onMouseEnter={handleMouseEnterTooltip}
|
onMouseEnter={() => {
|
||||||
onMouseLeave={handleMouseLeaveTooltip}
|
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 'calc(50% + 42px)',
|
top: 'calc(50% - 100px)',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
background: 'white',
|
background: 'rgba(33, 150, 243, 0.95)',
|
||||||
padding: '12px',
|
color: 'white',
|
||||||
borderRadius: '8px',
|
padding: '8px 16px',
|
||||||
boxShadow: '0 4px 16px rgba(33, 150, 243, 0.3)',
|
borderRadius: '20px',
|
||||||
zIndex: 2,
|
fontSize: '12px',
|
||||||
border: '1px solid #e3f2fd',
|
fontWeight: 500,
|
||||||
minWidth: '280px',
|
whiteSpace: 'nowrap',
|
||||||
maxWidth: '350px',
|
boxShadow: '0 2px 12px rgba(33, 150, 243, 0.4)',
|
||||||
maxHeight: '500px',
|
pointerEvents: 'auto',
|
||||||
|
animation: 'fadeInDown 0.3s ease-out',
|
||||||
|
zIndex: 10,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
pointerEvents: 'auto'
|
gap: '8px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
<span>要对这段文本做什么?</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onMouseDown={(e) => {
|
||||||
setIsTooltipOpen(false)
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
console.log('Close button clicked')
|
||||||
|
setShowTextPrompt(false)
|
||||||
setSelectedText('')
|
setSelectedText('')
|
||||||
setInputValue('')
|
setIsActionMenuOpen(false)
|
||||||
setAiResponse('')
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.4)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
background: 'rgba(255, 255, 255, 0.2)',
|
||||||
top: '8px',
|
|
||||||
right: '8px',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: 'transparent',
|
borderRadius: '50%',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '16px',
|
fontSize: '10px',
|
||||||
color: '#999',
|
color: 'white',
|
||||||
display: 'flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
lineHeight: 1
|
flexShrink: 0,
|
||||||
}}
|
transition: 'background 0.2s ease'
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = '#1976d2'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = '#999'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: '#1976d2',
|
|
||||||
marginBottom: '8px',
|
|
||||||
paddingRight: '20px',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedText ? '你想对这段文字做什么?' : '你好!我能帮你做些什么?'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected text display */}
|
|
||||||
{selectedText && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '8px',
|
|
||||||
background: '#f5f5f5',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
maxHeight: '60px',
|
|
||||||
overflow: 'auto',
|
|
||||||
color: '#666',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Response - scrollable area */}
|
|
||||||
{aiResponse && (
|
|
||||||
<div
|
|
||||||
ref={responseRef}
|
|
||||||
className="ai-response-container"
|
|
||||||
style={{
|
|
||||||
padding: '8px',
|
|
||||||
background: '#e3f2fd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
maxHeight: '300px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
color: '#333',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordWrap: 'break-word',
|
|
||||||
flexShrink: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{aiResponse}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input box - fixed at bottom */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="输入你的问题..."
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
disabled={isLoading}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '8px 10px',
|
|
||||||
border: '1px solid #e3f2fd',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
outline: 'none',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
opacity: isLoading ? 0.6 : 1
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#1976d2'
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#e3f2fd'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSendMessage}
|
|
||||||
disabled={isLoading || !inputValue.trim()}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
background: isLoading || !inputValue.trim() ? '#ccc' : '#1976d2',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: isLoading || !inputValue.trim() ? 'not-allowed' : 'pointer',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? '发送中...' : '发送'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arrow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-6px',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: '6px solid transparent',
|
|
||||||
borderRight: '6px solid transparent',
|
|
||||||
borderTop: '6px solid white',
|
|
||||||
pointerEvents: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Robot Ball Container - Separate for proper pointer events */}
|
{/* Action Menu Items */}
|
||||||
|
{isActionMenuOpen && (
|
||||||
|
<>
|
||||||
|
{/* Action Item 1 - Top Left */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 'calc(50% - 90px)',
|
||||||
|
top: 'calc(50% - 60px)',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 2px 8px rgba(76, 175, 80, 0.4)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
animation: 'slideIn1 0.3s ease-out',
|
||||||
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
console.log('Action 1 clicked - Opening chat window')
|
||||||
|
// Open chat window with selected text
|
||||||
|
window.electron.ipcRenderer.send('open-chat', selectedText || undefined)
|
||||||
|
setIsActionMenuOpen(false)
|
||||||
|
setShowTextPrompt(false)
|
||||||
|
setSelectedText('')
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||||
|
e.currentTarget.style.transform = 'scale(1.1)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.6)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(76, 175, 80, 0.4)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '20px', color: 'white' }}>✓</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Item 2 - Middle Left */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 'calc(50% - 100px)',
|
||||||
|
top: 'calc(50% - 20px)',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #FF9800 0%, #F57C00 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.4)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
animation: 'slideIn2 0.3s ease-out',
|
||||||
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
console.log('Action 2 clicked')
|
||||||
|
if (selectedText) {
|
||||||
|
console.log('Selected text:', selectedText)
|
||||||
|
}
|
||||||
|
setIsActionMenuOpen(false)
|
||||||
|
setShowTextPrompt(false)
|
||||||
|
setSelectedText('')
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.1)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.6)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 152, 0, 0.4)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '20px', color: 'white' }}>★</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Item 3 - Bottom Left */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 'calc(50% - 90px)',
|
||||||
|
top: 'calc(50% + 20px)',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'linear-gradient(135deg, #f44336 0%, #d32f2f 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 2px 8px rgba(244, 67, 54, 0.4)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
animation: 'slideIn3 0.3s ease-out',
|
||||||
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
console.log('Action 3 clicked - Opening settings window')
|
||||||
|
// Open settings window
|
||||||
|
window.electron.ipcRenderer.send('open-settings')
|
||||||
|
setIsActionMenuOpen(false)
|
||||||
|
setShowTextPrompt(false)
|
||||||
|
setSelectedText('')
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||||
|
e.currentTarget.style.transform = 'scale(1.1)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(244, 67, 54, 0.6)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)'
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(244, 67, 54, 0.4)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '20px', color: 'white' }}>✕</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Robot Ball Container */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -534,103 +463,66 @@ const FloatingBall: React.FC = () => {
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Robot Icon - Changes based on tooltip state */}
|
{/* Robot Icon - smiling robot */}
|
||||||
{!isTooltipOpen ? (
|
<svg
|
||||||
// Normal state - smiling robot
|
viewBox="0 0 100 100"
|
||||||
<svg
|
width="48"
|
||||||
viewBox="0 0 100 100"
|
height="48"
|
||||||
width="48"
|
fill="none"
|
||||||
height="48"
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{/* Antenna */}
|
||||||
|
<circle cx="50" cy="10" r="4" fill="white" />
|
||||||
|
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
|
||||||
|
|
||||||
|
{/* Head */}
|
||||||
|
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
|
||||||
|
|
||||||
|
{/* Face screen */}
|
||||||
|
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
|
||||||
|
|
||||||
|
{/* Eyes */}
|
||||||
|
{isBlinking ? (
|
||||||
|
<>
|
||||||
|
<line
|
||||||
|
x1="33"
|
||||||
|
y1="47"
|
||||||
|
x2="43"
|
||||||
|
y2="47"
|
||||||
|
stroke="#1976d2"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="57"
|
||||||
|
y1="47"
|
||||||
|
x2="67"
|
||||||
|
y2="47"
|
||||||
|
stroke="#1976d2"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
|
||||||
|
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Smile */}
|
||||||
|
<path
|
||||||
|
d="M 38 58 Q 50 64 62 58"
|
||||||
|
stroke="#1976d2"
|
||||||
|
strokeWidth="2.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
style={{ pointerEvents: 'none' }}
|
strokeLinecap="round"
|
||||||
>
|
/>
|
||||||
{/* Antenna */}
|
|
||||||
<circle cx="50" cy="10" r="4" fill="white" />
|
|
||||||
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
|
|
||||||
|
|
||||||
{/* Head */}
|
{/* Ears - elliptical, only showing outer half */}
|
||||||
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
|
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
|
||||||
|
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
|
||||||
{/* Face screen */}
|
</svg>
|
||||||
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
|
|
||||||
|
|
||||||
{/* Eyes */}
|
|
||||||
{isBlinking ? (
|
|
||||||
<>
|
|
||||||
<line
|
|
||||||
x1="33"
|
|
||||||
y1="47"
|
|
||||||
x2="43"
|
|
||||||
y2="47"
|
|
||||||
stroke="#1976d2"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="57"
|
|
||||||
y1="47"
|
|
||||||
x2="67"
|
|
||||||
y2="47"
|
|
||||||
stroke="#1976d2"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
|
|
||||||
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Smile */}
|
|
||||||
<path
|
|
||||||
d="M 38 58 Q 50 64 62 58"
|
|
||||||
stroke="#1976d2"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
fill="none"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Ears - elliptical, only showing outer half */}
|
|
||||||
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
|
|
||||||
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
// Active state - excited robot
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
width="48"
|
|
||||||
height="48"
|
|
||||||
fill="none"
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
{/* Antenna with sparkles */}
|
|
||||||
<circle cx="50" cy="10" r="4" fill="white" />
|
|
||||||
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
|
|
||||||
|
|
||||||
{/* Sparkle effects */}
|
|
||||||
<line x1="62" y1="12" x2="68" y2="12" stroke="white" strokeWidth="2" />
|
|
||||||
<line x1="65" y1="9" x2="65" y2="15" stroke="white" strokeWidth="2" />
|
|
||||||
|
|
||||||
{/* Head */}
|
|
||||||
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
|
|
||||||
|
|
||||||
{/* Face screen */}
|
|
||||||
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
|
|
||||||
|
|
||||||
{/* Eyes - excited/happy */}
|
|
||||||
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
|
|
||||||
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
|
|
||||||
|
|
||||||
{/* Open mouth - happy expression */}
|
|
||||||
<ellipse cx="50" cy="60" rx="10" ry="7" fill="#1976d2" />
|
|
||||||
|
|
||||||
{/* Ears - elliptical, only showing outer half */}
|
|
||||||
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
|
|
||||||
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import FloatingBall from './components/FloatingBall'
|
import FloatingBall from './components/FloatingBall'
|
||||||
import { ConfigProvider, theme } from 'antd'
|
import { ConfigProvider, theme } from 'antd'
|
||||||
|
|
||||||
const FloatingApp: React.FC = () => {
|
export const FloatingApp: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
|
|||||||
Reference in New Issue
Block a user