新增小黑盒内容爬取,AI消息自动发送评论
This commit is contained in:
Generated
+46
-1
@@ -13,7 +13,8 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"antd": "^5.28.0",
|
||||
"axios": "^1.13.2",
|
||||
"electron-updater": "^6.3.9"
|
||||
"electron-updater": "^6.3.9",
|
||||
"playwright": "^1.56.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
@@ -8435,6 +8436,50 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"antd": "^5.28.0",
|
||||
"axios": "^1.13.2",
|
||||
"electron-updater": "^6.3.9"
|
||||
"electron-updater": "^6.3.9",
|
||||
"playwright": "^1.56.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
|
||||
+318
-72
@@ -1,19 +1,37 @@
|
||||
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
|
||||
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 settingsWindow: BrowserWindow | null = null
|
||||
let floatingWindowReady = false
|
||||
let tooltipOpenCache = false
|
||||
let chatWindow: BrowserWindow | null = null
|
||||
|
||||
// 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 {
|
||||
const { width } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
floatingWindow = new BrowserWindow({
|
||||
width: 400,
|
||||
height: 400,
|
||||
x: width - 420,
|
||||
y: 60,
|
||||
width: 240,
|
||||
height: 210,
|
||||
x: width - 100,
|
||||
y: 20,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
@@ -32,63 +50,12 @@ function createFloatingWindow(): void {
|
||||
floatingWindow.setAlwaysOnTop(true, 'floating')
|
||||
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
|
||||
if (process.env['ELECTRON_RENDERER_URL']) {
|
||||
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
|
||||
} else {
|
||||
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 {
|
||||
@@ -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 {
|
||||
// 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 registered = globalShortcut.register(shortcut, () => {
|
||||
if (floatingWindow && !floatingWindow.isDestroyed() && floatingWindowReady) {
|
||||
// Get clipboard text immediately for faster response
|
||||
if (floatingWindow && !floatingWindow.isDestroyed()) {
|
||||
// Get clipboard text for selected text
|
||||
const selectedText = clipboard.readText('selection')
|
||||
const text = selectedText || clipboard.readText()
|
||||
|
||||
// Use cached state for instant response
|
||||
if (tooltipOpenCache) {
|
||||
// If tooltip is open, close it
|
||||
tooltipOpenCache = false
|
||||
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()
|
||||
}
|
||||
if (text && text.trim()) {
|
||||
// Send event to renderer to show prompt
|
||||
floatingWindow.webContents.send('show-text-prompt', text.trim())
|
||||
floatingWindow.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -161,6 +334,79 @@ app.whenReady().then(() => {
|
||||
// IPC test
|
||||
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()
|
||||
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 { streamChat } from '../services/aiService'
|
||||
import ContextMenu from './ContextMenu'
|
||||
|
||||
const FloatingBall: React.FC = () => {
|
||||
const [isTooltipOpen, setIsTooltipOpen] = 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 [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [isMouseOverBall, setIsMouseOverBall] = useState(false)
|
||||
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false)
|
||||
const [showTextPrompt, setShowTextPrompt] = useState(false)
|
||||
const [selectedText, setSelectedText] = useState('')
|
||||
const isDraggingRef = useRef(false)
|
||||
const startPosRef = useRef({ x: 0, y: 0 })
|
||||
const windowStartRef = useRef({ x: 0, y: 0 })
|
||||
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
|
||||
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
|
||||
// Blinking animation - blink every 3-5 seconds
|
||||
useEffect(() => {
|
||||
const scheduleNextBlink = (): void => {
|
||||
const delay = Math.random() * 2000 + 3000 // Random delay between 3-5 seconds
|
||||
blinkTimerRef.current = setTimeout(() => {
|
||||
if (!isTooltipOpen) {
|
||||
setIsBlinking(true)
|
||||
setTimeout(() => {
|
||||
setIsBlinking(false)
|
||||
scheduleNextBlink()
|
||||
}, 200) // Blink duration: 200ms
|
||||
} else {
|
||||
setIsBlinking(true)
|
||||
setTimeout(() => {
|
||||
setIsBlinking(false)
|
||||
scheduleNextBlink()
|
||||
}
|
||||
}, 200) // Blink duration: 200ms
|
||||
}, delay)
|
||||
}
|
||||
|
||||
@@ -110,7 +34,25 @@ const FloatingBall: React.FC = () => {
|
||||
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 => {
|
||||
setIsMouseOverBall(true)
|
||||
@@ -121,22 +63,11 @@ const FloatingBall: React.FC = () => {
|
||||
const handleMouseLeaveBall = (): void => {
|
||||
setIsMouseOverBall(false)
|
||||
// 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) {
|
||||
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 => {
|
||||
e.preventDefault()
|
||||
// Show custom context menu at cursor position
|
||||
@@ -149,10 +80,8 @@ const FloatingBall: React.FC = () => {
|
||||
const handleCloseContextMenu = (): void => {
|
||||
setIsContextMenuOpen(false)
|
||||
// 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(() => {
|
||||
// Check if mouse is still over the ball or tooltip
|
||||
if (!isMouseOverBall && !isTooltipOpen) {
|
||||
if (!isMouseOverBall) {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||
}
|
||||
}, 50)
|
||||
@@ -168,42 +97,6 @@ const FloatingBall: React.FC = () => {
|
||||
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> => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -220,7 +113,7 @@ const FloatingBall: React.FC = () => {
|
||||
const bounds = await window.electron.ipcRenderer.invoke('get-window-bounds')
|
||||
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 deltaY = moveEvent.screenY - startPosRef.current.y
|
||||
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('mouseup', handleMouseUp)
|
||||
|
||||
// If not dragged, treat as a click - toggle tooltip
|
||||
// If not dragged, treat as a click - toggle action menu
|
||||
if (!isDraggingRef.current) {
|
||||
// Use setTimeout to avoid state update conflicts
|
||||
setTimeout(() => {
|
||||
setIsTooltipOpen((prev) => {
|
||||
if (prev) {
|
||||
// Closing tooltip, clear all states
|
||||
setSelectedText('')
|
||||
setInputValue('')
|
||||
setAiResponse('')
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, 0)
|
||||
setIsActionMenuOpen((prev) => !prev)
|
||||
}
|
||||
|
||||
isDraggingRef.current = false
|
||||
@@ -269,6 +151,51 @@ const FloatingBall: React.FC = () => {
|
||||
|
||||
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
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
@@ -281,27 +208,11 @@ const FloatingBall: React.FC = () => {
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// When mouse leaves menu, restore click-through
|
||||
if (!isMouseOverBall && !isTooltipOpen) {
|
||||
if (!isMouseOverBall) {
|
||||
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
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -313,186 +224,204 @@ const FloatingBall: React.FC = () => {
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{/* Tooltip - Separate from ball container for proper click-through */}
|
||||
{isTooltipOpen && (
|
||||
{/* Text Prompt */}
|
||||
{showTextPrompt && selectedText && (
|
||||
<div
|
||||
onMouseEnter={handleMouseEnterTooltip}
|
||||
onMouseLeave={handleMouseLeaveTooltip}
|
||||
onMouseEnter={() => {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(50% + 42px)',
|
||||
top: 'calc(50% - 100px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'white',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 16px rgba(33, 150, 243, 0.3)',
|
||||
zIndex: 2,
|
||||
border: '1px solid #e3f2fd',
|
||||
minWidth: '280px',
|
||||
maxWidth: '350px',
|
||||
maxHeight: '500px',
|
||||
background: 'rgba(33, 150, 243, 0.95)',
|
||||
color: 'white',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 12px rgba(33, 150, 243, 0.4)',
|
||||
pointerEvents: 'auto',
|
||||
animation: 'fadeInDown 0.3s ease-out',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: 'auto'
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<span>要对这段文本做什么?</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsTooltipOpen(false)
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
console.log('Close button clicked')
|
||||
setShowTextPrompt(false)
|
||||
setSelectedText('')
|
||||
setInputValue('')
|
||||
setAiResponse('')
|
||||
setIsActionMenuOpen(false)
|
||||
}}
|
||||
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={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
color: '#999',
|
||||
display: 'flex',
|
||||
fontSize: '10px',
|
||||
color: 'white',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#1976d2'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#999'
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.2s ease'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -534,103 +463,66 @@ const FloatingBall: React.FC = () => {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Robot Icon - Changes based on tooltip state */}
|
||||
{!isTooltipOpen ? (
|
||||
// Normal state - smiling robot
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="48"
|
||||
height="48"
|
||||
{/* Robot Icon - smiling robot */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
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"
|
||||
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" />
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import FloatingBall from './components/FloatingBall'
|
||||
import { ConfigProvider, theme } from 'antd'
|
||||
|
||||
const FloatingApp: React.FC = () => {
|
||||
export const FloatingApp: React.FC = () => {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
|
||||
Reference in New Issue
Block a user