新增小黑盒内容爬取,AI消息自动发送评论
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>AI 对话</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/chat.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import Chat from './components/Chat'
|
||||
import { ConfigProvider, theme } from 'antd'
|
||||
|
||||
export const ChatApp: React.FC = () => {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm
|
||||
}}
|
||||
>
|
||||
<Chat />
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ChatApp />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,755 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Input, Button, Typography, Space, Modal, message } from 'antd'
|
||||
import { SendOutlined, CommentOutlined } from '@ant-design/icons'
|
||||
|
||||
const { TextArea } = Input
|
||||
const { Text } = Typography
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
interface ModelConfig {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
model: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
interface CommentData {
|
||||
author: string
|
||||
content: string
|
||||
time?: string
|
||||
replies?: CommentData[]
|
||||
}
|
||||
|
||||
const Chat: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [currentArticleUrl, setCurrentArticleUrl] = useState<string>('')
|
||||
const [lastAiResponse, setLastAiResponse] = useState<string>('')
|
||||
const [isPostingComment, setIsPostingComment] = useState(false)
|
||||
const [isQrModalVisible, setIsQrModalVisible] = useState(false)
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('')
|
||||
const [qrCodeError, setQrCodeError] = useState<string>('')
|
||||
const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false)
|
||||
const [confirmUsername, setConfirmUsername] = useState<string>('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const scrollTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const isArticleRequestRef = useRef(false) // 使用 ref 而不是 state 以避免异步问题
|
||||
|
||||
// Listen for initial text from main process
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.ipcRenderer.on(
|
||||
'set-initial-text',
|
||||
(_: unknown, text: string) => {
|
||||
setInputValue(text)
|
||||
}
|
||||
)
|
||||
|
||||
return (): void => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check if user is near bottom and update auto-scroll flag
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleScroll = (): void => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
// If user is within 100px of bottom, enable auto-scroll
|
||||
shouldAutoScrollRef.current = distanceFromBottom < 100
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Auto scroll to bottom when new messages arrive or content updates
|
||||
useEffect(() => {
|
||||
if (shouldAutoScrollRef.current) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: isLoading ? 'auto' : 'smooth' })
|
||||
}
|
||||
}, [messages, isLoading])
|
||||
|
||||
const handleSend = async (): Promise<void> => {
|
||||
if (!inputValue.trim() || isLoading) return
|
||||
|
||||
const input = inputValue.trim()
|
||||
|
||||
// Detect if input contains URL
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
const urls = input.match(urlRegex)
|
||||
|
||||
let finalContent = input
|
||||
|
||||
// If URL detected, try to fetch article content using Playwright
|
||||
if (urls && urls.length > 0) {
|
||||
const url = urls[0]
|
||||
setCurrentArticleUrl(url) // 保存文章 URL
|
||||
setLastAiResponse('') // 清空之前的 AI 回复
|
||||
isArticleRequestRef.current = true // 标记这是文章请求
|
||||
|
||||
// Show fetching status
|
||||
const fetchingMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: '正在抓取文章内容...',
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages((prev) => [...prev, fetchingMessage])
|
||||
|
||||
try {
|
||||
// Call main process to fetch article using Playwright
|
||||
const result = await window.electron.ipcRenderer.invoke('fetch-article', url)
|
||||
|
||||
// Remove fetching message
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== fetchingMessage.id))
|
||||
|
||||
if (result.success) {
|
||||
// Build formatted content with title, author, tags, article, and comments
|
||||
// 将文章内容作为附加信息添加到用户的原始输入后面
|
||||
let articleInfo = `\n\n--- 以下是文章详细信息 ---\n\n文章链接:${url}\n\n`
|
||||
|
||||
// Add title if available
|
||||
if (result.title) {
|
||||
articleInfo += `标题:${result.title}\n`
|
||||
}
|
||||
|
||||
// Add author info if available
|
||||
if (result.author) {
|
||||
let authorInfo = `作者:${result.author}`
|
||||
if (result.authorIp) {
|
||||
authorInfo += ` (${result.authorIp})`
|
||||
}
|
||||
if (result.publishTime) {
|
||||
authorInfo += ` - ${result.publishTime}`
|
||||
}
|
||||
articleInfo += `${authorInfo}\n`
|
||||
}
|
||||
|
||||
// Add tags if available
|
||||
if (result.tags && result.tags.length > 0) {
|
||||
articleInfo += `标签:${result.tags.join(', ')}\n`
|
||||
}
|
||||
|
||||
// Add statistics if available
|
||||
if (result.stats) {
|
||||
const { likes, favorites, commentCount, hotScore } = result.stats
|
||||
articleInfo += `数据统计:👍 ${likes} | ⭐ ${favorites} | 💬 ${commentCount} | 🔥 热度 ${hotScore}/100\n`
|
||||
}
|
||||
|
||||
articleInfo += '\n'
|
||||
|
||||
// Add main content
|
||||
const articleContent = result.content || ''
|
||||
const contentLimit = 5000
|
||||
articleInfo += `正文内容:\n${articleContent.substring(0, contentLimit)}`
|
||||
|
||||
if (articleContent.length > contentLimit) {
|
||||
articleInfo += '\n\n(正文过长,已截取部分内容)'
|
||||
}
|
||||
|
||||
// Add comments if available
|
||||
if (result.comments && result.comments.length > 0) {
|
||||
articleInfo += '\n\n评论区:\n'
|
||||
const commentLimit = 15
|
||||
const commentsToShow = result.comments.slice(0, commentLimit)
|
||||
|
||||
commentsToShow.forEach((comment: CommentData, index: number) => {
|
||||
articleInfo += `\n${index + 1}. ${comment.author}:${comment.content}`
|
||||
if (comment.time) {
|
||||
articleInfo += ` (${comment.time})`
|
||||
}
|
||||
|
||||
// Add replies if available
|
||||
if (comment.replies && comment.replies.length > 0) {
|
||||
comment.replies.forEach((reply: CommentData) => {
|
||||
articleInfo += `\n └─ ${reply.author}:${reply.content}`
|
||||
if (reply.time) {
|
||||
articleInfo += ` (${reply.time})`
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (result.comments.length > commentLimit) {
|
||||
articleInfo += `\n\n(共${result.comments.length}条评论,已显示前${commentLimit}条)`
|
||||
}
|
||||
}
|
||||
|
||||
// 将文章信息附加到用户原始输入后面
|
||||
finalContent = input + articleInfo
|
||||
} else {
|
||||
throw new Error(result.error || '抓取失败')
|
||||
}
|
||||
} catch (error) {
|
||||
// Remove fetching message and show error
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== fetchingMessage.id))
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: `${error instanceof Error ? error.message : '抓取失败'},将直接使用您的输入`,
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
// Reset article request flag if fetching fails
|
||||
isArticleRequestRef.current = false
|
||||
// Wait a bit before continuing
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
}
|
||||
} else {
|
||||
// 如果不是文章请求,确保标记为 false
|
||||
isArticleRequestRef.current = false
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: finalContent,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Create assistant message placeholder for streaming
|
||||
const assistantId = (Date.now() + 1).toString()
|
||||
const assistantMessage: Message = {
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMessage])
|
||||
|
||||
try {
|
||||
// Get active model config from localStorage
|
||||
const savedConfigs = localStorage.getItem('ai-model-configs')
|
||||
const savedActiveId = localStorage.getItem('ai-active-model-id')
|
||||
|
||||
if (!savedConfigs || !savedActiveId) {
|
||||
throw new Error('请先在设置中配置 AI 模型')
|
||||
}
|
||||
|
||||
const configs: ModelConfig[] = JSON.parse(savedConfigs)
|
||||
const activeConfig = configs.find((c) => c.id === savedActiveId)
|
||||
|
||||
if (!activeConfig) {
|
||||
throw new Error('未找到活跃的模型配置')
|
||||
}
|
||||
|
||||
// Prepare conversation history for API
|
||||
const conversationHistory = [...messages, userMessage].map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
}))
|
||||
|
||||
// Call AI API with streaming
|
||||
const response = await fetch(`${activeConfig.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${activeConfig.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: activeConfig.model,
|
||||
messages: conversationHistory,
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 请求失败: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法读取响应流')
|
||||
}
|
||||
|
||||
let accumulatedContent = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const lines = chunk.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6)
|
||||
|
||||
if (data === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed.choices[0]?.delta?.content
|
||||
|
||||
if (content) {
|
||||
accumulatedContent += content
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantId ? { ...msg, content: accumulatedContent } : msg
|
||||
)
|
||||
)
|
||||
// Throttled auto scroll during streaming (every 100ms)
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current)
|
||||
}
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
if (shouldAutoScrollRef.current) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE data:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!accumulatedContent) {
|
||||
throw new Error('没有收到任何回复内容')
|
||||
}
|
||||
|
||||
// 只有在处理文章请求时才保存 AI 回复内容用于发送评论
|
||||
if (isArticleRequestRef.current) {
|
||||
console.log('Saving AI response for article request:', accumulatedContent.substring(0, 100))
|
||||
setLastAiResponse(accumulatedContent)
|
||||
isArticleRequestRef.current = false // 重置标记
|
||||
} else {
|
||||
console.log('Not an article request, skipping save')
|
||||
}
|
||||
|
||||
// Final scroll to bottom after streaming completes
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current)
|
||||
}
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
} catch (error) {
|
||||
console.error('AI API Error:', error)
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, content: `错误: ${error instanceof Error ? error.message : '未知错误'}` }
|
||||
: msg
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const showQrCodeLogin = async (): Promise<void> => {
|
||||
console.log('showQrCodeLogin called')
|
||||
|
||||
// 打开 Modal
|
||||
setIsQrModalVisible(true)
|
||||
setQrCodeError('')
|
||||
setQrCodeDataUrl('')
|
||||
|
||||
// 获取二维码
|
||||
try {
|
||||
console.log('Fetching QR code...')
|
||||
const result = await window.electron.ipcRenderer.invoke('get-login-qrcode')
|
||||
console.log('QR code fetch result:', result)
|
||||
|
||||
if (result.success && result.qrCodeDataUrl) {
|
||||
console.log('QR code fetched successfully')
|
||||
setQrCodeDataUrl(result.qrCodeDataUrl)
|
||||
} else {
|
||||
console.error('Failed to fetch QR code:', result.error)
|
||||
setQrCodeError(result.error || '获取二维码失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Exception while fetching QR code:', error)
|
||||
setQrCodeError(error instanceof Error ? error.message : '未知错误')
|
||||
}
|
||||
}
|
||||
|
||||
const handleQrModalOk = async (): Promise<void> => {
|
||||
console.log('handleQrModalOk called')
|
||||
message.loading({ content: '正在验证登录状态...', key: 'verify-login', duration: 0 })
|
||||
|
||||
try {
|
||||
console.log('Invoking wait-qrcode-login...')
|
||||
const result = await window.electron.ipcRenderer.invoke('wait-qrcode-login')
|
||||
console.log('wait-qrcode-login result:', result)
|
||||
message.destroy('verify-login')
|
||||
|
||||
if (result.success) {
|
||||
console.log('Login successful, username:', result.username)
|
||||
message.success(`登录成功!欢迎 ${result.username}`)
|
||||
setIsQrModalVisible(false)
|
||||
|
||||
// 登录成功后,直接显示确认发送对话框
|
||||
console.log('Showing confirm dialog with username:', result.username)
|
||||
showConfirmDialog(result.username)
|
||||
} else {
|
||||
console.log('Login failed:', result.error)
|
||||
message.error(result.error || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Exception in handleQrModalOk:', error)
|
||||
message.destroy('verify-login')
|
||||
message.error(error instanceof Error ? error.message : '验证登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示确认发送对话框的独立函数
|
||||
const showConfirmDialog = (username?: string): void => {
|
||||
console.log('showConfirmDialog called with username:', username)
|
||||
setConfirmUsername(username || '当前用户')
|
||||
setIsConfirmModalVisible(true)
|
||||
}
|
||||
|
||||
// 处理确认发送评论
|
||||
const handleConfirmOk = async (): Promise<void> => {
|
||||
console.log('handleConfirmOk called')
|
||||
console.log('currentArticleUrl:', currentArticleUrl)
|
||||
console.log('lastAiResponse length:', lastAiResponse?.length)
|
||||
|
||||
setIsConfirmModalVisible(false)
|
||||
setIsPostingComment(true)
|
||||
message.loading({ content: '正在发送评论...', key: 'posting', duration: 0 })
|
||||
|
||||
try {
|
||||
console.log('Invoking post-comment...')
|
||||
const result = await window.electron.ipcRenderer.invoke('post-comment', {
|
||||
url: currentArticleUrl,
|
||||
comment: lastAiResponse
|
||||
})
|
||||
|
||||
console.log('post-comment result:', result)
|
||||
message.destroy('posting')
|
||||
|
||||
if (result.success) {
|
||||
console.log('Comment posted successfully')
|
||||
message.success('评论发送成功!')
|
||||
} else {
|
||||
console.error('Comment posting failed:', result.error)
|
||||
message.error(result.error || '评论发送失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Exception in handleConfirmOk:', error)
|
||||
message.destroy('posting')
|
||||
message.error(error instanceof Error ? error.message : '发送评论时出错')
|
||||
} finally {
|
||||
console.log('handleConfirmOk finally block')
|
||||
setIsPostingComment(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmCancel = (): void => {
|
||||
setIsConfirmModalVisible(false)
|
||||
}
|
||||
|
||||
const handleQrModalCancel = (): void => {
|
||||
setIsQrModalVisible(false)
|
||||
setQrCodeDataUrl('')
|
||||
setQrCodeError('')
|
||||
}
|
||||
|
||||
const handlePostComment = async (): Promise<void> => {
|
||||
console.log('handlePostComment called')
|
||||
console.log('currentArticleUrl:', currentArticleUrl)
|
||||
console.log('lastAiResponse:', lastAiResponse)
|
||||
|
||||
if (!currentArticleUrl || !lastAiResponse) {
|
||||
message.error('没有可发送的内容')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Checking login status...')
|
||||
// 先检查登录状态
|
||||
const loginStatus = await window.electron.ipcRenderer.invoke(
|
||||
'check-platform-login',
|
||||
currentArticleUrl
|
||||
)
|
||||
|
||||
console.log('Login status result:', loginStatus)
|
||||
|
||||
if (!loginStatus.success) {
|
||||
message.error(loginStatus.error || '检查登录状态失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (!loginStatus.isLoggedIn) {
|
||||
// 显示扫码登录对话框
|
||||
showQrCodeLogin()
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录,显示确认对话框
|
||||
showConfirmDialog(loginStatus.username)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
background: '#f5f5f5'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
background: '#fff',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)'
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: '16px' }}>
|
||||
AI 对话
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Messages List */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '24px'
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
color: '#999'
|
||||
}}
|
||||
>
|
||||
<Text type="secondary">开始新的对话...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '70%',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '12px',
|
||||
background: message.role === 'user' ? '#1890ff' : '#fff',
|
||||
color: message.role === 'user' ? '#fff' : '#000',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: message.role === 'user' ? '#fff' : '#000' }}>
|
||||
{message.content}
|
||||
{message.role === 'assistant' && !message.content && isLoading && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '16px',
|
||||
background: '#1890ff',
|
||||
marginLeft: '2px',
|
||||
animation: 'blink 1s infinite'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
fontSize: '11px',
|
||||
opacity: 0.7
|
||||
}}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
background: '#fff',
|
||||
borderTop: '1px solid #e8e8e8',
|
||||
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.06)'
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<TextArea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
disabled={isLoading}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{/* 发送评论按钮 */}
|
||||
{currentArticleUrl && lastAiResponse && (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CommentOutlined />}
|
||||
onClick={handlePostComment}
|
||||
disabled={isPostingComment}
|
||||
loading={isPostingComment}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
发送 AI 总结到评论区
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code Login Modal */}
|
||||
<Modal
|
||||
title="扫码登录小黑盒"
|
||||
open={isQrModalVisible}
|
||||
onOk={handleQrModalOk}
|
||||
onCancel={handleQrModalCancel}
|
||||
okText="已完成登录"
|
||||
cancelText="取消"
|
||||
width={400}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<p style={{ marginBottom: 16, color: '#666' }}>请使用小黑盒 APP 扫描二维码登录</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 200
|
||||
}}
|
||||
>
|
||||
{!qrCodeDataUrl && !qrCodeError && <span>正在获取二维码...</span>}
|
||||
{qrCodeError && <span style={{ color: '#ff4d4f' }}>{qrCodeError}</span>}
|
||||
{qrCodeDataUrl && (
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="登录二维码"
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
border: '1px solid #e8e8e8',
|
||||
borderRadius: 8
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Confirm Send Comment Modal */}
|
||||
<Modal
|
||||
title="确认发送评论"
|
||||
open={isConfirmModalVisible}
|
||||
onOk={handleConfirmOk}
|
||||
onCancel={handleConfirmCancel}
|
||||
okText="确认发送"
|
||||
cancelText="取消"
|
||||
width={500}
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
将以用户 <strong>{confirmUsername}</strong> 的身份发送评论到:
|
||||
</p>
|
||||
<p style={{ wordBreak: 'break-all', color: '#666' }}>{currentArticleUrl}</p>
|
||||
<p style={{ marginTop: 16 }}>评论内容:</p>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
padding: 8,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: 12
|
||||
}}
|
||||
>
|
||||
{lastAiResponse}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Chat
|
||||
@@ -1,105 +1,29 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { streamChat } from '../services/aiService'
|
||||
import ContextMenu from './ContextMenu'
|
||||
|
||||
const FloatingBall: React.FC = () => {
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false)
|
||||
const [isBlinking, setIsBlinking] = useState(false)
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const [aiResponse, setAiResponse] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [isMouseOverBall, setIsMouseOverBall] = useState(false)
|
||||
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false)
|
||||
const [showTextPrompt, setShowTextPrompt] = useState(false)
|
||||
const [selectedText, setSelectedText] = useState('')
|
||||
const isDraggingRef = useRef(false)
|
||||
const startPosRef = useRef({ x: 0, y: 0 })
|
||||
const windowStartRef = useRef({ x: 0, y: 0 })
|
||||
const blinkTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const responseRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Initialize and expose tooltip state to main process via window object immediately
|
||||
useEffect(() => {
|
||||
// Initialize immediately on mount
|
||||
;(window as Window & { __tooltipOpen?: boolean }).__tooltipOpen = false
|
||||
}, [])
|
||||
|
||||
// Update tooltip state whenever it changes
|
||||
useEffect(() => {
|
||||
;(window as Window & { __tooltipOpen?: boolean }).__tooltipOpen = isTooltipOpen
|
||||
// Notify main process to update cache for faster shortcut response
|
||||
window.electron.ipcRenderer.send('tooltip-state-changed', isTooltipOpen)
|
||||
}, [isTooltipOpen])
|
||||
|
||||
// Handle ESC key to close tooltip
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && isTooltipOpen) {
|
||||
setIsTooltipOpen(false)
|
||||
setSelectedText('')
|
||||
setInputValue('')
|
||||
setAiResponse('')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscapeKey)
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleEscapeKey)
|
||||
}
|
||||
}, [isTooltipOpen])
|
||||
|
||||
// Listen for global shortcut trigger from main process
|
||||
useEffect(() => {
|
||||
const handleTextActionPrompt = (_event: unknown, text: string): void => {
|
||||
setSelectedText(text)
|
||||
setIsTooltipOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseTooltip = (): void => {
|
||||
setIsTooltipOpen(false)
|
||||
setSelectedText('')
|
||||
setInputValue('')
|
||||
setAiResponse('')
|
||||
}
|
||||
|
||||
const unsubscribe1 = window.electron.ipcRenderer.on(
|
||||
'show-text-action-prompt',
|
||||
handleTextActionPrompt
|
||||
)
|
||||
const unsubscribe2 = window.electron.ipcRenderer.on('close-tooltip', handleCloseTooltip)
|
||||
|
||||
return (): void => {
|
||||
if (unsubscribe1) {
|
||||
unsubscribe1()
|
||||
}
|
||||
if (unsubscribe2) {
|
||||
unsubscribe2()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom when AI response updates
|
||||
useEffect(() => {
|
||||
if (responseRef.current) {
|
||||
responseRef.current.scrollTop = responseRef.current.scrollHeight
|
||||
}
|
||||
}, [aiResponse])
|
||||
|
||||
// Blinking animation - blink every 3-5 seconds when not active
|
||||
// Blinking animation - blink every 3-5 seconds
|
||||
useEffect(() => {
|
||||
const scheduleNextBlink = (): void => {
|
||||
const delay = Math.random() * 2000 + 3000 // Random delay between 3-5 seconds
|
||||
blinkTimerRef.current = setTimeout(() => {
|
||||
if (!isTooltipOpen) {
|
||||
setIsBlinking(true)
|
||||
setTimeout(() => {
|
||||
setIsBlinking(false)
|
||||
scheduleNextBlink()
|
||||
}, 200) // Blink duration: 200ms
|
||||
} else {
|
||||
setIsBlinking(true)
|
||||
setTimeout(() => {
|
||||
setIsBlinking(false)
|
||||
scheduleNextBlink()
|
||||
}
|
||||
}, 200) // Blink duration: 200ms
|
||||
}, delay)
|
||||
}
|
||||
|
||||
@@ -110,7 +34,25 @@ const FloatingBall: React.FC = () => {
|
||||
clearTimeout(blinkTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [isTooltipOpen])
|
||||
}, [])
|
||||
|
||||
// Handle Command+K shortcut from main process
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.ipcRenderer.on(
|
||||
'show-text-prompt',
|
||||
(_: unknown, text: string) => {
|
||||
setSelectedText(text)
|
||||
setShowTextPrompt(true)
|
||||
setIsActionMenuOpen(true)
|
||||
}
|
||||
)
|
||||
|
||||
return (): void => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseEnterBall = (): void => {
|
||||
setIsMouseOverBall(true)
|
||||
@@ -121,22 +63,11 @@ const FloatingBall: React.FC = () => {
|
||||
const handleMouseLeaveBall = (): void => {
|
||||
setIsMouseOverBall(false)
|
||||
// When mouse leaves the ball area, always restore click-through
|
||||
// If mouse enters tooltip, the tooltip's onMouseEnter will disable click-through again
|
||||
if (!isContextMenuOpen) {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnterTooltip = (): void => {
|
||||
// When mouse enters tooltip, stop ignoring mouse events
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||
}
|
||||
|
||||
const handleMouseLeaveTooltip = (): void => {
|
||||
// When mouse leaves tooltip, restore click-through
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent): void => {
|
||||
e.preventDefault()
|
||||
// Show custom context menu at cursor position
|
||||
@@ -149,10 +80,8 @@ const FloatingBall: React.FC = () => {
|
||||
const handleCloseContextMenu = (): void => {
|
||||
setIsContextMenuOpen(false)
|
||||
// Re-enable mouse events pass-through when menu closes, but only if mouse is not over the ball
|
||||
// Use setTimeout to ensure state update completes first
|
||||
setTimeout(() => {
|
||||
// Check if mouse is still over the ball or tooltip
|
||||
if (!isMouseOverBall && !isTooltipOpen) {
|
||||
if (!isMouseOverBall) {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||
}
|
||||
}, 50)
|
||||
@@ -168,42 +97,6 @@ const FloatingBall: React.FC = () => {
|
||||
window.electron.ipcRenderer.send('quit-app')
|
||||
}
|
||||
|
||||
const handleSendMessage = async (): Promise<void> => {
|
||||
const message = inputValue.trim()
|
||||
if (!message) return
|
||||
|
||||
setIsLoading(true)
|
||||
setAiResponse('')
|
||||
|
||||
try {
|
||||
await streamChat(message, {
|
||||
onStart: () => {
|
||||
setAiResponse('')
|
||||
},
|
||||
onToken: (token: string) => {
|
||||
setAiResponse((prev) => prev + token)
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setIsLoading(false)
|
||||
setAiResponse(`错误: ${error.message}`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
setIsLoading(false)
|
||||
setAiResponse(`错误: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = async (e: React.MouseEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -220,7 +113,7 @@ const FloatingBall: React.FC = () => {
|
||||
const bounds = await window.electron.ipcRenderer.invoke('get-window-bounds')
|
||||
windowStartRef.current = { x: bounds.x, y: bounds.y }
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const handleMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const deltaX = moveEvent.screenX - startPosRef.current.x
|
||||
const deltaY = moveEvent.screenY - startPosRef.current.y
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
@@ -237,24 +130,13 @@ const FloatingBall: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const handleMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
|
||||
// If not dragged, treat as a click - toggle tooltip
|
||||
// If not dragged, treat as a click - toggle action menu
|
||||
if (!isDraggingRef.current) {
|
||||
// Use setTimeout to avoid state update conflicts
|
||||
setTimeout(() => {
|
||||
setIsTooltipOpen((prev) => {
|
||||
if (prev) {
|
||||
// Closing tooltip, clear all states
|
||||
setSelectedText('')
|
||||
setInputValue('')
|
||||
setAiResponse('')
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, 0)
|
||||
setIsActionMenuOpen((prev) => !prev)
|
||||
}
|
||||
|
||||
isDraggingRef.current = false
|
||||
@@ -269,6 +151,51 @@ const FloatingBall: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes slideIn1 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(30px, 30px) scale(0.3);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn2 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(40px) scale(0.3);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn3 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(30px, -30px) scale(0.3);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<ContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
@@ -281,27 +208,11 @@ const FloatingBall: React.FC = () => {
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// When mouse leaves menu, restore click-through
|
||||
if (!isMouseOverBall && !isTooltipOpen) {
|
||||
if (!isMouseOverBall) {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<style>{`
|
||||
.ai-response-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.ai-response-container::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ai-response-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(25, 118, 210, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ai-response-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(25, 118, 210, 0.5);
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -313,186 +224,204 @@ const FloatingBall: React.FC = () => {
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{/* Tooltip - Separate from ball container for proper click-through */}
|
||||
{isTooltipOpen && (
|
||||
{/* Text Prompt */}
|
||||
{showTextPrompt && selectedText && (
|
||||
<div
|
||||
onMouseEnter={handleMouseEnterTooltip}
|
||||
onMouseLeave={handleMouseLeaveTooltip}
|
||||
onMouseEnter={() => {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(50% + 42px)',
|
||||
top: 'calc(50% - 100px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'white',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 16px rgba(33, 150, 243, 0.3)',
|
||||
zIndex: 2,
|
||||
border: '1px solid #e3f2fd',
|
||||
minWidth: '280px',
|
||||
maxWidth: '350px',
|
||||
maxHeight: '500px',
|
||||
background: 'rgba(33, 150, 243, 0.95)',
|
||||
color: 'white',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 12px rgba(33, 150, 243, 0.4)',
|
||||
pointerEvents: 'auto',
|
||||
animation: 'fadeInDown 0.3s ease-out',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: 'auto'
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<span>要对这段文本做什么?</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsTooltipOpen(false)
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
console.log('Close button clicked')
|
||||
setShowTextPrompt(false)
|
||||
setSelectedText('')
|
||||
setInputValue('')
|
||||
setAiResponse('')
|
||||
setIsActionMenuOpen(false)
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
color: '#999',
|
||||
display: 'flex',
|
||||
fontSize: '10px',
|
||||
color: 'white',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#1976d2'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#999'
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.2s ease'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#1976d2',
|
||||
marginBottom: '8px',
|
||||
paddingRight: '20px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{selectedText ? '你想对这段文字做什么?' : '你好!我能帮你做些什么?'}
|
||||
</div>
|
||||
|
||||
{/* Selected text display */}
|
||||
{selectedText && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
marginBottom: '8px',
|
||||
maxHeight: '60px',
|
||||
overflow: 'auto',
|
||||
color: '#666',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{selectedText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Response - scrollable area */}
|
||||
{aiResponse && (
|
||||
<div
|
||||
ref={responseRef}
|
||||
className="ai-response-container"
|
||||
style={{
|
||||
padding: '8px',
|
||||
background: '#e3f2fd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
marginBottom: '8px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
color: '#333',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
flexShrink: 1
|
||||
}}
|
||||
>
|
||||
{aiResponse}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input box - fixed at bottom */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="输入你的问题..."
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #e3f2fd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
opacity: isLoading ? 0.6 : 1
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1976d2'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e3f2fd'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={isLoading || !inputValue.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: isLoading || !inputValue.trim() ? '#ccc' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: isLoading || !inputValue.trim() ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{isLoading ? '发送中...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-6px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '6px solid white',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Robot Ball Container - Separate for proper pointer events */}
|
||||
{/* Action Menu Items */}
|
||||
{isActionMenuOpen && (
|
||||
<>
|
||||
{/* Action Item 1 - Top Left */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 'calc(50% - 90px)',
|
||||
top: 'calc(50% - 60px)',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(76, 175, 80, 0.4)',
|
||||
pointerEvents: 'auto',
|
||||
animation: 'slideIn1 0.3s ease-out',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}
|
||||
onClick={() => {
|
||||
console.log('Action 1 clicked - Opening chat window')
|
||||
// Open chat window with selected text
|
||||
window.electron.ipcRenderer.send('open-chat', selectedText || undefined)
|
||||
setIsActionMenuOpen(false)
|
||||
setShowTextPrompt(false)
|
||||
setSelectedText('')
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.6)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(76, 175, 80, 0.4)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px', color: 'white' }}>✓</span>
|
||||
</div>
|
||||
|
||||
{/* Action Item 2 - Middle Left */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 'calc(50% - 100px)',
|
||||
top: 'calc(50% - 20px)',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #FF9800 0%, #F57C00 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.4)',
|
||||
pointerEvents: 'auto',
|
||||
animation: 'slideIn2 0.3s ease-out',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}
|
||||
onClick={() => {
|
||||
console.log('Action 2 clicked')
|
||||
if (selectedText) {
|
||||
console.log('Selected text:', selectedText)
|
||||
}
|
||||
setIsActionMenuOpen(false)
|
||||
setShowTextPrompt(false)
|
||||
setSelectedText('')
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.6)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 152, 0, 0.4)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px', color: 'white' }}>★</span>
|
||||
</div>
|
||||
|
||||
{/* Action Item 3 - Bottom Left */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 'calc(50% - 90px)',
|
||||
top: 'calc(50% + 20px)',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #f44336 0%, #d32f2f 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(244, 67, 54, 0.4)',
|
||||
pointerEvents: 'auto',
|
||||
animation: 'slideIn3 0.3s ease-out',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}
|
||||
onClick={() => {
|
||||
console.log('Action 3 clicked - Opening settings window')
|
||||
// Open settings window
|
||||
window.electron.ipcRenderer.send('open-settings')
|
||||
setIsActionMenuOpen(false)
|
||||
setShowTextPrompt(false)
|
||||
setSelectedText('')
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(244, 67, 54, 0.6)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(244, 67, 54, 0.4)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '20px', color: 'white' }}>✕</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Robot Ball Container */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -534,103 +463,66 @@ const FloatingBall: React.FC = () => {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Robot Icon - Changes based on tooltip state */}
|
||||
{!isTooltipOpen ? (
|
||||
// Normal state - smiling robot
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="48"
|
||||
height="48"
|
||||
{/* Robot Icon - smiling robot */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Antenna */}
|
||||
<circle cx="50" cy="10" r="4" fill="white" />
|
||||
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
|
||||
|
||||
{/* Head */}
|
||||
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
|
||||
|
||||
{/* Face screen */}
|
||||
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
|
||||
|
||||
{/* Eyes */}
|
||||
{isBlinking ? (
|
||||
<>
|
||||
<line
|
||||
x1="33"
|
||||
y1="47"
|
||||
x2="43"
|
||||
y2="47"
|
||||
stroke="#1976d2"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="57"
|
||||
y1="47"
|
||||
x2="67"
|
||||
y2="47"
|
||||
stroke="#1976d2"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
|
||||
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Smile */}
|
||||
<path
|
||||
d="M 38 58 Q 50 64 62 58"
|
||||
stroke="#1976d2"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Antenna */}
|
||||
<circle cx="50" cy="10" r="4" fill="white" />
|
||||
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Head */}
|
||||
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
|
||||
|
||||
{/* Face screen */}
|
||||
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
|
||||
|
||||
{/* Eyes */}
|
||||
{isBlinking ? (
|
||||
<>
|
||||
<line
|
||||
x1="33"
|
||||
y1="47"
|
||||
x2="43"
|
||||
y2="47"
|
||||
stroke="#1976d2"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="57"
|
||||
y1="47"
|
||||
x2="67"
|
||||
y2="47"
|
||||
stroke="#1976d2"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
|
||||
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Smile */}
|
||||
<path
|
||||
d="M 38 58 Q 50 64 62 58"
|
||||
stroke="#1976d2"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Ears - elliptical, only showing outer half */}
|
||||
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
|
||||
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
|
||||
</svg>
|
||||
) : (
|
||||
// Active state - excited robot
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Antenna with sparkles */}
|
||||
<circle cx="50" cy="10" r="4" fill="white" />
|
||||
<line x1="50" y1="14" x2="50" y2="25" stroke="white" strokeWidth="2.5" />
|
||||
|
||||
{/* Sparkle effects */}
|
||||
<line x1="62" y1="12" x2="68" y2="12" stroke="white" strokeWidth="2" />
|
||||
<line x1="65" y1="9" x2="65" y2="15" stroke="white" strokeWidth="2" />
|
||||
|
||||
{/* Head */}
|
||||
<rect x="20" y="25" width="60" height="50" rx="12" fill="white" />
|
||||
|
||||
{/* Face screen */}
|
||||
<rect x="26" y="31" width="48" height="38" rx="8" fill="#e3f2fd" />
|
||||
|
||||
{/* Eyes - excited/happy */}
|
||||
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
|
||||
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
|
||||
|
||||
{/* Open mouth - happy expression */}
|
||||
<ellipse cx="50" cy="60" rx="10" ry="7" fill="#1976d2" />
|
||||
|
||||
{/* Ears - elliptical, only showing outer half */}
|
||||
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
|
||||
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Ears - elliptical, only showing outer half */}
|
||||
<ellipse cx="20" cy="50" rx="6" ry="10" fill="white" />
|
||||
<ellipse cx="80" cy="50" rx="6" ry="10" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import FloatingBall from './components/FloatingBall'
|
||||
import { ConfigProvider, theme } from 'antd'
|
||||
|
||||
const FloatingApp: React.FC = () => {
|
||||
export const FloatingApp: React.FC = () => {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
|
||||
Reference in New Issue
Block a user