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:
iven
2026-03-17 08:56:02 +08:00
parent 69c874ed59
commit 6bd9b841aa
13 changed files with 3729 additions and 9 deletions

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

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

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

View 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';

View 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,
];

View 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,
];

View 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);

View 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,
];

View 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[];
}

View File

@@ -10,6 +10,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings } from 'lucide-react';
import { BrowserHandCard } from './BrowserHand';
// === Status Badge Component ===
@@ -457,15 +458,25 @@ export function HandsPanel() {
{/* Hand Cards Grid */}
<div className="grid gap-3">
{hands.map((hand) => (
<HandCard
key={hand.id}
hand={hand}
onDetails={handleDetails}
onActivate={handleActivate}
isActivating={activatingHandId === hand.id}
/>
))}
{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
key={hand.id}
hand={hand}
onDetails={handleDetails}
onActivate={handleActivate}
isActivating={activatingHandId === hand.id}
/>
);
})}
</div>
{/* Details Modal */}

View 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);

View File

@@ -33,6 +33,22 @@ export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout }
export { useActiveLearningStore } 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 ===
import { useMemo } from 'react';

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