完善小黑盒搜索功能,将小黑盒操作作为工具给大模型
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import FloatingBall from './views/FloatingBall.vue'
|
||||
|
||||
const app = createApp(FloatingBall)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user