diff --git a/package-lock.json b/package-lock.json index daf476d..f4382e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 81191dd..cc79644 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/index.ts b/src/main/index.ts index 078886b..7ce3878 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { + 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() diff --git a/src/main/platforms/index.ts b/src/main/platforms/index.ts new file mode 100644 index 0000000..8d83fd6 --- /dev/null +++ b/src/main/platforms/index.ts @@ -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 + } +} diff --git a/src/main/platforms/xiaoheihe.ts b/src/main/platforms/xiaoheihe.ts new file mode 100644 index 0000000..5f1e6a8 --- /dev/null +++ b/src/main/platforms/xiaoheihe.ts @@ -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 = `

${text.replace(/\n/g, '
')}

` + // 触发 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 : '发送评论失败' + } + } + } +} diff --git a/src/main/scrapers/generic.ts b/src/main/scrapers/generic.ts new file mode 100644 index 0000000..5f59798 --- /dev/null +++ b/src/main/scrapers/generic.ts @@ -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 { + 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 + } +} diff --git a/src/main/scrapers/index.ts b/src/main/scrapers/index.ts new file mode 100644 index 0000000..500033b --- /dev/null +++ b/src/main/scrapers/index.ts @@ -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 +} + +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 + } +} diff --git a/src/main/scrapers/xiaoheihe.ts b/src/main/scrapers/xiaoheihe.ts new file mode 100644 index 0000000..2de7f6b --- /dev/null +++ b/src/main/scrapers/xiaoheihe.ts @@ -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 { + // 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 + } +} diff --git a/src/renderer/chat.html b/src/renderer/chat.html new file mode 100644 index 0000000..2acbc83 --- /dev/null +++ b/src/renderer/chat.html @@ -0,0 +1,31 @@ + + + + + AI 对话 + + + + +
+ + + diff --git a/src/renderer/src/chat.tsx b/src/renderer/src/chat.tsx new file mode 100644 index 0000000..c518ea4 --- /dev/null +++ b/src/renderer/src/chat.tsx @@ -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 ( + + + + ) +} + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +) diff --git a/src/renderer/src/components/Chat.tsx b/src/renderer/src/components/Chat.tsx new file mode 100644 index 0000000..00c9a0c --- /dev/null +++ b/src/renderer/src/components/Chat.tsx @@ -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([]) + const [inputValue, setInputValue] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [currentArticleUrl, setCurrentArticleUrl] = useState('') + const [lastAiResponse, setLastAiResponse] = useState('') + const [isPostingComment, setIsPostingComment] = useState(false) + const [isQrModalVisible, setIsQrModalVisible] = useState(false) + const [qrCodeDataUrl, setQrCodeDataUrl] = useState('') + const [qrCodeError, setQrCodeError] = useState('') + const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false) + const [confirmUsername, setConfirmUsername] = useState('') + const messagesEndRef = useRef(null) + const scrollTimerRef = useRef(null) + const messagesContainerRef = useRef(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 => { + 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): void => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const showQrCodeLogin = async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 ( + <> + +
+ {/* Header */} +
+ + AI 对话 + +
+ + {/* Messages List */} +
+ {messages.length === 0 ? ( +
+ 开始新的对话... +
+ ) : ( + + {messages.map((message) => ( +
+
+ + {message.content} + {message.role === 'assistant' && !message.content && isLoading && ( + + )} + +
+ {message.timestamp.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + })} +
+
+
+ ))} +
+ )} +
+
+ + {/* Input Area */} +
+ + +