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
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
520 lines
14 KiB
TypeScript
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);
|