新增小黑盒内容爬取,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
+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={{