feat(browser-hand): implement Browser Hand UI components
Add complete Browser Hand UI system for browser automation: Components: - BrowserHandCard: Main card with status display and screenshot preview - TaskTemplateModal: Template selection and parameter configuration - ScreenshotPreview: Screenshot display with fullscreen capability Templates: - Basic operations: navigate, screenshot, form fill, click, execute JS - Scraping: text, list, images, links, tables - Automation: login+action, multi-page, monitoring, pagination Features: - 15 built-in task templates across 3 categories - Real-time execution status with progress bar - Screenshot preview with zoom and fullscreen - Integration with HandsPanel for seamless UX - Zustand store for state management - Comprehensive test coverage (16 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
234
desktop/src/components/BrowserHand/BrowserHandCard.tsx
Normal file
234
desktop/src/components/BrowserHand/BrowserHandCard.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* BrowserHandCard Component
|
||||||
|
*
|
||||||
|
* Main card for Browser Hand with real-time status and screenshot preview.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
Camera,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Play,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { useBrowserHandStore } from '../../store/browserHandStore';
|
||||||
|
import { ScreenshotPreview } from './ScreenshotPreview';
|
||||||
|
import { TaskTemplateModal } from './TaskTemplateModal';
|
||||||
|
import type { Hand } from '../../types/hands';
|
||||||
|
|
||||||
|
interface BrowserHandCardProps {
|
||||||
|
hand: Hand;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserHandCard({ onOpenSettings }: BrowserHandCardProps) {
|
||||||
|
const {
|
||||||
|
execution,
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
isTemplateModalOpen,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
openTemplateModal,
|
||||||
|
closeTemplateModal,
|
||||||
|
takeScreenshot,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
clearError,
|
||||||
|
} = useBrowserHandStore();
|
||||||
|
|
||||||
|
const [isStarting, setIsStarting] = React.useState(false);
|
||||||
|
|
||||||
|
// Auto-start session if needed
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (sessions.length === 0 && !activeSessionId) {
|
||||||
|
setIsStarting(true);
|
||||||
|
createSession({ headless: true })
|
||||||
|
.then(() => setIsStarting(false))
|
||||||
|
.catch(() => setIsStarting(false));
|
||||||
|
}
|
||||||
|
}, [sessions.length, activeSessionId, createSession]);
|
||||||
|
|
||||||
|
// Get status display
|
||||||
|
const getStatusDisplay = () => {
|
||||||
|
if (isStarting || isLoading) {
|
||||||
|
return { text: '连接中...', color: 'text-yellow-500', icon: Loader2 };
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return { text: '错误', color: 'text-red-500', icon: XCircle };
|
||||||
|
}
|
||||||
|
if (execution.isRunning) {
|
||||||
|
return { text: '运行中', color: 'text-blue-500', icon: Play };
|
||||||
|
}
|
||||||
|
if (activeSessionId) {
|
||||||
|
return { text: '就绪', color: 'text-green-500', icon: CheckCircle };
|
||||||
|
}
|
||||||
|
return { text: '未连接', color: 'text-gray-500', icon: AlertCircle };
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = getStatusDisplay();
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||||
|
<Globe className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Browser Hand
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
浏览器自动化能力
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('flex items-center gap-1 text-sm', status.color)}>
|
||||||
|
<StatusIcon className="h-4 w-4" />
|
||||||
|
{status.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshot Preview */}
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<ScreenshotPreview
|
||||||
|
base64={execution.lastScreenshot}
|
||||||
|
isLoading={isLoading || execution.isRunning}
|
||||||
|
onRefresh={() => {
|
||||||
|
if (activeSessionId) {
|
||||||
|
takeScreenshot();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
altText={execution.isRunning ? '执行中...' : '等待截图'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Bar */}
|
||||||
|
{(execution.isRunning || execution.currentUrl) && (
|
||||||
|
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
{execution.currentUrl && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
<span className="truncate font-mono">{execution.currentUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{execution.isRunning && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
<span>{execution.currentAction || '处理中...'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all duration-300"
|
||||||
|
style={{ width: `${execution.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearError}
|
||||||
|
className="text-red-600 dark:text-red-400 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={openTemplateModal}
|
||||||
|
disabled={isLoading || execution.isRunning || !activeSessionId}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg font-medium transition-colors',
|
||||||
|
activeSessionId && !execution.isRunning
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
执行任务
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => activeSessionId && takeScreenshot()}
|
||||||
|
disabled={isLoading || execution.isRunning || !activeSessionId}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors',
|
||||||
|
activeSessionId && !execution.isRunning
|
||||||
|
? 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
title="截图"
|
||||||
|
>
|
||||||
|
<Camera className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (activeSessionId) {
|
||||||
|
closeSession(activeSessionId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading || execution.isRunning || !activeSessionId}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors',
|
||||||
|
activeSessionId && !execution.isRunning
|
||||||
|
? 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
title="重置会话"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onOpenSettings && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className="flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 transition-colors"
|
||||||
|
title="设置"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Modal */}
|
||||||
|
<TaskTemplateModal
|
||||||
|
isOpen={isTemplateModalOpen}
|
||||||
|
onClose={closeTemplateModal}
|
||||||
|
onSelect={(template, params) => {
|
||||||
|
const { executeTemplate } = useBrowserHandStore.getState();
|
||||||
|
executeTemplate(template.id, params);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
desktop/src/components/BrowserHand/ScreenshotPreview.tsx
Normal file
151
desktop/src/components/BrowserHand/ScreenshotPreview.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* ScreenshotPreview Component
|
||||||
|
*
|
||||||
|
* Displays browser screenshots with zoom and fullscreen capabilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Expand, RefreshCw, Loader2, Camera, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface ScreenshotPreviewProps {
|
||||||
|
/** Base64 encoded screenshot data */
|
||||||
|
base64: string | null;
|
||||||
|
/** Loading state */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Callback when refresh is requested */
|
||||||
|
onRefresh?: () => void;
|
||||||
|
/** Callback when clicked (for fullscreen) */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** Alt text when no screenshot */
|
||||||
|
altText?: string;
|
||||||
|
/** Container class name */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenshotPreview({
|
||||||
|
base64,
|
||||||
|
isLoading = false,
|
||||||
|
onRefresh,
|
||||||
|
onClick,
|
||||||
|
altText = '等待截图',
|
||||||
|
className = '',
|
||||||
|
}: ScreenshotPreviewProps) {
|
||||||
|
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||||
|
|
||||||
|
// Handle keyboard shortcut for fullscreen toggle
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isFullscreen) {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
if (!base64 && !isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center h-48 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 border-dashed',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Camera className="h-8 w-8 text-gray-400" />
|
||||||
|
<p className="mt-2 text-sm text-gray-400">{altText}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
setIsFullscreen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative group',
|
||||||
|
isFullscreen && 'fixed inset-0 z-50 bg-black/90 flex items-center justify-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-20">
|
||||||
|
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-2 right-2 flex items-center gap-2 z-10',
|
||||||
|
isFullscreen && 'bg-black/80 rounded-lg p-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{onRefresh && (
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="p-1.5 rounded-md bg-black/60 hover:bg-black/70 transition-colors text-white"
|
||||||
|
title="刷新截图"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="p-1.5 rounded-md bg-black/60 hover:bg-black/70 transition-colors text-white"
|
||||||
|
title="全屏查看"
|
||||||
|
>
|
||||||
|
<Expand className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshot image */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full overflow-auto bg-gray-900 rounded-lg cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${base64}`}
|
||||||
|
alt="Browser screenshot"
|
||||||
|
className={cn(
|
||||||
|
'max-w-full max-h-full object-contain transition-transform duration-200',
|
||||||
|
isFullscreen ? 'scale-150' : 'scale-100'
|
||||||
|
)}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen modal */}
|
||||||
|
{isFullscreen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${base64}`}
|
||||||
|
alt="Browser screenshot fullscreen"
|
||||||
|
className="max-h-[85vh] max-w-[85vw] object-contain shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-black/60 hover:bg-black/70 transition-colors text-white"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
417
desktop/src/components/BrowserHand/TaskTemplateModal.tsx
Normal file
417
desktop/src/components/BrowserHand/TaskTemplateModal.tsx
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* TaskTemplateModal Component
|
||||||
|
*
|
||||||
|
* Modal for selecting task templates and configuring parameters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Play,
|
||||||
|
AlertCircle,
|
||||||
|
Camera,
|
||||||
|
FileText,
|
||||||
|
List,
|
||||||
|
MousePointerClick,
|
||||||
|
Code,
|
||||||
|
Image,
|
||||||
|
Link,
|
||||||
|
Table,
|
||||||
|
LogIn,
|
||||||
|
Layers,
|
||||||
|
Activity,
|
||||||
|
ClipboardList,
|
||||||
|
ChevronsRight,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import {
|
||||||
|
validateTemplateParams,
|
||||||
|
mergeParamsWithDefaults,
|
||||||
|
getTemplatesByCategory,
|
||||||
|
type TaskTemplate,
|
||||||
|
type TemplateCategory,
|
||||||
|
type TaskTemplateParam,
|
||||||
|
} from './templates';
|
||||||
|
|
||||||
|
interface TaskTemplateModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (template: TaskTemplate, params: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryIcons: Record<TemplateCategory, React.FC<{ className?: string }>> = {
|
||||||
|
basic: Camera,
|
||||||
|
scraping: FileText,
|
||||||
|
automation: Layers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryColors: Record<TemplateCategory, string> = {
|
||||||
|
basic: 'bg-blue-500',
|
||||||
|
scraping: 'bg-green-500',
|
||||||
|
automation: 'bg-purple-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaskTemplateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: TaskTemplateModalProps) {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<TemplateCategory>('basic');
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<TaskTemplate | null>(null);
|
||||||
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const basicTemplates = getTemplatesByCategory('basic');
|
||||||
|
const scrapingTemplates = getTemplatesByCategory('scraping');
|
||||||
|
const automationTemplates = getTemplatesByCategory('automation');
|
||||||
|
|
||||||
|
// Reset when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setParams({});
|
||||||
|
setValidationErrors([]);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handle template selection
|
||||||
|
const handleTemplateSelect = (template: TaskTemplate) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setParams({});
|
||||||
|
setValidationErrors([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle param change
|
||||||
|
const handleParamChange = (key: string, value: unknown) => {
|
||||||
|
setParams((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setValidationErrors((prev) => prev.filter((e) => e !== key));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
|
||||||
|
// Validate params
|
||||||
|
const validation = validateTemplateParams(selectedTemplate.params, params);
|
||||||
|
if (!validation.valid) {
|
||||||
|
setValidationErrors(validation.errors.map((e) => e.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with defaults and execute
|
||||||
|
const mergedParams = mergeParamsWithDefaults(selectedTemplate.params, params);
|
||||||
|
onSelect(selectedTemplate, mergedParams);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get icon component
|
||||||
|
const getIconComponent = (iconName: string) => {
|
||||||
|
const icons: Record<string, React.FC<{ className?: string }>> = {
|
||||||
|
Camera,
|
||||||
|
FileText,
|
||||||
|
List,
|
||||||
|
MousePointerClick,
|
||||||
|
Code,
|
||||||
|
Image,
|
||||||
|
Link,
|
||||||
|
Table,
|
||||||
|
LogIn,
|
||||||
|
Layers,
|
||||||
|
Activity,
|
||||||
|
ClipboardList,
|
||||||
|
ChevronsRight,
|
||||||
|
Info,
|
||||||
|
};
|
||||||
|
const IconComponent = icons[iconName] || Info;
|
||||||
|
return <IconComponent className="h-5 w-5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
选择任务模板
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Tabs */}
|
||||||
|
<div className="flex gap-2 p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{(['basic', 'scraping', 'automation'] as TemplateCategory[]).map((category) => {
|
||||||
|
const CategoryIcon = categoryIcons[category];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
selectedCategory === category
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-100'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CategoryIcon className="h-4 w-4" />
|
||||||
|
{category === 'basic' && '基础操作'}
|
||||||
|
{category === 'scraping' && '数据采集'}
|
||||||
|
{category === 'automation' && '自动化流程'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Template List */}
|
||||||
|
<div className="w-1/2 p-4 overflow-y-auto border-r border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{(selectedCategory === 'basic' ? basicTemplates :
|
||||||
|
selectedCategory === 'scraping' ? scrapingTemplates :
|
||||||
|
automationTemplates
|
||||||
|
).map((template) => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => handleTemplateSelect(template)}
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-lg border text-left transition-all',
|
||||||
|
selectedTemplate?.id === template.id
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg text-white',
|
||||||
|
categoryColors[template.category]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getIconComponent(template.icon)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white text-sm">
|
||||||
|
{template.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parameter Form */}
|
||||||
|
<div className="w-1/2 p-4 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
|
||||||
|
{selectedTemplate ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg text-white',
|
||||||
|
categoryColors[selectedTemplate.category]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getIconComponent(selectedTemplate.icon)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{selectedTemplate.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{selectedTemplate.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTemplate.params.map((param) => (
|
||||||
|
<div key={param.key} className="space-y-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{param.label}
|
||||||
|
{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{param.description && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{param.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderParamInput(param, params[param.key], handleParamChange)}
|
||||||
|
|
||||||
|
{validationErrors.includes(`${param.label} 是必填项`) && !params[param.key] && (
|
||||||
|
<p className="text-sm text-red-500">{param.label} 是必填项</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<div className="mt-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
|
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">请检查以下错误:</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-2 text-sm text-red-600 dark:text-red-400 list-disc list-inside">
|
||||||
|
{validationErrors.map((error, i) => (
|
||||||
|
<li key={i}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
<Play className="h-12 w-12 mb-4" />
|
||||||
|
<p>选择左侧的模板以配置参数</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!selectedTemplate}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2',
|
||||||
|
selectedTemplate
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
执行任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to render param input
|
||||||
|
function renderParamInput(
|
||||||
|
param: TaskTemplateParam,
|
||||||
|
value: unknown,
|
||||||
|
onChange: (key: string, value: unknown) => void
|
||||||
|
) {
|
||||||
|
const inputId = `param-${param.key}`;
|
||||||
|
|
||||||
|
const baseInputClasses = 'w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
||||||
|
|
||||||
|
switch (param.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'url':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type={param.type === 'url' ? 'url' : 'text'}
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
onChange={(e) => onChange(param.key, e.target.value)}
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
className={baseInputClasses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="number"
|
||||||
|
value={(value as number) ?? param.default ?? ''}
|
||||||
|
onChange={(e) => onChange(param.key, e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
min={param.min}
|
||||||
|
max={param.max}
|
||||||
|
className={baseInputClasses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="checkbox"
|
||||||
|
checked={(value as boolean) ?? (param.default as boolean) ?? false}
|
||||||
|
onChange={(e) => onChange(param.key, e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{param.description || '启用'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id={inputId}
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
onChange={(e) => onChange(param.key, e.target.value)}
|
||||||
|
className={baseInputClasses}
|
||||||
|
>
|
||||||
|
<option value="">请选择...</option>
|
||||||
|
{param.options?.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
case 'json':
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={inputId}
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
onChange={(e) => onChange(param.key, e.target.value)}
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
rows={param.type === 'json' ? 4 : 3}
|
||||||
|
className={`${baseInputClasses} font-mono text-sm`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
onChange={(e) => onChange(param.key, e.target.value)}
|
||||||
|
placeholder={param.placeholder}
|
||||||
|
className={baseInputClasses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskTemplateModal;
|
||||||
42
desktop/src/components/BrowserHand/index.ts
Normal file
42
desktop/src/components/BrowserHand/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* BrowserHand Module
|
||||||
|
*
|
||||||
|
* Exports all Browser Hand components and utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { BrowserHandCard } from './BrowserHandCard';
|
||||||
|
export { TaskTemplateModal } from './TaskTemplateModal';
|
||||||
|
export { ScreenshotPreview } from './ScreenshotPreview';
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
export {
|
||||||
|
BUILTIN_TEMPLATES,
|
||||||
|
templateRegistry,
|
||||||
|
validateTemplateParams,
|
||||||
|
mergeParamsWithDefaults,
|
||||||
|
getTemplate,
|
||||||
|
getTemplatesByCategory,
|
||||||
|
getAllTemplates,
|
||||||
|
registerTemplate,
|
||||||
|
} from './templates';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
TaskTemplate,
|
||||||
|
TaskTemplateParam,
|
||||||
|
TemplateCategory,
|
||||||
|
ExecutionContext,
|
||||||
|
ExecutionState,
|
||||||
|
ExecutionStatus,
|
||||||
|
BrowserSession,
|
||||||
|
SessionStatus,
|
||||||
|
BrowserLog,
|
||||||
|
LogLevel,
|
||||||
|
RecentTask,
|
||||||
|
TaskResultStatus,
|
||||||
|
SessionOptions,
|
||||||
|
ValidationError,
|
||||||
|
ValidationResult,
|
||||||
|
TemplateRegistry,
|
||||||
|
} from './templates';
|
||||||
654
desktop/src/components/BrowserHand/templates/automation.ts
Normal file
654
desktop/src/components/BrowserHand/templates/automation.ts
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
/**
|
||||||
|
* Automation Templates for Browser Hand
|
||||||
|
*
|
||||||
|
* Contains complex automation workflow templates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TaskTemplate, ExecutionContext } from './types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Login and Action
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const loginActionTemplate: TaskTemplate = {
|
||||||
|
id: 'auto_login_action',
|
||||||
|
name: '登录并操作',
|
||||||
|
description: '登录网站后执行一系列操作',
|
||||||
|
category: 'automation',
|
||||||
|
icon: 'LogIn',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'loginUrl',
|
||||||
|
label: '登录页面',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/login',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'credentials',
|
||||||
|
label: '登录凭据',
|
||||||
|
type: 'json',
|
||||||
|
required: true,
|
||||||
|
default: {},
|
||||||
|
description: 'JSON 对象,包含用户名和密码字段选择器',
|
||||||
|
placeholder: '{"usernameSelector": "input[name=\\"username\\"]", "username": "user@example.com", "passwordSelector": "input[name=\\"password\\"]", "password": "pass123", "submitSelector": "button[type=\\"submit\\"]"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '操作序列',
|
||||||
|
type: 'json',
|
||||||
|
required: true,
|
||||||
|
default: [],
|
||||||
|
description: '登录后执行的操作数组',
|
||||||
|
placeholder: '[{"type": "click", "selector": ".button"}, {"type": "wait", "selector": ".result"}]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'takeFinalScreenshot',
|
||||||
|
label: '最终截图',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
description: '操作完成后是否截图',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const loginUrl = params.loginUrl as string;
|
||||||
|
const credentials = params.credentials as {
|
||||||
|
usernameSelector: string;
|
||||||
|
username: string;
|
||||||
|
passwordSelector: string;
|
||||||
|
password: string;
|
||||||
|
submitSelector: string;
|
||||||
|
};
|
||||||
|
const actions = params.actions as Array<{
|
||||||
|
type: 'click' | 'type' | 'wait' | 'navigate' | 'screenshot';
|
||||||
|
selector?: string;
|
||||||
|
value?: string;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
|
const takeFinalScreenshot = params.takeFinalScreenshot as boolean;
|
||||||
|
|
||||||
|
// Step 1: Navigate to login page
|
||||||
|
onProgress('正在导航到登录页面...', 0);
|
||||||
|
onLog('info', `访问: ${loginUrl}`);
|
||||||
|
await browser.goto(loginUrl);
|
||||||
|
|
||||||
|
// Step 2: Fill credentials
|
||||||
|
onProgress('正在填写登录信息...', 15);
|
||||||
|
onLog('action', `填写用户名: ${credentials.usernameSelector}`);
|
||||||
|
await browser.wait(credentials.usernameSelector, 10000);
|
||||||
|
await browser.type(credentials.usernameSelector, credentials.username, true);
|
||||||
|
|
||||||
|
onLog('action', `填写密码: ${credentials.passwordSelector}`);
|
||||||
|
await browser.type(credentials.passwordSelector, credentials.password, true);
|
||||||
|
|
||||||
|
// Step 3: Submit login
|
||||||
|
onProgress('正在登录...', 25);
|
||||||
|
onLog('action', `点击登录: ${credentials.submitSelector}`);
|
||||||
|
await browser.click(credentials.submitSelector);
|
||||||
|
|
||||||
|
// Wait for login to complete
|
||||||
|
onProgress('等待登录完成...', 35);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
onLog('info', `登录完成,当前 URL: ${await browser.url()}`);
|
||||||
|
|
||||||
|
// Step 4: Execute actions
|
||||||
|
const actionResults: unknown[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < actions.length; i++) {
|
||||||
|
const action = actions[i];
|
||||||
|
const progress = 40 + Math.floor((i / actions.length) * 50);
|
||||||
|
|
||||||
|
onProgress(`执行操作 ${i + 1}/${actions.length}...`, progress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'click':
|
||||||
|
if (action.selector) {
|
||||||
|
onLog('action', `点击: ${action.selector}`);
|
||||||
|
await browser.wait(action.selector, 5000);
|
||||||
|
await browser.click(action.selector);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'type':
|
||||||
|
if (action.selector && action.value) {
|
||||||
|
onLog('action', `输入: ${action.selector}`);
|
||||||
|
await browser.wait(action.selector, 5000);
|
||||||
|
await browser.type(action.selector, action.value, true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wait':
|
||||||
|
if (action.selector) {
|
||||||
|
onLog('action', `等待: ${action.selector}`);
|
||||||
|
await browser.wait(action.selector, 10000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'navigate':
|
||||||
|
if (action.url) {
|
||||||
|
onLog('action', `导航到: ${action.url}`);
|
||||||
|
await browser.goto(action.url);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'screenshot':
|
||||||
|
onLog('action', '截图');
|
||||||
|
const screenshot = await browser.screenshot();
|
||||||
|
actionResults.push({ type: 'screenshot', base64: screenshot.base64 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
actionResults.push({ type: action.type, success: true });
|
||||||
|
} catch (error) {
|
||||||
|
onLog('error', `操作失败: ${action.type}`, { error: String(error) });
|
||||||
|
actionResults.push({ type: action.type, success: false, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Final screenshot
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
loginUrl,
|
||||||
|
finalUrl: await browser.url(),
|
||||||
|
actionsCompleted: actions.length,
|
||||||
|
actionResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (takeFinalScreenshot) {
|
||||||
|
onProgress('正在截取最终快照...', 95);
|
||||||
|
const screenshot = await browser.screenshot();
|
||||||
|
result.screenshot = screenshot.base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Multi-Page Navigation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const multiPageTemplate: TaskTemplate = {
|
||||||
|
id: 'auto_multi_page',
|
||||||
|
name: '多页面导航',
|
||||||
|
description: '遍历多个页面并执行操作',
|
||||||
|
category: 'automation',
|
||||||
|
icon: 'Layers',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'urls',
|
||||||
|
label: 'URL 列表',
|
||||||
|
type: 'textarea',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/page1\nhttps://example.com/page2',
|
||||||
|
description: '每行一个 URL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '每页操作',
|
||||||
|
type: 'json',
|
||||||
|
required: true,
|
||||||
|
default: [],
|
||||||
|
description: '在每个页面执行的操作',
|
||||||
|
placeholder: '[{"type": "screenshot"}]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delayBetweenPages',
|
||||||
|
label: '页面间隔 (毫秒)',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 1000,
|
||||||
|
min: 0,
|
||||||
|
max: 10000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const urlsText = params.urls as string;
|
||||||
|
const urls = urlsText.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const actions = params.actions as Array<{
|
||||||
|
type: 'click' | 'type' | 'wait' | 'screenshot' | 'extract';
|
||||||
|
selector?: string;
|
||||||
|
value?: string;
|
||||||
|
}>;
|
||||||
|
const delayBetweenPages = (params.delayBetweenPages as number) ?? 1000;
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
url: string;
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < urls.length; i++) {
|
||||||
|
const url = urls[i];
|
||||||
|
const progress = Math.floor((i / urls.length) * 95);
|
||||||
|
|
||||||
|
onProgress(`处理页面 ${i + 1}/${urls.length}...`, progress);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.goto(url);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayBetweenPages));
|
||||||
|
|
||||||
|
const pageData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'screenshot':
|
||||||
|
const screenshot = await browser.screenshot();
|
||||||
|
pageData.screenshot = screenshot.base64;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'extract':
|
||||||
|
if (action.selector) {
|
||||||
|
const text = await browser.eval(`
|
||||||
|
(selector) => document.querySelector(selector)?.textContent?.trim()
|
||||||
|
`, [action.selector]);
|
||||||
|
pageData.extracted = text;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'click':
|
||||||
|
if (action.selector) {
|
||||||
|
await browser.click(action.selector);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'wait':
|
||||||
|
if (action.selector) {
|
||||||
|
await browser.wait(action.selector, 5000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ url, success: true, data: pageData });
|
||||||
|
onLog('info', `页面处理完成: ${url}`);
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ url, success: false, error: String(error) });
|
||||||
|
onLog('error', `页面处理失败: ${url}`, { error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
total: urls.length,
|
||||||
|
successful: results.filter((r) => r.success).length,
|
||||||
|
failed: results.filter((r) => !r.success).length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Monitor Page Changes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const monitorTemplate: TaskTemplate = {
|
||||||
|
id: 'auto_monitor',
|
||||||
|
name: '监控页面变化',
|
||||||
|
description: '定时检查页面内容变化',
|
||||||
|
category: 'automation',
|
||||||
|
icon: 'Activity',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '监控页面',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/price',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'selector',
|
||||||
|
label: '监控元素',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
placeholder: '.price',
|
||||||
|
description: '要监控的元素选择器',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'interval',
|
||||||
|
label: '检查间隔 (秒)',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 60,
|
||||||
|
min: 10,
|
||||||
|
max: 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'iterations',
|
||||||
|
label: '检查次数',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 5,
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'alertOnChange',
|
||||||
|
label: '变化时截图',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const selector = params.selector as string;
|
||||||
|
const interval = (params.interval as number) ?? 60;
|
||||||
|
const iterations = (params.iterations as number) ?? 5;
|
||||||
|
const alertOnChange = params.alertOnChange as boolean;
|
||||||
|
|
||||||
|
const snapshots: Array<{
|
||||||
|
iteration: number;
|
||||||
|
timestamp: string;
|
||||||
|
value: string | null;
|
||||||
|
changed: boolean;
|
||||||
|
screenshot?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let previousValue: string | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
const progress = Math.floor((i / iterations) * 95);
|
||||||
|
|
||||||
|
onProgress(`检查 ${i + 1}/${iterations}...`, progress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.goto(url);
|
||||||
|
await browser.wait(selector, 10000);
|
||||||
|
|
||||||
|
const currentValue = await browser.eval(`
|
||||||
|
(selector) => document.querySelector(selector)?.textContent?.trim()
|
||||||
|
`, [selector]) as string | null;
|
||||||
|
|
||||||
|
const changed = previousValue !== null && currentValue !== previousValue;
|
||||||
|
|
||||||
|
const snapshot: typeof snapshots[0] = {
|
||||||
|
iteration: i + 1,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
value: currentValue,
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changed && alertOnChange) {
|
||||||
|
onLog('warn', `检测到变化!`, { from: previousValue, to: currentValue });
|
||||||
|
const screenshot = await browser.screenshot();
|
||||||
|
snapshot.screenshot = screenshot.base64;
|
||||||
|
} else {
|
||||||
|
onLog('info', `值: ${currentValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots.push(snapshot);
|
||||||
|
previousValue = currentValue;
|
||||||
|
|
||||||
|
// Wait for next interval (except on last iteration)
|
||||||
|
if (i < iterations - 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onLog('error', `检查失败: ${error}`);
|
||||||
|
snapshots.push({
|
||||||
|
iteration: i + 1,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
value: null,
|
||||||
|
changed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
selector,
|
||||||
|
totalChecks: iterations,
|
||||||
|
changesDetected: snapshots.filter((s) => s.changed).length,
|
||||||
|
snapshots,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Form Submission Sequence
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const formSequenceTemplate: TaskTemplate = {
|
||||||
|
id: 'auto_form_sequence',
|
||||||
|
name: '表单提交序列',
|
||||||
|
description: '按顺序填写并提交多个表单',
|
||||||
|
category: 'automation',
|
||||||
|
icon: 'ClipboardList',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '起始页面',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/wizard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'steps',
|
||||||
|
label: '表单步骤',
|
||||||
|
type: 'json',
|
||||||
|
required: true,
|
||||||
|
default: [],
|
||||||
|
description: '每个步骤的字段和提交按钮',
|
||||||
|
placeholder: '[{"fields": [{"selector": "input", "value": "test"}], "submit": "button"}]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waitForNavigation',
|
||||||
|
label: '等待跳转',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
description: '提交后等待页面跳转',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const steps = params.steps as Array<{
|
||||||
|
fields: Array<{ selector: string; value: string; clearFirst?: boolean }>;
|
||||||
|
submit: string;
|
||||||
|
}>;
|
||||||
|
const waitForNavigation = params.waitForNavigation as boolean;
|
||||||
|
|
||||||
|
onProgress('正在导航到起始页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
const stepResults: Array<{
|
||||||
|
step: number;
|
||||||
|
success: boolean;
|
||||||
|
url: string;
|
||||||
|
error?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const step = steps[i];
|
||||||
|
const progress = Math.floor(((i + 0.5) / steps.length) * 90);
|
||||||
|
|
||||||
|
onProgress(`步骤 ${i + 1}/${steps.length}: 填写字段...`, progress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fill fields
|
||||||
|
for (const field of step.fields) {
|
||||||
|
onLog('action', `填写: ${field.selector}`);
|
||||||
|
await browser.wait(field.selector, 5000);
|
||||||
|
await browser.type(field.selector, field.value, field.clearFirst ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
onProgress(`步骤 ${i + 1}/${steps.length}: 提交...`, progress + Math.floor(45 / steps.length));
|
||||||
|
onLog('action', `提交: ${step.submit}`);
|
||||||
|
await browser.click(step.submit);
|
||||||
|
|
||||||
|
if (waitForNavigation) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
stepResults.push({
|
||||||
|
step: i + 1,
|
||||||
|
success: true,
|
||||||
|
url: await browser.url(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
onLog('error', `步骤 ${i + 1} 失败: ${error}`);
|
||||||
|
stepResults.push({
|
||||||
|
step: i + 1,
|
||||||
|
success: false,
|
||||||
|
url: await browser.url(),
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
// Continue to next step even if this one failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
startUrl: url,
|
||||||
|
finalUrl: await browser.url(),
|
||||||
|
totalSteps: steps.length,
|
||||||
|
successfulSteps: stepResults.filter((r) => r.success).length,
|
||||||
|
stepResults,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Pagination Scraping
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const paginationTemplate: TaskTemplate = {
|
||||||
|
id: 'auto_pagination',
|
||||||
|
name: '分页抓取',
|
||||||
|
description: '自动翻页并抓取数据',
|
||||||
|
category: 'automation',
|
||||||
|
icon: 'ChevronsRight',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '起始页面',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'itemSelector',
|
||||||
|
label: '项目选择器',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
placeholder: '.item',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'extractFields',
|
||||||
|
label: '提取字段',
|
||||||
|
type: 'json',
|
||||||
|
required: true,
|
||||||
|
default: {},
|
||||||
|
placeholder: '{"title": ".title", "price": ".price"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nextButtonSelector',
|
||||||
|
label: '下一页按钮',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
placeholder: '.next-page',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxPages',
|
||||||
|
label: '最大页数',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 5,
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const itemSelector = params.itemSelector as string;
|
||||||
|
const extractFields = params.extractFields as Record<string, string>;
|
||||||
|
const nextButtonSelector = params.nextButtonSelector as string;
|
||||||
|
const maxPages = (params.maxPages as number) ?? 5;
|
||||||
|
|
||||||
|
onProgress('正在导航到起始页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
const allItems: Array<Record<string, string>>[] = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
|
||||||
|
while (currentPage <= maxPages) {
|
||||||
|
const progress = Math.floor((currentPage / maxPages) * 90);
|
||||||
|
onProgress(`正在抓取第 ${currentPage} 页...`, progress);
|
||||||
|
|
||||||
|
// Wait for items to load
|
||||||
|
await browser.wait(itemSelector, 10000);
|
||||||
|
|
||||||
|
// Extract items
|
||||||
|
const items = await browser.eval(`
|
||||||
|
({ itemSelector, extractFields }) => {
|
||||||
|
const elements = document.querySelectorAll(itemSelector);
|
||||||
|
return Array.from(elements).map(el => {
|
||||||
|
const item = {};
|
||||||
|
for (const [field, selector] of Object.entries(extractFields)) {
|
||||||
|
const child = el.querySelector(selector);
|
||||||
|
item[field] = child?.textContent?.trim() || '';
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`, [{ itemSelector, extractFields }]) as Array<Record<string, string>>;
|
||||||
|
|
||||||
|
allItems.push(items);
|
||||||
|
onLog('info', `第 ${currentPage} 页: ${items.length} 条数据`);
|
||||||
|
|
||||||
|
// Try to go to next page
|
||||||
|
try {
|
||||||
|
const nextButton = await browser.$(nextButtonSelector);
|
||||||
|
if (!nextButton || !nextButton.is_enabled) {
|
||||||
|
onLog('info', '没有更多页面');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.click(nextButtonSelector);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
currentPage++;
|
||||||
|
} catch {
|
||||||
|
onLog('info', '已到达最后一页');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatItems = allItems.flat();
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
pagesScraped: currentPage,
|
||||||
|
totalItems: flatItems.length,
|
||||||
|
itemsPerPage: allItems.map((p) => p.length),
|
||||||
|
data: flatItems,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export All Automation Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const automationTemplates: TaskTemplate[] = [
|
||||||
|
loginActionTemplate,
|
||||||
|
multiPageTemplate,
|
||||||
|
monitorTemplate,
|
||||||
|
formSequenceTemplate,
|
||||||
|
paginationTemplate,
|
||||||
|
];
|
||||||
411
desktop/src/components/BrowserHand/templates/basic.ts
Normal file
411
desktop/src/components/BrowserHand/templates/basic.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
/**
|
||||||
|
* Basic Operation Templates for Browser Hand
|
||||||
|
*
|
||||||
|
* Contains fundamental browser operations: navigate, screenshot, form filling, clicking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TaskTemplate, ExecutionContext } from './types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Navigate and Screenshot
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const navigateScreenshotTemplate: TaskTemplate = {
|
||||||
|
id: 'basic_navigate_screenshot',
|
||||||
|
name: '打开网页并截图',
|
||||||
|
description: '访问指定 URL 并截取页面快照',
|
||||||
|
category: 'basic',
|
||||||
|
icon: 'Camera',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
description: '要访问的网页 URL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waitTime',
|
||||||
|
label: '等待时间 (毫秒)',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 2000,
|
||||||
|
min: 0,
|
||||||
|
max: 30000,
|
||||||
|
description: '页面加载后等待的时间',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waitFor',
|
||||||
|
label: '等待元素',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
placeholder: '.main-content',
|
||||||
|
description: '等待特定元素出现后再截图(CSS 选择器)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const waitTime = (params.waitTime as number) ?? 2000;
|
||||||
|
const waitFor = params.waitFor as string | undefined;
|
||||||
|
|
||||||
|
onProgress('正在创建浏览器会话...', 0);
|
||||||
|
onLog('info', `准备访问: ${url}`);
|
||||||
|
|
||||||
|
// Navigate to URL
|
||||||
|
onProgress('正在导航到页面...', 20);
|
||||||
|
const navResult = await browser.goto(url);
|
||||||
|
onLog('info', `页面标题: ${navResult.title}`);
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
if (waitFor) {
|
||||||
|
onProgress('等待页面元素加载...', 40);
|
||||||
|
onLog('action', `等待元素: ${waitFor}`);
|
||||||
|
await browser.wait(waitFor, 10000);
|
||||||
|
} else if (waitTime > 0) {
|
||||||
|
onProgress('等待页面加载...', 40);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take screenshot
|
||||||
|
onProgress('正在截取页面快照...', 80);
|
||||||
|
const screenshot = await browser.screenshot();
|
||||||
|
onLog('action', '截图完成', { size: screenshot.base64.length });
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: await browser.url(),
|
||||||
|
title: await browser.title(),
|
||||||
|
screenshot: screenshot.base64,
|
||||||
|
format: screenshot.format,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Fill Form
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const fillFormTemplate: TaskTemplate = {
|
||||||
|
id: 'basic_fill_form',
|
||||||
|
name: '填写表单',
|
||||||
|
description: '填写网页表单并可选提交',
|
||||||
|
category: 'basic',
|
||||||
|
icon: 'FileText',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/form',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fields',
|
||||||
|
label: '表单字段',
|
||||||
|
type: 'json',
|
||||||
|
required: true,
|
||||||
|
default: [],
|
||||||
|
description: 'JSON 数组,每项包含 selector 和 value',
|
||||||
|
placeholder: '[{"selector": "input[name=\\"email\\"]", "value": "test@example.com"}]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'submitSelector',
|
||||||
|
label: '提交按钮选择器',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
placeholder: 'button[type="submit"]',
|
||||||
|
description: '填写完成后点击此按钮提交',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waitForNavigation',
|
||||||
|
label: '等待页面跳转',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
description: '提交后等待新页面加载完成',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const fields = params.fields as Array<{ selector: string; value: string }>;
|
||||||
|
const submitSelector = params.submitSelector as string | undefined;
|
||||||
|
const waitForNavigation = params.waitForNavigation as boolean;
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('正在填写表单...', 30);
|
||||||
|
const totalFields = fields.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < fields.length; i++) {
|
||||||
|
const field = fields[i];
|
||||||
|
const progress = 30 + Math.floor((i / totalFields) * 40);
|
||||||
|
onProgress(`正在填写字段 ${i + 1}/${totalFields}...`, progress);
|
||||||
|
onLog('action', `填写: ${field.selector}`, { value: field.value });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.wait(field.selector, 5000);
|
||||||
|
await browser.type(field.selector, field.value, true);
|
||||||
|
} catch (error) {
|
||||||
|
onLog('warn', `字段填写失败: ${field.selector}`, {
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = {
|
||||||
|
url: await browser.url(),
|
||||||
|
fieldsFilled: fields.length,
|
||||||
|
submitted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitSelector) {
|
||||||
|
onProgress('正在提交表单...', 80);
|
||||||
|
onLog('action', `点击提交: ${submitSelector}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.click(submitSelector);
|
||||||
|
result.submitted = true;
|
||||||
|
|
||||||
|
if (waitForNavigation) {
|
||||||
|
onProgress('等待页面跳转...', 90);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onLog('error', `提交失败: ${submitSelector}`, {
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Click and Navigate
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const clickNavigateTemplate: TaskTemplate = {
|
||||||
|
id: 'basic_click_navigate',
|
||||||
|
name: '点击导航',
|
||||||
|
description: '点击页面元素并等待导航',
|
||||||
|
category: 'basic',
|
||||||
|
icon: 'MousePointerClick',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '起始页面',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'selector',
|
||||||
|
label: '点击目标',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'a.link-to-page',
|
||||||
|
description: '要点击的元素的 CSS 选择器',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waitAfter',
|
||||||
|
label: '等待时间 (毫秒)',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 2000,
|
||||||
|
description: '点击后等待的时间',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'takeScreenshot',
|
||||||
|
label: '截图结果',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
description: '点击后是否截图',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const selector = params.selector as string;
|
||||||
|
const waitAfter = (params.waitAfter as number) ?? 2000;
|
||||||
|
const takeScreenshot = params.takeScreenshot as boolean;
|
||||||
|
|
||||||
|
onProgress('正在导航到起始页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('正在查找点击目标...', 30);
|
||||||
|
onLog('action', `等待元素: ${selector}`);
|
||||||
|
await browser.wait(selector, 10000);
|
||||||
|
|
||||||
|
onProgress('正在点击...', 50);
|
||||||
|
onLog('action', `点击: ${selector}`);
|
||||||
|
await browser.click(selector);
|
||||||
|
|
||||||
|
onProgress('等待导航完成...', 70);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, waitAfter));
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
fromUrl: url,
|
||||||
|
toUrl: await browser.url(),
|
||||||
|
title: await browser.title(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (takeScreenshot) {
|
||||||
|
onProgress('正在截图...', 90);
|
||||||
|
const screenshot = await browser.screenshot();
|
||||||
|
result.screenshot = screenshot.base64;
|
||||||
|
onLog('action', '截图完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Get Page Info
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getPageInfoTemplate: TaskTemplate = {
|
||||||
|
id: 'basic_get_page_info',
|
||||||
|
name: '获取页面信息',
|
||||||
|
description: '获取页面标题、URL 和基本信息',
|
||||||
|
category: 'basic',
|
||||||
|
icon: 'Info',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'selectors',
|
||||||
|
label: '额外选择器',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
placeholder: '.title\n.description\n.price',
|
||||||
|
description: '要提取文本的 CSS 选择器(每行一个)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const selectorsText = params.selectors as string | undefined;
|
||||||
|
const selectors = selectorsText
|
||||||
|
? selectorsText.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('正在获取页面信息...', 50);
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
url: await browser.url(),
|
||||||
|
title: await browser.title(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectors.length > 0) {
|
||||||
|
onProgress('正在提取元素文本...', 70);
|
||||||
|
const extracted: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
try {
|
||||||
|
const text = await browser.eval(`
|
||||||
|
(selector) => {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
return el ? el.textContent?.trim() : null;
|
||||||
|
}
|
||||||
|
`, [selector]);
|
||||||
|
if (text) {
|
||||||
|
extracted[selector] = text as string;
|
||||||
|
onLog('info', `提取: ${selector}`, { text });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onLog('warn', `提取失败: ${selector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.extracted = extracted;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Execute JavaScript
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const executeJsTemplate: TaskTemplate = {
|
||||||
|
id: 'basic_execute_js',
|
||||||
|
name: '执行 JavaScript',
|
||||||
|
description: '在页面上执行自定义 JavaScript 代码',
|
||||||
|
category: 'basic',
|
||||||
|
icon: 'Code',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'script',
|
||||||
|
label: 'JavaScript 代码',
|
||||||
|
type: 'textarea',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'return document.title;',
|
||||||
|
description: '要执行的 JavaScript 代码',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const script = params.script as string;
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('正在执行 JavaScript...', 50);
|
||||||
|
onLog('action', '执行脚本', { script: script.substring(0, 100) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await browser.eval(script);
|
||||||
|
onLog('info', '执行成功', { result: JSON.stringify(result).substring(0, 200) });
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (error) {
|
||||||
|
onLog('error', `执行失败: ${error}`);
|
||||||
|
onProgress('失败', 100);
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export All Basic Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const basicTemplates: TaskTemplate[] = [
|
||||||
|
navigateScreenshotTemplate,
|
||||||
|
fillFormTemplate,
|
||||||
|
clickNavigateTemplate,
|
||||||
|
getPageInfoTemplate,
|
||||||
|
executeJsTemplate,
|
||||||
|
];
|
||||||
240
desktop/src/components/BrowserHand/templates/index.ts
Normal file
240
desktop/src/components/BrowserHand/templates/index.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Browser Hand Templates Registry
|
||||||
|
*
|
||||||
|
* Central registry for all browser automation task templates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TaskTemplate,
|
||||||
|
TemplateCategory,
|
||||||
|
TemplateRegistry,
|
||||||
|
TaskTemplateParam,
|
||||||
|
ValidationError,
|
||||||
|
ValidationResult,
|
||||||
|
} from './types';
|
||||||
|
import { basicTemplates } from './basic';
|
||||||
|
import { scrapingTemplates } from './scraping';
|
||||||
|
import { automationTemplates } from './automation';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Re-export Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// All Built-in Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const BUILTIN_TEMPLATES: TaskTemplate[] = [
|
||||||
|
...basicTemplates,
|
||||||
|
...scrapingTemplates,
|
||||||
|
...automationTemplates,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template Registry Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function createTemplateRegistry(): TemplateRegistry {
|
||||||
|
const templates = new Map<string, TaskTemplate>();
|
||||||
|
const byCategory = new Map<TemplateCategory, TaskTemplate[]>();
|
||||||
|
|
||||||
|
// Initialize category maps
|
||||||
|
byCategory.set('basic', []);
|
||||||
|
byCategory.set('scraping', []);
|
||||||
|
byCategory.set('automation', []);
|
||||||
|
|
||||||
|
function register(template: TaskTemplate): void {
|
||||||
|
if (templates.has(template.id)) {
|
||||||
|
console.warn(`[BrowserHand] Template "${template.id}" already registered, overwriting`);
|
||||||
|
}
|
||||||
|
templates.set(template.id, template);
|
||||||
|
|
||||||
|
const categoryList = byCategory.get(template.category);
|
||||||
|
if (categoryList) {
|
||||||
|
// Remove existing if updating
|
||||||
|
const existingIndex = categoryList.findIndex((t) => t.id === template.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
categoryList.splice(existingIndex, 1);
|
||||||
|
}
|
||||||
|
categoryList.push(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(id: string): TaskTemplate | undefined {
|
||||||
|
return templates.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getByCategory(category: TemplateCategory): TaskTemplate[] {
|
||||||
|
return byCategory.get(category) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAll(): TaskTemplate[] {
|
||||||
|
return Array.from(templates.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register all built-in templates
|
||||||
|
BUILTIN_TEMPLATES.forEach(register);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
byCategory,
|
||||||
|
register,
|
||||||
|
get,
|
||||||
|
getByCategory,
|
||||||
|
getAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Singleton Registry Instance
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const templateRegistry = createTemplateRegistry();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate template parameters against their definitions
|
||||||
|
*/
|
||||||
|
export function validateTemplateParams(
|
||||||
|
templateParams: TaskTemplateParam[],
|
||||||
|
providedParams: Record<string, unknown>
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
for (const param of templateParams) {
|
||||||
|
const value = providedParams[param.key];
|
||||||
|
|
||||||
|
// Check required
|
||||||
|
if (param.required && (value === undefined || value === null || value === '')) {
|
||||||
|
errors.push({
|
||||||
|
param: param.key,
|
||||||
|
message: `${param.label} 是必填项`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip further validation if not provided and not required
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-specific validation
|
||||||
|
switch (param.type) {
|
||||||
|
case 'url':
|
||||||
|
if (typeof value === 'string' && !isValidUrl(value)) {
|
||||||
|
errors.push({
|
||||||
|
param: param.key,
|
||||||
|
message: `${param.label} 必须是有效的 URL`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
const numValue = Number(value);
|
||||||
|
if (isNaN(numValue)) {
|
||||||
|
errors.push({
|
||||||
|
param: param.key,
|
||||||
|
message: `${param.label} 必须是数字`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (param.min !== undefined && numValue < param.min) {
|
||||||
|
errors.push({
|
||||||
|
param: param.key,
|
||||||
|
message: `${param.label} 不能小于 ${param.min}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (param.max !== undefined && numValue > param.max) {
|
||||||
|
errors.push({
|
||||||
|
param: param.key,
|
||||||
|
message: `${param.label} 不能大于 ${param.max}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
errors.push({
|
||||||
|
param: param.key,
|
||||||
|
message: `${param.label} 必须是有效的 JSON`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
case 'textarea':
|
||||||
|
if (param.pattern && typeof value === 'string') {
|
||||||
|
const regex = new RegExp(param.pattern);
|
||||||
|
if (!regex.test(value)) {
|
||||||
|
errors.push({
|
||||||
|
param: param.key,
|
||||||
|
message: `${param.label} 格式不正确`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if string is a valid URL
|
||||||
|
*/
|
||||||
|
function isValidUrl(str: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(str);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default values for template parameters
|
||||||
|
*/
|
||||||
|
export function getDefaultParams(templateParams: TaskTemplateParam[]): Record<string, unknown> {
|
||||||
|
const defaults: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const param of templateParams) {
|
||||||
|
if (param.default !== undefined) {
|
||||||
|
defaults[param.key] = param.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge provided params with defaults
|
||||||
|
*/
|
||||||
|
export function mergeParamsWithDefaults(
|
||||||
|
templateParams: TaskTemplateParam[],
|
||||||
|
providedParams: Record<string, unknown>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const defaults = getDefaultParams(templateParams);
|
||||||
|
return { ...defaults, ...providedParams };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Convenience Exports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const getTemplate = (id: string) => templateRegistry.get(id);
|
||||||
|
export const getTemplatesByCategory = (category: TemplateCategory) =>
|
||||||
|
templateRegistry.getByCategory(category);
|
||||||
|
export const getAllTemplates = () => templateRegistry.getAll();
|
||||||
|
export const registerTemplate = (template: TaskTemplate) => templateRegistry.register(template);
|
||||||
535
desktop/src/components/BrowserHand/templates/scraping.ts
Normal file
535
desktop/src/components/BrowserHand/templates/scraping.ts
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
/**
|
||||||
|
* Scraping Templates for Browser Hand
|
||||||
|
*
|
||||||
|
* Contains data scraping and extraction templates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TaskTemplate, ExecutionContext } from './types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Scrape Text
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const scrapeTextTemplate: TaskTemplate = {
|
||||||
|
id: 'scrape_text',
|
||||||
|
name: '抓取页面文本',
|
||||||
|
description: '从多个选择器提取文本内容',
|
||||||
|
category: 'scraping',
|
||||||
|
icon: 'FileText',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'selectors',
|
||||||
|
label: '选择器列表',
|
||||||
|
type: 'textarea',
|
||||||
|
required: true,
|
||||||
|
placeholder: '.title\n.description\n.price',
|
||||||
|
description: 'CSS 选择器(每行一个)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waitFor',
|
||||||
|
label: '等待元素',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
placeholder: '.content',
|
||||||
|
description: '等待此元素出现后再抓取',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const selectorsText = params.selectors as string;
|
||||||
|
const waitFor = params.waitFor as string | undefined;
|
||||||
|
const selectors = selectorsText.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
if (waitFor) {
|
||||||
|
onProgress('等待页面加载...', 20);
|
||||||
|
onLog('action', `等待元素: ${waitFor}`);
|
||||||
|
await browser.wait(waitFor, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('正在抓取文本...', 50);
|
||||||
|
const result: Record<string, string | string[]> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < selectors.length; i++) {
|
||||||
|
const selector = selectors[i];
|
||||||
|
const progress = 50 + Math.floor((i / selectors.length) * 40);
|
||||||
|
|
||||||
|
onProgress(`正在抓取 ${i + 1}/${selectors.length}...`, progress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get multiple elements first
|
||||||
|
const multipleResult = await browser.eval(`
|
||||||
|
(selector) => {
|
||||||
|
const elements = document.querySelectorAll(selector);
|
||||||
|
if (elements.length > 1) {
|
||||||
|
return Array.from(elements).map(el => el.textContent?.trim() || '');
|
||||||
|
} else if (elements.length === 1) {
|
||||||
|
return elements[0].textContent?.trim() || '';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
`, [selector]);
|
||||||
|
|
||||||
|
if (multipleResult !== null) {
|
||||||
|
result[selector] = multipleResult as string | string[];
|
||||||
|
onLog('info', `抓取成功: ${selector}`);
|
||||||
|
} else {
|
||||||
|
result[selector] = '';
|
||||||
|
onLog('warn', `未找到元素: ${selector}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result[selector] = '';
|
||||||
|
onLog('error', `抓取失败: ${selector}`, { error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return { url: await browser.url(), data: result };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Scrape List
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const scrapeListTemplate: TaskTemplate = {
|
||||||
|
id: 'scrape_list',
|
||||||
|
name: '提取列表数据',
|
||||||
|
description: '从重复元素中批量提取结构化数据',
|
||||||
|
category: 'scraping',
|
||||||
|
icon: 'List',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/products',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'itemSelector',
|
||||||
|
label: '列表项选择器',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
placeholder: '.product-item',
|
||||||
|
description: '每个列表项的 CSS 选择器',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fieldMappings',
|
||||||
|
label: '字段映射',
|
||||||
|
type: 'json',
|
||||||
|
required: true,
|
||||||
|
default: {},
|
||||||
|
description: 'JSON 对象,映射字段名到选择器',
|
||||||
|
placeholder: '{"title": ".title", "price": ".price", "link": "a@href"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'limit',
|
||||||
|
label: '最大数量',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 50,
|
||||||
|
min: 1,
|
||||||
|
max: 500,
|
||||||
|
description: '最多提取多少条数据',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const itemSelector = params.itemSelector as string;
|
||||||
|
const fieldMappings = params.fieldMappings as Record<string, string>;
|
||||||
|
const limit = (params.limit as number) ?? 50;
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('等待列表加载...', 30);
|
||||||
|
await browser.wait(itemSelector, 10000);
|
||||||
|
|
||||||
|
onProgress('正在提取列表数据...', 50);
|
||||||
|
|
||||||
|
const scrapingScript = `
|
||||||
|
({ itemSelector, fieldMappings, limit }) => {
|
||||||
|
const items = document.querySelectorAll(itemSelector);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(items.length, limit); i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const row = {};
|
||||||
|
|
||||||
|
for (const [field, selector] of Object.entries(fieldMappings)) {
|
||||||
|
// Handle attribute selectors like "a@href"
|
||||||
|
const parts = selector.split('@');
|
||||||
|
const cssSelector = parts[0];
|
||||||
|
const attr = parts[1];
|
||||||
|
|
||||||
|
const el = item.querySelector(cssSelector);
|
||||||
|
if (el) {
|
||||||
|
if (attr) {
|
||||||
|
row[field] = el.getAttribute(attr) || '';
|
||||||
|
} else {
|
||||||
|
row[field] = el.textContent?.trim() || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row[field] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await browser.eval(scrapingScript, [{
|
||||||
|
itemSelector,
|
||||||
|
fieldMappings,
|
||||||
|
limit,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const items = result as Array<Record<string, string>>;
|
||||||
|
onLog('info', `提取了 ${items.length} 条数据`);
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
url: await browser.url(),
|
||||||
|
count: items.length,
|
||||||
|
data: items,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Scrape Images
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const scrapeImagesTemplate: TaskTemplate = {
|
||||||
|
id: 'scrape_images',
|
||||||
|
name: '抓取图片列表',
|
||||||
|
description: '提取页面中的图片 URL',
|
||||||
|
category: 'scraping',
|
||||||
|
icon: 'Image',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/gallery',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'imageSelector',
|
||||||
|
label: '图片选择器',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
default: 'img',
|
||||||
|
placeholder: 'img.gallery-image',
|
||||||
|
description: '图片元素的 CSS 选择器',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minWidth',
|
||||||
|
label: '最小宽度',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 100,
|
||||||
|
description: '忽略小于此宽度的图片',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minHeight',
|
||||||
|
label: '最小高度',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 100,
|
||||||
|
description: '忽略小于此高度的图片',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const imageSelector = (params.imageSelector as string) ?? 'img';
|
||||||
|
const minWidth = (params.minWidth as number) ?? 100;
|
||||||
|
const minHeight = (params.minHeight as number) ?? 100;
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('正在提取图片...', 50);
|
||||||
|
|
||||||
|
const extractScript = `
|
||||||
|
({ imageSelector, minWidth, minHeight }) => {
|
||||||
|
const images = document.querySelectorAll(imageSelector);
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
images.forEach(img => {
|
||||||
|
const width = img.naturalWidth || img.width;
|
||||||
|
const height = img.naturalHeight || img.height;
|
||||||
|
|
||||||
|
if (width >= minWidth && height >= minHeight) {
|
||||||
|
results.push({
|
||||||
|
src: img.src,
|
||||||
|
alt: img.alt || '',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await browser.eval(extractScript, [{
|
||||||
|
imageSelector,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const images = result as Array<{
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
onLog('info', `找到 ${images.length} 张图片`);
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
url: await browser.url(),
|
||||||
|
count: images.length,
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Scrape Links
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const scrapeLinksTemplate: TaskTemplate = {
|
||||||
|
id: 'scrape_links',
|
||||||
|
name: '抓取链接列表',
|
||||||
|
description: '提取页面中的所有链接',
|
||||||
|
category: 'scraping',
|
||||||
|
icon: 'Link',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'linkSelector',
|
||||||
|
label: '链接选择器',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
default: 'a[href]',
|
||||||
|
placeholder: 'a[href]',
|
||||||
|
description: '链接元素的 CSS 选择器',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'filterPattern',
|
||||||
|
label: 'URL 过滤',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
placeholder: 'example.com',
|
||||||
|
description: '只保留包含此文本的链接',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'excludePattern',
|
||||||
|
label: '排除模式',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
placeholder: '#, javascript:',
|
||||||
|
description: '排除包含此文本的链接',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const linkSelector = (params.linkSelector as string) ?? 'a[href]';
|
||||||
|
const filterPattern = params.filterPattern as string | undefined;
|
||||||
|
const excludePattern = params.excludePattern as string | undefined;
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('正在提取链接...', 50);
|
||||||
|
|
||||||
|
const extractScript = `
|
||||||
|
({ linkSelector, filterPattern, excludePattern }) => {
|
||||||
|
const links = document.querySelectorAll(linkSelector);
|
||||||
|
const results = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
links.forEach(a => {
|
||||||
|
const href = a.href;
|
||||||
|
const text = a.textContent?.trim() || '';
|
||||||
|
|
||||||
|
if (!href || seen.has(href)) return;
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
if (filterPattern && !href.includes(filterPattern) && !text.includes(filterPattern)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply exclude
|
||||||
|
if (excludePattern) {
|
||||||
|
const patterns = excludePattern.split(',').map(p => p.trim());
|
||||||
|
for (const p of patterns) {
|
||||||
|
if (href.includes(p)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(href);
|
||||||
|
results.push({ href, text });
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await browser.eval(extractScript, [{
|
||||||
|
linkSelector,
|
||||||
|
filterPattern,
|
||||||
|
excludePattern,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const links = result as Array<{ href: string; text: string }>;
|
||||||
|
onLog('info', `找到 ${links.length} 个链接`);
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
url: await browser.url(),
|
||||||
|
count: links.length,
|
||||||
|
links,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template: Scrape Table
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const scrapeTableTemplate: TaskTemplate = {
|
||||||
|
id: 'scrape_table',
|
||||||
|
name: '抓取表格数据',
|
||||||
|
description: '从 HTML 表格中提取数据',
|
||||||
|
category: 'scraping',
|
||||||
|
icon: 'Table',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: 'url',
|
||||||
|
label: '网页地址',
|
||||||
|
type: 'url',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'https://example.com/data',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tableSelector',
|
||||||
|
label: '表格选择器',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
default: 'table',
|
||||||
|
placeholder: 'table.data-table',
|
||||||
|
description: '表格元素的 CSS 选择器',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'headerRow',
|
||||||
|
label: '表头行',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
default: 1,
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
description: '表头所在行(0 表示无表头)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
execute: async (params, context: ExecutionContext) => {
|
||||||
|
const { browser, onProgress, onLog } = context;
|
||||||
|
const url = params.url as string;
|
||||||
|
const tableSelector = (params.tableSelector as string) ?? 'table';
|
||||||
|
const headerRow = (params.headerRow as number) ?? 1;
|
||||||
|
|
||||||
|
onProgress('正在导航到页面...', 0);
|
||||||
|
onLog('info', `访问: ${url}`);
|
||||||
|
await browser.goto(url);
|
||||||
|
|
||||||
|
onProgress('正在提取表格数据...', 50);
|
||||||
|
|
||||||
|
const extractScript = `
|
||||||
|
({ tableSelector, headerRow }) => {
|
||||||
|
const table = document.querySelector(tableSelector);
|
||||||
|
if (!table) return { headers: [], rows: [] };
|
||||||
|
|
||||||
|
const allRows = table.querySelectorAll('tr');
|
||||||
|
|
||||||
|
// Extract headers
|
||||||
|
let headers = [];
|
||||||
|
if (headerRow > 0 && allRows[headerRow - 1]) {
|
||||||
|
const headerCells = allRows[headerRow - 1].querySelectorAll('th, td');
|
||||||
|
headers = Array.from(headerCells).map(cell => cell.textContent?.trim() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data rows
|
||||||
|
const startRow = headerRow > 0 ? headerRow : 0;
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (let i = startRow; i < allRows.length; i++) {
|
||||||
|
const cells = allRows[i].querySelectorAll('td, th');
|
||||||
|
const rowData = Array.from(cells).map(cell => cell.textContent?.trim() || '');
|
||||||
|
if (rowData.some(d => d)) { // Skip empty rows
|
||||||
|
rows.push(rowData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await browser.eval(extractScript, [{ tableSelector, headerRow }]) as {
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
onLog('info', `提取了 ${result.rows.length} 行数据,${result.headers.length} 列`);
|
||||||
|
|
||||||
|
onProgress('完成', 100);
|
||||||
|
return {
|
||||||
|
url: await browser.url(),
|
||||||
|
headers: result.headers,
|
||||||
|
rowCount: result.rows.length,
|
||||||
|
data: result.rows,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export All Scraping Templates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const scrapingTemplates: TaskTemplate[] = [
|
||||||
|
scrapeTextTemplate,
|
||||||
|
scrapeListTemplate,
|
||||||
|
scrapeImagesTemplate,
|
||||||
|
scrapeLinksTemplate,
|
||||||
|
scrapeTableTemplate,
|
||||||
|
];
|
||||||
240
desktop/src/components/BrowserHand/templates/types.ts
Normal file
240
desktop/src/components/BrowserHand/templates/types.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Task Template Types for Browser Hand
|
||||||
|
*
|
||||||
|
* Defines the structure for browser automation task templates
|
||||||
|
* that can be executed via the UI or by Agent scripts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Browser } from '../../../lib/browser-client';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template Parameter Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TemplateParamType =
|
||||||
|
| 'text'
|
||||||
|
| 'url'
|
||||||
|
| 'number'
|
||||||
|
| 'select'
|
||||||
|
| 'textarea'
|
||||||
|
| 'json'
|
||||||
|
| 'boolean';
|
||||||
|
|
||||||
|
export interface TaskTemplateParam {
|
||||||
|
/** Parameter key used in execution */
|
||||||
|
key: string;
|
||||||
|
/** Display label for the UI */
|
||||||
|
label: string;
|
||||||
|
/** Type of input */
|
||||||
|
type: TemplateParamType;
|
||||||
|
/** Whether this parameter is required */
|
||||||
|
required: boolean;
|
||||||
|
/** Default value if not provided */
|
||||||
|
default?: unknown;
|
||||||
|
/** Placeholder text for input */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Options for select type */
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
/** Help text / description */
|
||||||
|
description?: string;
|
||||||
|
/** Validation pattern (regex string) */
|
||||||
|
pattern?: string;
|
||||||
|
/** Minimum value for number type */
|
||||||
|
min?: number;
|
||||||
|
/** Maximum value for number type */
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TemplateCategory = 'basic' | 'scraping' | 'automation';
|
||||||
|
|
||||||
|
export interface TaskTemplate {
|
||||||
|
/** Unique template identifier */
|
||||||
|
id: string;
|
||||||
|
/** Display name */
|
||||||
|
name: string;
|
||||||
|
/** Short description */
|
||||||
|
description: string;
|
||||||
|
/** Category for grouping */
|
||||||
|
category: TemplateCategory;
|
||||||
|
/** Icon name (Lucide icon) */
|
||||||
|
icon: string;
|
||||||
|
/** Parameter definitions */
|
||||||
|
params: TaskTemplateParam[];
|
||||||
|
/** Execution function */
|
||||||
|
execute: (params: Record<string, unknown>, context: ExecutionContext) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Execution Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type LogLevel = 'info' | 'warn' | 'error' | 'action';
|
||||||
|
|
||||||
|
export interface BrowserLog {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionContext {
|
||||||
|
/** Browser client instance */
|
||||||
|
browser: Browser;
|
||||||
|
/** Progress callback */
|
||||||
|
onProgress: (action: string, progress: number) => void;
|
||||||
|
/** Log callback */
|
||||||
|
onLog: (level: LogLevel, message: string, details?: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Execution State Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ExecutionStatus = 'idle' | 'running' | 'success' | 'error' | 'cancelled';
|
||||||
|
|
||||||
|
export interface ExecutionState {
|
||||||
|
/** Whether a task is currently running */
|
||||||
|
isRunning: boolean;
|
||||||
|
/** Current action description */
|
||||||
|
currentAction: string | null;
|
||||||
|
/** Current URL being processed */
|
||||||
|
currentUrl: string | null;
|
||||||
|
/** Last screenshot (base64) */
|
||||||
|
lastScreenshot: string | null;
|
||||||
|
/** Progress percentage (0-100) */
|
||||||
|
progress: number;
|
||||||
|
/** When execution started */
|
||||||
|
startTime: string | null;
|
||||||
|
/** Current status */
|
||||||
|
status: ExecutionStatus;
|
||||||
|
/** Error message if failed */
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SessionStatus = 'connecting' | 'connected' | 'active' | 'idle' | 'error';
|
||||||
|
|
||||||
|
export interface BrowserSession {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
currentUrl: string | null;
|
||||||
|
title: string | null;
|
||||||
|
status: SessionStatus;
|
||||||
|
createdAt: string;
|
||||||
|
lastActivity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Recent Task Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TaskResultStatus = 'success' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
|
export interface RecentTask {
|
||||||
|
id: string;
|
||||||
|
templateId: string;
|
||||||
|
templateName: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
status: TaskResultStatus;
|
||||||
|
executedAt: string;
|
||||||
|
duration: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store State Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface BrowserHandState {
|
||||||
|
// Session management
|
||||||
|
sessions: BrowserSession[];
|
||||||
|
activeSessionId: string | null;
|
||||||
|
|
||||||
|
// Execution state
|
||||||
|
execution: ExecutionState;
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
logs: BrowserLog[];
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
templates: TaskTemplate[];
|
||||||
|
recentTasks: RecentTask[];
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isTemplateModalOpen: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store Actions Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SessionOptions {
|
||||||
|
webdriverUrl?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
browserType?: 'chrome' | 'firefox' | 'edge' | 'safari';
|
||||||
|
windowWidth?: number;
|
||||||
|
windowHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserHandActions {
|
||||||
|
// Session management
|
||||||
|
createSession: (options?: SessionOptions) => Promise<string>;
|
||||||
|
closeSession: (sessionId: string) => Promise<void>;
|
||||||
|
listSessions: () => Promise<void>;
|
||||||
|
|
||||||
|
// Template execution
|
||||||
|
executeTemplate: (templateId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
executeScript: (script: string, args?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
// State updates
|
||||||
|
updateExecutionState: (state: Partial<ExecutionState>) => void;
|
||||||
|
addLog: (log: Omit<BrowserLog, 'id' | 'timestamp'>) => void;
|
||||||
|
clearLogs: () => void;
|
||||||
|
|
||||||
|
// Screenshot
|
||||||
|
takeScreenshot: () => Promise<string>;
|
||||||
|
|
||||||
|
// UI control
|
||||||
|
openTemplateModal: () => void;
|
||||||
|
closeTemplateModal: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
param: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: ValidationError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template Registry Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TemplateRegistry {
|
||||||
|
templates: Map<string, TaskTemplate>;
|
||||||
|
byCategory: Map<TemplateCategory, TaskTemplate[]>;
|
||||||
|
register: (template: TaskTemplate) => void;
|
||||||
|
get: (id: string) => TaskTemplate | undefined;
|
||||||
|
getByCategory: (category: TemplateCategory) => TaskTemplate[];
|
||||||
|
getAll: () => TaskTemplate[];
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
|
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
|
||||||
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings } from 'lucide-react';
|
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings } from 'lucide-react';
|
||||||
|
import { BrowserHandCard } from './BrowserHand';
|
||||||
|
|
||||||
// === Status Badge Component ===
|
// === Status Badge Component ===
|
||||||
|
|
||||||
@@ -457,7 +458,16 @@ export function HandsPanel() {
|
|||||||
|
|
||||||
{/* Hand Cards Grid */}
|
{/* Hand Cards Grid */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{hands.map((hand) => (
|
{hands.map((hand) => {
|
||||||
|
// Check if this is a Browser Hand
|
||||||
|
const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser');
|
||||||
|
|
||||||
|
return isBrowserHand ? (
|
||||||
|
<BrowserHandCard
|
||||||
|
key={hand.id}
|
||||||
|
hand={hand}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<HandCard
|
<HandCard
|
||||||
key={hand.id}
|
key={hand.id}
|
||||||
hand={hand}
|
hand={hand}
|
||||||
@@ -465,7 +475,8 @@ export function HandsPanel() {
|
|||||||
onActivate={handleActivate}
|
onActivate={handleActivate}
|
||||||
isActivating={activatingHandId === hand.id}
|
isActivating={activatingHandId === hand.id}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details Modal */}
|
{/* Details Modal */}
|
||||||
|
|||||||
496
desktop/src/store/browserHandStore.ts
Normal file
496
desktop/src/store/browserHandStore.ts
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
/**
|
||||||
|
* Browser Hand State Management
|
||||||
|
*
|
||||||
|
* Zustand store for managing browser automation state, sessions, and execution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import Browser, {
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
listSessions,
|
||||||
|
} from '../lib/browser-client';
|
||||||
|
import {
|
||||||
|
BUILTIN_TEMPLATES,
|
||||||
|
validateTemplateParams,
|
||||||
|
mergeParamsWithDefaults,
|
||||||
|
type TaskTemplate,
|
||||||
|
type ExecutionState,
|
||||||
|
type BrowserSession,
|
||||||
|
type BrowserLog,
|
||||||
|
type RecentTask,
|
||||||
|
type SessionOptions,
|
||||||
|
type LogLevel,
|
||||||
|
type SessionStatus,
|
||||||
|
} from '../components/BrowserHand/templates';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store State Interface
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface BrowserHandState {
|
||||||
|
// Sessions
|
||||||
|
sessions: BrowserSession[];
|
||||||
|
activeSessionId: string | null;
|
||||||
|
|
||||||
|
// Execution
|
||||||
|
execution: ExecutionState;
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
logs: BrowserLog[];
|
||||||
|
maxLogs: number;
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
templates: TaskTemplate[];
|
||||||
|
recentTasks: RecentTask[];
|
||||||
|
maxRecentTasks: number;
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
isTemplateModalOpen: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserHandActions {
|
||||||
|
// Session Management
|
||||||
|
createSession: (options?: SessionOptions) => Promise<string>;
|
||||||
|
closeSession: (sessionId: string) => Promise<void>;
|
||||||
|
listSessions: () => Promise<void>;
|
||||||
|
setActiveSession: (sessionId: string | null) => void;
|
||||||
|
|
||||||
|
// Template Execution
|
||||||
|
executeTemplate: (templateId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
executeScript: (script: string, args?: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
// State Updates
|
||||||
|
updateExecutionState: (state: Partial<ExecutionState>) => void;
|
||||||
|
addLog: (log: Omit<BrowserLog, 'id' | 'timestamp'>) => void;
|
||||||
|
clearLogs: () => void;
|
||||||
|
|
||||||
|
// Screenshot
|
||||||
|
takeScreenshot: () => Promise<string>;
|
||||||
|
|
||||||
|
// UI Control
|
||||||
|
openTemplateModal: () => void;
|
||||||
|
closeTemplateModal: () => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
|
||||||
|
// Recent Tasks
|
||||||
|
addRecentTask: (task: Omit<RecentTask, 'id' | 'executedAt'>) => void;
|
||||||
|
clearRecentTasks: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Initial State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const initialExecutionState: ExecutionState = {
|
||||||
|
isRunning: false,
|
||||||
|
currentAction: null,
|
||||||
|
currentUrl: null,
|
||||||
|
lastScreenshot: null,
|
||||||
|
progress: 0,
|
||||||
|
startTime: null,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: BrowserHandState = {
|
||||||
|
sessions: [],
|
||||||
|
activeSessionId: null,
|
||||||
|
execution: initialExecutionState,
|
||||||
|
logs: [],
|
||||||
|
maxLogs: 100,
|
||||||
|
templates: BUILTIN_TEMPLATES,
|
||||||
|
recentTasks: [],
|
||||||
|
maxRecentTasks: 50,
|
||||||
|
isTemplateModalOpen: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useBrowserHandStore = create<BrowserHandState & BrowserHandActions>((set, get) => ({
|
||||||
|
// State
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// Session Management
|
||||||
|
createSession: async (options?: SessionOptions) => {
|
||||||
|
const store = get();
|
||||||
|
store.setLoading(true);
|
||||||
|
store.clearError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
store.addLog({ level: 'info', message: '正在创建浏览器会话...' });
|
||||||
|
|
||||||
|
const result = await createSession({
|
||||||
|
webdriverUrl: options?.webdriverUrl,
|
||||||
|
headless: options?.headless ?? true,
|
||||||
|
browserType: options?.browserType ?? 'chrome',
|
||||||
|
windowWidth: options?.windowWidth,
|
||||||
|
windowHeight: options?.windowHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = result.session_id;
|
||||||
|
|
||||||
|
// Fetch session info
|
||||||
|
const sessions = await listSessions();
|
||||||
|
const sessionInfo = sessions.find(s => s.id === sessionId);
|
||||||
|
|
||||||
|
const newSession: BrowserSession = {
|
||||||
|
id: sessionId,
|
||||||
|
name: `Browser ${sessionId.substring(0, 8)}`,
|
||||||
|
currentUrl: sessionInfo?.current_url ?? null,
|
||||||
|
title: sessionInfo?.title ?? null,
|
||||||
|
status: (sessionInfo?.status as SessionStatus) ?? 'connected',
|
||||||
|
createdAt: sessionInfo?.created_at ?? new Date().toISOString(),
|
||||||
|
lastActivity: sessionInfo?.last_activity ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
sessions: [...state.sessions, newSession],
|
||||||
|
activeSessionId: sessionId,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
store.addLog({ level: 'info', message: `会话已创建: ${sessionId}` });
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
store.addLog({ level: 'error', message: `创建会话失败: ${errorMsg}` });
|
||||||
|
set({ isLoading: false, error: errorMsg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSession: async (sessionId: string) => {
|
||||||
|
const store = get();
|
||||||
|
store.setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeSession(sessionId);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
sessions: state.sessions.filter(s => s.id !== sessionId),
|
||||||
|
activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
store.addLog({ level: 'info', message: `会话已关闭: ${sessionId}` });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
store.addLog({ level: 'error', message: `关闭会话失败: ${errorMsg}` });
|
||||||
|
set({ isLoading: false, error: errorMsg });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listSessions: async () => {
|
||||||
|
try {
|
||||||
|
const sessions = await listSessions();
|
||||||
|
|
||||||
|
const mappedSessions: BrowserSession[] = sessions.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
name: `Browser ${s.id.substring(0, 8)}`,
|
||||||
|
currentUrl: s.current_url,
|
||||||
|
title: s.title,
|
||||||
|
status: s.status as SessionStatus,
|
||||||
|
createdAt: s.created_at,
|
||||||
|
lastActivity: s.last_activity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
set({ sessions: mappedSessions });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BrowserHand] Failed to list sessions:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveSession: (sessionId: string | null) => {
|
||||||
|
set({ activeSessionId: sessionId });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Template Execution
|
||||||
|
executeTemplate: async (templateId: string, params: Record<string, unknown>) => {
|
||||||
|
const store = get();
|
||||||
|
|
||||||
|
// Find template
|
||||||
|
const template = store.templates.find(t => t.id === templateId);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Template not found: ${templateId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate params
|
||||||
|
const validation = validateTemplateParams(template.params, params);
|
||||||
|
if (!validation.valid) {
|
||||||
|
const errorMessages = validation.errors.map(e => e.message).join(', ');
|
||||||
|
throw new Error(`Invalid parameters: ${errorMessages}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with defaults
|
||||||
|
const mergedParams = mergeParamsWithDefaults(template.params, params);
|
||||||
|
|
||||||
|
// Initialize execution state
|
||||||
|
const startTime = new Date().toISOString();
|
||||||
|
set({
|
||||||
|
execution: {
|
||||||
|
...initialExecutionState,
|
||||||
|
isRunning: true,
|
||||||
|
startTime,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create browser instance
|
||||||
|
const browser = new Browser();
|
||||||
|
|
||||||
|
try {
|
||||||
|
store.addLog({ level: 'info', message: `开始执行模板: ${template.name}` });
|
||||||
|
|
||||||
|
// Start browser session
|
||||||
|
await browser.start({ headless: true });
|
||||||
|
|
||||||
|
// Create execution context
|
||||||
|
const context = {
|
||||||
|
browser,
|
||||||
|
onProgress: (action: string, progress: number) => {
|
||||||
|
store.updateExecutionState({ currentAction: action, progress });
|
||||||
|
store.addLog({ level: 'action', message: action });
|
||||||
|
},
|
||||||
|
onLog: (level: LogLevel, message: string, details?: Record<string, unknown>) => {
|
||||||
|
store.addLog({ level, message, details });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute template
|
||||||
|
const result = await template.execute(mergedParams, context);
|
||||||
|
|
||||||
|
// Update state on success
|
||||||
|
set((state) => ({
|
||||||
|
execution: {
|
||||||
|
...state.execution,
|
||||||
|
isRunning: false,
|
||||||
|
progress: 100,
|
||||||
|
status: 'success',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add to recent tasks
|
||||||
|
const duration = Date.now() - new Date(startTime).getTime();
|
||||||
|
store.addRecentTask({
|
||||||
|
templateId: template.id,
|
||||||
|
templateName: template.name,
|
||||||
|
params: mergedParams,
|
||||||
|
status: 'success',
|
||||||
|
duration,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.addLog({ level: 'info', message: `模板执行完成: ${template.name}` });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
execution: {
|
||||||
|
...state.execution,
|
||||||
|
isRunning: false,
|
||||||
|
status: 'error',
|
||||||
|
error: errorMsg,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add failed task
|
||||||
|
const duration = Date.now() - new Date(startTime).getTime();
|
||||||
|
store.addRecentTask({
|
||||||
|
templateId: template.id,
|
||||||
|
templateName: template.name,
|
||||||
|
params: mergedParams,
|
||||||
|
status: 'failed',
|
||||||
|
duration,
|
||||||
|
error: errorMsg,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.addLog({ level: 'error', message: `模板执行失败: ${errorMsg}` });
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
executeScript: async (script: string, args?: unknown[]) => {
|
||||||
|
const store = get();
|
||||||
|
|
||||||
|
if (!store.activeSessionId) {
|
||||||
|
throw new Error('No active browser session');
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateExecutionState({
|
||||||
|
isRunning: true,
|
||||||
|
currentAction: 'Executing script...',
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const browser = new Browser();
|
||||||
|
await browser.start();
|
||||||
|
|
||||||
|
const result = await browser.eval(script, args);
|
||||||
|
|
||||||
|
store.updateExecutionState({
|
||||||
|
isRunning: false,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
store.updateExecutionState({
|
||||||
|
isRunning: false,
|
||||||
|
status: 'error',
|
||||||
|
error: errorMsg,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// State Updates
|
||||||
|
updateExecutionState: (state: Partial<ExecutionState>) => {
|
||||||
|
set((prev) => ({
|
||||||
|
execution: { ...prev.execution, ...state },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
addLog: (log: Omit<BrowserLog, 'id' | 'timestamp'>) => {
|
||||||
|
const newLog: BrowserLog = {
|
||||||
|
...log,
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const logs = [...state.logs, newLog];
|
||||||
|
// Trim logs if exceeding max
|
||||||
|
if (logs.length > state.maxLogs) {
|
||||||
|
return { logs: logs.slice(-state.maxLogs) };
|
||||||
|
}
|
||||||
|
return { logs };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLogs: () => {
|
||||||
|
set({ logs: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Screenshot
|
||||||
|
takeScreenshot: async () => {
|
||||||
|
const store = get();
|
||||||
|
|
||||||
|
if (!store.activeSessionId) {
|
||||||
|
throw new Error('No active browser session');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const browser = new Browser();
|
||||||
|
await browser.start();
|
||||||
|
|
||||||
|
const result = await browser.screenshot();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
execution: {
|
||||||
|
...state.execution,
|
||||||
|
lastScreenshot: result.base64,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
store.addLog({ level: 'info', message: 'Screenshot captured' });
|
||||||
|
|
||||||
|
return result.base64;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
store.addLog({ level: 'error', message: `Screenshot failed: ${errorMsg}` });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI Control
|
||||||
|
openTemplateModal: () => {
|
||||||
|
set({ isTemplateModalOpen: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTemplateModal: () => {
|
||||||
|
set({ isTemplateModalOpen: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (loading: boolean) => {
|
||||||
|
set({ isLoading: loading });
|
||||||
|
},
|
||||||
|
|
||||||
|
setError: (error: string | null) => {
|
||||||
|
set({ error });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Recent Tasks
|
||||||
|
addRecentTask: (task: Omit<RecentTask, 'id' | 'executedAt'>) => {
|
||||||
|
const newTask: RecentTask = {
|
||||||
|
...task,
|
||||||
|
id: uuidv4(),
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const recentTasks = [newTask, ...state.recentTasks];
|
||||||
|
// Trim if exceeding max
|
||||||
|
if (recentTasks.length > state.maxRecentTasks) {
|
||||||
|
return { recentTasks: recentTasks.slice(0, state.maxRecentTasks) };
|
||||||
|
}
|
||||||
|
return { recentTasks };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearRecentTasks: () => {
|
||||||
|
set({ recentTasks: [] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Selector Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useActiveSession = () =>
|
||||||
|
useBrowserHandStore((state) => {
|
||||||
|
if (!state.activeSessionId) return null;
|
||||||
|
return state.sessions.find(s => s.id === state.activeSessionId) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useExecutionState = () =>
|
||||||
|
useBrowserHandStore((state) => state.execution);
|
||||||
|
|
||||||
|
export const useIsRunning = () =>
|
||||||
|
useBrowserHandStore((state) => state.execution.isRunning);
|
||||||
|
|
||||||
|
export const useTemplates = () =>
|
||||||
|
useBrowserHandStore((state) => state.templates);
|
||||||
|
|
||||||
|
export const useTemplatesByCategory = (category: string) =>
|
||||||
|
useBrowserHandStore((state) =>
|
||||||
|
state.templates.filter(t => t.category === category)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useRecentTasks = () =>
|
||||||
|
useBrowserHandStore((state) => state.recentTasks);
|
||||||
|
|
||||||
|
export const useLogs = () =>
|
||||||
|
useBrowserHandStore((state) => state.logs);
|
||||||
@@ -33,6 +33,22 @@ export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout }
|
|||||||
export { useActiveLearningStore } from './activeLearningStore';
|
export { useActiveLearningStore } from './activeLearningStore';
|
||||||
export type { ActiveLearningStore } from './activeLearningStore';
|
export type { ActiveLearningStore } from './activeLearningStore';
|
||||||
|
|
||||||
|
// === Browser Hand Store ===
|
||||||
|
export { useBrowserHandStore } from './browserHandStore';
|
||||||
|
export type {
|
||||||
|
BrowserHandState,
|
||||||
|
BrowserHandActions,
|
||||||
|
ExecutionState,
|
||||||
|
ExecutionStatus,
|
||||||
|
BrowserSession,
|
||||||
|
SessionStatus,
|
||||||
|
BrowserLog,
|
||||||
|
LogLevel,
|
||||||
|
RecentTask,
|
||||||
|
TaskResultStatus,
|
||||||
|
SessionOptions,
|
||||||
|
} from '../components/BrowserHand/templates/types';
|
||||||
|
|
||||||
// === Composite Store Hook ===
|
// === Composite Store Hook ===
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|||||||
273
tests/desktop/browserHandStore.test.ts
Normal file
273
tests/desktop/browserHandStore.test.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Browser Hand Store Tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { useBrowserHandStore } from '../../desktop/src/store/browserHandStore';
|
||||||
|
import {
|
||||||
|
validateTemplateParams,
|
||||||
|
mergeParamsWithDefaults,
|
||||||
|
} from '../../desktop/src/components/BrowserHand/templates';
|
||||||
|
import type { TaskTemplateParam } from '../../desktop/src/components/BrowserHand/templates';
|
||||||
|
|
||||||
|
// Mock the browser-client module
|
||||||
|
vi.mock('../../desktop/src/lib/browser-client', () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
start: vi.fn().mockResolvedValue('test-session-id'),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
goto: vi.fn().mockResolvedValue({ url: 'https://example.com', title: 'Test Page' }),
|
||||||
|
screenshot: vi.fn().mockResolvedValue({ base64: 'test-base64', format: 'png' }),
|
||||||
|
url: vi.fn().mockResolvedValue('https://example.com'),
|
||||||
|
title: vi.fn().mockResolvedValue('Test Page'),
|
||||||
|
click: vi.fn().mockResolvedValue(undefined),
|
||||||
|
type: vi.fn().mockResolvedValue(undefined),
|
||||||
|
wait: vi.fn().mockResolvedValue({}),
|
||||||
|
eval: vi.fn().mockResolvedValue(null),
|
||||||
|
})),
|
||||||
|
createSession: vi.fn().mockResolvedValue({ session_id: 'test-session-id' }),
|
||||||
|
closeSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
listSessions: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock uuid
|
||||||
|
vi.mock('uuid', () => ({
|
||||||
|
v4: vi.fn().mockReturnValue('test-uuid'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('browserHandStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store state before each test
|
||||||
|
useBrowserHandStore.setState({
|
||||||
|
sessions: [],
|
||||||
|
activeSessionId: null,
|
||||||
|
execution: {
|
||||||
|
isRunning: false,
|
||||||
|
currentAction: null,
|
||||||
|
currentUrl: null,
|
||||||
|
lastScreenshot: null,
|
||||||
|
progress: 0,
|
||||||
|
startTime: null,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
logs: [],
|
||||||
|
templates: [],
|
||||||
|
recentTasks: [],
|
||||||
|
isTemplateModalOpen: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('should have correct initial state', () => {
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
|
||||||
|
expect(state.sessions).toEqual([]);
|
||||||
|
expect(state.activeSessionId).toBeNull();
|
||||||
|
expect(state.execution.isRunning).toBe(false);
|
||||||
|
expect(state.logs).toEqual([]);
|
||||||
|
expect(state.isTemplateModalOpen).toBe(false);
|
||||||
|
expect(state.isLoading).toBe(false);
|
||||||
|
expect(state.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UI actions', () => {
|
||||||
|
it('should open template modal', () => {
|
||||||
|
const { openTemplateModal } = useBrowserHandStore.getState();
|
||||||
|
openTemplateModal();
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.isTemplateModalOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close template modal', () => {
|
||||||
|
const { openTemplateModal, closeTemplateModal } = useBrowserHandStore.getState();
|
||||||
|
openTemplateModal();
|
||||||
|
closeTemplateModal();
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.isTemplateModalOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set loading state', () => {
|
||||||
|
const { setLoading } = useBrowserHandStore.getState();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.isLoading).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and clear error', () => {
|
||||||
|
const { setError, clearError } = useBrowserHandStore.getState();
|
||||||
|
setError('Test error');
|
||||||
|
|
||||||
|
let state = useBrowserHandStore.getState();
|
||||||
|
expect(state.error).toBe('Test error');
|
||||||
|
|
||||||
|
clearError();
|
||||||
|
state = useBrowserHandStore.getState();
|
||||||
|
expect(state.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execution state', () => {
|
||||||
|
it('should update execution state', () => {
|
||||||
|
const { updateExecutionState } = useBrowserHandStore.getState();
|
||||||
|
updateExecutionState({
|
||||||
|
currentAction: 'Navigating...',
|
||||||
|
progress: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.execution.currentAction).toBe('Navigating...');
|
||||||
|
expect(state.execution.progress).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logs', () => {
|
||||||
|
it('should add log entries', () => {
|
||||||
|
const { addLog } = useBrowserHandStore.getState();
|
||||||
|
addLog({ level: 'info', message: 'Test log' });
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.logs).toHaveLength(1);
|
||||||
|
expect(state.logs[0].level).toBe('info');
|
||||||
|
expect(state.logs[0].message).toBe('Test log');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear logs', () => {
|
||||||
|
const { addLog, clearLogs } = useBrowserHandStore.getState();
|
||||||
|
addLog({ level: 'info', message: 'Test log' });
|
||||||
|
clearLogs();
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.logs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit log entries', () => {
|
||||||
|
const store = useBrowserHandStore.getState();
|
||||||
|
|
||||||
|
// Add more than max logs
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
store.addLog({ level: 'info', message: `Log ${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.logs.length).toBeLessThanOrEqual(state.maxLogs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recent tasks', () => {
|
||||||
|
it('should add recent task', () => {
|
||||||
|
const { addRecentTask } = useBrowserHandStore.getState();
|
||||||
|
addRecentTask({
|
||||||
|
templateId: 'basic_navigate_screenshot',
|
||||||
|
templateName: '打开网页并截图',
|
||||||
|
params: { url: 'https://example.com' },
|
||||||
|
status: 'success',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.recentTasks).toHaveLength(1);
|
||||||
|
expect(state.recentTasks[0].templateId).toBe('basic_navigate_screenshot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear recent tasks', () => {
|
||||||
|
const { addRecentTask, clearRecentTasks } = useBrowserHandStore.getState();
|
||||||
|
addRecentTask({
|
||||||
|
templateId: 'test',
|
||||||
|
templateName: 'Test',
|
||||||
|
params: {},
|
||||||
|
status: 'success',
|
||||||
|
duration: 100,
|
||||||
|
});
|
||||||
|
clearRecentTasks();
|
||||||
|
|
||||||
|
const state = useBrowserHandStore.getState();
|
||||||
|
expect(state.recentTasks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Template validation utilities', () => {
|
||||||
|
describe('validateTemplateParams', () => {
|
||||||
|
it('should validate required params', () => {
|
||||||
|
const params: TaskTemplateParam[] = [
|
||||||
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
||||||
|
{ key: 'name', label: 'Name', type: 'text', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Missing required param
|
||||||
|
let result = validateTemplateParams(params, {});
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
|
||||||
|
// All params provided
|
||||||
|
result = validateTemplateParams(params, { url: 'https://example.com' });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate URL type', () => {
|
||||||
|
const params: TaskTemplateParam[] = [
|
||||||
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Invalid URL
|
||||||
|
let result = validateTemplateParams(params, { url: 'not-a-url' });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
|
||||||
|
// Valid URL
|
||||||
|
result = validateTemplateParams(params, { url: 'https://example.com' });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate number type with min/max', () => {
|
||||||
|
const params: TaskTemplateParam[] = [
|
||||||
|
{ key: 'count', label: 'Count', type: 'number', required: true, min: 1, max: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Below min
|
||||||
|
let result = validateTemplateParams(params, { count: 0 });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
|
||||||
|
// Above max
|
||||||
|
result = validateTemplateParams(params, { count: 20 });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
result = validateTemplateParams(params, { count: 5 });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeParamsWithDefaults', () => {
|
||||||
|
it('should merge with default values', () => {
|
||||||
|
const params: TaskTemplateParam[] = [
|
||||||
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
||||||
|
{ key: 'timeout', label: 'Timeout', type: 'number', required: false, default: 5000 },
|
||||||
|
{ key: 'headless', label: 'Headless', type: 'boolean', required: false, default: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const merged = mergeParamsWithDefaults(params, { url: 'https://example.com' });
|
||||||
|
|
||||||
|
expect(merged.url).toBe('https://example.com');
|
||||||
|
expect(merged.timeout).toBe(5000);
|
||||||
|
expect(merged.headless).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should override defaults with provided values', () => {
|
||||||
|
const params: TaskTemplateParam[] = [
|
||||||
|
{ key: 'url', label: 'URL', type: 'url', required: true },
|
||||||
|
{ key: 'timeout', label: 'Timeout', type: 'number', required: false, default: 5000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const merged = mergeParamsWithDefaults(params, { url: 'https://example.com', timeout: 10000 });
|
||||||
|
|
||||||
|
expect(merged.url).toBe('https://example.com');
|
||||||
|
expect(merged.timeout).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user