mirror of
https://github.com/violettoolssite/CFspider.git
synced 2026-04-05 03:09:01 +08:00
feat: add AI PC control - keyboard/mouse, clipboard, files, processes, screenshots, scheduler
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,82 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
},
|
||||
onCloseTab: (callback: () => void) => {
|
||||
ipcRenderer.on('close-tab', () => callback())
|
||||
},
|
||||
|
||||
// 系统操作 API
|
||||
checkApp: (appName: string) => ipcRenderer.invoke('system:check-app', appName),
|
||||
runApp: (params: { appName?: string; path?: string; args?: string[]; url?: string }) =>
|
||||
ipcRenderer.invoke('system:run-app', params),
|
||||
openPath: (path: string) => ipcRenderer.invoke('system:open-path', path),
|
||||
runCommand: (params: { command: string; cwd?: string; timeout?: number }) =>
|
||||
ipcRenderer.invoke('system:run-command', params),
|
||||
listApps: () => ipcRenderer.invoke('system:list-apps'),
|
||||
getSystemInfo: () => ipcRenderer.invoke('system:info'),
|
||||
|
||||
// 键盘鼠标模拟 API
|
||||
typeText: (params: { text: string; delay?: number }) =>
|
||||
ipcRenderer.invoke('input:type-text', params),
|
||||
pressKey: (params: { key: string }) =>
|
||||
ipcRenderer.invoke('input:press-key', params),
|
||||
mouseClick: (params: { x: number; y: number; button?: 'left' | 'right' | 'middle'; clicks?: number }) =>
|
||||
ipcRenderer.invoke('input:mouse-click', params),
|
||||
mouseMove: (params: { x: number; y: number; smooth?: boolean }) =>
|
||||
ipcRenderer.invoke('input:mouse-move', params),
|
||||
mouseDrag: (params: { fromX: number; fromY: number; toX: number; toY: number }) =>
|
||||
ipcRenderer.invoke('input:mouse-drag', params),
|
||||
focusWindow: (params: { title?: string; process?: string }) =>
|
||||
ipcRenderer.invoke('input:focus-window', params),
|
||||
getMousePos: () => ipcRenderer.invoke('input:get-mouse-pos'),
|
||||
|
||||
// 剪贴板 API
|
||||
readClipboard: () => ipcRenderer.invoke('clipboard:read'),
|
||||
writeClipboard: (params: { text?: string; html?: string; image?: string }) =>
|
||||
ipcRenderer.invoke('clipboard:write', params),
|
||||
|
||||
// 系统通知 API
|
||||
sendNotification: (params: { title: string; body: string; icon?: string; silent?: boolean }) =>
|
||||
ipcRenderer.invoke('notify:send', params),
|
||||
|
||||
// 文件系统 API
|
||||
fsReadFile: (params: { path: string; encoding?: string }) =>
|
||||
ipcRenderer.invoke('fs:read-file', params),
|
||||
fsWriteFile: (params: { path: string; content: string; encoding?: string }) =>
|
||||
ipcRenderer.invoke('fs:write-file', params),
|
||||
fsListDirectory: (params: { path: string; recursive?: boolean }) =>
|
||||
ipcRenderer.invoke('fs:list-directory', params),
|
||||
fsCreateDirectory: (params: { path: string }) =>
|
||||
ipcRenderer.invoke('fs:create-directory', params),
|
||||
fsDelete: (params: { path: string; recursive?: boolean }) =>
|
||||
ipcRenderer.invoke('fs:delete', params),
|
||||
fsMove: (params: { from: string; to: string }) =>
|
||||
ipcRenderer.invoke('fs:move', params),
|
||||
fsSearch: (params: { path: string; pattern: string; maxResults?: number }) =>
|
||||
ipcRenderer.invoke('fs:search', params),
|
||||
fsGetInfo: (params: { path: string }) =>
|
||||
ipcRenderer.invoke('fs:get-info', params),
|
||||
|
||||
// 进程管理 API
|
||||
listProcesses: () => ipcRenderer.invoke('process:list'),
|
||||
killProcess: (params: { pid?: number; name?: string }) =>
|
||||
ipcRenderer.invoke('process:kill', params),
|
||||
getSystemUsage: () => ipcRenderer.invoke('process:usage'),
|
||||
|
||||
// 屏幕截图 API
|
||||
captureScreen: () => ipcRenderer.invoke('screen:capture'),
|
||||
listWindows: () => ipcRenderer.invoke('screen:list-windows'),
|
||||
captureWindow: (params: { name: string }) =>
|
||||
ipcRenderer.invoke('screen:capture-window', params),
|
||||
|
||||
// 定时任务 API
|
||||
createReminder: (params: { title: string; message: string; delay: number }) =>
|
||||
ipcRenderer.invoke('scheduler:create-reminder', params),
|
||||
createScheduledTask: (params: { title: string; message: string; time: string; repeat?: string }) =>
|
||||
ipcRenderer.invoke('scheduler:create-task', params),
|
||||
listScheduledTasks: () => ipcRenderer.invoke('scheduler:list'),
|
||||
cancelScheduledTask: (params: { id: string }) =>
|
||||
ipcRenderer.invoke('scheduler:cancel', params),
|
||||
onTaskTriggered: (callback: (task: object) => void) => {
|
||||
ipcRenderer.on('task:triggered', (_event, task) => callback(task))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -151,6 +227,81 @@ declare global {
|
||||
onFocusAddressbar: (callback: () => void) => void
|
||||
onNewTab: (callback: () => void) => void
|
||||
onCloseTab: (callback: () => void) => void
|
||||
// 系统操作
|
||||
checkApp: (appName: string) => Promise<{ installed: boolean; path?: string; isShortcut?: boolean }>
|
||||
runApp: (params: { appName?: string; path?: string; args?: string[]; url?: string }) =>
|
||||
Promise<{ success: boolean; message?: string; error?: string; pid?: number }>
|
||||
openPath: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||
runCommand: (params: { command: string; cwd?: string; timeout?: number }) =>
|
||||
Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }>
|
||||
listApps: () => Promise<Array<{ name: string; path: string }>>
|
||||
getSystemInfo: () => Promise<{
|
||||
platform: string
|
||||
arch: string
|
||||
hostname: string
|
||||
username: string
|
||||
homedir: string
|
||||
tmpdir: string
|
||||
cpus: number
|
||||
memory: { total: number; free: number }
|
||||
}>
|
||||
// 键盘鼠标模拟
|
||||
typeText: (params: { text: string; delay?: number }) =>
|
||||
Promise<{ success: boolean; typed?: number; error?: string }>
|
||||
pressKey: (params: { key: string }) =>
|
||||
Promise<{ success: boolean; key?: string; error?: string }>
|
||||
mouseClick: (params: { x: number; y: number; button?: 'left' | 'right' | 'middle'; clicks?: number }) =>
|
||||
Promise<{ success: boolean; x?: number; y?: number; error?: string }>
|
||||
mouseMove: (params: { x: number; y: number; smooth?: boolean }) =>
|
||||
Promise<{ success: boolean; x?: number; y?: number; error?: string }>
|
||||
mouseDrag: (params: { fromX: number; fromY: number; toX: number; toY: number }) =>
|
||||
Promise<{ success: boolean; error?: string }>
|
||||
focusWindow: (params: { title?: string; process?: string }) =>
|
||||
Promise<{ success: boolean; message?: string; error?: string }>
|
||||
getMousePos: () => Promise<{ success: boolean; x?: number; y?: number; error?: string }>
|
||||
// 剪贴板
|
||||
readClipboard: () => Promise<{ success: boolean; type?: string; content?: string; error?: string }>
|
||||
writeClipboard: (params: { text?: string; html?: string; image?: string }) =>
|
||||
Promise<{ success: boolean; type?: string; error?: string }>
|
||||
// 系统通知
|
||||
sendNotification: (params: { title: string; body: string; icon?: string; silent?: boolean }) =>
|
||||
Promise<{ success: boolean; error?: string }>
|
||||
// 文件系统
|
||||
fsReadFile: (params: { path: string; encoding?: string }) =>
|
||||
Promise<{ success: boolean; content?: string; error?: string }>
|
||||
fsWriteFile: (params: { path: string; content: string; encoding?: string }) =>
|
||||
Promise<{ success: boolean; path?: string; error?: string }>
|
||||
fsListDirectory: (params: { path: string; recursive?: boolean }) =>
|
||||
Promise<{ success: boolean; items?: Array<object>; error?: string }>
|
||||
fsCreateDirectory: (params: { path: string }) =>
|
||||
Promise<{ success: boolean; error?: string }>
|
||||
fsDelete: (params: { path: string; recursive?: boolean }) =>
|
||||
Promise<{ success: boolean; error?: string }>
|
||||
fsMove: (params: { from: string; to: string }) =>
|
||||
Promise<{ success: boolean; error?: string }>
|
||||
fsSearch: (params: { path: string; pattern: string; maxResults?: number }) =>
|
||||
Promise<{ success: boolean; files?: string[]; error?: string }>
|
||||
fsGetInfo: (params: { path: string }) =>
|
||||
Promise<{ success: boolean; info?: object; error?: string }>
|
||||
// 进程管理
|
||||
listProcesses: () => Promise<{ success: boolean; processes?: Array<object>; error?: string }>
|
||||
killProcess: (params: { pid?: number; name?: string }) =>
|
||||
Promise<{ success: boolean; error?: string }>
|
||||
getSystemUsage: () => Promise<{ success: boolean; cpu?: object; memory?: object; error?: string }>
|
||||
// 屏幕截图
|
||||
captureScreen: () => Promise<{ success: boolean; image?: string; size?: object; error?: string }>
|
||||
listWindows: () => Promise<{ success: boolean; windows?: Array<object>; error?: string }>
|
||||
captureWindow: (params: { name: string }) =>
|
||||
Promise<{ success: boolean; image?: string; error?: string }>
|
||||
// 定时任务
|
||||
createReminder: (params: { title: string; message: string; delay: number }) =>
|
||||
Promise<{ success: boolean; id?: string; triggerTime?: string; error?: string }>
|
||||
createScheduledTask: (params: { title: string; message: string; time: string; repeat?: string }) =>
|
||||
Promise<{ success: boolean; id?: string; triggerTime?: string; error?: string }>
|
||||
listScheduledTasks: () => Promise<{ success: boolean; tasks?: Array<object>; error?: string }>
|
||||
cancelScheduledTask: (params: { id: string }) =>
|
||||
Promise<{ success: boolean; error?: string }>
|
||||
onTaskTriggered: (callback: (task: object) => void) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,72 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MessageCircle, X, History, Trash2, Plus, ChevronDown } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { MessageCircle, X, History, Trash2, Plus, ChevronDown, Bell, AlertCircle, Lightbulb, CheckCircle2, LogIn, Coins, RotateCcw, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import Browser from './components/Browser/Browser'
|
||||
import AIChat from './components/AIChat/AIChat'
|
||||
import SettingsModal from './components/Settings/SettingsModal'
|
||||
import { useStore } from './store'
|
||||
import { useStore, HeartbeatNotification } from './store'
|
||||
import { startHeartbeat, stopHeartbeat } from './services/heartbeat'
|
||||
|
||||
// 格式化 token 数量显示
|
||||
function formatTokenCount(count: number): string {
|
||||
if (count >= 1000000) {
|
||||
return (count / 1000000).toFixed(1) + 'M'
|
||||
} else if (count >= 1000) {
|
||||
return (count / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
// 动画数字 hook - 实现数字渐变效果
|
||||
function useAnimatedNumber(targetValue: number, duration: number = 500): number {
|
||||
const [displayValue, setDisplayValue] = useState(targetValue)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const startValueRef = useRef(targetValue)
|
||||
const startTimeRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (targetValue === displayValue) return
|
||||
|
||||
// 取消之前的动画
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
|
||||
startValueRef.current = displayValue
|
||||
startTimeRef.current = null
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = timestamp
|
||||
}
|
||||
|
||||
const elapsed = timestamp - startTimeRef.current
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// 使用 easeOutQuad 缓动函数
|
||||
const easeProgress = 1 - (1 - progress) * (1 - progress)
|
||||
|
||||
const currentValue = Math.round(
|
||||
startValueRef.current + (targetValue - startValueRef.current) * easeProgress
|
||||
)
|
||||
|
||||
setDisplayValue(currentValue)
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [targetValue, duration])
|
||||
|
||||
return displayValue
|
||||
}
|
||||
|
||||
// 从模型名称获取简短的 AI 助手名称
|
||||
function getShortModelName(model: string): string {
|
||||
@@ -54,20 +117,54 @@ function getAIDisplayInfo(config: any): { name: string; isDual: boolean; models:
|
||||
}
|
||||
}
|
||||
|
||||
// 通知图标组件
|
||||
function NotificationIcon({ type }: { type: HeartbeatNotification['type'] }) {
|
||||
switch (type) {
|
||||
case 'login_detected': return <LogIn size={12} />
|
||||
case 'error_recovery': return <AlertCircle size={12} />
|
||||
case 'skill_suggestion': return <Lightbulb size={12} />
|
||||
case 'task_complete': return <CheckCircle2 size={12} />
|
||||
default: return <Bell size={12} />
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showAI, setShowAI] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
const [showTokenDetails, setShowTokenDetails] = useState(false)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const {
|
||||
loadConfig, loadSavedConfigs, loadBrowserSettings, loadChatSessions,
|
||||
aiConfig, chatSessions, clearMessages, newChatSession,
|
||||
switchChatSession, deleteChatSession
|
||||
switchChatSession, deleteChatSession,
|
||||
heartbeatNotifications, heartbeatEnabled, handleHeartbeatAction, removeHeartbeatNotification,
|
||||
tokenUsage, resetTokenUsage, updateTokenRateDirection
|
||||
} = useStore()
|
||||
|
||||
// 定时更新 token 速率方向
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
updateTokenRateDirection()
|
||||
}, 500) // 每 500ms 更新一次
|
||||
return () => clearInterval(interval)
|
||||
}, [updateTokenRateDirection])
|
||||
|
||||
const aiInfo = getAIDisplayInfo(aiConfig)
|
||||
const [showModelDetails, setShowModelDetails] = useState(false)
|
||||
const { currentModelType, isAILoading } = useStore()
|
||||
|
||||
// 使用动画数字显示 token
|
||||
const animatedTokenTotal = useAnimatedNumber(tokenUsage.total, 800)
|
||||
|
||||
// 启动心跳服务
|
||||
useEffect(() => {
|
||||
if (heartbeatEnabled) {
|
||||
startHeartbeat()
|
||||
}
|
||||
return () => stopHeartbeat()
|
||||
}, [heartbeatEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
// 并行加载所有配置
|
||||
@@ -189,6 +286,188 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Token 消耗显示 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowTokenDetails(!showTokenDetails)}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-white/20 rounded text-xs"
|
||||
title={`Token 消耗: ${tokenUsage.total}${tokenUsage.currentRate > 0 ? ` (${Math.round(tokenUsage.currentRate)} tokens/s)` : ''}`}
|
||||
>
|
||||
<Coins size={14} />
|
||||
<span className={`transition-colors duration-300 ${tokenUsage.rateDirection === 'up' ? 'text-green-300' : tokenUsage.rateDirection === 'down' ? 'text-yellow-300' : ''}`}>
|
||||
{formatTokenCount(animatedTokenTotal)}
|
||||
</span>
|
||||
{tokenUsage.rateDirection === 'up' && (
|
||||
<TrendingUp size={12} className="text-green-300 animate-pulse" />
|
||||
)}
|
||||
{tokenUsage.rateDirection === 'down' && (
|
||||
<TrendingDown size={12} className="text-yellow-300" />
|
||||
)}
|
||||
</button>
|
||||
{/* Token 详情下拉列表 */}
|
||||
{showTokenDetails && (
|
||||
<div className="absolute top-full right-0 mt-1 w-64 bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-50">
|
||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 font-medium">Token 消耗统计</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
resetTokenUsage()
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-500 hover:bg-gray-100 rounded"
|
||||
title="重置统计"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-gray-100">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-700 font-medium">总计</span>
|
||||
<span className="text-sm text-blue-600 font-bold">{formatTokenCount(tokenUsage.total)}</span>
|
||||
</div>
|
||||
{/* 按类型分类 */}
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
<div className="text-center p-1 bg-blue-50 rounded">
|
||||
<div className="text-gray-400">工具</div>
|
||||
<div className="text-blue-600 font-medium">{formatTokenCount(tokenUsage.byType?.tool || 0)}</div>
|
||||
</div>
|
||||
<div className="text-center p-1 bg-purple-50 rounded">
|
||||
<div className="text-gray-400">视觉</div>
|
||||
<div className="text-purple-600 font-medium">{formatTokenCount(tokenUsage.byType?.vision || 0)}</div>
|
||||
</div>
|
||||
<div className="text-center p-1 bg-gray-50 rounded">
|
||||
<div className="text-gray-400">对话</div>
|
||||
<div className="text-gray-600 font-medium">{formatTokenCount(tokenUsage.byType?.chat || 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{Object.keys(tokenUsage.models).length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-gray-400 text-xs">暂无消耗记录</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{Object.entries(tokenUsage.models).map(([modelName, usage]) => (
|
||||
<div key={modelName} className="px-3 py-2 border-b border-gray-50 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
usage.type === 'vision' ? 'bg-purple-500' :
|
||||
usage.type === 'tool' ? 'bg-blue-500' : 'bg-gray-400'
|
||||
}`}></span>
|
||||
<span className="text-xs text-gray-800 font-medium truncate flex-1" title={modelName}>
|
||||
{getShortModelName(modelName)}
|
||||
</span>
|
||||
<span className={`text-xs px-1 rounded ${
|
||||
usage.type === 'vision' ? 'bg-purple-100 text-purple-600' :
|
||||
usage.type === 'tool' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{usage.type === 'vision' ? '视觉' : usage.type === 'tool' ? '工具' : '对话'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-3 gap-1 text-xs">
|
||||
<div className="text-gray-500">
|
||||
<span className="text-gray-400">输入:</span> {formatTokenCount(usage.promptTokens)}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
<span className="text-gray-400">输出:</span> {formatTokenCount(usage.completionTokens)}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
<span className="text-gray-400">次数:</span> {usage.requestCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 通知按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="p-1 hover:bg-white/20 rounded relative"
|
||||
title="通知"
|
||||
>
|
||||
<Bell size={16} />
|
||||
{heartbeatNotifications.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{heartbeatNotifications.length > 9 ? '9+' : heartbeatNotifications.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* 通知下拉列表 */}
|
||||
{showNotifications && (
|
||||
<div className="absolute top-full right-0 mt-1 w-72 bg-white rounded-lg shadow-xl border border-gray-200 py-1 z-50 max-h-80 overflow-auto">
|
||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 font-medium">通知</span>
|
||||
{heartbeatNotifications.length > 0 && (
|
||||
<button
|
||||
onClick={() => useStore.getState().clearHeartbeatNotifications()}
|
||||
className="text-xs text-gray-400 hover:text-red-500"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{heartbeatNotifications.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-gray-400 text-xs">暂无通知</div>
|
||||
) : (
|
||||
heartbeatNotifications.slice(0, 10).map(notification => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`px-3 py-2 border-b border-gray-50 hover:bg-gray-50 ${
|
||||
notification.priority === 'high' ? 'bg-red-50/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`mt-0.5 ${
|
||||
notification.type === 'error_recovery' ? 'text-red-500' :
|
||||
notification.type === 'task_complete' ? 'text-green-500' :
|
||||
notification.type === 'login_detected' ? 'text-amber-500' :
|
||||
notification.type === 'skill_suggestion' ? 'text-purple-500' :
|
||||
'text-blue-500'
|
||||
}`}>
|
||||
<NotificationIcon type={notification.type} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-800 font-medium">{notification.title}</div>
|
||||
<div className="text-xs text-gray-500 line-clamp-2">{notification.message}</div>
|
||||
{notification.actions && notification.actions.length > 0 && (
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
{notification.actions.slice(0, 2).map(action => (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => {
|
||||
handleHeartbeatAction(notification.id, action.id)
|
||||
setShowNotifications(false)
|
||||
}}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
action.primary
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{new Date(notification.createdAt).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeHeartbeatNotification(notification.id)}
|
||||
className="text-gray-300 hover:text-gray-500 p-0.5"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 清空对话按钮 */}
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
695
cfspider-browser/src/services/heartbeat.ts
Normal file
695
cfspider-browser/src/services/heartbeat.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* CFSpider 心跳服务
|
||||
* 实现后台定时检查和主动通知机制
|
||||
*/
|
||||
|
||||
import { useStore } from '../store'
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
export type NotificationType = 'page_change' | 'reminder' | 'skill_suggestion' | 'task_complete' | 'error_recovery' | 'login_detected'
|
||||
|
||||
export type NotificationPriority = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface HeartbeatNotification {
|
||||
id: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
message: string
|
||||
priority: NotificationPriority
|
||||
createdAt: number
|
||||
actions?: {
|
||||
id: string
|
||||
label: string
|
||||
primary?: boolean
|
||||
}[]
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface HeartbeatTask {
|
||||
id: string
|
||||
type: 'page_monitor' | 'reminder' | 'skill_check' | 'task_check'
|
||||
interval: number // 检查间隔(毫秒)
|
||||
lastCheck: number
|
||||
enabled: boolean
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PageState {
|
||||
url: string
|
||||
title: string
|
||||
domain: string
|
||||
hasLoginForm: boolean
|
||||
hasModal: boolean
|
||||
hasVideo: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// ========== 状态管理 ==========
|
||||
|
||||
let heartbeatEnabled = false
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null
|
||||
let lastPageState: PageState | null = null
|
||||
let notifications: HeartbeatNotification[] = []
|
||||
let tasks: HeartbeatTask[] = []
|
||||
let notificationCallback: ((notification: HeartbeatNotification) => void) | null = null
|
||||
|
||||
// 默认任务配置
|
||||
const DEFAULT_TASKS: HeartbeatTask[] = [
|
||||
{
|
||||
id: 'page_monitor',
|
||||
type: 'page_monitor',
|
||||
interval: 30000, // 30秒
|
||||
lastCheck: 0,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'skill_check',
|
||||
type: 'skill_check',
|
||||
interval: 3600000, // 1小时
|
||||
lastCheck: 0,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'task_check',
|
||||
type: 'task_check',
|
||||
interval: 10000, // 10秒
|
||||
lastCheck: 0,
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
|
||||
// ========== 核心函数 ==========
|
||||
|
||||
/**
|
||||
* 启动心跳服务
|
||||
*/
|
||||
export function startHeartbeat(onNotification?: (notification: HeartbeatNotification) => void): void {
|
||||
if (heartbeatEnabled) return
|
||||
|
||||
heartbeatEnabled = true
|
||||
tasks = [...DEFAULT_TASKS]
|
||||
notificationCallback = onNotification || null
|
||||
|
||||
console.log('[Heartbeat] 心跳服务启动')
|
||||
|
||||
// 主心跳循环(每5秒检查一次)
|
||||
heartbeatInterval = setInterval(async () => {
|
||||
await heartbeatTick()
|
||||
}, 5000)
|
||||
|
||||
// 立即执行一次
|
||||
heartbeatTick()
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳服务
|
||||
*/
|
||||
export function stopHeartbeat(): void {
|
||||
if (!heartbeatEnabled) return
|
||||
|
||||
heartbeatEnabled = false
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
|
||||
console.log('[Heartbeat] 心跳服务停止')
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳执行周期
|
||||
*/
|
||||
async function heartbeatTick(): Promise<void> {
|
||||
if (!heartbeatEnabled) return
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.enabled) continue
|
||||
if (now - task.lastCheck < task.interval) continue
|
||||
|
||||
task.lastCheck = now
|
||||
|
||||
try {
|
||||
switch (task.type) {
|
||||
case 'page_monitor':
|
||||
await checkPageChanges()
|
||||
break
|
||||
case 'skill_check':
|
||||
await checkSkillSuggestions()
|
||||
break
|
||||
case 'task_check':
|
||||
await checkPendingTasks()
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Heartbeat] 任务执行失败:', task.type, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试自动关闭弹窗 - 使用更全面的选择器列表
|
||||
*/
|
||||
async function tryCloseModal(webview: any): Promise<{ closed: boolean; method?: string }> {
|
||||
try {
|
||||
// 第一轮:点击关闭按钮
|
||||
const clickResult = await webview.executeJavaScript(`
|
||||
(function() {
|
||||
// 按优先级排列的关闭按钮选择器(与 close_popup 工具保持一致)
|
||||
const closeSelectors = [
|
||||
// 通用关闭按钮
|
||||
'.close', '.close-btn', '.btn-close', '.icon-close', '.close-icon',
|
||||
'[class*="close"]', '[aria-label="关闭"]', '[aria-label="Close"]',
|
||||
'[title="关闭"]', '[title="Close"]', '.modal-close', '.dialog-close',
|
||||
|
||||
// 淘宝专用
|
||||
'.fm-btn-close', '.login-box .close', '.J_CloseLogin', '.sufei-dialog-close',
|
||||
'.baxia-dialog-close', '.next-dialog-close', '.next-icon-close',
|
||||
|
||||
// 京东专用
|
||||
'.jd-close', '.JDJRV-bigimg .close',
|
||||
|
||||
// Bilibili专用
|
||||
'.bili-mini-close', '.bili-popup-close', '.close-container',
|
||||
'.bili-modal__close', '.bpx-player-ending-panel-close',
|
||||
|
||||
// 腾讯视频专用
|
||||
'.popup_close', '.dialog_close', '.mod_popup .close', '.btn_close',
|
||||
'.player_close', '.txp_popup_close',
|
||||
|
||||
// GitHub专用
|
||||
'.js-cookie-consent-accept', '.Box-overlay-close', '.flash-close',
|
||||
|
||||
// 微博/微信等
|
||||
'.woo-dialog-close', '.weui-dialog__btn', '.W_close',
|
||||
|
||||
// 常见广告关闭
|
||||
'.ad-close', '.ad-skip', '.skip-ad', '[class*="ad"] .close',
|
||||
'.advertisement-close', '.ads-close'
|
||||
];
|
||||
|
||||
for (const sel of closeSelectors) {
|
||||
try {
|
||||
const elements = document.querySelectorAll(sel);
|
||||
for (const el of elements) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
// 确保元素可见且可点击
|
||||
if (style.display !== 'none' && style.visibility !== 'hidden' &&
|
||||
parseFloat(style.opacity) > 0 && rect.width > 0 && rect.height > 0) {
|
||||
el.click();
|
||||
return { closed: true, method: '点击关闭按钮: ' + sel };
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return { closed: false };
|
||||
})()
|
||||
`)
|
||||
|
||||
if (clickResult.closed) {
|
||||
return clickResult
|
||||
}
|
||||
|
||||
// 第二轮:按 ESC 键
|
||||
await webview.executeJavaScript(`
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
keyCode: 27,
|
||||
code: 'Escape',
|
||||
bubbles: true
|
||||
}));
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
keyCode: 27,
|
||||
code: 'Escape',
|
||||
bubbles: true
|
||||
}));
|
||||
`)
|
||||
|
||||
// 等待一下检查是否关闭
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
const stillHasPopup = await webview.executeJavaScript(`
|
||||
(function() {
|
||||
const modals = document.querySelectorAll('.modal, .popup, .dialog, [role="dialog"], .overlay, .mask');
|
||||
for (const m of modals) {
|
||||
const style = window.getComputedStyle(m);
|
||||
const rect = m.getBoundingClientRect();
|
||||
if (style.display !== 'none' && style.visibility !== 'hidden' &&
|
||||
rect.width > 100 && rect.height > 100) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`)
|
||||
|
||||
if (!stillHasPopup) {
|
||||
return { closed: true, method: '按 ESC 键' }
|
||||
}
|
||||
|
||||
// 第三轮:点击遮罩层外部
|
||||
await webview.executeJavaScript(`
|
||||
// 点击页面四角尝试关闭
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
document.elementFromPoint(10, 10)?.dispatchEvent(clickEvent);
|
||||
`)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
const finalCheck = await webview.executeJavaScript(`
|
||||
(function() {
|
||||
const modals = document.querySelectorAll('.modal, .popup, .dialog, [role="dialog"]');
|
||||
for (const m of modals) {
|
||||
const style = window.getComputedStyle(m);
|
||||
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`)
|
||||
|
||||
if (!finalCheck) {
|
||||
return { closed: true, method: '点击外部区域' }
|
||||
}
|
||||
|
||||
return { closed: false }
|
||||
} catch (e) {
|
||||
console.error('[Heartbeat] 关闭弹窗失败:', e)
|
||||
return { closed: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查页面变化
|
||||
*/
|
||||
async function checkPageChanges(): Promise<void> {
|
||||
const webview = document.querySelector('webview') as any
|
||||
if (!webview) return
|
||||
|
||||
try {
|
||||
const pageInfo = await webview.executeJavaScript(`
|
||||
(function() {
|
||||
// 登录表单选择器(增强版)
|
||||
const loginSelectors = [
|
||||
'input[type="password"]',
|
||||
'input[name*="pass"]',
|
||||
'input[name*="pwd"]',
|
||||
'input[placeholder*="密码"]',
|
||||
'input[placeholder*="password"]',
|
||||
'form[action*="login"]',
|
||||
'form[action*="signin"]',
|
||||
'.login-form',
|
||||
'#login-form',
|
||||
'#J_LoginBox',
|
||||
'.login-box',
|
||||
'.login-panel',
|
||||
'.login-dialog',
|
||||
'[class*="login-modal"]',
|
||||
'[class*="login-popup"]'
|
||||
];
|
||||
|
||||
// 模态框/弹窗选择器(增强版,包括淘宝、京东等)
|
||||
const modalSelectors = [
|
||||
'.modal:not([style*="display: none"])',
|
||||
'.dialog:not([style*="display: none"])',
|
||||
'[role="dialog"]:not([aria-hidden="true"])',
|
||||
'.popup:not(.hidden)',
|
||||
'.overlay:not(.hidden)',
|
||||
// 淘宝登录框
|
||||
'.login-box',
|
||||
'.J_LoginBox',
|
||||
'#J_LoginBox',
|
||||
'.login-mod',
|
||||
'.login-panel',
|
||||
'.fm-login',
|
||||
// 京东登录框
|
||||
'.modal-wrap',
|
||||
'.login-wrap',
|
||||
'#loginModal',
|
||||
// 通用遮罩层
|
||||
'[class*="mask"]:not([style*="display: none"])',
|
||||
'[class*="overlay"]:not([style*="display: none"])',
|
||||
'.baxia-dialog',
|
||||
'.sufei-dialog'
|
||||
];
|
||||
|
||||
// 检测是否有登录表单
|
||||
const hasLoginForm = loginSelectors.some(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return false;
|
||||
// 检查元素是否可见
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
||||
});
|
||||
|
||||
// 检测是否有模态框遮挡
|
||||
let hasModal = false;
|
||||
let modalInfo = '';
|
||||
for (const sel of modalSelectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
// 检查元素是否可见且有一定大小
|
||||
if (style.display !== 'none' && style.visibility !== 'hidden' &&
|
||||
rect.width > 100 && rect.height > 100) {
|
||||
hasModal = true;
|
||||
modalInfo = el.className || el.id || sel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否被登录框遮挡(检查固定定位的大元素)
|
||||
const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]');
|
||||
for (const el of fixedElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
if (rect.width > 200 && rect.height > 200 &&
|
||||
style.display !== 'none' && style.visibility !== 'hidden' &&
|
||||
parseFloat(style.zIndex) > 100) {
|
||||
// 检查是否包含登录相关内容
|
||||
const text = el.textContent || '';
|
||||
if (text.includes('登录') || text.includes('密码') || text.includes('账号') ||
|
||||
text.includes('Login') || text.includes('Sign in')) {
|
||||
hasModal = true;
|
||||
hasLoginForm = true;
|
||||
modalInfo = '登录弹窗';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasVideo = !!document.querySelector('video, iframe[src*="youtube"], iframe[src*="bilibili"]');
|
||||
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
domain: window.location.hostname,
|
||||
hasLoginForm: hasLoginForm,
|
||||
hasModal: hasModal,
|
||||
hasVideo: hasVideo,
|
||||
modalInfo: modalInfo
|
||||
};
|
||||
})()
|
||||
`)
|
||||
|
||||
const currentState: PageState = {
|
||||
...pageInfo,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// 检测变化或首次检测到登录框
|
||||
const isFirstCheck = !lastPageState
|
||||
const loginFormAppeared = isFirstCheck ? currentState.hasLoginForm :
|
||||
(!lastPageState.hasLoginForm && currentState.hasLoginForm)
|
||||
|
||||
// 检测登录表单出现
|
||||
if (loginFormAppeared && currentState.hasLoginForm) {
|
||||
addNotification({
|
||||
id: `login-${Date.now()}`,
|
||||
type: 'login_detected',
|
||||
title: '检测到登录弹窗',
|
||||
message: `页面被登录框遮挡,需要登录后才能继续操作。${pageInfo.modalInfo ? '\n(' + pageInfo.modalInfo + ')' : ''}`,
|
||||
priority: 'high',
|
||||
createdAt: Date.now(),
|
||||
actions: [
|
||||
{ id: 'auto_login', label: '自动登录', primary: true },
|
||||
{ id: 'manual_login', label: '手动登录' },
|
||||
{ id: 'dismiss', label: '忽略' }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 检测模态框出现(非登录框,可能是广告)- 自动尝试关闭
|
||||
const modalAppeared = isFirstCheck ? (currentState.hasModal && !currentState.hasLoginForm) :
|
||||
(!lastPageState?.hasModal && currentState.hasModal && !currentState.hasLoginForm)
|
||||
if (modalAppeared) {
|
||||
console.log('[Heartbeat] 检测到弹窗,自动尝试关闭...')
|
||||
// 自动尝试关闭弹窗
|
||||
const closeResult = await tryCloseModal(webview)
|
||||
if (closeResult.closed) {
|
||||
addNotification({
|
||||
id: `modal-closed-${Date.now()}`,
|
||||
type: 'page_change',
|
||||
title: '已自动关闭弹窗',
|
||||
message: closeResult.method || '弹窗已关闭',
|
||||
priority: 'low',
|
||||
createdAt: Date.now()
|
||||
})
|
||||
} else {
|
||||
// 关闭失败,通知用户
|
||||
addNotification({
|
||||
id: `modal-${Date.now()}`,
|
||||
type: 'page_change',
|
||||
title: '检测到弹窗',
|
||||
message: '自动关闭失败,需要手动处理',
|
||||
priority: 'medium',
|
||||
createdAt: Date.now(),
|
||||
actions: [
|
||||
{ id: 'close_modal', label: '重试关闭', primary: true },
|
||||
{ id: 'dismiss', label: '忽略' }
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检测页面跳转
|
||||
if (lastPageState && lastPageState.url !== currentState.url) {
|
||||
console.log('[Heartbeat] 页面跳转:', lastPageState.url, '->', currentState.url)
|
||||
|
||||
// 如果跳转到新域名,可能需要提示
|
||||
if (lastPageState.domain !== currentState.domain) {
|
||||
addNotification({
|
||||
id: `navigate-${Date.now()}`,
|
||||
type: 'page_change',
|
||||
title: '页面已跳转',
|
||||
message: `已到达 ${currentState.domain}`,
|
||||
priority: 'low',
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
lastPageState = currentState
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Heartbeat] 页面检查失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查技能建议
|
||||
*/
|
||||
async function checkSkillSuggestions(): Promise<void> {
|
||||
try {
|
||||
// 动态导入 skills 模块避免循环依赖
|
||||
const { getOperationStats, getAllSkills } = await import('./skills')
|
||||
|
||||
const stats = getOperationStats()
|
||||
const skills = await getAllSkills()
|
||||
|
||||
// 如果用户在同一域名上操作很多次,但没有相关技能
|
||||
if (stats.total >= 10 && stats.domains.length === 1) {
|
||||
const domain = stats.domains[0]
|
||||
const hasSkillForDomain = skills.some(s =>
|
||||
s.domains.includes(domain) && !s.isBuiltIn
|
||||
)
|
||||
|
||||
if (!hasSkillForDomain && stats.successRate >= 70) {
|
||||
addNotification({
|
||||
id: `skill-suggest-${Date.now()}`,
|
||||
type: 'skill_suggestion',
|
||||
title: '技能建议',
|
||||
message: `我注意到你经常在 ${domain} 上操作,要不要我创建一个快捷技能?`,
|
||||
priority: 'low',
|
||||
createdAt: Date.now(),
|
||||
actions: [
|
||||
{ id: 'create_skill', label: '创建技能', primary: true },
|
||||
{ id: 'dismiss', label: '以后再说' }
|
||||
],
|
||||
data: { domain }
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Heartbeat] 技能建议检查失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查待完成任务
|
||||
*/
|
||||
async function checkPendingTasks(): Promise<void> {
|
||||
// 这里可以扩展检查用户设置的定时任务、监控任务等
|
||||
// 目前作为占位符
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加通知
|
||||
*/
|
||||
export function addNotification(notification: HeartbeatNotification): void {
|
||||
// 检查是否有重复通知(同类型5分钟内)
|
||||
const duplicate = notifications.find(n =>
|
||||
n.type === notification.type &&
|
||||
Date.now() - n.createdAt < 300000
|
||||
)
|
||||
|
||||
if (duplicate) {
|
||||
console.log('[Heartbeat] 跳过重复通知:', notification.type)
|
||||
return
|
||||
}
|
||||
|
||||
notifications.push(notification)
|
||||
|
||||
// 只保留最近 20 条
|
||||
if (notifications.length > 20) {
|
||||
notifications = notifications.slice(-20)
|
||||
}
|
||||
|
||||
console.log('[Heartbeat] 新通知:', notification.type, notification.message)
|
||||
|
||||
// 触发回调
|
||||
if (notificationCallback) {
|
||||
notificationCallback(notification)
|
||||
}
|
||||
|
||||
// 同步到 store
|
||||
try {
|
||||
const store = useStore.getState()
|
||||
if (store.addHeartbeatNotification) {
|
||||
store.addHeartbeatNotification(notification)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Heartbeat] 同步通知到 store 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有通知
|
||||
*/
|
||||
export function getNotifications(): HeartbeatNotification[] {
|
||||
return [...notifications]
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除通知
|
||||
*/
|
||||
export function removeNotification(id: string): void {
|
||||
notifications = notifications.filter(n => n.id !== id)
|
||||
|
||||
try {
|
||||
const store = useStore.getState()
|
||||
if (store.removeHeartbeatNotification) {
|
||||
store.removeHeartbeatNotification(id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Heartbeat] 同步移除通知失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有通知
|
||||
*/
|
||||
export function clearNotifications(): void {
|
||||
notifications = []
|
||||
|
||||
try {
|
||||
const store = useStore.getState()
|
||||
if (store.clearHeartbeatNotifications) {
|
||||
store.clearHeartbeatNotifications()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Heartbeat] 同步清除通知失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发错误恢复通知
|
||||
*/
|
||||
export function notifyErrorRecovery(error: string, suggestion: string): void {
|
||||
addNotification({
|
||||
id: `error-${Date.now()}`,
|
||||
type: 'error_recovery',
|
||||
title: '操作失败',
|
||||
message: `${error}\n\n建议: ${suggestion}`,
|
||||
priority: 'high',
|
||||
createdAt: Date.now(),
|
||||
actions: [
|
||||
{ id: 'retry', label: '重试', primary: true },
|
||||
{ id: 'dismiss', label: '忽略' }
|
||||
],
|
||||
data: { error, suggestion }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发任务完成通知
|
||||
*/
|
||||
export function notifyTaskComplete(taskName: string, details?: string): void {
|
||||
addNotification({
|
||||
id: `complete-${Date.now()}`,
|
||||
type: 'task_complete',
|
||||
title: '任务完成',
|
||||
message: `${taskName}${details ? '\n' + details : ''}`,
|
||||
priority: 'medium',
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加自定义提醒任务
|
||||
*/
|
||||
export function addReminderTask(message: string, delayMs: number): string {
|
||||
const taskId = `reminder-${Date.now()}`
|
||||
|
||||
const task: HeartbeatTask = {
|
||||
id: taskId,
|
||||
type: 'reminder',
|
||||
interval: delayMs,
|
||||
lastCheck: Date.now(),
|
||||
enabled: true,
|
||||
data: { message }
|
||||
}
|
||||
|
||||
tasks.push(task)
|
||||
|
||||
// 一次性任务:触发后禁用
|
||||
setTimeout(() => {
|
||||
addNotification({
|
||||
id: taskId,
|
||||
type: 'reminder',
|
||||
title: '提醒',
|
||||
message,
|
||||
priority: 'medium',
|
||||
createdAt: Date.now()
|
||||
})
|
||||
|
||||
// 移除任务
|
||||
tasks = tasks.filter(t => t.id !== taskId)
|
||||
}, delayMs)
|
||||
|
||||
return taskId
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取心跳状态
|
||||
*/
|
||||
export function getHeartbeatStatus(): {
|
||||
enabled: boolean
|
||||
lastPageState: PageState | null
|
||||
notificationCount: number
|
||||
taskCount: number
|
||||
} {
|
||||
return {
|
||||
enabled: heartbeatEnabled,
|
||||
lastPageState,
|
||||
notificationCount: notifications.length,
|
||||
taskCount: tasks.length
|
||||
}
|
||||
}
|
||||
111
cfspider-browser/src/services/skills/bilibili.md
Normal file
111
cfspider-browser/src/services/skills/bilibili.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Bilibili 技能
|
||||
|
||||
## 基本信息
|
||||
- **ID**: `bilibili`
|
||||
- **名称**: B站操作
|
||||
- **描述**: 在Bilibili上搜索视频、观看视频、处理弹窗等
|
||||
- **触发词**: bilibili, b站, 哔哩哔哩, B站, 看视频bilibili
|
||||
- **适用域名**: bilibili.com, www.bilibili.com, search.bilibili.com
|
||||
|
||||
## 弹窗处理
|
||||
|
||||
### 识别弹窗
|
||||
B站常见弹窗:
|
||||
- 登录提示弹窗: `.login-tip`, `.bili-mini-mask`
|
||||
- APP下载弹窗: `.open-app-dialog`, `.download-app`
|
||||
- 活动弹窗: `.activity-popup`, `.bili-popup`
|
||||
|
||||
### 关闭弹窗
|
||||
- **动作**: click
|
||||
- **目标**: `.close-btn, .bili-mini-close, .close-icon, [class*="close"]`
|
||||
- **备选方案**:
|
||||
- `.bili-popup .close`
|
||||
- `.mask-close`
|
||||
- `button:contains("关闭")`
|
||||
- 按 ESC 键
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 搜索视频
|
||||
|
||||
#### 步骤 1: 定位搜索框
|
||||
- **动作**: scan
|
||||
- **说明**: 扫描页面找到搜索框
|
||||
|
||||
#### 步骤 2: 输入搜索关键词
|
||||
- **动作**: input
|
||||
- **目标**: `.nav-search-input, input.search-input, #nav-searchform input`
|
||||
- **值**: `{query}`
|
||||
- **备选方案**:
|
||||
- `input[type="search"]`
|
||||
- `.search-input-el`
|
||||
|
||||
#### 步骤 3: 点击搜索按钮
|
||||
- **动作**: click
|
||||
- **目标**: `.nav-search-btn, .search-btn, button[type="submit"]`
|
||||
- **备选方案**:
|
||||
- 按回车键
|
||||
- `.search-button`
|
||||
- **可选**: true
|
||||
|
||||
#### 步骤 4: 等待结果加载
|
||||
- **动作**: wait
|
||||
- **值**: 1500
|
||||
- **成功标志**: URL包含 `search.bilibili.com`
|
||||
|
||||
### 播放视频
|
||||
|
||||
#### 步骤 1: 找到视频
|
||||
- **动作**: scan
|
||||
- **说明**: 扫描视频列表
|
||||
|
||||
#### 步骤 2: 点击视频
|
||||
- **动作**: click
|
||||
- **目标**: `.video-card, .bili-video-card, .video-list-item a`
|
||||
- **备选方案**:
|
||||
- `.video-item a`
|
||||
- `.video-name`
|
||||
|
||||
#### 步骤 3: 等待播放器加载
|
||||
- **动作**: wait
|
||||
- **值**: 2000
|
||||
- **成功标志**: 存在 `.bilibili-player-video` 或 `video` 元素
|
||||
|
||||
### 视频操作
|
||||
|
||||
#### 暂停/播放
|
||||
- **动作**: click
|
||||
- **目标**: `.bilibili-player-video-btn-start, video`
|
||||
|
||||
#### 全屏
|
||||
- **动作**: click
|
||||
- **目标**: `.bilibili-player-video-btn-fullscreen, [data-text="全屏"]`
|
||||
|
||||
#### 音量控制
|
||||
- **动作**: click
|
||||
- **目标**: `.bilibili-player-video-btn-volume`
|
||||
|
||||
## 学习模式
|
||||
|
||||
### 已知模式
|
||||
- B站搜索框在顶部导航栏
|
||||
- 搜索结果页面 URL: `search.bilibili.com`
|
||||
- 视频页面 URL: `bilibili.com/video/BV...`
|
||||
- 播放器类名: `bilibili-player`
|
||||
|
||||
### 常见问题
|
||||
- **登录提示**: 某些功能需要登录(如弹幕、收藏)
|
||||
- **APP下载弹窗**: 移动端经常弹出下载提示
|
||||
- **视频加载慢**: 等待 2-3 秒确保播放器就绪
|
||||
|
||||
### 弹窗关闭选择器(优先级排序)
|
||||
1. `.bili-mini-close` - 小窗关闭
|
||||
2. `.close-btn` - 通用关闭
|
||||
3. `.bili-popup-close` - 弹窗关闭
|
||||
4. `.mask .close` - 遮罩层关闭
|
||||
5. `[aria-label="关闭"]` - 无障碍关闭
|
||||
|
||||
## 成功率
|
||||
- 搜索成功率: 95%
|
||||
- 视频播放成功率: 90%
|
||||
- 弹窗关闭成功率: 85%
|
||||
131
cfspider-browser/src/services/skills/github.md
Normal file
131
cfspider-browser/src/services/skills/github.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# GitHub 技能
|
||||
|
||||
## 基本信息
|
||||
- **ID**: `github`
|
||||
- **名称**: GitHub操作
|
||||
- **描述**: 在GitHub上搜索项目、浏览代码、查看仓库等
|
||||
- **触发词**: github, git, 代码, 仓库, 项目, repository, 开源
|
||||
- **适用域名**: github.com, www.github.com
|
||||
|
||||
## 弹窗处理
|
||||
|
||||
### 识别弹窗
|
||||
GitHub常见弹窗:
|
||||
- Cookie 同意弹窗: `.js-cookie-consent`, `[data-view-component="cookie"]`
|
||||
- 登录提示: `.flash`, `.js-flash-container`
|
||||
- 操作确认对话框: `.Box-overlay`, `[role="dialog"]`
|
||||
|
||||
### 关闭弹窗
|
||||
- **动作**: click
|
||||
- **目标**: `.js-cookie-consent-accept, button[data-action="click:analytics-event#accept"]`
|
||||
- **备选方案**:
|
||||
- `.close-button`
|
||||
- `[aria-label="Close"]`
|
||||
- `.Box-overlay-close`
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 搜索项目
|
||||
|
||||
#### 步骤 1: 定位搜索框
|
||||
- **动作**: scan
|
||||
- **说明**: 扫描页面找到搜索框
|
||||
|
||||
#### 步骤 2: 点击搜索框
|
||||
- **动作**: click
|
||||
- **目标**: `.header-search-button, .header-search-input, [data-target="qbsearch-input.inputButton"]`
|
||||
- **说明**: GitHub搜索框需要先点击激活
|
||||
|
||||
#### 步骤 3: 输入搜索关键词
|
||||
- **动作**: input
|
||||
- **目标**: `#query-builder-test, input[name="query"], .search-input`
|
||||
- **值**: `{query}`
|
||||
- **备选方案**:
|
||||
- `input[type="search"]`
|
||||
- `.header-search-input input`
|
||||
|
||||
#### 步骤 4: 提交搜索
|
||||
- **动作**: click
|
||||
- **目标**: 按回车键
|
||||
- **说明**: GitHub搜索主要通过回车提交
|
||||
|
||||
#### 步骤 5: 等待结果加载
|
||||
- **动作**: wait
|
||||
- **值**: 2000
|
||||
- **成功标志**: URL包含 `github.com/search`
|
||||
|
||||
### 浏览仓库
|
||||
|
||||
#### 步骤 1: 点击仓库链接
|
||||
- **动作**: click
|
||||
- **目标**: `.search-title a, .repo-list-item a, .v-align-middle`
|
||||
- **备选方案**:
|
||||
- `.text-bold a`
|
||||
- `[data-hovercard-type="repository"]`
|
||||
|
||||
#### 步骤 2: 等待仓库页面加载
|
||||
- **动作**: wait
|
||||
- **值**: 1500
|
||||
- **成功标志**: 存在 `.repository-content` 或 `#readme`
|
||||
|
||||
### 查看代码文件
|
||||
|
||||
#### 步骤 1: 点击文件
|
||||
- **动作**: click
|
||||
- **目标**: `.js-navigation-open, .Link--primary, .react-directory-row`
|
||||
- **备选方案**:
|
||||
- `.content a`
|
||||
- `[role="rowheader"] a`
|
||||
|
||||
#### 步骤 2: 等待代码加载
|
||||
- **动作**: wait
|
||||
- **值**: 1000
|
||||
- **成功标志**: 存在 `.blob-code` 或 `.highlight`
|
||||
|
||||
### 查看 README
|
||||
|
||||
#### 步骤 1: 滚动到 README
|
||||
- **动作**: scroll
|
||||
- **目标**: `#readme, .markdown-body`
|
||||
|
||||
### Star 仓库
|
||||
|
||||
#### 步骤 1: 点击 Star 按钮
|
||||
- **动作**: click
|
||||
- **目标**: `.starring-container button, [data-ga-click*="Star"]`
|
||||
- **注意**: 需要登录
|
||||
|
||||
## 学习模式
|
||||
|
||||
### 已知模式
|
||||
- GitHub 首页: `github.com`
|
||||
- 搜索结果: `github.com/search?q=...`
|
||||
- 仓库页面: `github.com/{owner}/{repo}`
|
||||
- 代码页面: `github.com/{owner}/{repo}/blob/...`
|
||||
- README 通常在页面底部的 `#readme` 锚点
|
||||
|
||||
### 常见问题
|
||||
- **搜索框需要激活**: 需要先点击搜索按钮
|
||||
- **登录限制**: Star、Fork、Issue 等功能需要登录
|
||||
- **Rate Limit**: API 请求有频率限制
|
||||
- **大文件加载慢**: 大型仓库或文件可能需要较长加载时间
|
||||
|
||||
### URL 模式
|
||||
- 仓库首页: `github.com/{owner}/{repo}`
|
||||
- 代码浏览: `github.com/{owner}/{repo}/tree/{branch}`
|
||||
- 文件查看: `github.com/{owner}/{repo}/blob/{branch}/{path}`
|
||||
- Issues: `github.com/{owner}/{repo}/issues`
|
||||
- Pull Requests: `github.com/{owner}/{repo}/pulls`
|
||||
|
||||
### 弹窗关闭选择器(优先级排序)
|
||||
1. `.js-cookie-consent-accept` - Cookie 同意
|
||||
2. `[aria-label="Close"]` - 通用关闭
|
||||
3. `.Box-overlay-close` - 覆盖层关闭
|
||||
4. `.close-button` - 按钮关闭
|
||||
5. `.flash .close` - Flash 消息关闭
|
||||
|
||||
## 成功率
|
||||
- 搜索成功率: 95%
|
||||
- 仓库浏览成功率: 98%
|
||||
- 代码查看成功率: 95%
|
||||
- 弹窗关闭成功率: 90%
|
||||
97
cfspider-browser/src/services/skills/taobao.md
Normal file
97
cfspider-browser/src/services/skills/taobao.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 淘宝技能
|
||||
|
||||
## 基本信息
|
||||
- **ID**: `taobao`
|
||||
- **名称**: 淘宝操作
|
||||
- **描述**: 在淘宝网站上搜索商品、浏览、处理登录弹窗等
|
||||
- **触发词**: 淘宝, taobao, 淘宝搜索, 淘宝商品, 淘宝购物
|
||||
- **适用域名**: taobao.com, www.taobao.com, s.taobao.com, login.taobao.com
|
||||
|
||||
## 登录弹窗处理
|
||||
|
||||
### 识别登录弹窗
|
||||
淘宝的登录弹窗特征:
|
||||
- 选择器: `.login-box`, `#J_LoginBox`, `.fm-login`, `.login-panel`
|
||||
- 包含文本: "密码登录", "短信登录", "扫码登录更安全"
|
||||
- 固定定位遮罩层
|
||||
|
||||
### 关闭登录弹窗
|
||||
- **动作**: click
|
||||
- **目标**: `.login-box .close`, `.fm-btn-close`, `.icon-close`, `[class*="close"]`
|
||||
- **备选方案**:
|
||||
- 点击遮罩层外部区域
|
||||
- 按 ESC 键
|
||||
- `.login-overlay .close`
|
||||
|
||||
### 自动处理策略
|
||||
1. 检测到登录弹窗时,优先尝试关闭
|
||||
2. 如果无法关闭,提示用户选择登录方式
|
||||
3. 记住关闭按钮的选择器供下次使用
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 搜索商品
|
||||
|
||||
#### 步骤 1: 定位搜索框
|
||||
- **动作**: scan
|
||||
- **说明**: 扫描页面找到搜索框
|
||||
|
||||
#### 步骤 2: 输入搜索关键词
|
||||
- **动作**: input
|
||||
- **目标**: `#q, input[name="q"], .search-input input`
|
||||
- **值**: `{query}`
|
||||
- **备选方案**:
|
||||
- `.tb-searchbar input`
|
||||
- `input[type="search"]`
|
||||
- `.search-combobox-input`
|
||||
|
||||
#### 步骤 3: 点击搜索按钮
|
||||
- **动作**: click
|
||||
- **目标**: `.btn-search, button[type="submit"], .search-btn`
|
||||
- **备选方案**:
|
||||
- 按回车键
|
||||
- `.searchbar-button`
|
||||
- **可选**: true
|
||||
|
||||
#### 步骤 4: 等待结果加载
|
||||
- **动作**: wait
|
||||
- **值**: 2000
|
||||
- **成功标志**: URL包含 `s.taobao.com/search`
|
||||
|
||||
### 点击商品
|
||||
|
||||
#### 步骤 1: 找到商品链接
|
||||
- **动作**: scan
|
||||
- **说明**: 扫描搜索结果页面
|
||||
|
||||
#### 步骤 2: 点击商品
|
||||
- **动作**: click
|
||||
- **目标**: `.item .title a, .Card--doubleCard a, .Content--title`
|
||||
- **备选方案**:
|
||||
- `.m-itemlist .item a`
|
||||
- `[data-item] a`
|
||||
- `.list-item a`
|
||||
|
||||
## 学习模式
|
||||
|
||||
### 已知模式
|
||||
- 淘宝搜索框 ID 为 `q`
|
||||
- 搜索结果在 `s.taobao.com`
|
||||
- 商品详情在 `detail.tmall.com` 或 `item.taobao.com`
|
||||
- 经常会弹出登录框遮挡页面
|
||||
|
||||
### 常见问题
|
||||
- **登录弹窗遮挡**: 需要先关闭登录弹窗或登录后才能继续操作
|
||||
- **验证码**: 可能需要滑块验证
|
||||
- **页面加载慢**: 淘宝页面较重,等待时间需要 2-3 秒
|
||||
|
||||
### 登录弹窗关闭选择器(优先级排序)
|
||||
1. `.fm-btn-close` - 表单关闭按钮
|
||||
2. `.login-box .close` - 登录框关闭
|
||||
3. `.icon-close` - 图标关闭
|
||||
4. `.J_CloseLogin` - JS 关闭钩子
|
||||
5. `[aria-label="关闭"]` - 无障碍关闭
|
||||
|
||||
## 成功率
|
||||
- 初始成功率: 70%
|
||||
- 登录弹窗关闭成功率: 60%
|
||||
114
cfspider-browser/src/services/skills/tencent-video.md
Normal file
114
cfspider-browser/src/services/skills/tencent-video.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 腾讯视频技能
|
||||
|
||||
## 基本信息
|
||||
- **ID**: `tencent-video`
|
||||
- **名称**: 腾讯视频操作
|
||||
- **描述**: 在腾讯视频上搜索视频、观看视频、处理弹窗等
|
||||
- **触发词**: 腾讯视频, qq视频, 腾讯, tencent video, v.qq.com
|
||||
- **适用域名**: v.qq.com, film.qq.com, m.v.qq.com
|
||||
|
||||
## 弹窗处理
|
||||
|
||||
### 识别弹窗
|
||||
腾讯视频常见弹窗:
|
||||
- VIP 广告弹窗: `.mod_vip_popup`, `.vip-popup`
|
||||
- 登录弹窗: `.login_dialog`, `.ptlogin_iframe`
|
||||
- 活动弹窗: `.activity_popup`, `.mod_popup`
|
||||
- APP下载弹窗: `.app-download-tip`
|
||||
|
||||
### 关闭弹窗
|
||||
- **动作**: click
|
||||
- **目标**: `.popup_close, .btn_close, .close-btn, [class*="close"]`
|
||||
- **备选方案**:
|
||||
- `.mod_popup .close`
|
||||
- `.dialog_close`
|
||||
- `[title="关闭"]`
|
||||
- 按 ESC 键
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 搜索视频
|
||||
|
||||
#### 步骤 1: 定位搜索框
|
||||
- **动作**: scan
|
||||
- **说明**: 扫描页面找到搜索框
|
||||
|
||||
#### 步骤 2: 输入搜索关键词
|
||||
- **动作**: input
|
||||
- **目标**: `#searchInput, .search_input input, input[name="query"]`
|
||||
- **值**: `{query}`
|
||||
- **备选方案**:
|
||||
- `.search-input`
|
||||
- `input[type="search"]`
|
||||
|
||||
#### 步骤 3: 点击搜索按钮
|
||||
- **动作**: click
|
||||
- **目标**: `.search_btn, .btn_search, button[type="submit"]`
|
||||
- **备选方案**:
|
||||
- 按回车键
|
||||
- `.search-button`
|
||||
- **可选**: true
|
||||
|
||||
#### 步骤 4: 等待结果加载
|
||||
- **动作**: wait
|
||||
- **值**: 2000
|
||||
- **成功标志**: URL包含 `v.qq.com/x/search`
|
||||
|
||||
### 播放视频
|
||||
|
||||
#### 步骤 1: 找到视频
|
||||
- **动作**: scan
|
||||
- **说明**: 扫描视频列表
|
||||
|
||||
#### 步骤 2: 点击视频
|
||||
- **动作**: click
|
||||
- **目标**: `.result_item a, .list_item a, .figure a`
|
||||
- **备选方案**:
|
||||
- `.video_item a`
|
||||
- `.card_link`
|
||||
|
||||
#### 步骤 3: 等待播放器加载
|
||||
- **动作**: wait
|
||||
- **值**: 3000
|
||||
- **成功标志**: 存在 `.txp_video_container` 或 `video` 元素
|
||||
|
||||
### 视频操作
|
||||
|
||||
#### 暂停/播放
|
||||
- **动作**: click
|
||||
- **目标**: `.txp_btn_play, .txp_video, video`
|
||||
|
||||
#### 全屏
|
||||
- **动作**: click
|
||||
- **目标**: `.txp_btn_fullscreen, [title="全屏"]`
|
||||
|
||||
#### 跳过广告
|
||||
- **动作**: click
|
||||
- **目标**: `.txp_ad_skip_btn, .ad_skip, .skip-ad`
|
||||
|
||||
## 学习模式
|
||||
|
||||
### 已知模式
|
||||
- 腾讯视频首页: `v.qq.com`
|
||||
- 搜索结果: `v.qq.com/x/search`
|
||||
- 视频播放页: `v.qq.com/x/cover/` 或 `v.qq.com/x/page/`
|
||||
- 播放器类名前缀: `txp_`
|
||||
|
||||
### 常见问题
|
||||
- **VIP广告**: 非会员有 15-120 秒广告
|
||||
- **登录弹窗**: 某些功能需要登录
|
||||
- **地区限制**: 部分内容有地区限制
|
||||
- **视频加载慢**: 广告加载可能需要较长时间
|
||||
|
||||
### 弹窗关闭选择器(优先级排序)
|
||||
1. `.popup_close` - 弹窗关闭
|
||||
2. `.btn_close` - 按钮关闭
|
||||
3. `.mod_popup .close` - 模块弹窗关闭
|
||||
4. `.dialog_close` - 对话框关闭
|
||||
5. `[title="关闭"]` - 标题关闭
|
||||
|
||||
## 成功率
|
||||
- 搜索成功率: 90%
|
||||
- 视频播放成功率: 85%
|
||||
- 弹窗关闭成功率: 80%
|
||||
- 跳过广告成功率: 70%(需要VIP或等待)
|
||||
@@ -119,6 +119,24 @@ export interface ElementSelectionRequest {
|
||||
selector?: string // 选择的选择器
|
||||
}
|
||||
|
||||
// 心跳通知
|
||||
export type HeartbeatNotificationType = 'page_change' | 'reminder' | 'skill_suggestion' | 'task_complete' | 'error_recovery' | 'login_detected'
|
||||
|
||||
export interface HeartbeatNotification {
|
||||
id: string
|
||||
type: HeartbeatNotificationType
|
||||
title: string
|
||||
message: string
|
||||
priority: 'low' | 'medium' | 'high'
|
||||
createdAt: number
|
||||
actions?: {
|
||||
id: string
|
||||
label: string
|
||||
primary?: boolean
|
||||
}[]
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// 浏览器设置
|
||||
export interface BrowserSettings {
|
||||
searchEngine: string // 搜索引擎 ID
|
||||
@@ -188,6 +206,34 @@ interface AppState {
|
||||
// 元素选择请求
|
||||
elementSelectionRequest: ElementSelectionRequest | null
|
||||
|
||||
// 心跳通知
|
||||
heartbeatNotifications: HeartbeatNotification[]
|
||||
heartbeatEnabled: boolean
|
||||
|
||||
// Token 消耗统计
|
||||
tokenUsage: {
|
||||
total: number
|
||||
// 按类型分类
|
||||
byType: {
|
||||
chat: number // 普通对话
|
||||
tool: number // 工具调用
|
||||
vision: number // 视觉模型
|
||||
}
|
||||
models: {
|
||||
[modelName: string]: {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
requestCount: number
|
||||
type: 'chat' | 'tool' | 'vision'
|
||||
}
|
||||
}
|
||||
// 速率跟踪
|
||||
recentChanges: { timestamp: number; amount: number }[]
|
||||
currentRate: number // tokens per second
|
||||
rateDirection: 'up' | 'down' | 'stable'
|
||||
}
|
||||
|
||||
// 标签页 Actions
|
||||
addTab: (url?: string) => void
|
||||
closeTab: (id: string) => void
|
||||
@@ -267,6 +313,19 @@ interface AppState {
|
||||
// 元素选择请求
|
||||
setElementSelectionRequest: (request: ElementSelectionRequest | null) => void
|
||||
respondToElementSelection: (mode: 'auto' | 'manual', selector?: string) => void
|
||||
|
||||
// 心跳通知
|
||||
setHeartbeatEnabled: (enabled: boolean) => void
|
||||
addHeartbeatNotification: (notification: HeartbeatNotification) => void
|
||||
removeHeartbeatNotification: (id: string) => void
|
||||
clearHeartbeatNotifications: () => void
|
||||
handleHeartbeatAction: (notificationId: string, actionId: string) => void
|
||||
|
||||
// Token 消耗统计
|
||||
addTokenUsage: (modelName: string, promptTokens: number, completionTokens: number, type?: 'chat' | 'tool' | 'vision') => void
|
||||
updateTokenRateDirection: () => void
|
||||
resetTokenUsage: () => void
|
||||
getTokenUsage: () => { total: number; byType: { chat: number; tool: number; vision: number }; models: { [key: string]: { promptTokens: number; completionTokens: number; totalTokens: number; requestCount: number; type: 'chat' | 'tool' | 'vision' } }; recentChanges: { timestamp: number; amount: number }[]; currentRate: number; rateDirection: 'up' | 'down' | 'stable' }
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
@@ -316,6 +375,20 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
downloadedImages: [],
|
||||
elementSelectionRequest: null,
|
||||
heartbeatNotifications: [],
|
||||
heartbeatEnabled: true,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
byType: {
|
||||
chat: 0,
|
||||
tool: 0,
|
||||
vision: 0
|
||||
},
|
||||
models: {},
|
||||
recentChanges: [],
|
||||
currentRate: 0,
|
||||
rateDirection: 'stable' as const
|
||||
},
|
||||
|
||||
// 标签页 Actions
|
||||
addTab: (url) => {
|
||||
@@ -855,5 +928,219 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 心跳通知
|
||||
setHeartbeatEnabled: (enabled) => set({ heartbeatEnabled: enabled }),
|
||||
|
||||
addHeartbeatNotification: (notification) => set((state) => ({
|
||||
heartbeatNotifications: [notification, ...state.heartbeatNotifications].slice(0, 20)
|
||||
})),
|
||||
|
||||
removeHeartbeatNotification: (id) => set((state) => ({
|
||||
heartbeatNotifications: state.heartbeatNotifications.filter(n => n.id !== id)
|
||||
})),
|
||||
|
||||
clearHeartbeatNotifications: () => set({ heartbeatNotifications: [] }),
|
||||
|
||||
handleHeartbeatAction: (notificationId, actionId) => {
|
||||
const notification = get().heartbeatNotifications.find(n => n.id === notificationId)
|
||||
if (!notification) return
|
||||
|
||||
console.log('[Heartbeat] 处理通知动作:', notificationId, actionId, notification.type)
|
||||
|
||||
// 根据动作类型处理
|
||||
switch (actionId) {
|
||||
case 'dismiss':
|
||||
get().removeHeartbeatNotification(notificationId)
|
||||
break
|
||||
case 'close_modal':
|
||||
// 触发关闭弹窗操作
|
||||
const webview = document.querySelector('webview') as any
|
||||
if (webview) {
|
||||
webview.executeJavaScript(`
|
||||
(function() {
|
||||
const closeSelectors = [
|
||||
'.modal .close', '.modal-close', '.dialog-close',
|
||||
'[aria-label="Close"]', '.popup-close', '.overlay-close',
|
||||
'.modal button:contains("关闭")', '.modal button:contains("取消")'
|
||||
];
|
||||
for (const sel of closeSelectors) {
|
||||
const btn = document.querySelector(sel);
|
||||
if (btn) { btn.click(); break; }
|
||||
}
|
||||
})()
|
||||
`)
|
||||
}
|
||||
get().removeHeartbeatNotification(notificationId)
|
||||
break
|
||||
case 'retry':
|
||||
// 重试操作 - 可以通过消息触发
|
||||
get().addMessage({
|
||||
role: 'user',
|
||||
content: '请重试刚才失败的操作'
|
||||
})
|
||||
get().removeHeartbeatNotification(notificationId)
|
||||
break
|
||||
case 'auto_login':
|
||||
// 自动登录 - 触发登录选择流程
|
||||
get().addMessage({
|
||||
role: 'user',
|
||||
content: '自动登录'
|
||||
})
|
||||
get().removeHeartbeatNotification(notificationId)
|
||||
break
|
||||
case 'manual_login':
|
||||
// 手动登录 - 只是移除通知
|
||||
get().removeHeartbeatNotification(notificationId)
|
||||
break
|
||||
case 'create_skill':
|
||||
// 创建技能
|
||||
const domain = notification.data?.domain as string
|
||||
if (domain) {
|
||||
get().addMessage({
|
||||
role: 'user',
|
||||
content: `为 ${domain} 创建一个快捷技能`
|
||||
})
|
||||
}
|
||||
get().removeHeartbeatNotification(notificationId)
|
||||
break
|
||||
default:
|
||||
// 默认移除通知
|
||||
get().removeHeartbeatNotification(notificationId)
|
||||
}
|
||||
},
|
||||
|
||||
// Token 消耗统计
|
||||
addTokenUsage: (modelName, promptTokens, completionTokens, type = 'tool') => {
|
||||
set((state) => {
|
||||
const totalTokens = promptTokens + completionTokens
|
||||
const now = Date.now()
|
||||
|
||||
const currentModel = state.tokenUsage.models[modelName] || {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
requestCount: 0,
|
||||
type
|
||||
}
|
||||
|
||||
// 记录最近的变化(保留最近 5 秒内的记录)
|
||||
const recentChanges = [
|
||||
...state.tokenUsage.recentChanges.filter(c => now - c.timestamp < 5000),
|
||||
{ timestamp: now, amount: totalTokens }
|
||||
]
|
||||
|
||||
// 计算速率(tokens per second)
|
||||
const oldestChange = recentChanges[0]
|
||||
const timeSpan = (now - oldestChange.timestamp) / 1000 || 1
|
||||
const totalRecentTokens = recentChanges.reduce((sum, c) => sum + c.amount, 0)
|
||||
const currentRate = totalRecentTokens / timeSpan
|
||||
|
||||
// 判断速率方向
|
||||
const previousRate = state.tokenUsage.currentRate
|
||||
let rateDirection: 'up' | 'down' | 'stable' = 'stable'
|
||||
if (currentRate > previousRate * 1.2) {
|
||||
rateDirection = 'up'
|
||||
} else if (currentRate < previousRate * 0.8 && previousRate > 0) {
|
||||
rateDirection = 'down'
|
||||
} else if (currentRate > 10) {
|
||||
// 如果速率较高,保持向上
|
||||
rateDirection = 'up'
|
||||
}
|
||||
|
||||
// 更新按类型统计
|
||||
const byType = { ...state.tokenUsage.byType }
|
||||
byType[type] = (byType[type] || 0) + totalTokens
|
||||
|
||||
return {
|
||||
tokenUsage: {
|
||||
total: state.tokenUsage.total + totalTokens,
|
||||
byType,
|
||||
models: {
|
||||
...state.tokenUsage.models,
|
||||
[modelName]: {
|
||||
promptTokens: currentModel.promptTokens + promptTokens,
|
||||
completionTokens: currentModel.completionTokens + completionTokens,
|
||||
totalTokens: currentModel.totalTokens + totalTokens,
|
||||
requestCount: currentModel.requestCount + 1,
|
||||
type
|
||||
}
|
||||
},
|
||||
recentChanges,
|
||||
currentRate,
|
||||
rateDirection
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 更新速率方向(用于定时检查)
|
||||
updateTokenRateDirection: () => {
|
||||
set((state) => {
|
||||
const now = Date.now()
|
||||
// 清理 5 秒前的记录
|
||||
const recentChanges = state.tokenUsage.recentChanges.filter(c => now - c.timestamp < 5000)
|
||||
|
||||
if (recentChanges.length === 0) {
|
||||
return {
|
||||
tokenUsage: {
|
||||
...state.tokenUsage,
|
||||
recentChanges: [],
|
||||
currentRate: 0,
|
||||
rateDirection: 'stable' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重新计算速率
|
||||
const oldestChange = recentChanges[0]
|
||||
const timeSpan = (now - oldestChange.timestamp) / 1000 || 1
|
||||
const totalRecentTokens = recentChanges.reduce((sum, c) => sum + c.amount, 0)
|
||||
const currentRate = totalRecentTokens / timeSpan
|
||||
|
||||
// 判断方向
|
||||
const previousRate = state.tokenUsage.currentRate
|
||||
let rateDirection: 'up' | 'down' | 'stable' = 'stable'
|
||||
|
||||
if (recentChanges.length > 0 && now - recentChanges[recentChanges.length - 1].timestamp < 2000) {
|
||||
// 最近 2 秒内有变化
|
||||
if (currentRate > previousRate * 1.1) {
|
||||
rateDirection = 'up'
|
||||
} else if (currentRate > 50) {
|
||||
rateDirection = 'up'
|
||||
} else if (currentRate > 10) {
|
||||
rateDirection = currentRate < previousRate * 0.9 ? 'down' : 'up'
|
||||
} else {
|
||||
rateDirection = 'down'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tokenUsage: {
|
||||
...state.tokenUsage,
|
||||
recentChanges,
|
||||
currentRate,
|
||||
rateDirection
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
resetTokenUsage: () => set({
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
byType: {
|
||||
chat: 0,
|
||||
tool: 0,
|
||||
vision: 0
|
||||
},
|
||||
models: {},
|
||||
recentChanges: [],
|
||||
currentRate: 0,
|
||||
rateDirection: 'stable' as const
|
||||
}
|
||||
}),
|
||||
|
||||
getTokenUsage: () => get().tokenUsage
|
||||
}))
|
||||
|
||||
12
cfspider-browser/src/vite-env.d.ts
vendored
Normal file
12
cfspider-browser/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// 支持导入 .md 文件作为原始文本
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*.md' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
Reference in New Issue
Block a user