Files
zclaw_openfang/desktop/src/store/browserHandStore.ts
iven 6bd9b841aa 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>
2026-03-17 08:56:02 +08:00

497 lines
13 KiB
TypeScript

/**
* 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);