feat: add AI PC control - keyboard/mouse, clipboard, files, processes, screenshots, scheduler

This commit is contained in:
陈海富
2026-01-30 04:43:35 +08:00
parent d0b4810da5
commit 85881220eb
11 changed files with 5372 additions and 63 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}
}

View File

@@ -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

View 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
}
}

View 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%

View 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%

View 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%

View 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或等待

View File

@@ -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
View 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
}