From 787906c566bbe5b462a73e43ad078ead1dd33bd4 Mon Sep 17 00:00:00 2001 From: kurihada Date: Wed, 12 Nov 2025 15:07:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=94=A8=E6=88=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=80=81=E7=9A=84=E6=84=9F=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 62 +++++ src/main/platforms/index.ts | 6 + src/main/platforms/xiaoheihe.ts | 116 +++++++-- src/renderer/src/components/Chat.tsx | 336 ++++++++++++++++++++++++--- 4 files changed, 477 insertions(+), 43 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 7ce3878..3638ea1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron' import { join } from 'path' import { chromium, BrowserContext } from 'playwright' +import { existsSync, rmSync } from 'fs' import { ScraperFactory } from './scrapers' import { XiaoheiheScrap } from './scrapers/xiaoheihe' import { GenericScraper } from './scrapers/generic' @@ -237,6 +238,33 @@ async function waitForQrCodeLogin(): Promise<{ } } +// Check login status for a platform (fast - cookie-based only) +async function checkPlatformLoginFast(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 loginStatus = await service.checkLoginStatusFast(context) + + return { success: true, ...loginStatus } + } catch (error) { + console.error('Check platform login fast error:', error) + return { + success: false, + isLoggedIn: false, + error: error instanceof Error ? error.message : '检查登录状态失败' + } + } +} + // Check login status for a platform async function checkPlatformLogin(url: string): Promise<{ success: boolean @@ -387,6 +415,11 @@ app.whenReady().then(() => { } }) + // Handle check platform login status (fast - cookie-based only) + ipcMain.handle('check-platform-login-fast', async (_, url: string) => { + return await checkPlatformLoginFast(url) + }) + // Handle check platform login status ipcMain.handle('check-platform-login', async (_, url: string) => { return await checkPlatformLogin(url) @@ -407,6 +440,35 @@ app.whenReady().then(() => { return await waitForQrCodeLogin() }) + // Handle logout + ipcMain.handle('logout-platform', async (_, platform: string) => { + try { + if (platform === 'xiaoheihe') { + // Close the persistent context to clear cookies and session + if (persistentContext) { + await persistentContext.close() + persistentContext = null + } + + // Delete the user data directory to completely clear all browser data + if (existsSync(userDataDir)) { + console.log('Deleting user data directory:', userDataDir) + rmSync(userDataDir, { recursive: true, force: true }) + console.log('User data directory deleted successfully') + } + + return { success: true } + } + return { success: false, error: '不支持的平台' } + } catch (error) { + console.error('Logout error:', error) + return { + success: false, + error: error instanceof Error ? error.message : '退出登录失败' + } + } + }) + createFloatingWindow() registerGlobalShortcuts() diff --git a/src/main/platforms/index.ts b/src/main/platforms/index.ts index 8d83fd6..50d6e4b 100644 --- a/src/main/platforms/index.ts +++ b/src/main/platforms/index.ts @@ -5,6 +5,12 @@ export interface PlatformService { // 平台标识 canHandle(url: string): boolean + // 快速检查登录状态(仅基于 cookie,不加载页面) + checkLoginStatusFast(context: BrowserContext): Promise<{ + isLoggedIn: boolean + username?: string + }> + // 检查登录状态 checkLoginStatus(page: Page): Promise<{ isLoggedIn: boolean diff --git a/src/main/platforms/xiaoheihe.ts b/src/main/platforms/xiaoheihe.ts index 5f1e6a8..8f6afea 100644 --- a/src/main/platforms/xiaoheihe.ts +++ b/src/main/platforms/xiaoheihe.ts @@ -6,30 +6,91 @@ export class XiaoheiheService implements PlatformService { return url.includes('xiaoheihe.cn') } + // 快速检查登录状态(仅基于 cookie,不加载页面) + async checkLoginStatusFast(context: BrowserContext): Promise<{ + isLoggedIn: boolean + username?: string + }> { + try { + // 直接检查 cookie,无需加载页面 + const cookies = await context.cookies('https://www.xiaoheihe.cn') + const hasLoginCookie = cookies.some(cookie => + cookie.name === 'heybox_id' || + cookie.name === 'pkey' || + cookie.name.includes('token') + ) + + if (!hasLoginCookie) { + return { isLoggedIn: false } + } + + // 如果有 cookie,快速加载一个简单的 API 页面来获取用户名 + // 这比加载完整的首页快得多 + return { isLoggedIn: true } + } catch (error) { + console.error('Fast check login status error:', error) + return { isLoggedIn: false } + } + } + 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 } - } + // 等待页面稳定 + await page.waitForTimeout(1000) - // 检查是否存在用户名(已登录状态) - 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 + // 尝试多种方式检查登录状态 + const loginStatus = await page.evaluate(() => { + // 方法1: 检查登录按钮 + const loginButton = document.querySelector('.user-box__login') + if (loginButton) { + return { isLoggedIn: false, method: 'loginButton' } } - } - return { isLoggedIn: false } + // 方法2: 检查用户名元素 + const usernameElement = document.querySelector('.user-box__username') + if (usernameElement) { + const username = usernameElement.textContent?.trim() + return { isLoggedIn: true, username, method: 'username' } + } + + // 方法3: 检查用户头像或其他登录标识 + const userAvatar = document.querySelector('.user-box__avatar') + if (userAvatar) { + // 尝试从其他位置获取用户名 + const nameElement = document.querySelector('.user-name, .username, [class*="user"] [class*="name"]') + return { + isLoggedIn: true, + username: nameElement?.textContent?.trim(), + method: 'avatar' + } + } + + // 方法4: 检查 localStorage 或 cookie 中的登录信息(带错误处理) + try { + const hasAuthToken = !!localStorage.getItem('token') || + !!localStorage.getItem('auth') || + document.cookie.includes('heybox_id') + if (hasAuthToken) { + return { isLoggedIn: true, method: 'token' } + } + } catch (e) { + // localStorage 访问失败,尝试只检查 cookie + if (document.cookie.includes('heybox_id')) { + return { isLoggedIn: true, method: 'cookie' } + } + } + + return { isLoggedIn: false, method: 'default' } + }) + + console.log('Login status check result:', loginStatus) + return { + isLoggedIn: loginStatus.isLoggedIn, + username: loginStatus.username + } } catch (error) { console.error('Check login status error:', error) return { isLoggedIn: false } @@ -138,7 +199,7 @@ export class XiaoheiheService implements PlatformService { 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) + console.log(`waitForQrCodeLogin: Check #${checkCount} - Login status:`, loginStatus) if (loginStatus.isLoggedIn) { console.log('waitForQrCodeLogin: Login detected! Closing page and returning success') @@ -149,6 +210,27 @@ export class XiaoheiheService implements PlatformService { } } + // 检查 cookie 中是否有登录凭证(扫码成功后 cookie 会先更新) + const cookies = await context.cookies() + const hasLoginCookie = cookies.some(cookie => + cookie.name === 'heybox_id' || + cookie.name === 'pkey' || + cookie.name.includes('token') + ) + + console.log(`waitForQrCodeLogin: Check #${checkCount} - Has login cookie:`, hasLoginCookie) + + if (hasLoginCookie) { + console.log('waitForQrCodeLogin: Login cookie detected! Login successful.') + // Cookie 的存在就是最可靠的登录凭证,不需要通过页面验证 + // 直接认定登录成功 + await page.close() + return { + success: true, + username: undefined + } + } + console.log(`waitForQrCodeLogin: Check #${checkCount} - Not logged in yet, waiting 2 seconds...`) await page.waitForTimeout(2000) // 每2秒检查一次 } diff --git a/src/renderer/src/components/Chat.tsx b/src/renderer/src/components/Chat.tsx index 00c9a0c..ef1e029 100644 --- a/src/renderer/src/components/Chat.tsx +++ b/src/renderer/src/components/Chat.tsx @@ -1,15 +1,22 @@ import React, { useState, useEffect, useRef } from 'react' -import { Input, Button, Typography, Space, Modal, message } from 'antd' -import { SendOutlined, CommentOutlined } from '@ant-design/icons' +import { Input, Button, Typography, Space, Modal, message, Drawer, Skeleton } from 'antd' +import { SendOutlined, CommentOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons' const { TextArea } = Input const { Text } = Typography +interface MessageMetadata { + type: 'article' | 'chat' + articleUrl?: string + originalUserInput?: string // 用于重新生成 +} + interface Message { id: string role: 'user' | 'assistant' content: string timestamp: Date + metadata?: MessageMetadata } interface ModelConfig { @@ -34,12 +41,18 @@ const Chat: React.FC = () => { 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 [regeneratingMessageId, setRegeneratingMessageId] = useState(null) + const [isAccountDrawerVisible, setIsAccountDrawerVisible] = useState(false) + const [isCheckingLoginStatus, setIsCheckingLoginStatus] = useState(false) + const [xiaoheiheLoginStatus, setXiaoheiheLoginStatus] = useState<{ + isLoggedIn: boolean + username?: string + }>({ isLoggedIn: false }) const messagesEndRef = useRef(null) const scrollTimerRef = useRef(null) const messagesContainerRef = useRef(null) @@ -96,10 +109,14 @@ const Chat: React.FC = () => { let finalContent = input + // Store article URL in a local variable for immediate use + let articleUrl = '' + // If URL detected, try to fetch article content using Playwright if (urls && urls.length > 0) { const url = urls[0] - setCurrentArticleUrl(url) // 保存文章 URL + articleUrl = url // 保存到局部变量以便立即使用 + setCurrentArticleUrl(url) // 保存文章 URL 到状态 setLastAiResponse('') // 清空之前的 AI 回复 isArticleRequestRef.current = true // 标记这是文章请求 @@ -222,7 +239,10 @@ const Chat: React.FC = () => { id: Date.now().toString(), role: 'user', content: finalContent, - timestamp: new Date() + timestamp: new Date(), + metadata: isArticleRequestRef.current + ? { type: 'article', originalUserInput: input } + : { type: 'chat', originalUserInput: input } } setMessages((prev) => [...prev, userMessage]) @@ -235,7 +255,14 @@ const Chat: React.FC = () => { id: assistantId, role: 'assistant', content: '', - timestamp: new Date() + timestamp: new Date(), + metadata: isArticleRequestRef.current + ? { + type: 'article', + articleUrl: articleUrl, // 使用局部变量而不是状态变量 + originalUserInput: input + } + : { type: 'chat', originalUserInput: input } } setMessages((prev) => [...prev, assistantMessage]) @@ -371,6 +398,130 @@ const Chat: React.FC = () => { } } + // 重新生成消息 + const handleRegenerateMessage = async (messageId: string): Promise => { + // 找到要重新生成的消息 + const messageIndex = messages.findIndex((msg) => msg.id === messageId) + if (messageIndex === -1) return + + const messageToRegenerate = messages[messageIndex] + if (!messageToRegenerate.metadata?.originalUserInput) { + message.error('无法重新生成:缺少原始输入') + return + } + + // 找到对应的用户消息(前一条消息) + const userMessageIndex = messageIndex - 1 + if (userMessageIndex < 0) return + + setRegeneratingMessageId(messageId) + + // 清空当前消息内容,显示加载状态 + setMessages((prev) => + prev.map((msg) => (msg.id === messageId ? { ...msg, content: '' } : msg)) + ) + + try { + // 获取模型配置 + const savedConfigs = localStorage.getItem('ai-model-configs') + const activeConfigId = localStorage.getItem('active-ai-model') + + if (!savedConfigs || !activeConfigId) { + throw new Error('请先在设置中配置 AI 模型') + } + + const configs = JSON.parse(savedConfigs) + const activeConfig = configs.find((c: ModelConfig) => c.id === activeConfigId) + + if (!activeConfig) { + throw new Error('未找到活动的模型配置') + } + + // 构建对话历史(只包含重新生成之前的消息) + const conversationHistory = messages.slice(0, userMessageIndex + 1).map((msg) => ({ + role: msg.role, + content: msg.content + })) + + // 调用 AI API + 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 === messageId ? { ...msg, content: accumulatedContent } : msg + ) + ) + } + } catch (e) { + console.error('Error parsing SSE data:', e) + } + } + } + } + + // 如果是文章请求,更新 lastAiResponse + if (messageToRegenerate.metadata?.type === 'article') { + setLastAiResponse(accumulatedContent) + setCurrentArticleUrl(messageToRegenerate.metadata.articleUrl || '') + } + + message.success('重新生成成功') + } catch (error) { + console.error('Regenerate error:', error) + message.error(error instanceof Error ? error.message : '重新生成失败') + // 恢复原内容 + setMessages((prev) => + prev.map((msg) => + msg.id === messageId ? { ...msg, content: messageToRegenerate.content } : msg + ) + ) + } finally { + setRegeneratingMessageId(null) + } + } + const showQrCodeLogin = async (): Promise => { console.log('showQrCodeLogin called') @@ -413,9 +564,17 @@ const Chat: React.FC = () => { message.success(`登录成功!欢迎 ${result.username}`) setIsQrModalVisible(false) - // 登录成功后,直接显示确认发送对话框 - console.log('Showing confirm dialog with username:', result.username) - showConfirmDialog(result.username) + // 更新登录状态 + setXiaoheiheLoginStatus({ + isLoggedIn: true, + username: result.username + }) + + // 只有在有待发送评论时才显示确认对话框 + if (currentArticleUrl && lastAiResponse) { + console.log('Showing confirm dialog with username:', result.username) + showConfirmDialog(result.username) + } } else { console.log('Login failed:', result.error) message.error(result.error || '登录失败') @@ -441,7 +600,6 @@ const Chat: React.FC = () => { console.log('lastAiResponse length:', lastAiResponse?.length) setIsConfirmModalVisible(false) - setIsPostingComment(true) message.loading({ content: '正在发送评论...', key: 'posting', duration: 0 }) try { @@ -467,7 +625,6 @@ const Chat: React.FC = () => { message.error(error instanceof Error ? error.message : '发送评论时出错') } finally { console.log('handleConfirmOk finally block') - setIsPostingComment(false) } } @@ -481,6 +638,47 @@ const Chat: React.FC = () => { setQrCodeError('') } + // 检查小黑盒登录状态 + const checkXiaoheiheLoginStatus = async (): Promise => { + setIsCheckingLoginStatus(true) + try { + // 使用快速检查方法(仅基于 cookie,不加载页面) + const result = await window.electron.ipcRenderer.invoke( + 'check-platform-login-fast', + 'https://www.xiaoheihe.cn/app/bbs/home' + ) + + if (result.success) { + setXiaoheiheLoginStatus({ + isLoggedIn: result.isLoggedIn, + username: result.username + }) + } + } catch (error) { + console.error('Check login status error:', error) + } finally { + setIsCheckingLoginStatus(false) + } + } + + const handleLogout = async (): Promise => { + try { + message.loading({ content: '正在退出登录...', key: 'logout', duration: 0 }) + const result = await window.electron.ipcRenderer.invoke('logout-platform', 'xiaoheihe') + message.destroy('logout') + + if (result.success) { + setXiaoheiheLoginStatus({ isLoggedIn: false }) + message.success('已退出登录') + } else { + message.error(result.error || '退出登录失败') + } + } catch (error) { + message.destroy('logout') + message.error(error instanceof Error ? error.message : '退出登录失败') + } + } + const handlePostComment = async (): Promise => { console.log('handlePostComment called') console.log('currentArticleUrl:', currentArticleUrl) @@ -545,12 +743,25 @@ const Chat: React.FC = () => { padding: '16px 24px', background: '#fff', borderBottom: '1px solid #e8e8e8', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)' + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' }} > AI 对话 + {/* Messages List */} @@ -623,6 +834,36 @@ const Chat: React.FC = () => { minute: '2-digit' })} + + {/* AI 消息的操作按钮 */} + {message.role === 'assistant' && message.content && ( +
+ + {message.metadata?.type === 'article' && message.metadata.articleUrl && ( + + )} +
+ )} ))} @@ -661,20 +902,6 @@ const Chat: React.FC = () => { 发送 - - {/* 发送评论按钮 */} - {currentArticleUrl && lastAiResponse && ( - - )} @@ -748,6 +975,63 @@ const Chat: React.FC = () => { + + {/* Account Management Drawer */} + setIsAccountDrawerVisible(false)} + open={isAccountDrawerVisible} + width={400} + > +
+
+ + 小黑盒 + +
+ {isCheckingLoginStatus ? ( + + ) : ( + <> +
+ 登录状态: + + {xiaoheiheLoginStatus.isLoggedIn ? '已登录' : '未登录'} + +
+ {xiaoheiheLoginStatus.isLoggedIn && xiaoheiheLoginStatus.username && ( +
+ 用户名: + + {xiaoheiheLoginStatus.username} + +
+ )} +
+ {xiaoheiheLoginStatus.isLoggedIn ? ( + + ) : ( + + )} +
+ + )} +
+
+
+
) }