新增小黑盒内容爬取,AI消息自动发送评论

This commit is contained in:
2025-11-12 13:48:17 +08:00
parent 1094191020
commit 7b8d31f899
13 changed files with 2191 additions and 493 deletions
+46 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+33
View File
@@ -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
}
}
+291
View File
@@ -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 : '发送评论失败'
}
}
}
}
+66
View File
@@ -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
}
}
+41
View File
@@ -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
}
}
+275
View File
@@ -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
}
}
+31
View File
@@ -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>
+22
View File
@@ -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>
)
+755
View File
@@ -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
+310 -418
View File
@@ -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>
+1 -1
View File
@@ -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={{