Files
zclaw_openfang/desktop/src/store/browserHandStore.ts
iven 985644dd9a
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(memory): FTS5 full-text search + browser hand autonomy gate
M4-05: Replace LIKE-only search with FTS5-first strategy:
- Add memories_fts virtual table (unicode61 tokenizer)
- FTS5 MATCH primary path with CJK LIKE fallback
- Sync FTS index on store()

M3-03: Add autonomy approval check to browserHandStore:
- executeTemplate: check canAutoExecute before running
- executeScript: check approval gate for JS execution
2026-04-04 18:52:02 +08:00

520 lines
14 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,
screenshot as screenshotFn,
executeScript as executeScriptFn,
} 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';
import { canAutoExecute } from '../lib/autonomy-manager';
// ============================================================================
// 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>) => {
// Autonomy approval gate — browser hand requires_approval=true
const { canProceed, decision } = canAutoExecute('hand_trigger' as any, 5);
if (!canProceed) {
set({ error: `Browser 操作需要审批: ${decision.reason || '请确认后重试'}` });
throw new Error(`Browser 操作需要审批: ${decision.reason || 'requires approval'}`);
}
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 — reuse active session if available
const browser = new Browser();
let createdOwnSession = false;
try {
store.addLog({ level: 'info', message: `开始执行模板: ${template.name}` });
// Attach to existing session or start a new one
if (store.activeSessionId) {
browser.connect(store.activeSessionId);
createdOwnSession = false;
} else {
await browser.start({ headless: true });
createdOwnSession = 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 {
// Only close the session if we created it (no pre-existing active session)
if (createdOwnSession) {
await browser.close();
}
}
},
executeScript: async (script: string, args?: unknown[]) => {
// Autonomy approval gate — arbitrary JS execution is high risk
const { canProceed, decision } = canAutoExecute('hand_trigger' as any, 8);
if (!canProceed) {
set({ error: `脚本执行需要审批: ${decision.reason || '请确认后重试'}` });
throw new Error(`Script execution requires approval: ${decision.reason || 'requires approval'}`);
}
const store = get();
if (!store.activeSessionId) {
throw new Error('No active browser session');
}
store.updateExecutionState({
isRunning: true,
currentAction: 'Executing script...',
status: 'running',
});
try {
// Use the standalone function with the existing session — no new session created
const result = await executeScriptFn(store.activeSessionId, 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 {
// Use the standalone function with the existing session — no new session created
const result = await screenshotFn(store.activeSessionId);
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);