完善小黑盒搜索功能,将小黑盒操作作为工具给大模型

This commit is contained in:
2025-11-14 15:50:34 +08:00
parent 7b955de2f0
commit d86c4b21ae
46 changed files with 8477 additions and 5800 deletions
+8 -3
View File
@@ -3,6 +3,11 @@
<head>
<meta charset="UTF-8" />
<title>AI 对话</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.openai.com https://api.deepseek.com https://*.openai.com https://*.deepseek.com"
/>
<style>
* {
margin: 0;
@@ -17,7 +22,7 @@
overflow: hidden;
}
#root {
#app {
width: 100%;
height: 100%;
}
@@ -25,7 +30,7 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/chat.tsx"></script>
<div id="app"></div>
<script type="module" src="/src/chat.ts"></script>
</body>
</html>
+3 -3
View File
@@ -19,7 +19,7 @@
-webkit-app-region: no-drag;
}
#root {
#app {
width: 100%;
height: 100%;
}
@@ -27,7 +27,7 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/floating.tsx"></script>
<div id="app"></div>
<script type="module" src="/src/floating.ts"></script>
</body>
</html>
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7 -2
View File
@@ -3,9 +3,14 @@
<head>
<meta charset="UTF-8" />
<title>设置</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.openai.com https://api.deepseek.com https://*.openai.com https://*.deepseek.com"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/settings.tsx"></script>
<div id="app"></div>
<script type="module" src="/src/settings.ts"></script>
</body>
</html>
-62
View File
@@ -1,62 +0,0 @@
import Versions from './components/Versions'
import electronLogo from './assets/electron.svg'
import { Button, Card, Space, Typography, ConfigProvider, theme } from 'antd'
import { RocketOutlined, ThunderboltOutlined } from '@ant-design/icons'
const { Title, Paragraph } = Typography
function App(): React.JSX.Element {
const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
return (
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<img alt="logo" className="logo" src={electronLogo} style={{ width: '120px' }} />
<Title level={2}>AI Desktop Application</Title>
<Paragraph type="secondary">
Build with Electron + React + TypeScript + Ant Design
</Paragraph>
</div>
<Card title="Welcome to Ant Design">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Paragraph>
Ant Design has been successfully installed! You can now use all the beautiful
components from Ant Design in your AI desktop application.
</Paragraph>
<Space wrap>
<Button type="primary" icon={<RocketOutlined />}>
Primary Button
</Button>
<Button type="default" icon={<ThunderboltOutlined />} onClick={ipcHandle}>
Send IPC
</Button>
<Button type="dashed">Dashed Button</Button>
<Button type="link" href="https://ant.design" target="_blank">
Ant Design Docs
</Button>
</Space>
</Space>
</Card>
<Card title="Quick Start">
<Paragraph>
Press <code>F12</code> to open DevTools and start building your AI features!
</Paragraph>
</Card>
<Versions></Versions>
</Space>
</div>
</ConfigProvider>
)
}
export default App
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Chat from './views/Chat.vue'
const app = createApp(Chat)
app.use(ElementPlus)
app.mount('#app')
-22
View File
@@ -1,22 +0,0 @@
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,245 @@
<template>
<div class="article-result-card">
<div v-if="data.success === false" class="error-message">
<el-icon color="#f56c6c"><CircleClose /></el-icon>
<span>{{ data.error || '获取文章失败' }}</span>
</div>
<div v-else-if="data.article" class="article-content">
<div class="article-header">
<h3 class="article-title">{{ data.article.title }}</h3>
<div class="article-meta">
<span v-if="data.article.author" class="meta-item">
<el-icon><User /></el-icon>
{{ data.article.author }}
</span>
<span v-if="data.article.authorIp" class="meta-item">
<el-icon><Location /></el-icon>
{{ data.article.authorIp }}
</span>
<span v-if="data.article.publishTime" class="meta-item">
<el-icon><Clock /></el-icon>
{{ data.article.publishTime }}
</span>
</div>
<div v-if="data.article.stats" class="article-stats">
<el-tag v-if="data.article.stats.likes" size="small" type="danger">
<el-icon><Star /></el-icon>
{{ data.article.stats.likes }}
</el-tag>
<el-tag v-if="data.article.stats.favorites" size="small" type="warning">
<el-icon><Collection /></el-icon>
{{ data.article.stats.favorites }}
</el-tag>
<el-tag v-if="data.article.stats.commentCount" size="small" type="info">
<el-icon><ChatDotRound /></el-icon>
{{ data.article.stats.commentCount }}
</el-tag>
<el-tag v-if="data.article.stats.hotScore" size="small" type="success">
<el-icon><TrendCharts /></el-icon>
热度 {{ data.article.stats.hotScore }}
</el-tag>
</div>
<div v-if="data.article.tags && data.article.tags.length > 0" class="article-tags">
<el-tag v-for="tag in data.article.tags" :key="tag" size="small" effect="plain">
{{ tag }}
</el-tag>
</div>
</div>
<div class="article-body">
<div class="content-text">{{ data.article.content }}</div>
</div>
<div v-if="data.article.topComments && data.article.topComments.length > 0" class="article-comments">
<div class="comments-header">
<el-icon><ChatDotRound /></el-icon>
<span>热门评论 ({{ data.article.commentCount || data.article.topComments.length }})</span>
</div>
<div class="comments-list">
<div v-for="(comment, index) in data.article.topComments" :key="index" class="comment-item">
<div class="comment-author">{{ comment.author || '匿名用户' }}</div>
<div class="comment-content">{{ comment.content }}</div>
<div v-if="comment.likes || comment.time" class="comment-meta">
<span v-if="comment.time" class="meta-item">{{ comment.time }}</span>
<span v-if="comment.likes" class="meta-item">
<el-icon><Star /></el-icon>
{{ comment.likes }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { User, Location, Clock, Star, Collection, ChatDotRound, TrendCharts, CircleClose } from '@element-plus/icons-vue'
interface ArticleResult {
success?: boolean
error?: string
article?: {
title: string
author?: string
authorIp?: string
publishTime?: string
content: string
tags?: string[]
stats?: {
likes?: number
favorites?: number
commentCount?: number
hotScore?: number
}
commentCount?: number
topComments?: Array<{
author?: string
content: string
time?: string
likes?: number
}>
}
}
defineProps<{
data: ArticleResult
}>()
</script>
<style scoped>
.article-result-card {
font-size: 13px;
}
.error-message {
display: flex;
align-items: center;
gap: 6px;
padding: 12px;
background: #fef0f0;
border-radius: 8px;
color: #f56c6c;
font-weight: 500;
}
.article-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.article-header {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #e4e7ed;
}
.article-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
line-height: 1.5;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #909399;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.article-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-stats :deep(.el-tag) {
display: flex;
align-items: center;
gap: 4px;
}
.article-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.article-body {
padding: 12px;
background: #f9fafb;
border-radius: 8px;
}
.content-text {
color: #606266;
line-height: 1.8;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.article-comments {
display: flex;
flex-direction: column;
gap: 10px;
}
.comments-header {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: #409eff;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment-item {
padding: 10px;
background: #f9fafb;
border-radius: 6px;
border-left: 3px solid #409eff;
}
.comment-author {
font-weight: 500;
color: #303133;
margin-bottom: 6px;
font-size: 12px;
}
.comment-content {
color: #606266;
line-height: 1.6;
margin-bottom: 6px;
}
.comment-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: #909399;
}
</style>
File diff suppressed because it is too large Load Diff
-190
View File
@@ -1,190 +0,0 @@
import React from 'react'
interface ContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onSettings: () => void
onQuit: () => void
onMouseEnter?: () => void
onMouseLeave?: () => void
}
const ContextMenu: React.FC<ContextMenuProps> = ({
isOpen,
position,
onClose,
onSettings,
onQuit,
onMouseEnter,
onMouseLeave
}) => {
if (!isOpen) return null
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
pointerEvents: 'auto'
}}
>
{/* Backdrop to catch clicks outside menu */}
<div
onClick={onClose}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'auto'
}}
/>
{/* Menu container */}
<div
onClick={(e) => e.stopPropagation()}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{
position: 'absolute',
left: `${position.x}px`,
top: `${position.y}px`,
pointerEvents: 'auto',
minWidth: '180px',
background: 'rgba(255, 255, 255, 0.98)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.8)',
padding: '8px',
animation: 'menuFadeIn 0.15s ease-out',
transformOrigin: 'top left'
}}
>
<style>{`
@keyframes menuFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-5px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.menu-item {
padding: 10px 16px;
cursor: pointer;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: #333;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.15s ease;
user-select: none;
position: relative;
overflow: hidden;
}
.menu-item:hover {
background: linear-gradient(135deg, rgba(33, 150, 243, 0.1) 0%, rgba(25, 118, 210, 0.15) 100%);
color: #1976d2;
transform: translateX(2px);
}
.menu-item:active {
transform: translateX(2px) scale(0.98);
}
.menu-item-danger:hover {
background: linear-gradient(135deg, rgba(244, 67, 54, 0.1) 0%, rgba(211, 47, 47, 0.15) 100%);
color: #d32f2f;
}
.menu-divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.08), transparent);
margin: 6px 0;
}
.menu-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
`}</style>
{/* Settings menu item */}
<div
className="menu-item"
onClick={() => {
onSettings()
onClose()
}}
>
<div className="menu-icon">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6m0 6v6m-9-9h6m6 0h6" />
<path d="M4.22 4.22l4.24 4.24m7.08 0l4.24-4.24m0 15.56l-4.24-4.24m-7.08 0l-4.24 4.24" />
</svg>
</div>
<span></span>
</div>
{/* Divider */}
<div className="menu-divider" />
{/* Quit menu item */}
<div
className="menu-item menu-item-danger"
onClick={() => {
onQuit()
onClose()
}}
>
<div className="menu-icon">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</div>
<span>退</span>
</div>
</div>
</div>
)
}
export default ContextMenu
@@ -1,543 +0,0 @@
import React, { useState, useRef, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
const FloatingBall: React.FC = () => {
const [isBlinking, setIsBlinking] = useState(false)
const [isMouseOverBall, setIsMouseOverBall] = useState(false)
const [isActionMenuOpen, setIsActionMenuOpen] = 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 })
const blinkTimerRef = useRef<NodeJS.Timeout | null>(null)
// 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(() => {
setIsBlinking(true)
setTimeout(() => {
setIsBlinking(false)
scheduleNextBlink()
}, 200) // Blink duration: 200ms
}, delay)
}
scheduleNextBlink()
return (): void => {
if (blinkTimerRef.current) {
clearTimeout(blinkTimerRef.current)
}
}
}, [])
// Handle Command+K shortcut from main process
useEffect(() => {
const unsubscribe = window.electron.ipcRenderer.on(
'show-text-prompt',
(_: unknown, text: string) => {
setSelectedText(text)
// 切换按钮显示状态
setIsActionMenuOpen((prev) => {
const newState = !prev
if (newState) {
// When opening menu, reset to first button
setSelectedButtonIndex(0)
}
return newState
})
}
)
return (): void => {
if (unsubscribe) {
unsubscribe()
}
}
}, [])
// 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
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}
const handleMouseLeaveBall = (): void => {
setIsMouseOverBall(false)
// When mouse leaves the ball area, always restore click-through
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
if (e.button === 2) {
return
}
isDraggingRef.current = false
startPosRef.current = { x: e.screenX, y: e.screenY }
try {
const bounds = await window.electron.ipcRenderer.invoke('get-window-bounds')
windowStartRef.current = { x: bounds.x, y: bounds.y }
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)
// Only start dragging if moved more than 3 pixels
if (distance > 3) {
isDraggingRef.current = true
}
if (isDraggingRef.current) {
const newX = windowStartRef.current.x + deltaX
const newY = windowStartRef.current.y + deltaY
window.electron.ipcRenderer.send('floating-window-move', { x: newX, y: newY })
}
}
const handleMouseUp = (): void => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// If not dragged, treat as a click - toggle action menu
if (!isDraggingRef.current) {
setIsActionMenuOpen((prev) => !prev)
}
isDraggingRef.current = false
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
} catch (error) {
console.error('Failed to get window bounds:', error)
}
}
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>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
pointerEvents: 'none'
}}
>
{/* Action Menu Items */}
<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% - 105px)',
top: 'calc(50% - 75px)',
width: '44px',
height: '44px',
borderRadius: '50%',
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: selectedButtonIndex === 0
? '0 4px 16px rgba(0, 122, 255, 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 === 0
? '2px solid #007AFF'
: '0.5px solid rgba(0, 0, 0, 0.04)'
}}
onClick={() => {
console.log('对话按钮点击 - 打开聊天窗口')
window.electron.ipcRenderer.send('open-chat', selectedText || undefined)
setIsActionMenuOpen(false)
setSelectedText('')
}}
onMouseEnter={(e) => {
setSelectedButtonIndex(0)
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="#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) */}
<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% - 120px)',
top: 'calc(50% - 22px)',
width: '44px',
height: '44px',
borderRadius: '50%',
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: selectedButtonIndex === 1
? '0 4px 16px rgba(142, 142, 147, 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 === 1
? '2px solid #8E8E93'
: '0.5px solid rgba(0, 0, 0, 0.04)'
}}
onClick={() => {
console.log('设置按钮点击 - 打开设置窗口')
window.electron.ipcRenderer.send('open-settings')
setIsActionMenuOpen(false)
setSelectedText('')
}}
onMouseEnter={() => {
setSelectedButtonIndex(1)
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="#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
style={{
position: 'relative',
width: '60px',
height: '60px',
pointerEvents: 'auto'
}}
>
{/* Robot Ball */}
<div
onMouseDown={handleMouseDown}
onMouseEnter={(e) => {
handleMouseEnterBall()
e.currentTarget.style.boxShadow = '0 4px 14px rgba(33, 150, 243, 0.6)'
e.currentTarget.style.transform = 'scale(1.05)'
}}
onMouseLeave={(e) => {
handleMouseLeaveBall()
e.currentTarget.style.boxShadow = '0 3px 10px rgba(33, 150, 243, 0.4)'
e.currentTarget.style.transform = 'scale(1)'
}}
style={
{
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #2196f3 0%, #1976d2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'grab',
boxShadow: '0 3px 10px rgba(33, 150, 243, 0.4)',
transition: 'box-shadow 0.3s ease, transform 0.1s ease',
userSelect: 'none',
WebkitUserDrag: 'none',
WebkitAppRegion: 'no-drag',
border: '2px solid rgba(255, 255, 255, 0.3)'
} as React.CSSProperties
}
>
{/* 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"
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>
</div>
</div>
</div>
</>
)
}
export default FloatingBall
@@ -0,0 +1,156 @@
<template>
<div class="login-status-card">
<div v-if="data.success === false" class="error-message">
<el-icon color="#f56c6c"><CircleClose /></el-icon>
<span>{{ data.error || '检查登录状态失败' }}</span>
</div>
<div v-else class="status-content">
<div class="status-info" :class="{ logged: data.isLoggedIn }">
<el-icon :color="data.isLoggedIn ? '#67c23a' : '#909399'" :size="32">
<component :is="data.isLoggedIn ? CircleCheck : CircleClose" />
</el-icon>
<div class="info-text">
<div class="info-title">
{{ data.isLoggedIn ? '已登录' : '未登录' }}
</div>
<div v-if="data.platform" class="info-platform">
平台: {{ data.platform }}
</div>
<div v-if="data.username" class="info-username">
用户: {{ data.username }}
</div>
</div>
</div>
<div v-if="data.message" class="status-message">
{{ data.message }}
</div>
<div v-if="data.loginGuide" class="login-guide">
<div class="guide-title">
<el-icon><InfoFilled /></el-icon>
<span>登录指南</span>
</div>
<pre class="guide-content">{{ data.loginGuide }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CircleCheck, CircleClose, InfoFilled } from '@element-plus/icons-vue'
interface LoginStatus {
success?: boolean
error?: string
isLoggedIn: boolean
platform?: string
username?: string
message?: string
loginGuide?: string
}
defineProps<{
data: LoginStatus
}>()
</script>
<style scoped>
.login-status-card {
font-size: 13px;
}
.error-message {
display: flex;
align-items: center;
gap: 6px;
padding: 12px;
background: #fef0f0;
border-radius: 8px;
color: #f56c6c;
font-weight: 500;
}
.status-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-info {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: #f9fafb;
border-radius: 8px;
border: 2px solid #e4e7ed;
}
.status-info.logged {
background: #f0f9ff;
border-color: #67c23a;
}
.info-text {
flex: 1;
}
.info-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.info-platform,
.info-username {
font-size: 12px;
color: #606266;
margin-top: 2px;
}
.info-username {
color: #409eff;
font-weight: 500;
}
.status-message {
padding: 10px 12px;
background: white;
border-radius: 6px;
color: #606266;
line-height: 1.6;
border-left: 3px solid #409eff;
}
.login-guide {
padding: 12px;
background: #fff7e6;
border-radius: 8px;
border: 1px solid #ffd666;
}
.guide-title {
display: flex;
align-items: center;
gap: 6px;
color: #fa8c16;
font-weight: 500;
margin-bottom: 8px;
}
.guide-content {
margin: 0;
padding: 10px;
background: white;
border-radius: 6px;
color: #606266;
font-size: 12px;
white-space: pre-wrap;
font-family: inherit;
line-height: 1.6;
}
</style>
@@ -0,0 +1,185 @@
<template>
<div class="markdown-content" v-html="renderedMarkdown"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
const props = defineProps<{
content: string
}>()
// Configure marked options
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub Flavored Markdown
headerIds: false,
mangle: false
})
const renderedMarkdown = computed(() => {
if (!props.content) return ''
try {
return marked(props.content)
} catch (error) {
console.error('Markdown parsing error:', error)
return props.content
}
})
</script>
<style scoped>
.markdown-content {
line-height: 1.8;
color: #303133;
font-size: 14px;
}
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3),
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
margin: 1em 0 0.5em 0;
font-weight: 600;
line-height: 1.4;
}
.markdown-content :deep(h1) {
font-size: 1.8em;
border-bottom: 2px solid #e4e7ed;
padding-bottom: 0.3em;
}
.markdown-content :deep(h2) {
font-size: 1.5em;
border-bottom: 1px solid #e4e7ed;
padding-bottom: 0.3em;
}
.markdown-content :deep(h3) {
font-size: 1.3em;
}
.markdown-content :deep(h4) {
font-size: 1.1em;
}
.markdown-content :deep(p) {
margin: 0.8em 0;
}
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
margin: 0.8em 0;
padding-left: 2em;
}
.markdown-content :deep(li) {
margin: 0.3em 0;
}
.markdown-content :deep(code) {
background: #f5f7fa;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Courier New', monospace;
font-size: 0.9em;
color: #e83e8c;
}
.markdown-content :deep(pre) {
background: #f5f7fa;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
border: 1px solid #e4e7ed;
}
.markdown-content :deep(pre code) {
background: transparent;
padding: 0;
color: #303133;
font-size: 0.9em;
}
.markdown-content :deep(blockquote) {
border-left: 4px solid #409eff;
padding-left: 1em;
margin: 1em 0;
color: #606266;
background: #f0f9ff;
padding: 0.8em 1em;
border-radius: 4px;
}
.markdown-content :deep(a) {
color: #409eff;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.markdown-content :deep(a:hover) {
color: #66b1ff;
text-decoration: underline;
}
.markdown-content :deep(img) {
max-width: 100%;
border-radius: 8px;
margin: 1em 0;
}
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 0.95em;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid #e4e7ed;
padding: 0.6em 1em;
text-align: left;
}
.markdown-content :deep(th) {
background: #f5f7fa;
font-weight: 600;
}
.markdown-content :deep(tr:nth-child(even)) {
background: #fafafa;
}
.markdown-content :deep(hr) {
border: none;
border-top: 2px solid #e4e7ed;
margin: 1.5em 0;
}
.markdown-content :deep(strong) {
font-weight: 600;
color: #1d1d1f;
}
.markdown-content :deep(em) {
font-style: italic;
color: #606266;
}
/* First paragraph no top margin */
.markdown-content :deep(p:first-child) {
margin-top: 0;
}
/* Last element no bottom margin */
.markdown-content :deep(*:last-child) {
margin-bottom: 0;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<template>
<div class="message-card" :class="roleClass">
<div class="message-content-wrapper">
<!-- User message - simple text display -->
<div v-if="message.role === 'user'" class="user-message">
{{ message.content }}
</div>
<!-- Assistant message - markdown + tool calls -->
<div v-else-if="message.role === 'assistant'" class="assistant-message">
<!-- Markdown content -->
<div v-if="message.content" class="message-text">
<MarkdownContent :content="message.content" />
</div>
<!-- Tool calls section -->
<div v-if="message.toolCalls && message.toolCalls.length > 0" class="tool-calls-section">
<ToolCallCard
v-for="(toolCall, index) in message.toolCalls"
:key="index"
:tool-call="toolCall"
/>
</div>
</div>
<!-- Timestamp -->
<div class="message-timestamp">
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownContent from './MarkdownContent.vue'
import ToolCallCard from './ToolCallCard.vue'
interface ToolCallInfo {
name: string
args?: Record<string, any>
result?: any
status: 'loading' | 'success' | 'error'
}
interface Message {
id: string
role: 'user' | 'assistant' | 'tool'
content: string
timestamp: Date
toolCalls?: ToolCallInfo[]
}
const props = defineProps<{
message: Message
}>()
const roleClass = computed(() => {
return `message-${props.message.role}`
})
const formatTime = (date: Date): string => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return `${days}天前`
} else if (hours > 0) {
return `${hours}小时前`
} else if (minutes > 0) {
return `${minutes}分钟前`
} else {
return '刚刚'
}
}
</script>
<style scoped>
.message-card {
display: flex;
margin: 12px 0;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-user {
justify-content: flex-end;
}
.message-assistant {
justify-content: flex-start;
}
.message-content-wrapper {
max-width: 85%;
min-width: 200px;
}
.message-user .message-content-wrapper {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: white;
border-radius: 16px 16px 4px 16px;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
}
.message-assistant .message-content-wrapper {
background: white;
color: #303133;
border-radius: 16px 16px 16px 4px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #e4e7ed;
}
.user-message {
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
}
.assistant-message {
display: flex;
flex-direction: column;
gap: 16px;
}
.message-text {
font-size: 14px;
}
.tool-calls-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.message-timestamp {
font-size: 11px;
color: #909399;
margin-top: 6px;
text-align: right;
opacity: 0.7;
}
.message-user .message-timestamp {
color: rgba(255, 255, 255, 0.8);
}
/* Responsive */
@media (max-width: 768px) {
.message-content-wrapper {
max-width: 90%;
}
}
</style>
@@ -0,0 +1,221 @@
<template>
<div class="search-result-card">
<div v-if="data.success === false" class="error-message">
<el-icon color="#f56c6c"><CircleClose /></el-icon>
<span>{{ data.error || data.message || '搜索失败' }}</span>
<div v-if="data.loginGuide" class="login-guide">
<pre>{{ data.loginGuide }}</pre>
</div>
<div v-if="data.suggestions" class="suggestions-guide">
<pre>{{ data.suggestions }}</pre>
</div>
</div>
<div v-else-if="data.results && data.results.length > 0">
<div class="result-header">
<el-icon><Search /></el-icon>
<span>找到 {{ data.count || data.results.length }} 条结果</span>
</div>
<div class="results-list">
<div v-for="(item, index) in data.results" :key="index" class="result-item">
<div class="result-title">
<a :href="item.url" target="_blank" class="title-link" @click.prevent="handleArticleClick(item.url)">
{{ item.title }}
</a>
</div>
<div v-if="item.summary" class="result-summary">
{{ item.summary }}
</div>
<div class="result-meta">
<span v-if="item.author" class="meta-item">
<el-icon><User /></el-icon>
{{ item.author }}
</span>
<span v-if="item.publishTime" class="meta-item">
<el-icon><Clock /></el-icon>
{{ item.publishTime }}
</span>
<span v-if="item.likeCount !== undefined" class="meta-item">
<el-icon><Star /></el-icon>
{{ item.likeCount }}
</span>
<span v-if="item.commentCount !== undefined" class="meta-item">
<el-icon><ChatDotRound /></el-icon>
{{ item.commentCount }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-message">
<el-empty description="未找到相关结果" :image-size="60" />
</div>
</div>
</template>
<script setup lang="ts">
import { Search, User, Clock, Star, ChatDotRound, CircleClose } from '@element-plus/icons-vue'
interface SearchResult {
success?: boolean
error?: string
message?: string
loginGuide?: string
count?: number
results?: Array<{
title: string
url: string
author?: string
publishTime?: string
summary?: string
likeCount?: number
commentCount?: number
}>
}
defineProps<{
data: SearchResult
}>()
const emit = defineEmits<{
(e: 'article-click', url: string): void
}>()
const handleArticleClick = (url: string) => {
emit('article-click', url)
}
</script>
<style scoped>
.search-result-card {
font-size: 13px;
}
.error-message {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: #fef0f0;
border-radius: 8px;
color: #f56c6c;
}
.error-message > span:first-of-type {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.login-guide {
margin-top: 8px;
padding: 10px;
background: white;
border-radius: 6px;
color: #606266;
font-size: 12px;
}
.login-guide pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
}
.suggestions-guide {
margin-top: 8px;
padding: 10px;
background: #fff7e6;
border-radius: 6px;
color: #fa8c16;
font-size: 12px;
border: 1px solid #ffd666;
}
.suggestions-guide pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
color: #606266;
}
.result-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
font-weight: 500;
color: #409eff;
}
.results-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-item {
padding: 12px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #e4e7ed;
transition: all 0.2s;
}
.result-item:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.result-title {
margin-bottom: 8px;
}
.title-link {
color: #303133;
font-weight: 600;
text-decoration: none;
font-size: 14px;
line-height: 1.5;
display: inline-block;
transition: color 0.2s;
}
.title-link:hover {
color: #409eff;
}
.result-summary {
color: #606266;
line-height: 1.6;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #909399;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.empty-message {
padding: 20px;
text-align: center;
}
</style>
-311
View File
@@ -1,311 +0,0 @@
import React, { useState, useEffect } from 'react'
import {
Form,
Input,
Select,
Button,
message,
Card,
List,
Modal,
Radio,
Space,
Typography,
Divider,
Empty
} from 'antd'
import { PlusOutlined, DeleteOutlined, CheckCircleOutlined } from '@ant-design/icons'
const { Option } = Select
const { Title, Text } = Typography
interface ModelConfig {
id: string
name: string
provider: string
model: string
apiKey: string
baseUrl: string
}
const Settings: React.FC = () => {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [provider, setProvider] = useState('openai')
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([])
const [activeModelId, setActiveModelId] = useState<string>('')
const [isModalVisible, setIsModalVisible] = useState(false)
useEffect(() => {
// Load model configs from localStorage
const savedConfigs = localStorage.getItem('ai-model-configs')
if (savedConfigs) {
const configs = JSON.parse(savedConfigs) as ModelConfig[]
setModelConfigs(configs)
}
// Load active model id
const savedActiveId = localStorage.getItem('ai-active-model-id')
if (savedActiveId) {
setActiveModelId(savedActiveId)
}
}, [])
const handleProviderChange = (value: string): void => {
setProvider(value)
// Update default values based on provider
if (value === 'openai') {
form.setFieldsValue({
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo'
})
} else if (value === 'deepseek') {
form.setFieldsValue({
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat'
})
}
}
const handleAddModel = async (): Promise<void> => {
try {
setLoading(true)
const values = await form.validateFields()
const newConfig: ModelConfig = {
id: Date.now().toString(),
name: values.name || `${values.provider}-${values.model}`,
provider: values.provider,
model: values.model,
apiKey: values.apiKey,
baseUrl: values.baseUrl
}
const updatedConfigs = [...modelConfigs, newConfig]
setModelConfigs(updatedConfigs)
localStorage.setItem('ai-model-configs', JSON.stringify(updatedConfigs))
// If this is the first model, set it as active
if (modelConfigs.length === 0) {
setActiveModelId(newConfig.id)
localStorage.setItem('ai-active-model-id', newConfig.id)
}
message.success('模型添加成功')
setIsModalVisible(false)
form.resetFields()
} catch {
message.error('请填写完整信息')
} finally {
setLoading(false)
}
}
const handleDeleteModel = (id: string): void => {
const updatedConfigs = modelConfigs.filter((config) => config.id !== id)
setModelConfigs(updatedConfigs)
localStorage.setItem('ai-model-configs', JSON.stringify(updatedConfigs))
// If deleted active model, clear active id
if (activeModelId === id) {
const newActiveId = updatedConfigs.length > 0 ? updatedConfigs[0].id : ''
setActiveModelId(newActiveId)
localStorage.setItem('ai-active-model-id', newActiveId)
}
message.success('模型删除成功')
}
const handleSetActive = (id: string): void => {
setActiveModelId(id)
localStorage.setItem('ai-active-model-id', id)
message.success('已切换活跃模型')
}
return (
<div
style={{
padding: '32px',
maxWidth: '900px',
margin: '0 auto',
background: '#f5f5f5',
minHeight: '100vh'
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={2} style={{ margin: 0 }}>
AI
</Title>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={() => setIsModalVisible(true)}
>
</Button>
</div>
<Card
title={
<Space>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Text strong></Text>
</Space>
}
bordered={false}
style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}
>
{modelConfigs.length === 0 ? (
<Empty
description={
<Space direction="vertical">
<Text type="secondary"></Text>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsModalVisible(true)}
>
</Button>
</Space>
}
/>
) : (
<List
dataSource={modelConfigs}
renderItem={(config) => (
<List.Item
actions={[
<Button
key="delete"
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteModel(config.id)}
>
</Button>
]}
>
<List.Item.Meta
avatar={
<Radio
checked={activeModelId === config.id}
onChange={() => handleSetActive(config.id)}
/>
}
title={
<Space>
<Text strong>{config.name}</Text>
{activeModelId === config.id && (
<Text type="success" style={{ fontSize: '12px' }}>
(使)
</Text>
)}
</Space>
}
description={
<Space split={<Divider type="vertical" />}>
<Text type="secondary">{config.provider}</Text>
<Text type="secondary">{config.model}</Text>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
</Space>
<Modal
title={
<Title level={4} style={{ margin: 0 }}>
</Title>
}
open={isModalVisible}
width={600}
onCancel={() => {
setIsModalVisible(false)
form.resetFields()
}}
footer={
<Space>
<Button onClick={() => setIsModalVisible(false)}></Button>
<Button type="primary" loading={loading} onClick={handleAddModel}>
</Button>
</Space>
}
>
<Divider style={{ marginTop: 0 }} />
<Form
form={form}
layout="vertical"
initialValues={{ provider: 'openai' }}
style={{ marginTop: '16px' }}
>
<Form.Item
label={<Text strong></Text>}
name="name"
rules={[{ required: true, message: '请输入配置名称' }]}
>
<Input placeholder="例如:我的 GPT-4" size="large" />
</Form.Item>
<Form.Item
label={<Text strong></Text>}
name="provider"
rules={[{ required: true, message: '请选择平台' }]}
>
<Select placeholder="选择平台" size="large" onChange={handleProviderChange}>
<Option value="openai">OpenAI</Option>
<Option value="deepseek">DeepSeek</Option>
</Select>
</Form.Item>
<Form.Item
label={<Text strong></Text>}
name="model"
rules={[{ required: true, message: '请选择模型' }]}
>
{provider === 'openai' ? (
<Select placeholder="选择模型" size="large">
<Option value="gpt-3.5-turbo">GPT-3.5 Turbo</Option>
<Option value="gpt-4">GPT-4</Option>
<Option value="gpt-4-turbo">GPT-4 Turbo</Option>
<Option value="gpt-4o">GPT-4o</Option>
</Select>
) : (
<Select placeholder="选择模型" size="large">
<Option value="deepseek-chat">DeepSeek Chat</Option>
<Option value="deepseek-coder">DeepSeek Coder</Option>
</Select>
)}
</Form.Item>
<Form.Item
label={<Text strong>API Key</Text>}
name="apiKey"
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input.Password placeholder="sk-..." size="large" />
</Form.Item>
<Form.Item
label={<Text strong>Base URL</Text>}
name="baseUrl"
rules={[{ required: true, message: '请输入 Base URL' }]}
>
<Input placeholder="https://api.openai.com/v1" size="large" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default Settings
@@ -0,0 +1,293 @@
<template>
<div class="tool-call-card">
<div class="tool-header">
<el-icon class="tool-icon" :class="statusClass">
<component :is="statusIcon" />
</el-icon>
<div class="tool-info">
<div class="tool-name">{{ toolDisplayName }}</div>
<div class="tool-status">{{ statusText }}</div>
</div>
</div>
<div v-if="toolCall.args" class="tool-args">
<div class="args-label">参数:</div>
<div class="args-content">
<div v-for="(value, key) in toolCall.args" :key="key" class="arg-item">
<span class="arg-key">{{ key }}:</span>
<span class="arg-value">{{ formatValue(value) }}</span>
</div>
</div>
</div>
<div v-if="toolCall.result" class="tool-result">
<el-collapse v-model="activeCollapse">
<el-collapse-item name="result">
<template #title>
<div class="result-header">
<el-icon><Document /></el-icon>
<span>查看结果</span>
<el-tag v-if="resultCount" size="small" type="info" style="margin-left: 8px">
{{ resultCount }}
</el-tag>
</div>
</template>
<div class="result-content">
<component :is="resultComponent" :data="parsedResult" />
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Loading, CircleCheck, CircleClose, Document, Search, Link, User } from '@element-plus/icons-vue'
import SearchResultCard from './SearchResultCard.vue'
import ArticleResultCard from './ArticleResultCard.vue'
import LoginStatusCard from './LoginStatusCard.vue'
interface ToolCall {
name: string
args?: Record<string, any>
result?: any
status: 'loading' | 'success' | 'error'
}
const props = defineProps<{
toolCall: ToolCall
}>()
const activeCollapse = ref<string[]>([])
const toolDisplayNames: Record<string, string> = {
check_platform_login: '检查登录状态',
search_platform: '搜索平台内容',
fetch_article: '获取文章详情'
}
const toolDisplayName = computed(() => {
return toolDisplayNames[props.toolCall.name] || props.toolCall.name
})
const statusIcon = computed(() => {
switch (props.toolCall.status) {
case 'loading':
return Loading
case 'success':
return CircleCheck
case 'error':
return CircleClose
default:
return Loading
}
})
const statusClass = computed(() => {
return `status-${props.toolCall.status}`
})
const statusText = computed(() => {
switch (props.toolCall.status) {
case 'loading':
return '执行中...'
case 'success':
return '执行成功'
case 'error':
return '执行失败'
default:
return ''
}
})
const parsedResult = computed(() => {
if (!props.toolCall.result) return null
try {
if (typeof props.toolCall.result === 'string') {
return JSON.parse(props.toolCall.result)
}
return props.toolCall.result
} catch (e) {
return props.toolCall.result
}
})
const resultComponent = computed(() => {
if (!parsedResult.value) return null
// 根据工具类型返回不同的展示组件
switch (props.toolCall.name) {
case 'search_platform':
return SearchResultCard
case 'fetch_article':
return ArticleResultCard
case 'check_platform_login':
return LoginStatusCard
default:
return null
}
})
const resultCount = computed(() => {
if (!parsedResult.value) return null
if (props.toolCall.name === 'search_platform' && parsedResult.value.results) {
return `${parsedResult.value.results.length} 条结果`
}
return null
})
const formatValue = (value: any): string => {
if (typeof value === 'object') {
return JSON.stringify(value, null, 2)
}
return String(value)
}
// Auto expand result on success
if (props.toolCall.status === 'success' && props.toolCall.result) {
activeCollapse.value = ['result']
}
</script>
<style scoped>
.tool-call-card {
background: linear-gradient(135deg, #f5f7fa 0%, #f0f2f5 100%);
border-radius: 12px;
padding: 14px;
margin: 8px 0;
border-left: 3px solid #409eff;
}
.tool-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.tool-icon {
font-size: 24px;
flex-shrink: 0;
}
.status-loading {
color: #409eff;
animation: rotating 1s linear infinite;
}
.status-success {
color: #67c23a;
}
.status-error {
color: #f56c6c;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tool-info {
flex: 1;
}
.tool-name {
font-size: 15px;
font-weight: 600;
color: #1d1d1f;
margin-bottom: 2px;
}
.tool-status {
font-size: 13px;
color: #909399;
}
.tool-args {
background: white;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
.args-label {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
font-weight: 500;
}
.args-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.arg-item {
font-size: 13px;
line-height: 1.6;
}
.arg-key {
color: #606266;
font-weight: 500;
margin-right: 4px;
}
.arg-value {
color: #409eff;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Courier New', monospace;
}
.tool-result {
margin-top: 10px;
}
.tool-result :deep(.el-collapse) {
border: none;
background: transparent;
}
.tool-result :deep(.el-collapse-item__header) {
background: white;
border-radius: 8px;
padding: 10px 12px;
border: none;
font-size: 13px;
height: auto;
line-height: 1.4;
}
.tool-result :deep(.el-collapse-item__wrap) {
border: none;
background: transparent;
}
.tool-result :deep(.el-collapse-item__content) {
padding: 10px 0 0 0;
}
.result-header {
display: flex;
align-items: center;
gap: 6px;
color: #606266;
}
.result-content {
background: white;
border-radius: 8px;
padding: 12px;
max-height: 400px;
overflow-y: auto;
}
</style>
-15
View File
@@ -1,15 +0,0 @@
import { useState } from 'react'
function Versions(): React.JSX.Element {
const [versions] = useState(window.electron.process.versions)
return (
<ul className="versions">
<li className="electron-version">Electron v{versions.electron}</li>
<li className="chrome-version">Chromium v{versions.chrome}</li>
<li className="node-version">Node v{versions.node}</li>
</ul>
)
}
export default Versions
+40
View File
@@ -0,0 +1,40 @@
import { ref, computed } from 'vue'
import { lightTheme, darkTheme, type Theme } from '../theme'
const isDark = ref(false)
export function useTheme() {
const theme = computed<Theme>(() => {
return isDark.value ? darkTheme : lightTheme
})
const toggleTheme = () => {
isDark.value = !isDark.value
// 保存到 localStorage
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
const setTheme = (mode: 'light' | 'dark') => {
isDark.value = mode === 'dark'
localStorage.setItem('theme', mode)
}
// 初始化时从 localStorage 读取
const initTheme = () => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
} else {
// 检测系统主题
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
}
return {
theme,
isDark,
toggleTheme,
setTheme,
initTheme
}
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import FloatingBall from './views/FloatingBall.vue'
const app = createApp(FloatingBall)
app.mount('#app')
-22
View File
@@ -1,22 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import FloatingBall from './components/FloatingBall'
import { ConfigProvider, theme } from 'antd'
export const FloatingApp: React.FC = () => {
return (
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<FloatingBall />
</ConfigProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<FloatingApp />
</React.StrictMode>
)
-11
View File
@@ -1,11 +0,0 @@
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+257 -7
View File
@@ -1,3 +1,5 @@
import { availableTools, ToolExecutor, type ToolCall } from './tools'
interface ModelConfig {
id: string
name: string
@@ -7,30 +9,38 @@ interface ModelConfig {
baseUrl: string
}
export interface Message {
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
tool_calls?: ToolCall[]
tool_call_id?: string
name?: string
}
export interface StreamCallbacks {
onStart?: () => void
onToken: (token: string) => void
onComplete: () => void
onError: (error: Error) => void
onToolCall?: (toolName: string, args: any) => void
}
export async function streamChat(message: string, callbacks: StreamCallbacks): Promise<void> {
const { onStart, onToken, onComplete, onError } = callbacks
try {
// Get active model config
const activeModelId = localStorage.getItem('ai-active-model-id')
if (!activeModelId) {
// Get active model config from file
const settings = await window.electron.ipcRenderer.invoke('read-settings')
if (!settings.activeModelId) {
throw new Error('请先在设置中配置并选择一个 AI 模型')
}
const configsStr = localStorage.getItem('ai-model-configs')
if (!configsStr) {
if (!settings.modelConfigs || settings.modelConfigs.length === 0) {
throw new Error('未找到模型配置')
}
const configs: ModelConfig[] = JSON.parse(configsStr)
const activeConfig = configs.find((c) => c.id === activeModelId)
const activeConfig = settings.modelConfigs.find((c: ModelConfig) => c.id === settings.activeModelId)
if (!activeConfig) {
throw new Error('未找到活跃的模型配置')
}
@@ -112,3 +122,243 @@ export async function streamChat(message: string, callbacks: StreamCallbacks): P
onError(error as Error)
}
}
// 支持 tool calling 的聊天函数
export async function chatWithTools(
messages: Message[],
callbacks: StreamCallbacks
): Promise<Message> {
const { onStart, onToken, onComplete, onError, onToolCall } = callbacks
try {
// Get active model config from file
const settings = await window.electron.ipcRenderer.invoke('read-settings')
if (!settings.activeModelId) {
throw new Error('请先在设置中配置并选择一个 AI 模型')
}
if (!settings.modelConfigs || settings.modelConfigs.length === 0) {
throw new Error('未找到模型配置')
}
const activeConfig = settings.modelConfigs.find((c: ModelConfig) => c.id === settings.activeModelId)
if (!activeConfig) {
throw new Error('未找到活跃的模型配置')
}
onStart?.()
// Build API request with tools
const endpoint = `${activeConfig.baseUrl}/chat/completions`
// 构建系统提示词,指导 AI 如何使用工具
const systemMessage: Message = {
role: 'system',
content: `你是一个智能助手,可以帮助用户搜索和获取游戏相关信息。你有以下工具可以使用:
1. **search_platform**: 搜索小黑盒平台的文章,返回文章列表(包含标题、URL、摘要等)
2. **fetch_article**: 根据 URL 获取文章的完整内容
3. **check_platform_login**: 检查用户登录状态
## 重要使用规则:
### 场景一:用户首次询问
- 当用户询问游戏相关问题(如"三角洲的M"、"M7战斗步枪"等),先使用 search_platform 搜索相关文章
- 搜索后,向用户展示找到的文章列表,并询问用户想了解哪个主题
### 场景二:用户深入询问
- 当用户在看到搜索结果后,说"我想了解XXX"、"告诉我XXX的详情"时:
1. 从之前的搜索结果中,根据标题和摘要,筛选出1-3篇最相关的文章
2. 依次调用 fetch_article 获取这些文章的完整内容
3. 整合文章内容,提取关键信息(如装备配置、攻略要点等)
4. 以结构化的方式返回给用户
### 场景三:用户要求查看特定文章
- 当用户说"看第X篇"、"打开第X个链接"时,使用对应的 URL 调用 fetch_article
## 示例对话流程:
用户:"三角洲的M"
助手:[调用 search_platform] → 展示搜索结果列表,询问用户想了解哪个
用户:"我想了解M7战斗步枪"
助手:[分析搜索结果,找到标题包含"M7"的文章URL] → [依次调用 fetch_article 获取1-3篇相关文章] → [整合内容后返回]
## 注意事项:
- 工具返回的搜索结果中包含 URL 字段,记得保存这些 URL,后续需要用它们调用 fetch_article
- 如果搜索结果中有多篇相关文章,可以获取2-3篇并综合内容返回,不要只返回一篇
- 始终以用户友好的方式呈现信息,提取关键要点而不是直接复制粘贴原文`
}
// 将 system message 插入到消息列表的开头(如果还没有)
const messagesWithSystem = messages[0]?.role === 'system'
? messages
: [systemMessage, ...messages]
const requestBody: any = {
model: activeConfig.model,
messages: messagesWithSystem.map((msg) => {
const formattedMsg: any = {
role: msg.role,
content: msg.content || null
}
if (msg.tool_calls) {
formattedMsg.tool_calls = msg.tool_calls
}
if (msg.tool_call_id) {
formattedMsg.tool_call_id = msg.tool_call_id
}
if (msg.name) {
formattedMsg.name = msg.name
}
return formattedMsg
}),
tools: availableTools,
tool_choice: 'auto',
stream: true
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeConfig.apiKey}`
},
body: JSON.stringify(requestBody)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`API 请求失败: ${response.status} ${errorText}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('无法读取响应流')
}
const decoder = new TextDecoder('utf-8')
let buffer = ''
let assistantMessage = ''
let toolCalls: ToolCall[] = []
let currentToolCall: Partial<ToolCall> | null = null
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine || trimmedLine === 'data: [DONE]') continue
if (trimmedLine.startsWith('data: ')) {
try {
const jsonStr = trimmedLine.substring(6)
const data = JSON.parse(jsonStr)
const choice = data.choices?.[0]
if (!choice) continue
// Handle content delta
if (choice.delta?.content) {
assistantMessage += choice.delta.content
onToken(choice.delta.content)
}
// Handle tool calls delta
if (choice.delta?.tool_calls) {
const toolCallDeltas = choice.delta.tool_calls
for (const delta of toolCallDeltas) {
const index = delta.index
if (delta.id) {
// New tool call
if (currentToolCall && currentToolCall.id) {
toolCalls.push(currentToolCall as ToolCall)
}
currentToolCall = {
id: delta.id,
type: delta.type || 'function',
function: {
name: delta.function?.name || '',
arguments: delta.function?.arguments || ''
}
}
} else if (currentToolCall) {
// Continue current tool call
if (delta.function?.name) {
currentToolCall.function!.name += delta.function.name
}
if (delta.function?.arguments) {
currentToolCall.function!.arguments += delta.function.arguments
}
}
}
}
// Check if finish
if (choice.finish_reason === 'tool_calls' || choice.finish_reason === 'stop') {
if (currentToolCall && currentToolCall.id) {
toolCalls.push(currentToolCall as ToolCall)
currentToolCall = null
}
}
} catch (e) {
console.warn('Failed to parse SSE data:', trimmedLine, e)
}
}
}
}
// If there are tool calls, execute them
if (toolCalls.length > 0) {
const executor = new ToolExecutor()
for (const toolCall of toolCalls) {
try {
const args = JSON.parse(toolCall.function.arguments)
onToolCall?.(toolCall.function.name, args)
} catch (e) {
console.warn('Failed to parse tool call arguments:', e)
}
}
onComplete()
return {
role: 'assistant',
content: assistantMessage || null,
tool_calls: toolCalls
} as Message
}
onComplete()
return {
role: 'assistant',
content: assistantMessage
}
} catch (error) {
onError(error as Error)
throw error
}
}
// Execute tool calls and get results
export async function executeToolCalls(toolCalls: ToolCall[]): Promise<Message[]> {
const executor = new ToolExecutor()
const results: Message[] = []
for (const toolCall of toolCalls) {
const result = await executor.execute(toolCall)
results.push(result as Message)
}
return results
}
+304
View File
@@ -0,0 +1,304 @@
// AI Tool 定义和执行器
export interface ToolDefinition {
type: 'function'
function: {
name: string
description: string
parameters: {
type: 'object'
properties: Record<string, any>
required: string[]
}
}
}
export interface ToolCall {
id: string
type: 'function'
function: {
name: string
arguments: string
}
}
export interface ToolResult {
tool_call_id: string
role: 'tool'
name: string
content: string
}
// 定义所有可用的工具
export const availableTools: ToolDefinition[] = [
{
type: 'function',
function: {
name: 'check_platform_login',
description:
'检查用户是否已登录指定平台。在执行需要登录的操作(如搜索)之前,建议先调用此工具检查登录状态。返回登录状态和用户信息(如已登录)。',
parameters: {
type: 'object',
properties: {
platform: {
type: 'string',
enum: ['xiaoheihe'],
description: '要检查的平台名称,目前支持: xiaoheihe(小黑盒)'
}
},
required: ['platform']
}
}
},
{
type: 'function',
function: {
name: 'search_platform',
description:
'⚠️ 需要登录:此功能需要用户已登录指定平台。建议先使用 check_platform_login 工具检查登录状态。\n\n在指定平台搜索内容,获取相关文章列表。支持的平台:xiaoheihe(小黑盒游戏社区)。\n\n返回包含标题、URL链接、作者、发布时间、摘要等信息的文章列表。搜索结果中的 URL 可以传递给 fetch_article 工具来获取完整文章内容。\n\n使用场景:当用户询问某个游戏、攻略、装备等相关信息时,先用此工具搜索相关文章。',
parameters: {
type: 'object',
properties: {
platform: {
type: 'string',
enum: ['xiaoheihe'],
description: '要搜索的平台名称,目前支持: xiaoheihe(小黑盒)'
},
query: {
type: 'string',
description: '搜索关键词,例如:三角洲行动M7战斗步枪、黑神话悟空攻略、游戏装备配置等'
}
},
required: ['platform', 'query']
}
}
},
{
type: 'function',
function: {
name: 'fetch_article',
description:
'获取指定 URL 的文章完整内容,包括标题、作者、发布时间、正文内容、热门评论、统计数据(点赞数、评论数、收藏数等)。\n\n✨ 重要使用场景:\n1. 当用户要求"查看文章详情"、"打开文章"、"获取完整内容"时\n2. 当用户询问具体内容(如"M7战斗步枪怎么配置"),而搜索结果中有相关文章时,应该主动调用此工具获取1-3篇最相关的文章内容\n3. 当用户说"我想了解XXX",应根据之前的搜索结果,选择最相关的文章URL,调用此工具获取详细内容\n\n💡 提示:通常与 search_platform 配合使用。先搜索获取文章列表,然后根据用户需求,选择相关文章的URL调用此工具获取详细内容。如果有多篇相关文章,可以依次获取并整合内容后返回给用户。',
parameters: {
type: 'object',
properties: {
url: {
type: 'string',
description: '文章的完整 URL 地址,通常从 search_platform 的搜索结果中获取,例如:https://www.xiaoheihe.cn/article/123456'
}
},
required: ['url']
}
}
}
]
// 工具执行器
export class ToolExecutor {
async execute(toolCall: ToolCall): Promise<ToolResult> {
const { id, function: func } = toolCall
const { name, arguments: argsStr } = func
console.log('ToolExecutor.execute called:', { name, argsStr })
let args: any
try {
args = JSON.parse(argsStr)
console.log('Parsed arguments:', args)
} catch (error) {
console.error('Failed to parse tool arguments:', error)
return {
tool_call_id: id,
role: 'tool',
name,
content: JSON.stringify({ error: '参数解析失败', details: String(error) })
}
}
try {
let result: any
console.log('Executing tool:', name)
switch (name) {
case 'check_platform_login':
console.log('Calling checkPlatformLogin with platform:', args.platform)
result = await this.checkPlatformLogin(args.platform)
break
case 'search_platform':
console.log('Calling searchPlatform with platform:', args.platform, 'query:', args.query)
result = await this.searchPlatform(args.platform, args.query)
break
case 'fetch_article':
console.log('Calling fetchArticle with url:', args.url)
result = await this.fetchArticle(args.url)
break
default:
console.error('Unknown tool:', name)
result = { error: `未知的工具: ${name}` }
}
console.log('Tool execution result:', result)
return {
tool_call_id: id,
role: 'tool',
name,
content: JSON.stringify(result)
}
} catch (error) {
console.error('Tool execution error:', error)
return {
tool_call_id: id,
role: 'tool',
name,
content: JSON.stringify({
error: '工具执行失败',
details: error instanceof Error ? error.message : String(error)
})
}
}
}
private async checkPlatformLogin(platform: string): Promise<any> {
const result = await window.electron.ipcRenderer.invoke('check-platform-login', {
platform
})
if (!result.success) {
return {
success: false,
error: result.error || '检查登录状态失败'
}
}
// 返回格式化的登录状态
return {
success: true,
isLoggedIn: result.isLoggedIn,
platform: platform,
username: result.username || null,
message: result.isLoggedIn
? `用户已登录 ${platform}${result.username ? `,用户名:${result.username}` : ''}`
: `用户未登录 ${platform},需要先登录才能使用搜索等功能`,
loginGuide: result.isLoggedIn
? null
: '登录方式:\n1. 点击设置按钮\n2. 扫描二维码登录小黑盒账号\n3. 登录成功后即可使用搜索功能'
}
}
private async searchPlatform(platform: string, query: string): Promise<any> {
console.log('searchPlatform called with:', { platform, query })
const result = await window.electron.ipcRenderer.invoke('search-platform', {
platform,
query
})
console.log('searchPlatform IPC result:', result)
if (!result.success) {
// 特殊处理未登录错误
if (result.error === 'NOT_LOGGED_IN') {
return {
success: false,
error: 'NOT_LOGGED_IN',
message: `搜索 ${platform} 需要登录。请先登录小黑盒账号。`,
needLogin: true,
platform: platform,
// 提供登录引导信息
loginGuide: '你可以通过以下方式登录:\n1. 点击设置按钮\n2. 扫描二维码登录小黑盒账号\n3. 登录成功后即可使用搜索功能'
}
}
// 特殊处理安全验证错误
if (result.error === 'SECURITY_VERIFICATION') {
return {
success: false,
error: 'SECURITY_VERIFICATION',
message: '触发了安全验证,请稍后再试',
verificationRequired: true,
suggestions:
'建议:\n1. 等待 30-60 秒后再次尝试\n2. 减少搜索频率\n3. 尝试在浏览器中手动访问小黑盒完成验证\n4. 更换搜索关键词'
}
}
// 特殊处理频率限制错误
if (result.error === 'RATE_LIMIT_EXCEEDED') {
return {
success: false,
error: 'RATE_LIMIT_EXCEEDED',
message: '搜索频率过快,请稍后再试',
rateLimited: true,
suggestions:
'频率限制说明:\n1. 每次搜索间隔至少 3 秒\n2. 每分钟最多搜索 10 次\n3. 请等待几秒后再试\n4. 这是为了避免触发平台安全验证'
}
}
return {
error: result.error || '搜索失败',
success: false
}
}
if (!result.results || result.results.length === 0) {
return {
success: true,
message: '未找到相关结果',
results: []
}
}
// 返回格式化的搜索结果
return {
success: true,
count: result.results.length,
results: result.results.map((item: any) => ({
title: item.title,
url: item.url,
author: item.author,
publishTime: item.publishTime,
summary: item.summary,
commentCount: item.commentCount || 0,
likeCount: item.likeCount || 0
}))
}
}
private async fetchArticle(url: string): Promise<any> {
console.log('fetchArticle called with url:', url)
const result = await window.electron.ipcRenderer.invoke('fetch-article', url)
console.log('fetchArticle IPC result:', result)
if (!result.success) {
console.error('fetchArticle failed:', result.error)
return {
error: result.error || '获取文章失败',
success: false
}
}
// 返回格式化的文章内容
const formattedResult = {
success: true,
article: {
title: result.title,
author: result.author,
authorIp: result.authorIp,
publishTime: result.publishTime,
content: result.content,
tags: result.tags || [],
stats: {
likes: result.stats?.likes || 0,
favorites: result.stats?.favorites || 0,
commentCount: result.stats?.commentCount || 0,
hotScore: result.stats?.hotScore || 0
},
commentCount: result.comments?.length || 0,
topComments: result.comments?.slice(0, 5) || []
}
}
console.log('fetchArticle returning formatted result:', formattedResult)
return formattedResult
}
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Settings from './views/Settings.vue'
const app = createApp(Settings)
app.use(ElementPlus)
app.mount('#app')
-13
View File
@@ -1,13 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import Settings from './components/Settings'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<Settings />
</ConfigProvider>
</React.StrictMode>
)
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import ToolsPanel from './views/ToolsPanel.vue'
const app = createApp(ToolsPanel)
app.use(ElementPlus)
app.mount('#app')
+609
View File
@@ -0,0 +1,609 @@
<template>
<div class="chat-container" :style="containerStyles">
<!-- Header -->
<div class="chat-header">
<h1 class="title">AI 对话</h1>
<div class="header-actions">
<el-button circle @click="openToolsPanel">
<el-icon><Tools /></el-icon>
</el-button>
<el-button circle @click="handleClear">
<el-icon><Delete /></el-icon>
</el-button>
<el-button circle @click="openSettings">
<el-icon><Setting /></el-icon>
</el-button>
</div>
</div>
<!-- Messages Area -->
<div ref="messagesContainer" class="messages-container">
<div v-if="messages.length === 0" class="empty-state">
<el-empty description="开始新对话" />
</div>
<MessageCard
v-for="message in messages"
:key="message.id"
:message="message"
/>
<!-- Loading indicator -->
<div v-if="isLoading" class="loading-indicator">
<el-icon class="is-loading" :size="20">
<Loading />
</el-icon>
<span>正在思考...</span>
</div>
</div>
<!-- Input Area -->
<div class="input-container">
<div class="input-wrapper">
<el-input
ref="inputRef"
v-model="inputValue"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="输入消息..."
@keydown.enter.exact="handleKeyDown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
<el-button
type="primary"
circle
class="send-button"
:disabled="!inputValue.trim() || isLoading"
@click="handleSend"
>
<el-icon><Promotion /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Delete, Setting, Loading, Promotion, Tools } from '@element-plus/icons-vue'
import { useTheme } from '../composables/useTheme'
import { chatWithTools, executeToolCalls, type Message as AIMessage } from '../services/aiService'
import MessageCard from '../components/MessageCard.vue'
interface ToolCallInfo {
name: string
args?: Record<string, any>
result?: any
status: 'loading' | 'success' | 'error'
}
interface Message {
id: string
role: 'user' | 'assistant' | 'tool'
content: string
timestamp: Date
toolCalls?: ToolCallInfo[]
}
interface ModelConfig {
id: string
name: string
provider: string
model: string
apiKey: string
baseUrl: string
}
const { theme } = useTheme()
const messages = ref<Message[]>([])
const inputValue = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement>()
const inputRef = ref<any>(null)
// IME composition state
const isComposing = ref(false)
// Debounce timer for localStorage writes
let saveTimer: NodeJS.Timeout | null = null
const containerStyles = computed(() => ({
backgroundColor: theme.value.colors.background,
color: theme.value.colors.textPrimary
}))
// Load messages from localStorage with lazy loading
onMounted(() => {
const mountTime = Date.now()
console.log('[PERF] Chat component mounted at:', mountTime)
// Use setTimeout to defer message loading after initial render
setTimeout(() => {
const loadStartTime = Date.now()
console.log('[PERF] Starting message load at:', loadStartTime)
const savedMessages = localStorage.getItem('chat-messages')
if (savedMessages) {
try {
const parsed = JSON.parse(savedMessages)
console.log('[PERF] Total messages in storage:', parsed.length)
// Only load last 50 messages initially for better performance
const recentMessages = parsed.slice(-50)
messages.value = recentMessages.map((msg: any) => ({
...msg,
timestamp: new Date(msg.timestamp)
}))
const loadEndTime = Date.now()
console.log('[PERF] Messages loaded. Count:', messages.value.length)
console.log('[PERF] Message load time:', loadEndTime - loadStartTime, 'ms')
} catch (error) {
console.error('Failed to load messages:', error)
messages.value = []
}
}
}, 0)
// Focus input on mount to ensure IME works
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
}
})
// Listen for initial text from main process (Command+K shortcut)
window.electron.ipcRenderer.on('set-initial-text', (_: unknown, text: string) => {
if (text) {
inputValue.value = text
// Focus input after setting text
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
}
})
}
})
})
onUnmounted(() => {
// Clean up IPC listener to prevent memory leaks
window.electron.ipcRenderer.removeAllListeners('set-initial-text')
// Flush any pending saves before unmounting
if (saveTimer) {
clearTimeout(saveTimer)
localStorage.setItem('chat-messages', JSON.stringify(messages.value))
}
})
const getActiveModel = async (): Promise<ModelConfig | null> => {
try {
const settings = await window.electron.ipcRenderer.invoke('read-settings')
if (!settings.activeModelId) {
return null
}
if (!settings.modelConfigs || settings.modelConfigs.length === 0) {
return null
}
return settings.modelConfigs.find((config: ModelConfig) => config.id === settings.activeModelId) || null
} catch (error) {
console.error('Failed to get active model:', error)
return null
}
}
// Handle IME composition start
const handleCompositionStart = () => {
isComposing.value = true
}
// Handle IME composition end
const handleCompositionEnd = () => {
isComposing.value = false
}
// Handle keydown event (for Enter key)
const handleKeyDown = (event: KeyboardEvent) => {
// Prevent sending message if IME is active
if (isComposing.value) {
return
}
// Prevent default and send message
event.preventDefault()
handleSend()
}
const handleSend = async () => {
// Prevent sending if IME is active
if (isComposing.value) {
return
}
if (!inputValue.value.trim() || isLoading.value) {
return
}
const activeModel = await getActiveModel()
if (!activeModel) {
ElMessage.error('请先在设置中配置 AI 模型')
return
}
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: inputValue.value.trim(),
timestamp: new Date()
}
messages.value.push(userMessage)
inputValue.value = ''
// Save messages (debounced)
saveMessages()
scrollToBottom()
// Send to AI with tool calling support
isLoading.value = true
try {
// Convert to AI message format
const aiMessages: AIMessage[] = messages.value.map((msg) => ({
role: msg.role,
content: msg.content
}))
let currentContent = ''
let currentToolCalls: ToolCallInfo[] = []
const assistantResponse = await chatWithTools(aiMessages, {
onStart: () => {
// Add placeholder for assistant message
const placeholderMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '',
timestamp: new Date(),
toolCalls: []
}
messages.value.push(placeholderMessage)
},
onToken: (token: string) => {
currentContent += token
// Update the last message (assistant's message)
const lastMessage = messages.value[messages.value.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
lastMessage.content = currentContent
scrollToBottom()
}
},
onComplete: () => {
saveMessages(true)
},
onError: (error: Error) => {
console.error('AI request failed:', error)
ElMessage.error(error.message || '请求失败,请检查配置')
// Remove the last message (failed assistant message)
messages.value.pop()
},
onToolCall: (toolName: string, args: any) => {
// Add tool call to current message
const toolCall: ToolCallInfo = {
name: toolName,
args,
status: 'loading'
}
currentToolCalls.push(toolCall)
// Update last message with tool calls
const lastMessage = messages.value[messages.value.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
lastMessage.toolCalls = [...currentToolCalls]
scrollToBottom()
}
}
})
// Tool calling loop - keep calling tools until AI stops requesting them
let currentResponse = assistantResponse
let conversationMessages = [...aiMessages]
let maxIterations = 10 // Prevent infinite loops
let iteration = 0
while (currentResponse.tool_calls && currentResponse.tool_calls.length > 0 && iteration < maxIterations) {
iteration++
console.log(`=== Tool Call Iteration ${iteration} ===`)
console.log('AI Response:', currentResponse)
console.log('Tool calls received:', currentResponse.tool_calls)
// Get the last assistant message (which should already exist from previous iteration or initial call)
let lastMessage = messages.value[messages.value.length - 1]
// Ensure we have an assistant message to work with
if (!lastMessage || lastMessage.role !== 'assistant') {
console.error('Expected assistant message but got:', lastMessage)
break
}
// Update the message with tool calls if not already set
if (!lastMessage.toolCalls || lastMessage.toolCalls.length === 0) {
lastMessage.toolCalls = currentResponse.tool_calls.map((tc) => ({
name: tc.function.name,
args: JSON.parse(tc.function.arguments),
status: 'loading' as const
}))
console.log('Tool call cards created:', lastMessage.toolCalls)
scrollToBottom()
}
// Execute tool calls one by one and update status
const toolResults = []
for (let i = 0; i < currentResponse.tool_calls.length; i++) {
const toolCall = currentResponse.tool_calls[i]
console.log(`Executing tool call ${i + 1}/${currentResponse.tool_calls.length}:`, toolCall.function.name)
try {
const results = await executeToolCalls([toolCall])
const toolResult = results[0]
console.log(`Tool call ${i + 1} result:`, toolResult)
toolResults.push(toolResult)
// Update this tool call's status with result
if (lastMessage && lastMessage.toolCalls && lastMessage.toolCalls[i]) {
lastMessage.toolCalls[i].result = toolResult.content
lastMessage.toolCalls[i].status = 'success'
scrollToBottom()
}
} catch (error) {
console.error(`Tool call ${i + 1} error:`, error)
// Mark tool as failed
if (lastMessage && lastMessage.toolCalls && lastMessage.toolCalls[i]) {
lastMessage.toolCalls[i].status = 'error'
lastMessage.toolCalls[i].result = JSON.stringify({ error: '工具执行失败' })
scrollToBottom()
}
}
}
// Add current response and tool results to conversation history
conversationMessages.push(currentResponse)
conversationMessages.push(...toolResults)
// Get next response from AI
currentContent = ''
console.log('Sending tool results back to AI. Conversation:', conversationMessages)
// Create a new assistant message for the next response
const nextAssistantMessage: Message = {
id: (Date.now() + iteration + 1000).toString(),
role: 'assistant',
content: '',
timestamp: new Date(),
toolCalls: []
}
messages.value.push(nextAssistantMessage)
scrollToBottom()
currentResponse = await chatWithTools(conversationMessages, {
onStart: () => {
console.log('AI processing tool results...')
},
onToken: (token: string) => {
currentContent += token
// Update the new assistant message content
nextAssistantMessage.content = currentContent
scrollToBottom()
},
onComplete: () => {
console.log('AI response iteration completed')
},
onError: (error: Error) => {
console.error('AI request failed:', error)
ElMessage.error(error.message || '请求失败')
// Remove the failed message
const index = messages.value.indexOf(nextAssistantMessage)
if (index > -1) {
messages.value.splice(index, 1)
}
}
})
console.log('Next AI Response:', currentResponse)
}
if (iteration >= maxIterations) {
console.warn('Reached maximum tool call iterations')
ElMessage.warning('工具调用次数过多,已停止')
}
// Save final state
saveMessages(true)
scrollToBottom()
} catch (error: any) {
console.error('AI request failed:', error)
ElMessage.error(error.message || '请求失败,请检查配置')
// Remove user message if failed
messages.value = messages.value.filter((msg) => msg.id !== userMessage.id)
} finally {
isLoading.value = false
// Re-focus input after message is sent
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
}
})
}
}
const handleClear = () => {
messages.value = []
// Clear any pending save timer
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
localStorage.removeItem('chat-messages')
ElMessage.success('对话已清空')
}
const openSettings = () => {
window.electron.ipcRenderer.send('open-settings')
}
const openToolsPanel = () => {
window.electron.ipcRenderer.send('open-tools-panel')
}
// Debounced save to localStorage (300ms delay)
const saveMessages = (immediate = false) => {
if (immediate) {
// Save immediately (e.g., when closing window or after AI response)
localStorage.setItem('chat-messages', JSON.stringify(messages.value))
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
} else {
// Debounced save
if (saveTimer) {
clearTimeout(saveTimer)
}
saveTimer = setTimeout(() => {
localStorage.setItem('chat-messages', JSON.stringify(messages.value))
saveTimer = null
}, 300)
}
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Arial,
sans-serif;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 10;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loading-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(64, 158, 255, 0.1);
color: #409eff;
border-radius: 12px;
font-size: 14px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.input-container {
padding: 16px 24px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 12px;
}
.input-wrapper :deep(.el-textarea__inner) {
border-radius: 12px;
padding: 12px 16px;
}
.send-button {
flex-shrink: 0;
width: 40px;
height: 40px;
}
/* Scrollbar styles */
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<template>
<div class="floating-container">
<!-- Action Menu Items -->
<Transition
v-for="(item, index) in actionItems"
:key="item.name"
:name="`action-${index}`"
>
<div
v-if="isActionMenuOpen"
:class="['action-item', `action-item-${index}`, { selected: selectedButtonIndex === index }]"
@click="() => handleActionClick(index)"
@mouseenter="() => handleActionMouseEnter(index)"
@mouseleave="handleActionMouseLeave"
>
<svg
:width="20"
:height="20"
viewBox="0 0 24 24"
fill="none"
:stroke="item.color"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<component :is="item.icon" />
</svg>
</div>
</Transition>
<!-- Robot Ball Container -->
<div class="robot-ball-container">
<div
class="robot-ball"
@mousedown="handleMouseDown"
@mouseenter="handleBallMouseEnter"
@mouseleave="handleBallMouseLeave"
>
<!-- Robot Icon -->
<svg viewBox="0 0 100 100" width="48" height="48" fill="none" style="pointer-events: none">
<!-- Antenna -->
<circle cx="50" cy="10" r="4" fill="white" />
<line x1="50" y1="14" x2="50" y2="25" stroke="white" stroke-width="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 -->
<template v-if="isBlinking">
<line
x1="33"
y1="47"
x2="43"
y2="47"
stroke="#1976d2"
stroke-width="2.5"
stroke-linecap="round"
/>
<line
x1="57"
y1="47"
x2="67"
y2="47"
stroke="#1976d2"
stroke-width="2.5"
stroke-linecap="round"
/>
</template>
<template v-else>
<circle cx="38" cy="47" r="4.5" fill="#1976d2" />
<circle cx="62" cy="47" r="4.5" fill="#1976d2" />
</template>
<!-- Smile -->
<path
d="M 38 58 Q 50 64 62 58"
stroke="#1976d2"
stroke-width="2.5"
fill="none"
stroke-linecap="round"
/>
<!-- Ears -->
<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>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, h } from 'vue'
const isBlinking = ref(false)
const isActionMenuOpen = ref(false)
const selectedText = ref('')
const selectedButtonIndex = ref(0)
let blinkTimer: NodeJS.Timeout | null = null
let isDragging = false
let startPos = { x: 0, y: 0 }
let windowStart = { x: 0, y: 0 }
// Action items configuration
const actionItems = [
{
name: 'chat',
color: '#007AFF',
icon: () =>
h('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'
})
},
{
name: 'settings',
color: '#8E8E93',
icon: () => [
h('circle', { cx: 12, cy: 12, r: 3 }),
h('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'
})
]
},
{
name: 'quit',
color: '#FF3B30',
icon: () => h('polyline', { points: '15 18 9 12 15 6' })
}
]
// Blinking animation
const scheduleNextBlink = () => {
const delay = Math.random() * 2000 + 3000
blinkTimer = setTimeout(() => {
isBlinking.value = true
setTimeout(() => {
isBlinking.value = false
scheduleNextBlink()
}, 200)
}, delay)
}
// Handle Command+K shortcut
const handleShowTextPrompt = (_: any, text: string) => {
selectedText.value = text
isActionMenuOpen.value = !isActionMenuOpen.value
if (isActionMenuOpen.value) {
selectedButtonIndex.value = 0
}
}
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (!isActionMenuOpen.value) return
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedButtonIndex.value = selectedButtonIndex.value === 0 ? 2 : selectedButtonIndex.value - 1
break
case 'ArrowDown':
e.preventDefault()
selectedButtonIndex.value = selectedButtonIndex.value === 2 ? 0 : selectedButtonIndex.value + 1
break
case 'Tab':
e.preventDefault()
selectedButtonIndex.value = selectedButtonIndex.value === 2 ? 0 : selectedButtonIndex.value + 1
break
case 'Enter':
e.preventDefault()
executeSelectedAction()
break
case 'Escape':
e.preventDefault()
isActionMenuOpen.value = false
selectedText.value = ''
break
}
}
// Execute action
const executeSelectedAction = () => {
switch (selectedButtonIndex.value) {
case 0:
console.log('对话按钮选中 - 打开聊天窗口')
window.electron.ipcRenderer.send('open-chat', selectedText.value || undefined)
break
case 1:
console.log('设置按钮选中 - 打开设置窗口')
window.electron.ipcRenderer.send('open-settings')
break
case 2:
console.log('退出按钮选中 - 退出应用')
window.electron.ipcRenderer.send('quit-app')
break
}
isActionMenuOpen.value = false
selectedText.value = ''
}
// Action click handler
const handleActionClick = (index: number) => {
selectedButtonIndex.value = index
executeSelectedAction()
}
// Action mouse events
const handleActionMouseEnter = (index: number) => {
selectedButtonIndex.value = index
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
}
const handleActionMouseLeave = () => {
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
}
// Ball mouse events
const handleBallMouseEnter = (e: MouseEvent) => {
window.electron.ipcRenderer.send('set-ignore-mouse-events', false)
;(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)'
}
const handleBallMouseLeave = (e: MouseEvent) => {
window.electron.ipcRenderer.send('set-ignore-mouse-events', true, { forward: true })
;(e.currentTarget as HTMLElement).style.transform = 'scale(1)'
}
// Ball drag handling
const handleMouseDown = async (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.button === 2) return
isDragging = false
startPos = { x: e.screenX, y: e.screenY }
try {
const bounds = await window.electron.ipcRenderer.invoke('get-window-bounds')
windowStart = { x: bounds.x, y: bounds.y }
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.screenX - startPos.x
const deltaY = moveEvent.screenY - startPos.y
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (distance > 3) {
isDragging = true
}
if (isDragging) {
const newX = windowStart.x + deltaX
const newY = windowStart.y + deltaY
window.electron.ipcRenderer.send('floating-window-move', { x: newX, y: newY })
}
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
if (!isDragging) {
isActionMenuOpen.value = !isActionMenuOpen.value
if (isActionMenuOpen.value) {
selectedButtonIndex.value = 0
}
}
isDragging = false
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
} catch (error) {
console.error('Failed to get window bounds:', error)
}
}
onMounted(() => {
scheduleNextBlink()
window.electron.ipcRenderer.on('show-text-prompt', handleShowTextPrompt)
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
if (blinkTimer) {
clearTimeout(blinkTimer)
}
window.removeEventListener('keydown', handleKeyDown)
// Clean up IPC listener to prevent memory leaks
window.electron.ipcRenderer.removeAllListeners('show-text-prompt')
})
</script>
<style scoped>
.floating-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
position: relative;
pointer-events: none;
padding-right: 8px;
}
.robot-ball-container {
position: relative;
width: 60px;
height: 60px;
pointer-events: auto;
}
.robot-ball {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
box-shadow: none;
transition: box-shadow 0.3s ease, transform 0.1s ease;
user-select: none;
-webkit-user-drag: none;
-webkit-app-region: no-drag;
border: none;
}
/* Action items positioning */
.action-item {
position: absolute;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
pointer-events: auto;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 0.5px solid rgba(0, 0, 0, 0.04);
}
.action-item.selected {
transform: scale(1.1);
}
.action-item-0 {
right: 80px;
top: 10px;
}
.action-item-0.selected {
background: rgba(0, 122, 255, 0.15);
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.3);
border: 2px solid #007aff;
}
.action-item-1 {
right: 95px;
top: 50%;
transform: translateY(-50%);
}
.action-item-1.selected {
background: rgba(142, 142, 147, 0.15);
box-shadow: 0 4px 16px rgba(142, 142, 147, 0.3);
border: 2px solid #8e8e93;
transform: translateY(-50%) scale(1.1);
}
.action-item-2 {
right: 80px;
bottom: 10px;
}
.action-item-2.selected {
background: rgba(255, 59, 48, 0.15);
box-shadow: 0 4px 16px rgba(255, 59, 48, 0.3);
border: 2px solid #ff3b30;
}
/* Transitions */
.action-0-enter-active,
.action-0-leave-active {
transition: all 0.2s ease-out;
}
.action-0-enter-from,
.action-0-leave-to {
opacity: 0;
transform: translate(30px, 30px) scale(0.3);
}
.action-1-enter-active,
.action-1-leave-active {
transition: all 0.2s ease-out 0.05s;
}
.action-1-enter-from,
.action-1-leave-to {
opacity: 0;
transform: translateX(40px) scale(0.3);
}
.action-2-enter-active,
.action-2-leave-active {
transition: all 0.2s ease-out 0.1s;
}
.action-2-enter-from,
.action-2-leave-to {
opacity: 0;
transform: translate(30px, -30px) scale(0.3);
}
</style>
+680
View File
@@ -0,0 +1,680 @@
<template>
<div class="settings-container">
<div class="settings-content">
<div class="header">
<h2>AI 模型管理</h2>
<el-button type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>
添加模型
</el-button>
</div>
<el-card class="models-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon color="#67c23a" style="margin-right: 8px"><SuccessFilled /></el-icon>
<span class="card-title">已配置的模型</span>
</div>
</template>
<el-empty v-if="modelConfigs.length === 0" description="暂无配置的模型">
<el-button type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon>
立即添加
</el-button>
</el-empty>
<div v-else class="models-list">
<div v-for="config in modelConfigs" :key="config.id" class="model-item">
<div class="model-radio">
<el-radio
:model-value="activeModelId"
:label="config.id"
@change="handleSetActive(config.id)"
/>
</div>
<div class="model-info">
<div class="model-title">
<span class="model-name">{{ config.name }}</span>
<el-tag v-if="activeModelId === config.id" type="success" size="small">
当前使用
</el-tag>
</div>
<div class="model-desc">
<span>{{ config.provider }}</span>
<el-divider direction="vertical" />
<span>{{ config.model }}</span>
</div>
</div>
<div class="model-actions">
<el-button type="danger" link @click="handleDeleteModel(config.id)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
</div>
</el-card>
<!-- 小黑盒登录管理 -->
<el-card class="login-card" shadow="never" style="margin-top: 10px">
<template #header>
<div class="card-header">
<el-icon color="#409eff" style="margin-right: 8px"><User /></el-icon>
<span class="card-title">小黑盒账号</span>
</div>
</template>
<div v-if="loginLoading" style="text-align: center; padding: 12px">
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
<div style="margin-top: 6px; color: #909399; font-size: 12px">加载中...</div>
</div>
<div v-else-if="loginStatus.isLoggedIn" class="login-info">
<div class="login-status">
<el-icon color="#67c23a" :size="28"><CircleCheck /></el-icon>
<div class="status-text">
<div class="status-title">已登录</div>
<div class="status-username" v-if="loginStatus.username">
{{ loginStatus.username }}
</div>
</div>
</div>
<el-button type="danger" size="small" @click="handleLogout" :loading="logoutLoading">
退出登录
</el-button>
</div>
<div v-else class="login-empty">
<el-empty description="未登录小黑盒账号" :image-size="60">
<el-button type="primary" size="small" @click="showLoginDialog">
<el-icon><Key /></el-icon>
立即登录
</el-button>
</el-empty>
</div>
</el-card>
</div>
<!-- 添加模型对话框 -->
<el-dialog
v-model="dialogVisible"
title="添加模型配置"
width="600px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
label-position="top"
>
<el-form-item label="配置名称" prop="name">
<el-input v-model="formData.name" placeholder="例如:我的 GPT-4" size="large" />
</el-form-item>
<el-form-item label="平台" prop="provider">
<el-select
v-model="formData.provider"
placeholder="选择平台"
size="large"
style="width: 100%"
@change="handleProviderChange"
>
<el-option label="OpenAI" value="openai" />
<el-option label="DeepSeek" value="deepseek" />
</el-select>
</el-form-item>
<el-form-item label="模型" prop="model">
<el-select
v-model="formData.model"
placeholder="选择模型"
size="large"
style="width: 100%"
>
<template v-if="formData.provider === 'openai'">
<el-option label="GPT-3.5 Turbo" value="gpt-3.5-turbo" />
<el-option label="GPT-4" value="gpt-4" />
<el-option label="GPT-4 Turbo" value="gpt-4-turbo" />
<el-option label="GPT-4o" value="gpt-4o" />
</template>
<template v-else>
<el-option label="DeepSeek Chat" value="deepseek-chat" />
<el-option label="DeepSeek Coder" value="deepseek-coder" />
</template>
</el-select>
</el-form-item>
<el-form-item label="API Key" prop="apiKey">
<el-input
v-model="formData.apiKey"
type="password"
placeholder="sk-..."
size="large"
show-password
/>
</el-form-item>
<el-form-item label="Base URL" prop="baseUrl">
<el-input
v-model="formData.baseUrl"
placeholder="https://api.openai.com/v1"
size="large"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleAddModel"> 添加 </el-button>
</template>
</el-dialog>
<!-- 登录对话框 -->
<el-dialog
v-model="loginDialogVisible"
title="登录小黑盒"
width="400px"
:close-on-click-modal="false"
>
<div v-if="qrCodeLoading" style="text-align: center; padding: 40px">
<el-icon class="is-loading" :size="48"><Loading /></el-icon>
<div style="margin-top: 16px">正在获取二维码...</div>
</div>
<div v-else-if="qrCodeUrl" class="qr-code-container">
<img :src="qrCodeUrl" alt="登录二维码" class="qr-code" />
<div class="qr-hint">请使用小黑盒 APP 扫描二维码登录</div>
<div v-if="qrCodeStatus === 'waiting'" class="qr-status">
<el-icon class="is-loading"><Loading /></el-icon>
<span style="margin-left: 8px">等待扫码...</span>
</div>
<div v-else-if="qrCodeStatus === 'scanned'" class="qr-status success">
<el-icon><CircleCheck /></el-icon>
<span style="margin-left: 8px">已扫码请在手机上确认</span>
</div>
</div>
<template #footer>
<el-button @click="cancelLogin">取消</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
Plus,
Delete,
SuccessFilled,
User,
Key,
Loading,
CircleCheck
} from '@element-plus/icons-vue'
interface ModelConfig {
id: string
name: string
provider: string
model: string
apiKey: string
baseUrl: string
}
const formRef = ref<FormInstance>()
const dialogVisible = ref(false)
const loading = ref(false)
const modelConfigs = ref<ModelConfig[]>([])
const activeModelId = ref<string>('')
// 登录相关状态
const loginDialogVisible = ref(false)
const loginLoading = ref(false)
const logoutLoading = ref(false)
const qrCodeLoading = ref(false)
const qrCodeUrl = ref('')
const qrCodeStatus = ref<'waiting' | 'scanned' | ''>('')
const loginStatus = ref<{ isLoggedIn: boolean; username?: string }>({
isLoggedIn: false
})
const formData = reactive({
name: '',
provider: 'openai',
model: '',
apiKey: '',
baseUrl: 'https://api.openai.com/v1'
})
const rules: FormRules = {
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
provider: [{ required: true, message: '请选择平台', trigger: 'change' }],
model: [{ required: true, message: '请选择模型', trigger: 'change' }],
apiKey: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
baseUrl: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }]
}
onMounted(async () => {
// Load settings from file
const settings = await window.electron.ipcRenderer.invoke('read-settings')
if (settings.modelConfigs) {
modelConfigs.value = settings.modelConfigs
}
if (settings.activeModelId) {
activeModelId.value = settings.activeModelId
}
// Migrate from localStorage if file is empty but localStorage has data
if (modelConfigs.value.length === 0) {
const savedConfigs = localStorage.getItem('ai-model-configs')
const savedActiveId = localStorage.getItem('ai-active-model-id')
if (savedConfigs) {
modelConfigs.value = JSON.parse(savedConfigs) as ModelConfig[]
if (savedActiveId) {
activeModelId.value = savedActiveId
}
// Save migrated data to file
await saveSettings()
// Clear localStorage after migration
localStorage.removeItem('ai-model-configs')
localStorage.removeItem('ai-active-model-id')
ElMessage.success('已迁移配置到本地文件')
}
}
// Load login status
await loadLoginStatus()
})
// 加载登录状态
const loadLoginStatus = async () => {
try {
loginLoading.value = true
const result = await window.electron.ipcRenderer.invoke(
'check-platform-login-fast',
'https://www.xiaoheihe.cn'
)
if (result.success) {
loginStatus.value = {
isLoggedIn: result.isLoggedIn,
username: result.username
}
}
} catch (error) {
console.error('Failed to load login status:', error)
} finally {
loginLoading.value = false
}
}
// Save settings to file
const saveSettings = async () => {
try {
// Convert Vue reactive objects to plain objects using JSON parse/stringify
const settings = {
modelConfigs: JSON.parse(JSON.stringify(modelConfigs.value)),
activeModelId: activeModelId.value
}
const result = await window.electron.ipcRenderer.invoke('write-settings', settings)
if (!result.success) {
console.error('Failed to save settings:', result.error)
ElMessage.error('保存配置失败: ' + result.error)
}
} catch (error) {
console.error('Error saving settings:', error)
ElMessage.error('保存配置时出错')
}
}
const showAddDialog = () => {
dialogVisible.value = true
}
const handleDialogClose = () => {
formRef.value?.resetFields()
}
const handleProviderChange = (value: string) => {
if (value === 'openai') {
formData.baseUrl = 'https://api.openai.com/v1'
formData.model = ''
} else if (value === 'deepseek') {
formData.baseUrl = 'https://api.deepseek.com'
formData.model = ''
}
}
const handleAddModel = async () => {
if (!formRef.value) return
try {
loading.value = true
await formRef.value.validate()
const newConfig: ModelConfig = {
id: Date.now().toString(),
name: formData.name,
provider: formData.provider,
model: formData.model,
apiKey: formData.apiKey,
baseUrl: formData.baseUrl
}
modelConfigs.value.push(newConfig)
// If this is the first model, set it as active
if (modelConfigs.value.length === 1) {
activeModelId.value = newConfig.id
}
// Save to file
await saveSettings()
ElMessage.success('模型添加成功')
dialogVisible.value = false
formRef.value.resetFields()
} catch (error) {
console.error('Form validation failed:', error)
} finally {
loading.value = false
}
}
const handleDeleteModel = async (id: string) => {
modelConfigs.value = modelConfigs.value.filter((config) => config.id !== id)
// If deleted active model, set new active
if (activeModelId.value === id) {
const newActiveId = modelConfigs.value.length > 0 ? modelConfigs.value[0].id : ''
activeModelId.value = newActiveId
}
// Save to file
await saveSettings()
ElMessage.success('模型删除成功')
}
const handleSetActive = async (id: string) => {
activeModelId.value = id
// Save to file
await saveSettings()
ElMessage.success('已切换活跃模型')
}
// 显示登录对话框
const showLoginDialog = async () => {
loginDialogVisible.value = true
qrCodeUrl.value = ''
qrCodeStatus.value = ''
try {
qrCodeLoading.value = true
const result = await window.electron.ipcRenderer.invoke('get-login-qrcode')
if (result.success && result.qrCodeDataUrl) {
qrCodeUrl.value = result.qrCodeDataUrl
qrCodeStatus.value = 'waiting'
// Start polling for login status
startLoginPolling()
} else {
ElMessage.error('获取二维码失败: ' + (result.error || '未知错误'))
loginDialogVisible.value = false
}
} catch (error) {
console.error('Failed to get QR code:', error)
ElMessage.error('获取二维码失败')
loginDialogVisible.value = false
} finally {
qrCodeLoading.value = false
}
}
// 轮询登录状态
let loginPollingTimer: number | null = null
const startLoginPolling = async () => {
try {
const result = await window.electron.ipcRenderer.invoke('wait-qrcode-login')
if (result.success) {
qrCodeStatus.value = 'scanned'
ElMessage.success('登录成功!')
loginDialogVisible.value = false
// Reload login status
await loadLoginStatus()
} else {
ElMessage.error('登录失败: ' + (result.error || '未知错误'))
loginDialogVisible.value = false
}
} catch (error) {
console.error('Login polling error:', error)
ElMessage.error('登录失败')
loginDialogVisible.value = false
}
}
// 取消登录
const cancelLogin = () => {
if (loginPollingTimer) {
clearTimeout(loginPollingTimer)
loginPollingTimer = null
}
loginDialogVisible.value = false
}
// 退出登录
const handleLogout = async () => {
try {
logoutLoading.value = true
const result = await window.electron.ipcRenderer.invoke('logout-platform', 'xiaoheihe')
if (result.success) {
ElMessage.success('已退出登录')
loginStatus.value = { isLoggedIn: false }
} else {
ElMessage.error('退出登录失败: ' + (result.error || '未知错误'))
}
} catch (error) {
console.error('Failed to logout:', error)
ElMessage.error('退出登录失败')
} finally {
logoutLoading.value = false
}
}
</script>
<style scoped>
.settings-container {
width: 98%;
height: 100%;
background: #f5f5f5;
padding: 12px;
overflow-y: auto;
}
.settings-content {
max-width: 900px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.header h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
color: #1d1d1f;
line-height: 1;
}
.models-card {
border-radius: 12px;
}
.models-card :deep(.el-card__header) {
padding: 10px 14px;
}
.models-card :deep(.el-card__body) {
padding: 14px;
}
.card-header {
display: flex;
align-items: center;
font-weight: 600;
}
.card-title {
font-size: 15px;
}
.models-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.model-item {
display: flex;
align-items: center;
padding: 10px;
border-radius: 8px;
border: 1px solid #e5e5ea;
background: white;
transition: all 0.2s ease;
}
.model-item:hover {
border-color: #007aff;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.1);
}
.model-radio {
margin-right: 10px;
}
.model-info {
flex: 1;
}
.model-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
}
.model-name {
font-size: 14px;
font-weight: 600;
color: #1d1d1f;
}
.model-desc {
font-size: 12px;
color: #6e6e73;
}
.model-actions {
margin-left: 10px;
}
/* 登录卡片样式 */
.login-card {
border-radius: 12px;
}
.login-card :deep(.el-card__header) {
padding: 10px 14px;
}
.login-card :deep(.el-card__body) {
padding: 10px 14px;
}
.login-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
}
.login-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.status-title {
font-size: 14px;
font-weight: 600;
color: #1d1d1f;
}
.status-username {
font-size: 12px;
color: #6e6e73;
}
.login-empty {
padding: 6px;
}
/* 二维码样式 */
.qr-code-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.qr-code {
width: 240px;
height: 240px;
border: 1px solid #e5e5ea;
border-radius: 8px;
margin-bottom: 16px;
}
.qr-hint {
font-size: 14px;
color: #6e6e73;
margin-bottom: 16px;
}
.qr-status {
display: flex;
align-items: center;
font-size: 14px;
color: #409eff;
}
.qr-status.success {
color: #67c23a;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<template>
<div class="tools-panel-container" :style="containerStyles">
<!-- Header -->
<div class="header">
<div class="header-title">
<el-icon :size="24"><Tools /></el-icon>
<h1>工具箱</h1>
</div>
<div class="header-actions">
<el-button circle @click="closePanel">
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
<!-- Content -->
<div class="content">
<!-- Search Section -->
<div class="tool-section">
<div class="section-header">
<el-icon :size="20"><Search /></el-icon>
<h2>搜索小黑盒</h2>
</div>
<div class="section-content">
<el-input
v-model="searchQuery"
placeholder="搜索游戏、攻略、装备等..."
size="large"
clearable
@keydown.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
size="large"
:loading="searchLoading"
:disabled="!searchQuery.trim()"
@click="handleSearch"
>
<el-icon><Search /></el-icon>
搜索
</el-button>
</div>
<!-- Search Results -->
<div v-if="searchResult" class="results-container">
<SearchResultCard :data="searchResult" @article-click="handleArticleClick" />
</div>
</div>
<!-- Fetch Article Section -->
<div class="tool-section">
<div class="section-header">
<el-icon :size="20"><Document /></el-icon>
<h2>获取文章详情</h2>
</div>
<div class="section-content">
<el-input
v-model="articleUrl"
placeholder="输入文章 URL,例如:https://www.xiaoheihe.cn/article/123456"
size="large"
clearable
@keydown.enter="handleFetchArticle"
>
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
<el-button
type="primary"
size="large"
:loading="fetchLoading"
:disabled="!articleUrl.trim()"
@click="handleFetchArticle"
>
<el-icon><Document /></el-icon>
获取
</el-button>
</div>
<!-- Article Result -->
<div v-if="articleResult" class="results-container">
<ArticleResultCard :data="articleResult" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Tools, Close, Search, Document, Link } from '@element-plus/icons-vue'
import { useTheme } from '../composables/useTheme'
import { executeToolCalls } from '../services/aiService'
import SearchResultCard from '../components/SearchResultCard.vue'
import ArticleResultCard from '../components/ArticleResultCard.vue'
const { theme } = useTheme()
// Search state
const searchQuery = ref('')
const searchLoading = ref(false)
const searchResult = ref<any>(null)
// Fetch article state
const articleUrl = ref('')
const fetchLoading = ref(false)
const articleResult = ref<any>(null)
const containerStyles = {
backgroundColor: theme.value.colors.background,
color: theme.value.colors.textPrimary
}
const closePanel = () => {
window.electron.ipcRenderer.send('close-tools-panel')
}
const handleSearch = async () => {
if (!searchQuery.value.trim() || searchLoading.value) {
return
}
try {
searchLoading.value = true
searchResult.value = null
const toolCall = {
id: `search_${Date.now()}`,
type: 'function' as const,
function: {
name: 'search_platform',
arguments: JSON.stringify({
platform: 'xiaoheihe',
query: searchQuery.value
})
}
}
const results = await executeToolCalls([toolCall])
const toolResult = results[0]
// Parse result
const parsedResult = JSON.parse(toolResult.content)
searchResult.value = parsedResult
if (parsedResult.success) {
ElMessage.success(`找到 ${parsedResult.count || 0} 条结果`)
} else {
ElMessage.error(parsedResult.message || '搜索失败')
}
} catch (error: any) {
console.error('Search failed:', error)
ElMessage.error(error.message || '搜索失败')
searchResult.value = {
success: false,
error: '搜索失败'
}
} finally {
searchLoading.value = false
}
}
const handleFetchArticle = async () => {
if (!articleUrl.value.trim() || fetchLoading.value) {
return
}
try {
fetchLoading.value = true
articleResult.value = null
const toolCall = {
id: `fetch_${Date.now()}`,
type: 'function' as const,
function: {
name: 'fetch_article',
arguments: JSON.stringify({
url: articleUrl.value
})
}
}
const results = await executeToolCalls([toolCall])
const toolResult = results[0]
// Parse result
const parsedResult = JSON.parse(toolResult.content)
articleResult.value = parsedResult
if (parsedResult.success) {
ElMessage.success('文章获取成功')
} else {
ElMessage.error(parsedResult.error || '获取文章失败')
}
} catch (error: any) {
console.error('Fetch article failed:', error)
ElMessage.error(error.message || '获取文章失败')
articleResult.value = {
success: false,
error: '获取文章失败'
}
} finally {
fetchLoading.value = false
}
}
const handleArticleClick = (url: string) => {
articleUrl.value = url
handleFetchArticle()
}
</script>
<style scoped>
.tools-panel-container {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Arial,
sans-serif;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
}
.header-title h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 32px;
}
.tool-section {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid #f0f2f5;
}
.section-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1d1d1f;
}
.section-content {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.section-content .el-input {
flex: 1;
}
.section-content .el-button {
min-width: 100px;
}
.results-container {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f2f5;
}
/* Scrollbar styling */
.content::-webkit-scrollbar {
width: 6px;
}
.content::-webkit-scrollbar-track {
background: transparent;
}
.content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.content::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>工具箱</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.openai.com https://api.deepseek.com https://*.openai.com https://*.deepseek.com"
/>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
#app {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/tools.ts"></script>
</body>
</html>