This commit is contained in:
2025-11-13 10:18:36 +08:00
parent 787906c566
commit 7b955de2f0
6 changed files with 1301 additions and 427 deletions
+404 -176
View File
@@ -1,6 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'
import { Input, Button, Typography, Space, Modal, message, Drawer, Skeleton } from 'antd'
import { Input, Button, Typography, Modal, message, Drawer, Skeleton } from 'antd'
import type { TextAreaRef } from 'antd/es/input/TextArea'
import { SendOutlined, CommentOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
import { motion, AnimatePresence } from 'framer-motion'
import styled from '@emotion/styled'
import { lightTheme, darkTheme, Theme } from '../theme'
const { TextArea } = Input
const { Text } = Typography
@@ -35,10 +39,178 @@ interface CommentData {
replies?: CommentData[]
}
// Styled Components
const ChatContainer = styled.div<{ theme: Theme }>`
display: flex;
flex-direction: column;
height: 100vh;
background: ${(props) => props.theme.colors.background};
font-family: ${(props) => props.theme.typography.fontFamily};
transition: background ${(props) => props.theme.animation.normal} ease;
`
const Header = styled.div<{ theme: Theme }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${(props) => props.theme.spacing.md} ${(props) => props.theme.spacing.lg};
background: ${(props) => props.theme.colors.glassBackground};
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid ${(props) => props.theme.colors.glassBorder};
position: sticky;
top: 0;
z-index: 10;
`
const Title = styled.h1<{ theme: Theme }>`
margin: 0;
font-size: ${(props) => props.theme.typography.fontSize.xl};
font-weight: ${(props) => props.theme.typography.fontWeight.semibold};
color: ${(props) => props.theme.colors.textPrimary};
`
const HeaderActions = styled.div`
display: flex;
gap: 8px;
`
const MessagesContainer = styled.div<{ theme: Theme }>`
flex: 1;
overflow-y: auto;
padding: ${(props) => props.theme.spacing.lg};
display: flex;
flex-direction: column;
gap: ${(props) => props.theme.spacing.md};
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.colors.border};
border-radius: ${(props) => props.theme.borderRadius.full};
}
&::-webkit-scrollbar-thumb:hover {
background: ${(props) => props.theme.colors.textTertiary};
}
`
const MessageBubble = styled(motion.div)<{ role: 'user' | 'assistant'; theme: Theme }>`
max-width: 70%;
padding: ${(props) => props.theme.spacing.md} ${(props) => props.theme.spacing.lg};
border-radius: ${(props) => props.theme.borderRadius.md};
align-self: ${(props) => (props.role === 'user' ? 'flex-end' : 'flex-start')};
background: ${(props) =>
props.role === 'user' ? props.theme.colors.userBubble : props.theme.colors.aiBubble};
color: ${(props) =>
props.role === 'user' ? props.theme.colors.userBubbleText : props.theme.colors.aiBubbleText};
box-shadow: ${(props) => props.theme.shadows.md};
word-wrap: break-word;
white-space: pre-wrap;
font-size: ${(props) => props.theme.typography.fontSize.base};
line-height: 1.5;
backdrop-filter: ${(props) => (props.role === 'assistant' ? 'blur(20px)' : 'none')};
-webkit-backdrop-filter: ${(props) => (props.role === 'assistant' ? 'blur(20px)' : 'none')};
border: ${(props) =>
props.role === 'assistant' ? `1px solid ${props.theme.colors.glassBorder}` : 'none'};
transition: transform ${(props) => props.theme.animation.fast} ease;
&:hover {
transform: scale(1.01);
}
`
const MessageActions = styled(motion.div)<{ theme: Theme }>`
display: flex;
gap: ${(props) => props.theme.spacing.sm};
margin-top: ${(props) => props.theme.spacing.sm};
`
const InputContainer = styled.div<{ theme: Theme }>`
padding: ${(props) => props.theme.spacing.lg};
background: ${(props) => props.theme.colors.glassBackground};
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid ${(props) => props.theme.colors.glassBorder};
`
const InputWrapper = styled(motion.div)<{ theme: Theme; isMultiLine: boolean }>`
background: ${(props) => props.theme.colors.surface};
border-radius: ${(props) => props.theme.borderRadius.lg};
box-shadow: ${(props) => props.theme.shadows.lg};
overflow: hidden;
display: flex;
flex-direction: ${(props) => (props.isMultiLine ? 'column' : 'row')};
align-items: ${(props) => (props.isMultiLine ? 'flex-end' : 'flex-end')};
gap: ${(props) => props.theme.spacing.sm};
padding: ${(props) => props.theme.spacing.sm};
transition: box-shadow ${(props) => props.theme.animation.fast} ease;
&:focus-within {
box-shadow: ${(props) => props.theme.shadows.xl};
}
`
const SendButton = styled(motion.button)<{ theme: Theme; disabled: boolean }>`
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: ${(props) =>
props.disabled
? props.theme.colors.border
: `linear-gradient(135deg, ${props.theme.colors.primary} 0%, #0051d5 100%)`};
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
box-shadow: ${(props) =>
props.disabled ? 'none' : '0 2px 8px rgba(0, 122, 255, 0.3)'};
transition: all ${(props) => props.theme.animation.fast} ease;
flex-shrink: 0;
padding: 0;
outline: none;
&:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4);
}
&:active:not(:disabled) {
transform: scale(0.95);
}
svg {
width: 18px;
height: 18px;
transform: translateX(1px);
}
`
const EmptyState = styled.div<{ theme: Theme }>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${(props) => props.theme.colors.textSecondary};
font-size: ${(props) => props.theme.typography.fontSize.lg};
`
const Chat: React.FC = () => {
const [isDarkMode, setIsDarkMode] = useState(false)
const theme = isDarkMode ? darkTheme : lightTheme
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isMultiLine, setIsMultiLine] = useState(false)
const [currentArticleUrl, setCurrentArticleUrl] = useState<string>('')
const [lastAiResponse, setLastAiResponse] = useState<string>('')
const [isQrModalVisible, setIsQrModalVisible] = useState(false)
@@ -46,6 +218,7 @@ const Chat: React.FC = () => {
const [qrCodeError, setQrCodeError] = useState<string>('')
const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false)
const [confirmUsername, setConfirmUsername] = useState<string>('')
const [editableComment, setEditableComment] = useState<string>('')
const [regeneratingMessageId, setRegeneratingMessageId] = useState<string | null>(null)
const [isAccountDrawerVisible, setIsAccountDrawerVisible] = useState(false)
const [isCheckingLoginStatus, setIsCheckingLoginStatus] = useState(false)
@@ -58,13 +231,28 @@ const Chat: React.FC = () => {
const messagesContainerRef = useRef<HTMLDivElement>(null)
const shouldAutoScrollRef = useRef(true)
const isArticleRequestRef = useRef(false) // 使用 ref 而不是 state 以避免异步问题
const inputRef = useRef<TextAreaRef>(null) // Ant Design TextArea ref
// Listen for initial text from main process
useEffect(() => {
const unsubscribe = window.electron.ipcRenderer.on(
'set-initial-text',
(_: unknown, text: string) => {
setInputValue(text)
console.log('Chat: Received initial text:', text?.substring(0, 50))
if (text && text.trim()) {
setInputValue(text)
// Focus the input area after setting the value
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus()
// Move cursor to the end
const textarea = inputRef.current.resizableTextArea?.textArea
if (textarea) {
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
}
}
}, 100)
}
}
)
@@ -417,9 +605,7 @@ const Chat: React.FC = () => {
setRegeneratingMessageId(messageId)
// 清空当前消息内容,显示加载状态
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, content: '' } : msg))
)
setMessages((prev) => prev.map((msg) => (msg.id === messageId ? { ...msg, content: '' } : msg)))
try {
// 获取模型配置
@@ -590,6 +776,7 @@ const Chat: React.FC = () => {
const showConfirmDialog = (username?: string): void => {
console.log('showConfirmDialog called with username:', username)
setConfirmUsername(username || '当前用户')
setEditableComment(lastAiResponse) // 初始化可编辑内容
setIsConfirmModalVisible(true)
}
@@ -597,7 +784,7 @@ const Chat: React.FC = () => {
const handleConfirmOk = async (): Promise<void> => {
console.log('handleConfirmOk called')
console.log('currentArticleUrl:', currentArticleUrl)
console.log('lastAiResponse length:', lastAiResponse?.length)
console.log('editableComment length:', editableComment?.length)
setIsConfirmModalVisible(false)
message.loading({ content: '正在发送评论...', key: 'posting', duration: 0 })
@@ -606,7 +793,7 @@ const Chat: React.FC = () => {
console.log('Invoking post-comment...')
const result = await window.electron.ipcRenderer.invoke('post-comment', {
url: currentArticleUrl,
comment: lastAiResponse
comment: editableComment // 使用编辑后的内容
})
console.log('post-comment result:', result)
@@ -629,7 +816,9 @@ const Chat: React.FC = () => {
}
const handleConfirmCancel = (): void => {
console.log('handleConfirmCancel called')
setIsConfirmModalVisible(false)
setEditableComment('')
}
const handleQrModalCancel = (): void => {
@@ -728,183 +917,205 @@ const Chat: React.FC = () => {
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)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Text strong style={{ fontSize: '16px' }}>
AI
</Text>
<Button
icon={<SettingOutlined />}
onClick={() => {
setIsAccountDrawerVisible(true)
// 打开 Drawer 时检查登录状态
checkXiaoheiheLoginStatus()
}}
>
</Button>
</div>
{/* Messages List */}
<div
ref={messagesContainerRef}
style={{
flex: 1,
overflowY: 'auto',
padding: '24px'
}}
>
{messages.length === 0 ? (
<div
/* 自定义 TextArea 滚动条样式 - WebKit 浏览器 */
.ant-input::-webkit-scrollbar {
width: 6px;
}
.ant-input::-webkit-scrollbar-track {
background: transparent;
}
.ant-input::-webkit-scrollbar-thumb {
background: ${theme.colors.border};
border-radius: 3px;
}
.ant-input::-webkit-scrollbar-thumb:hover {
background: ${theme.colors.textTertiary};
}
/* Firefox 滚动条样式 */
.ant-input {
scrollbar-width: thin;
scrollbar-color: ${theme.colors.border} transparent;
}
`}</style>
<ChatContainer theme={theme}>
{/* Header */}
<Header theme={theme}>
<Title theme={theme}>AI </Title>
<HeaderActions>
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => setIsDarkMode(!isDarkMode)}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
color: '#999'
color: theme.colors.textPrimary,
transition: `all ${theme.animation.fast} ease`
}}
/>
<Button
type="primary"
icon={<SettingOutlined />}
onClick={() => {
setIsAccountDrawerVisible(true)
checkXiaoheiheLoginStatus()
}}
>
</Button>
</HeaderActions>
</Header>
{/* Messages List */}
<MessagesContainer ref={messagesContainerRef} theme={theme}>
{messages.length === 0 ? (
<EmptyState theme={theme}>
<Text type="secondary">...</Text>
</div>
</EmptyState>
) : (
<Space direction="vertical" style={{ width: '100%' }} size="large">
{messages.map((message) => (
<div
<AnimatePresence mode="popLayout">
{messages.map((message, index) => (
<MessageBubble
key={message.id}
style={{
display: 'flex',
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start'
role={message.role}
theme={theme}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{
duration: 0.15,
delay: index * 0.03,
ease: [0.25, 0.1, 0.25, 1]
}}
whileHover={{ scale: 1.01 }}
>
<div
<Text
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'
color:
message.role === 'user'
? theme.colors.userBubbleText
: theme.colors.aiBubbleText
}}
>
<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>
{message.content}
{message.role === 'assistant' && !message.content && isLoading && (
<span
style={{
display: 'inline-block',
width: '8px',
height: '16px',
background: theme.colors.primary,
marginLeft: '2px',
animation: 'blink 1s infinite'
}}
/>
)}
</Text>
<div
style={{
marginTop: '4px',
fontSize: theme.typography.fontSize.xs,
opacity: 0.7
}}
>
{message.timestamp.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
{/* AI 消息的操作按钮 */}
{message.role === 'assistant' && message.content && (
<div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
{/* AI 消息的操作按钮 */}
{message.role === 'assistant' && message.content && (
<MessageActions
theme={theme}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => handleRegenerateMessage(message.id)}
loading={regeneratingMessageId === message.id}
disabled={regeneratingMessageId !== null}
>
</Button>
{message.metadata?.type === 'article' && message.metadata.articleUrl && (
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => handleRegenerateMessage(message.id)}
loading={regeneratingMessageId === message.id}
disabled={regeneratingMessageId !== null}
type="primary"
icon={<CommentOutlined />}
onClick={() => {
setCurrentArticleUrl(message.metadata!.articleUrl!)
setLastAiResponse(message.content)
handlePostComment()
}}
>
</Button>
{message.metadata?.type === 'article' && message.metadata.articleUrl && (
<Button
size="small"
type="primary"
icon={<CommentOutlined />}
onClick={() => {
// 更新当前文章 URL 和 AI 回复
setCurrentArticleUrl(message.metadata!.articleUrl!)
setLastAiResponse(message.content)
handlePostComment()
}}
>
</Button>
)}
</div>
)}
</div>
</div>
)}
</MessageActions>
)}
</MessageBubble>
))}
</Space>
</AnimatePresence>
)}
<div ref={messagesEndRef} />
</div>
</MessagesContainer>
{/* 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>
</Space>
</div>
</div>
<InputContainer theme={theme}>
<InputWrapper
theme={theme}
isMultiLine={isMultiLine}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.2 }}
>
<TextArea
rows={4}
ref={inputRef}
value={inputValue}
onChange={(e) => {
const value = e.target.value
setInputValue(value)
// Check if text contains newline or has multiple lines
setIsMultiLine(value.includes('\n') || value.split('\n').length > 1)
}}
onKeyPress={handleKeyPress}
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
disabled={isLoading}
style={{
flex: 1,
border: 'none',
boxShadow: 'none',
resize: 'none',
outline: 'none'
}}
styles={{
textarea: {
scrollbarWidth: 'thin',
scrollbarColor: `${theme.colors.border} transparent`
}
}}
/>
<SendButton
theme={theme}
disabled={!inputValue.trim() || isLoading}
onClick={handleSend}
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.15 }}
>
<SendOutlined />
</SendButton>
</InputWrapper>
</InputContainer>
</ChatContainer>
{/* QR Code Login Modal */}
<Modal
@@ -915,6 +1126,11 @@ const Chat: React.FC = () => {
okText="已完成登录"
cancelText="取消"
width={400}
styles={{
mask: { backdropFilter: 'blur(10px)', background: 'rgba(0, 0, 0, 0.45)' }
}}
transitionName="zoom"
maskTransitionName="fade"
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<p style={{ marginBottom: 16, color: '#666' }}>使 APP </p>
@@ -952,27 +1168,35 @@ const Chat: React.FC = () => {
onCancel={handleConfirmCancel}
okText="确认发送"
cancelText="取消"
width={500}
width={600}
maskClosable={true}
keyboard={true}
styles={{
mask: { backdropFilter: 'blur(10px)', background: 'rgba(0, 0, 0, 0.45)' }
}}
>
<div>
<p>
<strong>{confirmUsername}</strong>
</p>
<p style={{ wordBreak: 'break-all', color: '#666' }}>{currentArticleUrl}</p>
<p style={{ marginTop: 16 }}></p>
<div
<p style={{ wordBreak: 'break-all', color: '#666', marginBottom: 16 }}>
{currentArticleUrl}
</p>
<p style={{ marginTop: 16, marginBottom: 8, fontWeight: 500 }}></p>
<TextArea
value={editableComment}
onChange={(e) => setEditableComment(e.target.value)}
placeholder="请输入评论内容"
autoSize={{ minRows: 4, maxRows: 12 }}
style={{
maxHeight: 200,
overflow: 'auto',
padding: 8,
background: '#f5f5f5',
borderRadius: 4,
whiteSpace: 'pre-wrap',
fontSize: 12
fontSize: 14,
borderRadius: 8,
border: '1px solid #d9d9d9'
}}
>
{lastAiResponse}
</div>
/>
<p style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
{editableComment?.length || 0}
</p>
</div>
</Modal>
@@ -983,6 +1207,10 @@ const Chat: React.FC = () => {
onClose={() => setIsAccountDrawerVisible(false)}
open={isAccountDrawerVisible}
width={400}
styles={{
mask: { backdropFilter: 'blur(10px)', background: 'rgba(0, 0, 0, 0.45)' },
body: { padding: '24px' }
}}
>
<div>
<div style={{ marginBottom: 24 }}>
+223 -213
View File
@@ -1,14 +1,12 @@
import React, { useState, useRef, useEffect } from 'react'
import ContextMenu from './ContextMenu'
import { AnimatePresence, motion } from 'framer-motion'
const FloatingBall: React.FC = () => {
const [isBlinking, setIsBlinking] = 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 [selectedButtonIndex, setSelectedButtonIndex] = useState(0) // 0: 对话, 1: 设置, 2: 退出
const isDraggingRef = useRef(false)
const startPosRef = useRef({ x: 0, y: 0 })
const windowStartRef = useRef({ x: 0, y: 0 })
@@ -42,8 +40,15 @@ const FloatingBall: React.FC = () => {
'show-text-prompt',
(_: unknown, text: string) => {
setSelectedText(text)
setShowTextPrompt(true)
setIsActionMenuOpen(true)
// 切换按钮显示状态
setIsActionMenuOpen((prev) => {
const newState = !prev
if (newState) {
// When opening menu, reset to first button
setSelectedButtonIndex(0)
}
return newState
})
}
)
@@ -54,6 +59,61 @@ const FloatingBall: React.FC = () => {
}
}, [])
// Handle keyboard navigation when menu is open
useEffect(() => {
if (!isActionMenuOpen) return
const handleKeyDown = (e: KeyboardEvent): void => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
setSelectedButtonIndex((prev) => (prev === 0 ? 2 : prev - 1))
break
case 'ArrowDown':
e.preventDefault()
setSelectedButtonIndex((prev) => (prev === 2 ? 0 : prev + 1))
break
case 'Tab':
e.preventDefault()
// Tab cycles through options
setSelectedButtonIndex((prev) => (prev === 2 ? 0 : prev + 1))
break
case 'Enter':
e.preventDefault()
executeSelectedAction()
break
case 'Escape':
e.preventDefault()
setIsActionMenuOpen(false)
setSelectedText('')
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isActionMenuOpen, selectedButtonIndex, selectedText])
// Execute the action for the currently selected button
const executeSelectedAction = (): void => {
switch (selectedButtonIndex) {
case 0: // 对话
console.log('对话按钮选中 - 打开聊天窗口')
window.electron.ipcRenderer.send('open-chat', selectedText || undefined)
break
case 1: // 设置
console.log('设置按钮选中 - 打开设置窗口')
window.electron.ipcRenderer.send('open-settings')
break
case 2: // 退出
console.log('退出按钮选中 - 退出应用')
window.electron.ipcRenderer.send('quit-app')
break
}
setIsActionMenuOpen(false)
setSelectedText('')
}
const handleMouseEnterBall = (): void => {
setIsMouseOverBall(true)
// When mouse enters the ball area, stop ignoring mouse events
@@ -63,45 +123,14 @@ const FloatingBall: React.FC = () => {
const handleMouseLeaveBall = (): void => {
setIsMouseOverBall(false)
// When mouse leaves the ball area, always restore click-through
if (!isContextMenuOpen) {
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
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
// Disable mouse events pass-through when menu is open
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}
const handleCloseContextMenu = (): void => {
setIsContextMenuOpen(false)
// Re-enable mouse events pass-through when menu closes, but only if mouse is not over the ball
setTimeout(() => {
if (!isMouseOverBall) {
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}
}, 50)
}
const handleSettingsClick = (): void => {
// Send message to main process to open settings
window.electron.ipcRenderer.send('open-settings')
}
const handleQuitClick = (): void => {
// Send message to main process to quit app
window.electron.ipcRenderer.send('quit-app')
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}
const handleMouseDown = async (e: React.MouseEvent): Promise<void> => {
e.preventDefault()
e.stopPropagation()
// Ignore right click for context menu
// Ignore right click
if (e.button === 2) {
return
}
@@ -196,23 +225,6 @@ const FloatingBall: React.FC = () => {
}
}
`}</style>
<ContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
onSettings={handleSettingsClick}
onQuit={handleQuitClick}
onMouseEnter={() => {
// Keep mouse events enabled when hovering over menu
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}}
onMouseLeave={() => {
// When mouse leaves menu, restore click-through
if (!isMouseOverBall) {
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}
}}
/>
<div
style={{
width: '100%',
@@ -224,202 +236,201 @@ const FloatingBall: React.FC = () => {
pointerEvents: 'none'
}}
>
{/* Text Prompt */}
{showTextPrompt && selectedText && (
<div
onMouseEnter={() => {
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}}
onMouseDown={(e) => {
e.stopPropagation()
}}
style={{
position: 'absolute',
top: 'calc(50% - 100px)',
left: '50%',
transform: 'translateX(-50%)',
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',
alignItems: 'center',
gap: '8px'
}}
>
<span></span>
<button
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
console.log('Close button clicked')
setShowTextPrompt(false)
setSelectedText('')
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={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
borderRadius: '50%',
width: '16px',
height: '16px',
cursor: 'pointer',
fontSize: '10px',
color: 'white',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
flexShrink: 0,
transition: 'background 0.2s ease'
}}
>
×
</button>
</div>
)}
{/* Action Menu Items */}
{isActionMenuOpen && (
<>
{/* Action Item 1 - Top Left */}
<div
style={{
<AnimatePresence>
{isActionMenuOpen && (
<>
{/* Action Item 1 - 对话 (Top Left) */}
<motion.div
initial={{ opacity: 0, scale: 0.3, x: 30, y: 30 }}
animate={{
opacity: 1,
scale: selectedButtonIndex === 0 ? 1.1 : 1,
x: 0,
y: 0
}}
exit={{ opacity: 0, scale: 0.3, x: 30, y: 30 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
style={{
position: 'absolute',
left: 'calc(50% - 90px)',
top: 'calc(50% - 60px)',
width: '40px',
height: '40px',
left: 'calc(50% - 105px)',
top: 'calc(50% - 75px)',
width: '44px',
height: '44px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
background: selectedButtonIndex === 0
? 'rgba(0, 122, 255, 0.15)'
: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(76, 175, 80, 0.4)',
boxShadow: selectedButtonIndex === 0
? '0 4px 16px rgba(0, 122, 255, 0.3)'
: '0 2px 8px rgba(0, 0, 0, 0.1)',
pointerEvents: 'auto',
animation: 'slideIn1 0.3s ease-out',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
border: selectedButtonIndex === 0
? '2px solid #007AFF'
: '0.5px solid rgba(0, 0, 0, 0.04)'
}}
onClick={() => {
console.log('Action 1 clicked - Opening chat window')
// Open chat window with selected text
console.log('对话按钮点击 - 打开聊天窗口')
window.electron.ipcRenderer.send('open-chat', selectedText || undefined)
setIsActionMenuOpen(false)
setShowTextPrompt(false)
setSelectedText('')
}}
onMouseEnter={(e) => {
setSelectedButtonIndex(0)
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)'
onMouseLeave={() => {
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}}
>
<span style={{ fontSize: '20px', color: 'white' }}></span>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#007AFF"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</motion.div>
{/* Action Item 2 - Middle Left */}
<div
{/* Action Item 2 - 设置 (Middle Left) */}
<motion.div
initial={{ opacity: 0, scale: 0.3, x: 40 }}
animate={{
opacity: 1,
scale: selectedButtonIndex === 1 ? 1.1 : 1,
x: 0
}}
exit={{ opacity: 0, scale: 0.3, x: 40 }}
transition={{ duration: 0.2, ease: 'easeOut', delay: 0.05 }}
style={{
position: 'absolute',
left: 'calc(50% - 100px)',
top: 'calc(50% - 20px)',
width: '40px',
height: '40px',
left: 'calc(50% - 120px)',
top: 'calc(50% - 22px)',
width: '44px',
height: '44px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #FF9800 0%, #F57C00 100%)',
background: selectedButtonIndex === 1
? 'rgba(142, 142, 147, 0.15)'
: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.4)',
boxShadow: selectedButtonIndex === 1
? '0 4px 16px rgba(142, 142, 147, 0.3)'
: '0 2px 8px rgba(0, 0, 0, 0.1)',
pointerEvents: 'auto',
animation: 'slideIn2 0.3s ease-out',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
border: selectedButtonIndex === 1
? '2px solid #8E8E93'
: '0.5px solid rgba(0, 0, 0, 0.04)'
}}
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
console.log('设置按钮点击 - 打开设置窗口')
window.electron.ipcRenderer.send('open-settings')
setIsActionMenuOpen(false)
setShowTextPrompt(false)
setSelectedText('')
}}
onMouseEnter={(e) => {
onMouseEnter={() => {
setSelectedButtonIndex(1)
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)'
onMouseLeave={() => {
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}}
>
<span style={{ fontSize: '20px', color: 'white' }}></span>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#8E8E93"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6M4.22 19.78l4.24-4.24M15.54 8.46l4.24-4.24" />
</svg>
</motion.div>
{/* Action Item 3 - 退出 (Bottom Left) */}
<motion.div
initial={{ opacity: 0, scale: 0.3, x: 30, y: -30 }}
animate={{
opacity: 1,
scale: selectedButtonIndex === 2 ? 1.1 : 1,
x: 0,
y: 0
}}
exit={{ opacity: 0, scale: 0.3, x: 30, y: -30 }}
transition={{ duration: 0.2, ease: 'easeOut', delay: 0.1 }}
style={{
position: 'absolute',
left: 'calc(50% - 105px)',
top: 'calc(50% + 31px)',
width: '44px',
height: '44px',
borderRadius: '50%',
background: selectedButtonIndex === 2
? 'rgba(255, 59, 48, 0.15)'
: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: selectedButtonIndex === 2
? '0 4px 16px rgba(255, 59, 48, 0.3)'
: '0 2px 8px rgba(0, 0, 0, 0.1)',
pointerEvents: 'auto',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
border: selectedButtonIndex === 2
? '2px solid #FF3B30'
: '0.5px solid rgba(0, 0, 0, 0.04)'
}}
onClick={() => {
console.log('退出按钮点击 - 退出应用')
window.electron.ipcRenderer.send('quit-app')
setIsActionMenuOpen(false)
setSelectedText('')
}}
onMouseEnter={() => {
setSelectedButtonIndex(2)
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}}
onMouseLeave={() => {
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#FF3B30"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</motion.div>
</>
)}
</AnimatePresence>
{/* Robot Ball Container */}
<div
@@ -433,7 +444,6 @@ const FloatingBall: React.FC = () => {
{/* Robot Ball */}
<div
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
onMouseEnter={(e) => {
handleMouseEnterBall()
e.currentTarget.style.boxShadow = '0 4px 14px rgba(33, 150, 243, 0.6)'
+262
View File
@@ -0,0 +1,262 @@
// Apple-inspired design system
export interface Theme {
colors: {
// Surfaces
background: string
surface: string
surfaceElevated: string
surfaceHover: string
// Text
textPrimary: string
textSecondary: string
textTertiary: string
// Brand
primary: string
primaryHover: string
primaryActive: string
// Message bubbles
userBubble: string
userBubbleText: string
aiBubble: string
aiBubbleText: string
// Borders
border: string
borderLight: string
// States
success: string
error: string
warning: string
// Glass effect
glassBackground: string
glassBorder: string
}
shadows: {
sm: string
md: string
lg: string
xl: string
}
spacing: {
xs: string
sm: string
md: string
lg: string
xl: string
xxl: string
}
borderRadius: {
sm: string
md: string
lg: string
xl: string
full: string
}
typography: {
fontFamily: string
fontSize: {
xs: string
sm: string
base: string
lg: string
xl: string
xxl: string
}
fontWeight: {
normal: number
medium: number
semibold: number
bold: number
}
}
animation: {
fast: string
normal: string
slow: string
}
}
export const lightTheme: Theme = {
colors: {
// Surfaces - Apple's clean light grays
background: '#f5f5f7',
surface: '#ffffff',
surfaceElevated: '#ffffff',
surfaceHover: '#f9f9f9',
// Text - Apple's neutral grays
textPrimary: '#1d1d1f',
textSecondary: '#6e6e73',
textTertiary: '#86868b',
// Brand - Apple's blue accent
primary: '#007aff',
primaryHover: '#0051d5',
primaryActive: '#004ecb',
// Message bubbles
userBubble: 'linear-gradient(135deg, #007aff 0%, #0051d5 100%)',
userBubbleText: '#ffffff',
aiBubble: 'rgba(255, 255, 255, 0.7)',
aiBubbleText: '#1d1d1f',
// Borders
border: '#d2d2d7',
borderLight: '#e5e5ea',
// States
success: '#34c759',
error: '#ff3b30',
warning: '#ff9500',
// Glass effect
glassBackground: 'rgba(255, 255, 255, 0.7)',
glassBorder: 'rgba(255, 255, 255, 0.18)',
},
shadows: {
sm: '0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02)',
md: '0 4px 6px rgba(0, 0, 0, 0.04), 0 2px 4px rgba(0, 0, 0, 0.02)',
lg: '0 10px 15px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03)',
xl: '0 20px 25px rgba(0, 0, 0, 0.08), 0 10px 10px rgba(0, 0, 0, 0.04)',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
xxl: '48px',
},
borderRadius: {
sm: '6px',
md: '12px',
lg: '16px',
xl: '24px',
full: '9999px',
},
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Arial, sans-serif',
fontSize: {
xs: '12px',
sm: '14px',
base: '16px',
lg: '18px',
xl: '20px',
xxl: '24px',
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
},
animation: {
fast: '150ms',
normal: '250ms',
slow: '350ms',
},
}
export const darkTheme: Theme = {
colors: {
// Surfaces - Apple's dark mode grays
background: '#000000',
surface: '#1c1c1e',
surfaceElevated: '#2c2c2e',
surfaceHover: '#3a3a3c',
// Text - Apple's light grays for dark mode
textPrimary: '#f5f5f7',
textSecondary: '#98989d',
textTertiary: '#636366',
// Brand - Apple's blue accent (slightly brighter for dark mode)
primary: '#0a84ff',
primaryHover: '#409cff',
primaryActive: '#0077ed',
// Message bubbles
userBubble: 'linear-gradient(135deg, #0a84ff 0%, #0077ed 100%)',
userBubbleText: '#ffffff',
aiBubble: 'rgba(44, 44, 46, 0.9)',
aiBubbleText: '#f5f5f7',
// Borders
border: '#38383a',
borderLight: '#48484a',
// States
success: '#32d74b',
error: '#ff453a',
warning: '#ff9f0a',
// Glass effect
glassBackground: 'rgba(44, 44, 46, 0.7)',
glassBorder: 'rgba(255, 255, 255, 0.1)',
},
shadows: {
sm: '0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2)',
md: '0 4px 6px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2)',
lg: '0 10px 15px rgba(0, 0, 0, 0.4), 0 4px 6px rgba(0, 0, 0, 0.3)',
xl: '0 20px 25px rgba(0, 0, 0, 0.5), 0 10px 10px rgba(0, 0, 0, 0.4)',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
xxl: '48px',
},
borderRadius: {
sm: '6px',
md: '12px',
lg: '16px',
xl: '24px',
full: '9999px',
},
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Arial, sans-serif',
fontSize: {
xs: '12px',
sm: '14px',
base: '16px',
lg: '18px',
xl: '20px',
xxl: '24px',
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
},
animation: {
fast: '150ms',
normal: '250ms',
slow: '350ms',
},
}