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:
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);
|
||||
Reference in New Issue
Block a user