Compare commits

...

5 Commits

Author SHA1 Message Date
iven
74dbf42644 refactor(startup): simplify stack to Tauri-managed OpenFang + optional ChromeDriver
- Remove OpenFang CLI dependency from startup scripts
- OpenFang now bundled with Tauri and managed via gateway_start/gateway_status commands
- Add bootstrap screen in App.tsx to auto-start local gateway before UI loads
- Update Makefile: replace start-no-gateway with start-desktop-only
- Fix gateway config endpoints: use /api/config instead of /api/config/quick
- Add Playwright dependencies for future E2E testing
2026-03-17 14:08:03 +08:00
iven
6c6d21400c fix(scripts): use powershell instead of pwsh and add DesktopOnly mode
- Replace pwsh with powershell for Windows compatibility
- Add -DesktopOnly flag to skip all external services
- Add automatic port 1420 cleanup before starting
- Improve stop command to kill all related processes
- Update package.json scripts for easier access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:40:16 +08:00
iven
d890fa1858 feat(scripts): add unified startup scripts for full stack development
Add comprehensive startup scripts for managing all ZCLAW services:

Windows (PowerShell):
- start.ps1 / start-all.ps1 - Unified service launcher
- Supports -NoBrowser, -NoGateway, -Dev, -Stop flags

Unix (Bash):
- start.sh - Cross-platform launcher for macOS/Linux

Makefile:
- make start / make start-unix
- make desktop / make desktop-build
- make setup / make test / make clean

pnpm commands:
- pnpm start - Start all services
- pnpm start:dev - Development mode with hot reload
- pnpm start:no-browser - Skip ChromeDriver
- pnpm start:no-gateway - Skip OpenFang gateway
- pnpm desktop - Start Tauri only
- pnpm chromedriver - Start ChromeDriver only

Services managed:
1. ChromeDriver (port 4444) - Browser automation
2. OpenFang Gateway (port 4200) - AI Agent runtime
3. Tauri Desktop - React + Rust frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:23:44 +08:00
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
iven
69c874ed59 docs(spec): add Browser Hand UI design specification
Design for browser automation UI component integrated into HandsPanel:
- Dual trigger mechanism: frontend templates + agent scripts
- Real-time status + screenshot preview
- Complete template system (basic, scraping, automation)
- Task runner with progress tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:14:41 +08:00
98 changed files with 10575 additions and 20 deletions

73
Makefile Normal file
View File

@@ -0,0 +1,73 @@
# ZCLAW Makefile
# Cross-platform task runner
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean
help: ## Show this help message
@echo "ZCLAW - OpenFang Desktop Client"
@echo ""
@echo "Usage: make [target]"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
# === Startup Commands ===
start: ## Start all services (Windows: PowerShell)
@powershell -ExecutionPolicy Bypass -File ./start-all.ps1
start-dev: ## Start all services in dev mode
@powershell -ExecutionPolicy Bypass -File ./start-all.ps1 -Dev
start-no-browser: ## Start without ChromeDriver
@powershell -ExecutionPolicy Bypass -File ./start-all.ps1 -NoBrowser
start-desktop-only: ## Start desktop only (no external services)
@powershell -ExecutionPolicy Bypass -File ./start-all.ps1 -DesktopOnly
start-unix: ## Start all services (Unix: macOS/Linux)
@chmod +x ./start.sh && ./start.sh
start-unix-dev: ## Start all services in dev mode (Unix)
@chmod +x ./start.sh && ./start.sh --dev
# === Desktop App ===
desktop: ## Start Tauri desktop app in dev mode
@cd desktop && pnpm tauri dev
desktop-build: ## Build Tauri desktop app
@cd desktop && pnpm build && pnpm tauri build
# === Development ===
setup: ## Run first-time setup
@tsx scripts/setup.ts
test: ## Run all tests
@pnpm test
test-desktop: ## Run desktop tests
@cd desktop && pnpm test
typecheck: ## Run TypeScript type check
@cd desktop && pnpm typecheck
# === Services ===
chromedriver: ## Start ChromeDriver on port 4444
@chromedriver --port=4444
# === Cleanup ===
clean: ## Clean build artifacts
@rm -rf dist/
@rm -rf desktop/dist/
@rm -rf desktop/src-tauri/target/
@rm -rf node_modules/
@rm -rf desktop/node_modules/
@echo "Cleaned build artifacts"
clean-deep: clean ## Deep clean (including pnpm cache)
@rm -rf desktop/pnpm-lock.yaml
@rm -rf pnpm-lock.yaml
@echo "Deep clean complete. Run 'pnpm install' to reinstall."

View File

@@ -14,10 +14,15 @@
"prepare:tauri-tools": "node scripts/preseed-tauri-tools.mjs",
"prepare:tauri-tools:dry-run": "node scripts/preseed-tauri-tools.mjs --dry-run",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:bundled": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs",
"tauri:build:bundled:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug",
"tauri:build:nsis:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug --bundles nsis",
"tauri:build:msi:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug --bundles msi"
"tauri:build:msi:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug --bundles msi",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tauri-apps/api": "^2",
@@ -36,6 +41,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
@@ -44,6 +50,7 @@
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.27",
"playwright": "^1.58.2",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.3",

41
desktop/pnpm-lock.yaml generated
View File

@@ -51,6 +51,9 @@ importers:
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
devDependencies:
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))
@@ -75,6 +78,9 @@ importers:
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.8)
playwright:
specifier: ^1.58.2
version: 1.58.2
postcss:
specifier: ^8.5.8
version: 8.5.8
@@ -345,6 +351,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -786,6 +797,11 @@ packages:
react-dom:
optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -924,6 +940,16 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@@ -1277,6 +1303,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.59.0':
@@ -1615,6 +1645,9 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -1707,6 +1740,14 @@ snapshots:
picomatch@4.0.3: {}
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
postcss-value-parser@4.2.0: {}
postcss@8.5.8:

View File

@@ -14,16 +14,31 @@ import { useTeamStore } from './store/teamStore';
import { getStoredGatewayToken } from './lib/gateway-client';
import { pageVariants, defaultTransition, fadeInVariants } from './lib/animations';
import { silentErrorHandler } from './lib/error-utils';
import { Bot, Users } from 'lucide-react';
import { Bot, Users, Loader2 } from 'lucide-react';
import { EmptyState } from './components/ui';
import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/tauri-gateway';
type View = 'main' | 'settings';
// Bootstrap component that ensures OpenFang is running before rendering main UI
function BootstrapScreen({ status }: { status: string }) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<p className="text-gray-600 text-sm">{status}</p>
</div>
</div>
);
}
function App() {
const [view, setView] = useState<View>('main');
const [mainContentView, setMainContentView] = useState<MainViewType>('chat');
const [selectedHandId, setSelectedHandId] = useState<string | undefined>(undefined);
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(undefined);
const [bootstrapping, setBootstrapping] = useState(true);
const [bootstrapStatus, setBootstrapStatus] = useState('Initializing...');
const { connect, connectionState } = useGatewayStore();
const { activeTeam, setActiveTeam, teams } = useTeamStore();
@@ -31,12 +46,61 @@ function App() {
document.title = 'ZCLAW';
}, []);
// Bootstrap: Start OpenFang Gateway before rendering main UI
useEffect(() => {
if (connectionState === 'disconnected') {
const gatewayToken = getStoredGatewayToken();
connect(undefined, gatewayToken).catch(silentErrorHandler('App'));
let mounted = true;
const bootstrap = async () => {
try {
// Step 1: Check and start local gateway in Tauri environment
if (isTauriRuntime()) {
setBootstrapStatus('Checking gateway status...');
try {
const status = await getLocalGatewayStatus();
const isRunning = status.portStatus === 'busy' || status.listenerPids.length > 0;
if (!isRunning && status.cliAvailable) {
setBootstrapStatus('Starting OpenFang Gateway...');
console.log('[App] Local gateway not running, auto-starting...');
await startLocalGateway();
// Wait for gateway to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('[App] Local gateway started');
} else if (isRunning) {
console.log('[App] Local gateway already running');
}
}, [connect, connectionState]);
} catch (err) {
console.warn('[App] Failed to check/start local gateway:', err);
}
}
if (!mounted) return;
// Step 2: Connect to gateway
setBootstrapStatus('Connecting to gateway...');
const gatewayToken = getStoredGatewayToken();
await connect(undefined, gatewayToken);
if (!mounted) return;
// Step 3: Bootstrap complete
setBootstrapping(false);
} catch (err) {
console.error('[App] Bootstrap failed:', err);
// Still allow app to load, connection status will show error
setBootstrapping(false);
}
};
bootstrap();
return () => {
mounted = false;
};
}, [connect]);
// 当切换到非 hands 视图时清除选中的 Hand
const handleMainViewChange = (view: MainViewType) => {
@@ -59,6 +123,11 @@ function App() {
return <SettingsLayout onBack={() => setView('main')} />;
}
// Show bootstrap screen while starting gateway
if (bootstrapping) {
return <BootstrapScreen status={bootstrapStatus} />;
}
return (
<div className="h-screen flex overflow-hidden text-gray-800 text-sm">
{/* 左侧边栏 */}

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,7 +458,16 @@ export function HandsPanel() {
{/* Hand Cards Grid */}
<div className="grid gap-3">
{hands.map((hand) => (
{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}
@@ -465,7 +475,8 @@ export function HandsPanel() {
onActivate={handleActivate}
isActivating={activatingHandId === hand.id}
/>
))}
);
})}
</div>
{/* Details Modal */}

View File

@@ -1086,7 +1086,28 @@ export class GatewayClient {
}
async getQuickConfig(): Promise<any> {
try {
return await this.restGet('/api/config/quick');
// Use /api/config endpoint (OpenFang's actual config endpoint)
const config = await this.restGet('/api/config');
// Map OpenFang config to frontend expected format
return {
quickConfig: {
agentName: 'ZCLAW',
agentRole: 'AI 助手',
userName: '用户',
userRole: '用户',
agentNickname: 'ZCLAW',
scenarios: ['通用对话', '代码助手', '文档编写'],
workspaceDir: config.data_dir || config.home_dir,
gatewayUrl: this.baseUrl,
defaultModel: config.default_model?.model,
defaultProvider: config.default_model?.provider,
theme: 'dark',
showToolCalls: true,
autoSaveContext: true,
fileWatching: true,
privacyOptIn: false,
}
};
} catch (error) {
// Return structured fallback if API not available (404)
if (isNotFoundError(error)) {
@@ -1096,7 +1117,16 @@ export class GatewayClient {
}
}
async saveQuickConfig(config: Record<string, any>): Promise<any> {
return this.restPut('/api/config/quick', config);
// Use /api/config endpoint for saving config
// Map frontend config back to OpenFang format
const openfangConfig = {
data_dir: config.workspaceDir,
default_model: config.defaultModel ? {
model: config.defaultModel,
provider: config.defaultProvider || 'bailian',
} : undefined,
};
return this.restPut('/api/config', openfangConfig);
}
async listSkills(): Promise<any> {
return this.restGet('/api/skills');

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

@@ -219,6 +219,26 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
} catch {
/* ignore local gateway preparation failures during connection bootstrap */
}
// Auto-start local gateway if not running
try {
const localStatus = await fetchLocalGatewayStatus();
const isRunning = localStatus.portStatus === 'busy' || localStatus.listenerPids.length > 0;
if (!isRunning && localStatus.cliAvailable) {
console.log('[ConnectionStore] Local gateway not running, auto-starting...');
set({ localGatewayBusy: true });
await startLocalGatewayCommand();
set({ localGatewayBusy: false });
// Wait for gateway to be ready
await new Promise(resolve => setTimeout(resolve, 1500));
console.log('[ConnectionStore] Local gateway started');
}
} catch (startError) {
console.warn('[ConnectionStore] Failed to auto-start local gateway:', startError);
set({ localGatewayBusy: false });
}
}
// Resolve effective token: param > quickConfig > localStorage > local auth

View File

@@ -659,12 +659,34 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
try {
set({ error: null });
// Prepare local gateway for Tauri
if (isTauriRuntime()) {
try {
await prepareLocalGatewayForTauri();
} catch {
/* ignore local gateway preparation failures during connection bootstrap */
}
// Auto-start local gateway if not running
try {
const localStatus = await getLocalGatewayStatus();
const isRunning = localStatus.portStatus === 'busy' || localStatus.listenerPids.length > 0;
if (!isRunning && localStatus.cliAvailable) {
console.log('[GatewayStore] Local gateway not running, auto-starting...');
set({ localGatewayBusy: true });
await startLocalGatewayCommand();
set({ localGatewayBusy: false });
// Wait for gateway to be ready
await new Promise(resolve => setTimeout(resolve, 1500));
console.log('[GatewayStore] Local gateway started');
}
} catch (startError) {
console.warn('[GatewayStore] Failed to auto-start local gateway:', startError);
set({ localGatewayBusy: false });
}
}
// Use the first non-empty token from: param > quickConfig > localStorage
let effectiveToken = token || get().quickConfig.gatewayToken || getStoredGatewayToken();

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,11 @@
{
"status": "failed",
"failedTests": [
"ea562bc8f2f5f42dadea-a9ad995be4600240d5d9",
"ea562bc8f2f5f42dadea-aa98d5dacb19aae6a62f",
"ea562bc8f2f5f42dadea-24005574dbd87061e5f7",
"ea562bc8f2f5f42dadea-faee21c3e777f7004b5c",
"ea562bc8f2f5f42dadea-27f22490c6765498e906",
"ea562bc8f2f5f42dadea-233185470e18cdb79c26"
]
}

View File

@@ -0,0 +1,246 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- complementary [ref=e4]:
- tablist [ref=e5]:
- tab "分身" [selected] [ref=e6]:
- img [ref=e7]
- generic [ref=e10]: 分身
- tab "Hands" [ref=e11]:
- img [ref=e12]
- generic [ref=e14]: Hands
- tab "工作流" [ref=e15]:
- img [ref=e16]
- generic [ref=e20]: 工作流
- tab "团队" [ref=e21]:
- img [ref=e22]
- generic [ref=e27]: 团队
- tab "协作" [ref=e28]:
- img [ref=e29]
- generic [ref=e33]: 协作
- generic [ref=e37]:
- generic [ref=e38] [cursor=pointer]:
- img [ref=e40]
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]: trader-hand
- generic [ref=e46]: 当前
- paragraph [ref=e47]: 新分身
- button "删除":
- img
- generic [ref=e48] [cursor=pointer]:
- img [ref=e50]
- generic [ref=e53]:
- generic [ref=e55]: researcher
- paragraph [ref=e56]: 新分身
- button "删除":
- img
- generic [ref=e57] [cursor=pointer]:
- img [ref=e59]
- generic [ref=e62]:
- generic [ref=e64]: browser-hand
- paragraph [ref=e65]: 新分身
- button "删除":
- img
- generic [ref=e66] [cursor=pointer]:
- img [ref=e68]
- generic [ref=e71]:
- generic [ref=e73]: collector-hand
- paragraph [ref=e74]: 新分身
- button "删除":
- img
- generic [ref=e75] [cursor=pointer]:
- img [ref=e77]
- generic [ref=e80]:
- generic [ref=e82]: researcher-hand
- paragraph [ref=e83]: 新分身
- button "删除":
- img
- generic [ref=e84] [cursor=pointer]:
- img [ref=e86]
- generic [ref=e89]:
- generic [ref=e91]: lead-hand
- paragraph [ref=e92]: 新分身
- button "删除":
- img
- generic [ref=e93] [cursor=pointer]:
- img [ref=e95]
- generic [ref=e98]:
- generic [ref=e100]: test-agent
- paragraph [ref=e101]: 新分身
- button "删除":
- img
- generic [ref=e102] [cursor=pointer]:
- img [ref=e104]
- generic [ref=e107]:
- generic [ref=e109]: predictor-hand
- paragraph [ref=e110]: 新分身
- button "删除":
- img
- generic [ref=e111] [cursor=pointer]:
- img [ref=e113]
- generic [ref=e116]:
- generic [ref=e118]: 测试助手
- paragraph [ref=e119]: 新分身
- button "删除":
- img
- generic [ref=e120] [cursor=pointer]:
- img [ref=e122]
- generic [ref=e125]: 创建新 Agent
- generic [ref=e127]:
- generic [ref=e128]:
- generic [ref=e129]: 用户
- button "打开设置" [ref=e130]:
- img [ref=e131]
- main [ref=e134]:
- generic [ref=e136]:
- heading "trader-hand" [level=2] [ref=e137]
- generic [ref=e138]: Gateway 已连接
- generic [ref=e142]:
- generic [ref=e145]: 🦞
- paragraph [ref=e147]: 你好! 我是 trader-hand。有什么我可以帮你的吗
- generic [ref=e148]:
- generic [ref=e149]:
- img [ref=e150]
- generic [ref=e152]: 快速开始
- button "💡 帮我写一个 Python 脚本处理 Excel 文件" [ref=e153]:
- generic [ref=e154]: 💡
- generic [ref=e155]: 帮我写一个 Python 脚本处理 Excel 文件
- img [ref=e156]
- button "📊 分析这个数据集的趋势和关键指标" [ref=e158]:
- generic [ref=e159]: 📊
- generic [ref=e160]: 分析这个数据集的趋势和关键指标
- img [ref=e161]
- button "✍️ 帮我起草一份产品需求文档" [ref=e163]:
- generic [ref=e164]: ✍️
- generic [ref=e165]: 帮我起草一份产品需求文档
- img [ref=e166]
- paragraph [ref=e168]: 发送消息开始对话,或点击上方建议
- generic [ref=e170]:
- generic [ref=e171]:
- button "添加附件" [ref=e172]:
- img [ref=e173]
- textbox "发送给 trader-hand" [ref=e176]
- generic [ref=e177]:
- button "选择模型" [ref=e178]:
- generic [ref=e179]: glm-5
- img [ref=e180]
- button "发送消息" [disabled] [ref=e182]:
- img [ref=e183]
- generic [ref=e185]: Agent 在本地运行,内容由 AI 生成
- complementary [ref=e186]:
- generic [ref=e187]:
- generic [ref=e188]:
- generic [ref=e189]:
- img [ref=e190]
- generic [ref=e192]: "0"
- generic [ref=e193]: 当前消息
- tablist [ref=e194]:
- tab "Status" [selected] [ref=e195]:
- img [ref=e196]
- tab "Files" [ref=e198]:
- img [ref=e199]
- tab "Agent" [ref=e202]:
- img [ref=e203]
- tab "Memory" [ref=e206]:
- img [ref=e207]
- generic [ref=e215]:
- generic [ref=e216]:
- generic [ref=e217]:
- generic [ref=e218]:
- img [ref=e219]
- generic [ref=e223]: Gateway Connected
- button "Refresh data" [ref=e224]:
- img [ref=e225]
- generic [ref=e230]:
- generic [ref=e231]:
- generic [ref=e232]: 地址
- generic [ref=e233]: ws://127.0.0.1:50051/ws
- generic [ref=e234]:
- generic [ref=e235]: 当前模型
- generic [ref=e236]: glm-5
- generic [ref=e237]:
- heading "当前会话" [level=3] [ref=e238]:
- img [ref=e239]
- text: 当前会话
- generic [ref=e241]:
- generic [ref=e242]:
- generic [ref=e243]: 用户消息
- generic [ref=e244]: "0"
- generic [ref=e245]:
- generic [ref=e246]: 助手回复
- generic [ref=e247]: "0"
- generic [ref=e248]:
- generic [ref=e249]: 工具调用
- generic [ref=e250]: "0"
- generic [ref=e251]:
- generic [ref=e252]: 总消息数
- generic [ref=e253]: "0"
- generic [ref=e254]:
- heading "分身状态" [level=3] [ref=e255]:
- img [ref=e256]
- text: 分身状态
- generic [ref=e259]:
- generic [ref=e260]:
- img [ref=e262]
- generic [ref=e265]: trader-hand
- generic [ref=e266]:
- img [ref=e268]
- generic [ref=e271]: researcher
- generic [ref=e272]:
- img [ref=e274]
- generic [ref=e277]: browser-hand
- generic [ref=e278]:
- img [ref=e280]
- generic [ref=e283]: collector-hand
- generic [ref=e284]:
- img [ref=e286]
- generic [ref=e289]: researcher-hand
- paragraph [ref=e290]: +4 个分身
- generic [ref=e291]:
- heading "用量统计" [level=3] [ref=e292]:
- img [ref=e293]
- text: 用量统计
- generic [ref=e295]:
- generic [ref=e296]:
- generic [ref=e297]: 总会话数
- generic [ref=e298]: "0"
- generic [ref=e299]:
- generic [ref=e300]: 总消息数
- generic [ref=e301]: "0"
- generic [ref=e302]:
- generic [ref=e303]: 总 Token
- generic [ref=e304]: "0"
- generic [ref=e305]:
- heading "插件 (3)" [level=3] [ref=e306]:
- img [ref=e307]
- text: 插件 (3)
- generic [ref=e309]:
- generic [ref=e310]:
- generic [ref=e311]: Chat
- generic [ref=e312]: 运行中
- generic [ref=e313]:
- generic [ref=e314]: Code
- generic [ref=e315]: 运行中
- generic [ref=e316]:
- generic [ref=e317]: File
- generic [ref=e318]: 运行中
- generic [ref=e319]:
- heading "运行概览" [level=3] [ref=e320]:
- img [ref=e321]
- text: 运行概览
- generic [ref=e324]:
- generic [ref=e325]:
- generic [ref=e326]: 连接状态
- generic [ref=e327]: 已连接
- generic [ref=e328]:
- generic [ref=e329]: Gateway 版本
- generic [ref=e330]: "-"
- generic [ref=e331]:
- generic [ref=e332]: 已加载分身
- generic [ref=e333]: "9"
- generic [ref=e334]:
- generic [ref=e335]: 已加载插件
- generic [ref=e336]: "3"
```

View File

@@ -0,0 +1,243 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- complementary [ref=e4]:
- tablist [ref=e5]:
- tab "分身" [selected] [ref=e6]:
- img [ref=e7]
- generic [ref=e10]: 分身
- tab "Hands" [ref=e11]:
- img [ref=e12]
- generic [ref=e14]: Hands
- tab "工作流" [ref=e15]:
- img [ref=e16]
- generic [ref=e20]: 工作流
- tab "团队" [ref=e21]:
- img [ref=e22]
- generic [ref=e27]: 团队
- tab "协作" [ref=e28]:
- img [ref=e29]
- generic [ref=e33]: 协作
- generic [ref=e37]:
- generic [ref=e38] [cursor=pointer]:
- img [ref=e40]
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]: trader-hand
- generic [ref=e46]: 当前
- paragraph [ref=e47]: 新分身
- button "删除":
- img
- generic [ref=e48] [cursor=pointer]:
- img [ref=e50]
- generic [ref=e53]:
- generic [ref=e55]: researcher
- paragraph [ref=e56]: 新分身
- button "删除":
- img
- generic [ref=e57] [cursor=pointer]:
- img [ref=e59]
- generic [ref=e62]:
- generic [ref=e64]: browser-hand
- paragraph [ref=e65]: 新分身
- button "删除":
- img
- generic [ref=e66] [cursor=pointer]:
- img [ref=e68]
- generic [ref=e71]:
- generic [ref=e73]: collector-hand
- paragraph [ref=e74]: 新分身
- button "删除":
- img
- generic [ref=e75] [cursor=pointer]:
- img [ref=e77]
- generic [ref=e80]:
- generic [ref=e82]: researcher-hand
- paragraph [ref=e83]: 新分身
- button "删除":
- img
- generic [ref=e84] [cursor=pointer]:
- img [ref=e86]
- generic [ref=e89]:
- generic [ref=e91]: lead-hand
- paragraph [ref=e92]: 新分身
- button "删除":
- img
- generic [ref=e93] [cursor=pointer]:
- img [ref=e95]
- generic [ref=e98]:
- generic [ref=e100]: test-agent
- paragraph [ref=e101]: 新分身
- button "删除":
- img
- generic [ref=e102] [cursor=pointer]:
- img [ref=e104]
- generic [ref=e107]:
- generic [ref=e109]: predictor-hand
- paragraph [ref=e110]: 新分身
- button "删除":
- img
- generic [ref=e111] [cursor=pointer]:
- img [ref=e113]
- generic [ref=e116]:
- generic [ref=e118]: 测试助手
- paragraph [ref=e119]: 新分身
- button "删除":
- img
- generic [ref=e120] [cursor=pointer]:
- img [ref=e122]
- generic [ref=e125]: 创建新 Agent
- generic [ref=e127]:
- generic [ref=e128]:
- generic [ref=e129]: 用户
- button "打开设置" [ref=e130]:
- img [ref=e131]
- main [ref=e134]:
- generic [ref=e135]:
- generic [ref=e136]:
- heading "trader-hand" [level=2] [ref=e137]
- generic [ref=e138]: 正在输入中
- button "开始新对话" [ref=e141]:
- img [ref=e142]
- text: 新对话
- generic [ref=e145]:
- generic [ref=e147]:
- generic [ref=e148]:
- generic [ref=e151]: 你好
- generic [ref=e153]:
- generic [ref=e154]: Z
- generic [ref=e156]:
- generic [ref=e157]: "⚠️ Request failed: Request failed: Missing API key: No LLM provider configured. Set an API key (e.g. GROQ_API_KEY) and restart, configure a provider via the dashboard, or use Ollama for local models (no API key neede..."
- paragraph [ref=e158]: "Request failed: Request failed: Missing API key: No LLM provider configured. Set an API key (e.g. GROQ_API_KEY) and restart, configure a provider via the dashboard, or use Ollama for local models (no API key neede..."
- generic [ref=e160]:
- generic [ref=e161]:
- generic [ref=e164]: 请帮我写一个简单的函数
- generic [ref=e167]: Z
- generic [ref=e173]:
- generic [ref=e174]:
- button "添加附件" [ref=e175]:
- img [ref=e176]
- textbox "Agent 正在回复..." [disabled] [ref=e179]
- generic [ref=e180]:
- button "选择模型" [ref=e181]:
- generic [ref=e182]: glm-5
- img [ref=e183]
- button "发送消息" [disabled] [ref=e185]:
- img [ref=e186]
- generic [ref=e188]: Agent 在本地运行,内容由 AI 生成
- complementary [ref=e189]:
- generic [ref=e190]:
- generic [ref=e191]:
- generic [ref=e192]:
- img [ref=e193]
- generic [ref=e195]: "4"
- generic [ref=e196]: 当前消息
- tablist [ref=e197]:
- tab "Status" [selected] [ref=e198]:
- img [ref=e199]
- tab "Files" [ref=e201]:
- img [ref=e202]
- tab "Agent" [ref=e205]:
- img [ref=e206]
- tab "Memory" [ref=e209]:
- img [ref=e210]
- generic [ref=e218]:
- generic [ref=e219]:
- generic [ref=e220]:
- generic [ref=e221]:
- img [ref=e222]
- generic [ref=e226]: Gateway Connected
- button "Refresh data" [ref=e227]:
- img [ref=e228]
- generic [ref=e233]:
- generic [ref=e234]:
- generic [ref=e235]: 地址
- generic [ref=e236]: ws://127.0.0.1:50051/ws
- generic [ref=e237]:
- generic [ref=e238]: 当前模型
- generic [ref=e239]: glm-5
- generic [ref=e240]:
- heading "当前会话" [level=3] [ref=e241]:
- img [ref=e242]
- text: 当前会话
- generic [ref=e244]:
- generic [ref=e245]:
- generic [ref=e246]: 用户消息
- generic [ref=e247]: "2"
- generic [ref=e248]:
- generic [ref=e249]: 助手回复
- generic [ref=e250]: "2"
- generic [ref=e251]:
- generic [ref=e252]: 工具调用
- generic [ref=e253]: "0"
- generic [ref=e254]:
- generic [ref=e255]: 总消息数
- generic [ref=e256]: "4"
- generic [ref=e257]:
- heading "分身状态" [level=3] [ref=e258]:
- img [ref=e259]
- text: 分身状态
- generic [ref=e262]:
- generic [ref=e263]:
- img [ref=e265]
- generic [ref=e268]: trader-hand
- generic [ref=e269]:
- img [ref=e271]
- generic [ref=e274]: researcher
- generic [ref=e275]:
- img [ref=e277]
- generic [ref=e280]: browser-hand
- generic [ref=e281]:
- img [ref=e283]
- generic [ref=e286]: collector-hand
- generic [ref=e287]:
- img [ref=e289]
- generic [ref=e292]: researcher-hand
- paragraph [ref=e293]: +4 个分身
- generic [ref=e294]:
- heading "用量统计" [level=3] [ref=e295]:
- img [ref=e296]
- text: 用量统计
- generic [ref=e298]:
- generic [ref=e299]:
- generic [ref=e300]: 总会话数
- generic [ref=e301]: "0"
- generic [ref=e302]:
- generic [ref=e303]: 总消息数
- generic [ref=e304]: "0"
- generic [ref=e305]:
- generic [ref=e306]: 总 Token
- generic [ref=e307]: "0"
- generic [ref=e308]:
- heading "插件 (3)" [level=3] [ref=e309]:
- img [ref=e310]
- text: 插件 (3)
- generic [ref=e312]:
- generic [ref=e313]:
- generic [ref=e314]: Chat
- generic [ref=e315]: 运行中
- generic [ref=e316]:
- generic [ref=e317]: Code
- generic [ref=e318]: 运行中
- generic [ref=e319]:
- generic [ref=e320]: File
- generic [ref=e321]: 运行中
- generic [ref=e322]:
- heading "运行概览" [level=3] [ref=e323]:
- img [ref=e324]
- text: 运行概览
- generic [ref=e327]:
- generic [ref=e328]:
- generic [ref=e329]: 连接状态
- generic [ref=e330]: 已连接
- generic [ref=e331]:
- generic [ref=e332]: Gateway 版本
- generic [ref=e333]: "-"
- generic [ref=e334]:
- generic [ref=e335]: 已加载分身
- generic [ref=e336]: "9"
- generic [ref=e337]:
- generic [ref=e338]: 已加载插件
- generic [ref=e339]: "3"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -0,0 +1,243 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- complementary [ref=e4]:
- tablist [ref=e5]:
- tab "分身" [selected] [ref=e6]:
- img [ref=e7]
- generic [ref=e10]: 分身
- tab "Hands" [ref=e11]:
- img [ref=e12]
- generic [ref=e14]: Hands
- tab "工作流" [ref=e15]:
- img [ref=e16]
- generic [ref=e20]: 工作流
- tab "团队" [ref=e21]:
- img [ref=e22]
- generic [ref=e27]: 团队
- tab "协作" [ref=e28]:
- img [ref=e29]
- generic [ref=e33]: 协作
- generic [ref=e37]:
- generic [ref=e38] [cursor=pointer]:
- img [ref=e40]
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]: trader-hand
- generic [ref=e46]: 当前
- paragraph [ref=e47]: 新分身
- button "删除":
- img
- generic [ref=e48] [cursor=pointer]:
- img [ref=e50]
- generic [ref=e53]:
- generic [ref=e55]: researcher
- paragraph [ref=e56]: 新分身
- button "删除":
- img
- generic [ref=e57] [cursor=pointer]:
- img [ref=e59]
- generic [ref=e62]:
- generic [ref=e64]: browser-hand
- paragraph [ref=e65]: 新分身
- button "删除":
- img
- generic [ref=e66] [cursor=pointer]:
- img [ref=e68]
- generic [ref=e71]:
- generic [ref=e73]: collector-hand
- paragraph [ref=e74]: 新分身
- button "删除":
- img
- generic [ref=e75] [cursor=pointer]:
- img [ref=e77]
- generic [ref=e80]:
- generic [ref=e82]: researcher-hand
- paragraph [ref=e83]: 新分身
- button "删除":
- img
- generic [ref=e84] [cursor=pointer]:
- img [ref=e86]
- generic [ref=e89]:
- generic [ref=e91]: lead-hand
- paragraph [ref=e92]: 新分身
- button "删除":
- img
- generic [ref=e93] [cursor=pointer]:
- img [ref=e95]
- generic [ref=e98]:
- generic [ref=e100]: test-agent
- paragraph [ref=e101]: 新分身
- button "删除":
- img
- generic [ref=e102] [cursor=pointer]:
- img [ref=e104]
- generic [ref=e107]:
- generic [ref=e109]: predictor-hand
- paragraph [ref=e110]: 新分身
- button "删除":
- img
- generic [ref=e111] [cursor=pointer]:
- img [ref=e113]
- generic [ref=e116]:
- generic [ref=e118]: 测试助手
- paragraph [ref=e119]: 新分身
- button "删除":
- img
- generic [ref=e120] [cursor=pointer]:
- img [ref=e122]
- generic [ref=e125]: 创建新 Agent
- generic [ref=e127]:
- generic [ref=e128]:
- generic [ref=e129]: 用户
- button "打开设置" [ref=e130]:
- img [ref=e131]
- main [ref=e134]:
- generic [ref=e135]:
- generic [ref=e136]:
- heading "trader-hand" [level=2] [ref=e137]
- generic [ref=e138]: 正在输入中
- button "开始新对话" [ref=e141]:
- img [ref=e142]
- text: 新对话
- generic [ref=e145]:
- generic [ref=e147]:
- generic [ref=e148]:
- generic [ref=e151]: 测试消息 1
- generic [ref=e153]:
- generic [ref=e154]: Z
- generic [ref=e156]:
- generic [ref=e157]: "⚠️ Request failed: Request failed: Missing API key: No LLM provider configured. Set an API key (e.g. GROQ_API_KEY) and restart, configure a provider via the dashboard, or use Ollama for local models (no API key neede..."
- paragraph [ref=e158]: "Request failed: Request failed: Missing API key: No LLM provider configured. Set an API key (e.g. GROQ_API_KEY) and restart, configure a provider via the dashboard, or use Ollama for local models (no API key neede..."
- generic [ref=e160]:
- generic [ref=e161]:
- generic [ref=e164]: 测试消息 2
- generic [ref=e167]: Z
- generic [ref=e173]:
- generic [ref=e174]:
- button "添加附件" [ref=e175]:
- img [ref=e176]
- textbox "Agent 正在回复..." [disabled] [ref=e179]
- generic [ref=e180]:
- button "选择模型" [ref=e181]:
- generic [ref=e182]: glm-5
- img [ref=e183]
- button "发送消息" [disabled] [ref=e185]:
- img [ref=e186]
- generic [ref=e188]: Agent 在本地运行,内容由 AI 生成
- complementary [ref=e189]:
- generic [ref=e190]:
- generic [ref=e191]:
- generic [ref=e192]:
- img [ref=e193]
- generic [ref=e195]: "4"
- generic [ref=e196]: 当前消息
- tablist [ref=e197]:
- tab "Status" [selected] [ref=e198]:
- img [ref=e199]
- tab "Files" [ref=e201]:
- img [ref=e202]
- tab "Agent" [ref=e205]:
- img [ref=e206]
- tab "Memory" [ref=e209]:
- img [ref=e210]
- generic [ref=e218]:
- generic [ref=e219]:
- generic [ref=e220]:
- generic [ref=e221]:
- img [ref=e222]
- generic [ref=e226]: Gateway Connected
- button "Refresh data" [ref=e227]:
- img [ref=e228]
- generic [ref=e233]:
- generic [ref=e234]:
- generic [ref=e235]: 地址
- generic [ref=e236]: ws://127.0.0.1:50051/ws
- generic [ref=e237]:
- generic [ref=e238]: 当前模型
- generic [ref=e239]: glm-5
- generic [ref=e240]:
- heading "当前会话" [level=3] [ref=e241]:
- img [ref=e242]
- text: 当前会话
- generic [ref=e244]:
- generic [ref=e245]:
- generic [ref=e246]: 用户消息
- generic [ref=e247]: "2"
- generic [ref=e248]:
- generic [ref=e249]: 助手回复
- generic [ref=e250]: "2"
- generic [ref=e251]:
- generic [ref=e252]: 工具调用
- generic [ref=e253]: "0"
- generic [ref=e254]:
- generic [ref=e255]: 总消息数
- generic [ref=e256]: "4"
- generic [ref=e257]:
- heading "分身状态" [level=3] [ref=e258]:
- img [ref=e259]
- text: 分身状态
- generic [ref=e262]:
- generic [ref=e263]:
- img [ref=e265]
- generic [ref=e268]: trader-hand
- generic [ref=e269]:
- img [ref=e271]
- generic [ref=e274]: researcher
- generic [ref=e275]:
- img [ref=e277]
- generic [ref=e280]: browser-hand
- generic [ref=e281]:
- img [ref=e283]
- generic [ref=e286]: collector-hand
- generic [ref=e287]:
- img [ref=e289]
- generic [ref=e292]: researcher-hand
- paragraph [ref=e293]: +4 个分身
- generic [ref=e294]:
- heading "用量统计" [level=3] [ref=e295]:
- img [ref=e296]
- text: 用量统计
- generic [ref=e298]:
- generic [ref=e299]:
- generic [ref=e300]: 总会话数
- generic [ref=e301]: "0"
- generic [ref=e302]:
- generic [ref=e303]: 总消息数
- generic [ref=e304]: "0"
- generic [ref=e305]:
- generic [ref=e306]: 总 Token
- generic [ref=e307]: "0"
- generic [ref=e308]:
- heading "插件 (3)" [level=3] [ref=e309]:
- img [ref=e310]
- text: 插件 (3)
- generic [ref=e312]:
- generic [ref=e313]:
- generic [ref=e314]: Chat
- generic [ref=e315]: 运行中
- generic [ref=e316]:
- generic [ref=e317]: Code
- generic [ref=e318]: 运行中
- generic [ref=e319]:
- generic [ref=e320]: File
- generic [ref=e321]: 运行中
- generic [ref=e322]:
- heading "运行概览" [level=3] [ref=e323]:
- img [ref=e324]
- text: 运行概览
- generic [ref=e327]:
- generic [ref=e328]:
- generic [ref=e329]: 连接状态
- generic [ref=e330]: 已连接
- generic [ref=e331]:
- generic [ref=e332]: Gateway 版本
- generic [ref=e333]: "-"
- generic [ref=e334]:
- generic [ref=e335]: 已加载分身
- generic [ref=e336]: "9"
- generic [ref=e337]:
- generic [ref=e338]: 已加载插件
- generic [ref=e339]: "3"
```

View File

@@ -0,0 +1,246 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- complementary [ref=e4]:
- tablist [ref=e5]:
- tab "分身" [selected] [ref=e6]:
- img [ref=e7]
- generic [ref=e10]: 分身
- tab "Hands" [ref=e11]:
- img [ref=e12]
- generic [ref=e14]: Hands
- tab "工作流" [ref=e15]:
- img [ref=e16]
- generic [ref=e20]: 工作流
- tab "团队" [ref=e21]:
- img [ref=e22]
- generic [ref=e27]: 团队
- tab "协作" [ref=e28]:
- img [ref=e29]
- generic [ref=e33]: 协作
- generic [ref=e37]:
- generic [ref=e38] [cursor=pointer]:
- img [ref=e40]
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]: trader-hand
- generic [ref=e46]: 当前
- paragraph [ref=e47]: 新分身
- button "删除":
- img
- generic [ref=e48] [cursor=pointer]:
- img [ref=e50]
- generic [ref=e53]:
- generic [ref=e55]: researcher
- paragraph [ref=e56]: 新分身
- button "删除":
- img
- generic [ref=e57] [cursor=pointer]:
- img [ref=e59]
- generic [ref=e62]:
- generic [ref=e64]: browser-hand
- paragraph [ref=e65]: 新分身
- button "删除":
- img
- generic [ref=e66] [cursor=pointer]:
- img [ref=e68]
- generic [ref=e71]:
- generic [ref=e73]: collector-hand
- paragraph [ref=e74]: 新分身
- button "删除":
- img
- generic [ref=e75] [cursor=pointer]:
- img [ref=e77]
- generic [ref=e80]:
- generic [ref=e82]: researcher-hand
- paragraph [ref=e83]: 新分身
- button "删除":
- img
- generic [ref=e84] [cursor=pointer]:
- img [ref=e86]
- generic [ref=e89]:
- generic [ref=e91]: lead-hand
- paragraph [ref=e92]: 新分身
- button "删除":
- img
- generic [ref=e93] [cursor=pointer]:
- img [ref=e95]
- generic [ref=e98]:
- generic [ref=e100]: test-agent
- paragraph [ref=e101]: 新分身
- button "删除":
- img
- generic [ref=e102] [cursor=pointer]:
- img [ref=e104]
- generic [ref=e107]:
- generic [ref=e109]: predictor-hand
- paragraph [ref=e110]: 新分身
- button "删除":
- img
- generic [ref=e111] [cursor=pointer]:
- img [ref=e113]
- generic [ref=e116]:
- generic [ref=e118]: 测试助手
- paragraph [ref=e119]: 新分身
- button "删除":
- img
- generic [ref=e120] [cursor=pointer]:
- img [ref=e122]
- generic [ref=e125]: 创建新 Agent
- generic [ref=e127]:
- generic [ref=e128]:
- generic [ref=e129]: 用户
- button "打开设置" [ref=e130]:
- img [ref=e131]
- main [ref=e134]:
- generic [ref=e136]:
- heading "trader-hand" [level=2] [ref=e137]
- generic [ref=e138]: Gateway 已连接
- generic [ref=e142]:
- generic [ref=e145]: 🦞
- paragraph [ref=e147]: 你好! 我是 trader-hand。有什么我可以帮你的吗
- generic [ref=e148]:
- generic [ref=e149]:
- img [ref=e150]
- generic [ref=e152]: 快速开始
- button "💡 帮我写一个 Python 脚本处理 Excel 文件" [ref=e153]:
- generic [ref=e154]: 💡
- generic [ref=e155]: 帮我写一个 Python 脚本处理 Excel 文件
- img [ref=e156]
- button "📊 分析这个数据集的趋势和关键指标" [ref=e158]:
- generic [ref=e159]: 📊
- generic [ref=e160]: 分析这个数据集的趋势和关键指标
- img [ref=e161]
- button "✍️ 帮我起草一份产品需求文档" [ref=e163]:
- generic [ref=e164]: ✍️
- generic [ref=e165]: 帮我起草一份产品需求文档
- img [ref=e166]
- paragraph [ref=e168]: 发送消息开始对话,或点击上方建议
- generic [ref=e170]:
- generic [ref=e171]:
- button "添加附件" [ref=e172]:
- img [ref=e173]
- textbox "发送给 trader-hand" [ref=e176]
- generic [ref=e177]:
- button "选择模型" [ref=e178]:
- generic [ref=e179]: glm-5
- img [ref=e180]
- button "发送消息" [disabled] [ref=e182]:
- img [ref=e183]
- generic [ref=e185]: Agent 在本地运行,内容由 AI 生成
- complementary [ref=e186]:
- generic [ref=e187]:
- generic [ref=e188]:
- generic [ref=e189]:
- img [ref=e190]
- generic [ref=e192]: "0"
- generic [ref=e193]: 当前消息
- tablist [ref=e194]:
- tab "Status" [selected] [ref=e195]:
- img [ref=e196]
- tab "Files" [ref=e198]:
- img [ref=e199]
- tab "Agent" [ref=e202]:
- img [ref=e203]
- tab "Memory" [ref=e206]:
- img [ref=e207]
- generic [ref=e215]:
- generic [ref=e216]:
- generic [ref=e217]:
- generic [ref=e218]:
- img [ref=e219]
- generic [ref=e223]: Gateway Connected
- button "Refresh data" [ref=e224]:
- img [ref=e225]
- generic [ref=e230]:
- generic [ref=e231]:
- generic [ref=e232]: 地址
- generic [ref=e233]: ws://127.0.0.1:50051/ws
- generic [ref=e234]:
- generic [ref=e235]: 当前模型
- generic [ref=e236]: glm-5
- generic [ref=e237]:
- heading "当前会话" [level=3] [ref=e238]:
- img [ref=e239]
- text: 当前会话
- generic [ref=e241]:
- generic [ref=e242]:
- generic [ref=e243]: 用户消息
- generic [ref=e244]: "0"
- generic [ref=e245]:
- generic [ref=e246]: 助手回复
- generic [ref=e247]: "0"
- generic [ref=e248]:
- generic [ref=e249]: 工具调用
- generic [ref=e250]: "0"
- generic [ref=e251]:
- generic [ref=e252]: 总消息数
- generic [ref=e253]: "0"
- generic [ref=e254]:
- heading "分身状态" [level=3] [ref=e255]:
- img [ref=e256]
- text: 分身状态
- generic [ref=e259]:
- generic [ref=e260]:
- img [ref=e262]
- generic [ref=e265]: trader-hand
- generic [ref=e266]:
- img [ref=e268]
- generic [ref=e271]: researcher
- generic [ref=e272]:
- img [ref=e274]
- generic [ref=e277]: browser-hand
- generic [ref=e278]:
- img [ref=e280]
- generic [ref=e283]: collector-hand
- generic [ref=e284]:
- img [ref=e286]
- generic [ref=e289]: researcher-hand
- paragraph [ref=e290]: +4 个分身
- generic [ref=e291]:
- heading "用量统计" [level=3] [ref=e292]:
- img [ref=e293]
- text: 用量统计
- generic [ref=e295]:
- generic [ref=e296]:
- generic [ref=e297]: 总会话数
- generic [ref=e298]: "0"
- generic [ref=e299]:
- generic [ref=e300]: 总消息数
- generic [ref=e301]: "0"
- generic [ref=e302]:
- generic [ref=e303]: 总 Token
- generic [ref=e304]: "0"
- generic [ref=e305]:
- heading "插件 (3)" [level=3] [ref=e306]:
- img [ref=e307]
- text: 插件 (3)
- generic [ref=e309]:
- generic [ref=e310]:
- generic [ref=e311]: Chat
- generic [ref=e312]: 运行中
- generic [ref=e313]:
- generic [ref=e314]: Code
- generic [ref=e315]: 运行中
- generic [ref=e316]:
- generic [ref=e317]: File
- generic [ref=e318]: 运行中
- generic [ref=e319]:
- heading "运行概览" [level=3] [ref=e320]:
- img [ref=e321]
- text: 运行概览
- generic [ref=e324]:
- generic [ref=e325]:
- generic [ref=e326]: 连接状态
- generic [ref=e327]: 已连接
- generic [ref=e328]:
- generic [ref=e329]: Gateway 版本
- generic [ref=e330]: "-"
- generic [ref=e331]:
- generic [ref=e332]: 已加载分身
- generic [ref=e333]: "9"
- generic [ref=e334]:
- generic [ref=e335]: 已加载插件
- generic [ref=e336]: "3"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -0,0 +1,246 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- complementary [ref=e4]:
- tablist [ref=e5]:
- tab "分身" [selected] [ref=e6]:
- img [ref=e7]
- generic [ref=e10]: 分身
- tab "Hands" [ref=e11]:
- img [ref=e12]
- generic [ref=e14]: Hands
- tab "工作流" [ref=e15]:
- img [ref=e16]
- generic [ref=e20]: 工作流
- tab "团队" [ref=e21]:
- img [ref=e22]
- generic [ref=e27]: 团队
- tab "协作" [ref=e28]:
- img [ref=e29]
- generic [ref=e33]: 协作
- generic [ref=e37]:
- generic [ref=e38] [cursor=pointer]:
- img [ref=e40]
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]: trader-hand
- generic [ref=e46]: 当前
- paragraph [ref=e47]: 新分身
- button "删除":
- img
- generic [ref=e48] [cursor=pointer]:
- img [ref=e50]
- generic [ref=e53]:
- generic [ref=e55]: researcher
- paragraph [ref=e56]: 新分身
- button "删除":
- img
- generic [ref=e57] [cursor=pointer]:
- img [ref=e59]
- generic [ref=e62]:
- generic [ref=e64]: browser-hand
- paragraph [ref=e65]: 新分身
- button "删除":
- img
- generic [ref=e66] [cursor=pointer]:
- img [ref=e68]
- generic [ref=e71]:
- generic [ref=e73]: collector-hand
- paragraph [ref=e74]: 新分身
- button "删除":
- img
- generic [ref=e75] [cursor=pointer]:
- img [ref=e77]
- generic [ref=e80]:
- generic [ref=e82]: researcher-hand
- paragraph [ref=e83]: 新分身
- button "删除":
- img
- generic [ref=e84] [cursor=pointer]:
- img [ref=e86]
- generic [ref=e89]:
- generic [ref=e91]: lead-hand
- paragraph [ref=e92]: 新分身
- button "删除":
- img
- generic [ref=e93] [cursor=pointer]:
- img [ref=e95]
- generic [ref=e98]:
- generic [ref=e100]: test-agent
- paragraph [ref=e101]: 新分身
- button "删除":
- img
- generic [ref=e102] [cursor=pointer]:
- img [ref=e104]
- generic [ref=e107]:
- generic [ref=e109]: predictor-hand
- paragraph [ref=e110]: 新分身
- button "删除":
- img
- generic [ref=e111] [cursor=pointer]:
- img [ref=e113]
- generic [ref=e116]:
- generic [ref=e118]: 测试助手
- paragraph [ref=e119]: 新分身
- button "删除":
- img
- generic [ref=e120] [cursor=pointer]:
- img [ref=e122]
- generic [ref=e125]: 创建新 Agent
- generic [ref=e127]:
- generic [ref=e128]:
- generic [ref=e129]: 用户
- button "打开设置" [ref=e130]:
- img [ref=e131]
- main [ref=e134]:
- generic [ref=e136]:
- heading "trader-hand" [level=2] [ref=e137]
- generic [ref=e138]: Gateway 已连接
- generic [ref=e142]:
- generic [ref=e145]: 🦞
- paragraph [ref=e147]: 你好! 我是 trader-hand。有什么我可以帮你的吗
- generic [ref=e148]:
- generic [ref=e149]:
- img [ref=e150]
- generic [ref=e152]: 快速开始
- button "💡 帮我写一个 Python 脚本处理 Excel 文件" [ref=e153]:
- generic [ref=e154]: 💡
- generic [ref=e155]: 帮我写一个 Python 脚本处理 Excel 文件
- img [ref=e156]
- button "📊 分析这个数据集的趋势和关键指标" [ref=e158]:
- generic [ref=e159]: 📊
- generic [ref=e160]: 分析这个数据集的趋势和关键指标
- img [ref=e161]
- button "✍️ 帮我起草一份产品需求文档" [ref=e163]:
- generic [ref=e164]: ✍️
- generic [ref=e165]: 帮我起草一份产品需求文档
- img [ref=e166]
- paragraph [ref=e168]: 发送消息开始对话,或点击上方建议
- generic [ref=e170]:
- generic [ref=e171]:
- button "添加附件" [ref=e172]:
- img [ref=e173]
- textbox "发送给 trader-hand" [ref=e176]
- generic [ref=e177]:
- button "选择模型" [ref=e178]:
- generic [ref=e179]: glm-5
- img [ref=e180]
- button "发送消息" [disabled] [ref=e182]:
- img [ref=e183]
- generic [ref=e185]: Agent 在本地运行,内容由 AI 生成
- complementary [ref=e186]:
- generic [ref=e187]:
- generic [ref=e188]:
- generic [ref=e189]:
- img [ref=e190]
- generic [ref=e192]: "0"
- generic [ref=e193]: 当前消息
- tablist [ref=e194]:
- tab "Status" [selected] [ref=e195]:
- img [ref=e196]
- tab "Files" [ref=e198]:
- img [ref=e199]
- tab "Agent" [ref=e202]:
- img [ref=e203]
- tab "Memory" [ref=e206]:
- img [ref=e207]
- generic [ref=e215]:
- generic [ref=e216]:
- generic [ref=e217]:
- generic [ref=e218]:
- img [ref=e219]
- generic [ref=e223]: Gateway Connected
- button "Refresh data" [ref=e224]:
- img [ref=e225]
- generic [ref=e230]:
- generic [ref=e231]:
- generic [ref=e232]: 地址
- generic [ref=e233]: ws://127.0.0.1:50051/ws
- generic [ref=e234]:
- generic [ref=e235]: 当前模型
- generic [ref=e236]: glm-5
- generic [ref=e237]:
- heading "当前会话" [level=3] [ref=e238]:
- img [ref=e239]
- text: 当前会话
- generic [ref=e241]:
- generic [ref=e242]:
- generic [ref=e243]: 用户消息
- generic [ref=e244]: "0"
- generic [ref=e245]:
- generic [ref=e246]: 助手回复
- generic [ref=e247]: "0"
- generic [ref=e248]:
- generic [ref=e249]: 工具调用
- generic [ref=e250]: "0"
- generic [ref=e251]:
- generic [ref=e252]: 总消息数
- generic [ref=e253]: "0"
- generic [ref=e254]:
- heading "分身状态" [level=3] [ref=e255]:
- img [ref=e256]
- text: 分身状态
- generic [ref=e259]:
- generic [ref=e260]:
- img [ref=e262]
- generic [ref=e265]: trader-hand
- generic [ref=e266]:
- img [ref=e268]
- generic [ref=e271]: researcher
- generic [ref=e272]:
- img [ref=e274]
- generic [ref=e277]: browser-hand
- generic [ref=e278]:
- img [ref=e280]
- generic [ref=e283]: collector-hand
- generic [ref=e284]:
- img [ref=e286]
- generic [ref=e289]: researcher-hand
- paragraph [ref=e290]: +4 个分身
- generic [ref=e291]:
- heading "用量统计" [level=3] [ref=e292]:
- img [ref=e293]
- text: 用量统计
- generic [ref=e295]:
- generic [ref=e296]:
- generic [ref=e297]: 总会话数
- generic [ref=e298]: "0"
- generic [ref=e299]:
- generic [ref=e300]: 总消息数
- generic [ref=e301]: "0"
- generic [ref=e302]:
- generic [ref=e303]: 总 Token
- generic [ref=e304]: "0"
- generic [ref=e305]:
- heading "插件 (3)" [level=3] [ref=e306]:
- img [ref=e307]
- text: 插件 (3)
- generic [ref=e309]:
- generic [ref=e310]:
- generic [ref=e311]: Chat
- generic [ref=e312]: 运行中
- generic [ref=e313]:
- generic [ref=e314]: Code
- generic [ref=e315]: 运行中
- generic [ref=e316]:
- generic [ref=e317]: File
- generic [ref=e318]: 运行中
- generic [ref=e319]:
- heading "运行概览" [level=3] [ref=e320]:
- img [ref=e321]
- text: 运行概览
- generic [ref=e324]:
- generic [ref=e325]:
- generic [ref=e326]: 连接状态
- generic [ref=e327]: 已连接
- generic [ref=e328]:
- generic [ref=e329]: Gateway 版本
- generic [ref=e330]: "-"
- generic [ref=e331]:
- generic [ref=e332]: 已加载分身
- generic [ref=e333]: "9"
- generic [ref=e334]:
- generic [ref=e335]: 已加载插件
- generic [ref=e336]: "3"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -0,0 +1,89 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- complementary [ref=e4]:
- button "返回应用" [ref=e6]:
- img [ref=e7]
- generic [ref=e9]: 返回应用
- navigation [ref=e10]:
- button "通用" [ref=e11]:
- img [ref=e12]
- generic [ref=e15]: 通用
- button "用量统计" [ref=e16]:
- img [ref=e17]
- generic [ref=e19]: 用量统计
- button "积分详情" [ref=e20]:
- img [ref=e21]
- generic [ref=e26]: 积分详情
- button "模型与 API" [ref=e27]:
- img [ref=e28]
- generic [ref=e31]: 模型与 API
- button "MCP 服务" [ref=e32]:
- img [ref=e33]
- generic [ref=e35]: MCP 服务
- button "技能" [ref=e36]:
- img [ref=e37]
- generic [ref=e39]: 技能
- button "IM 频道" [ref=e40]:
- img [ref=e41]
- generic [ref=e43]: IM 频道
- button "工作区" [ref=e44]:
- img [ref=e45]
- generic [ref=e47]: 工作区
- button "数据与隐私" [ref=e48]:
- img [ref=e49]
- generic [ref=e51]: 数据与隐私
- button "安全状态" [ref=e52]:
- img [ref=e53]
- generic [ref=e55]: 安全状态
- button "审计日志" [ref=e56]:
- img [ref=e57]
- generic [ref=e60]: 审计日志
- button "定时任务" [ref=e61]:
- img [ref=e62]
- generic [ref=e65]: 定时任务
- button "提交反馈" [ref=e66]:
- img [ref=e67]
- generic [ref=e70]: 提交反馈
- button "关于" [ref=e71]:
- img [ref=e72]
- generic [ref=e74]: 关于
- main [ref=e75]:
- generic [ref=e76]:
- heading "通用设置" [level=1] [ref=e77]
- heading "Gateway 连接" [level=2] [ref=e78]
- generic [ref=e79]:
- generic [ref=e80]:
- generic [ref=e81]: 状态
- generic [ref=e84]: 已连接
- generic [ref=e85]:
- generic [ref=e86]: 地址
- generic [ref=e87]: ws://127.0.0.1:50051
- generic [ref=e88]:
- generic [ref=e89]: Token
- textbox "可选Gateway auth token" [ref=e90]
- generic [ref=e91]:
- generic [ref=e92]: 当前模型
- generic [ref=e93]: glm-5
- button "断开连接" [ref=e95]
- heading "外观与行为" [level=2] [ref=e96]
- generic [ref=e97]:
- generic [ref=e98]:
- generic [ref=e99]:
- generic [ref=e100]: 主题模式
- generic [ref=e101]: 选择浅色或深色模式。
- generic [ref=e102]:
- button [ref=e103]
- button [ref=e104]
- generic [ref=e105]:
- generic [ref=e106]:
- generic [ref=e107]: 开机自启
- generic [ref=e108]: 登录时自动启动 ZCLAW。
- button [ref=e109]
- generic [ref=e111]:
- generic [ref=e112]:
- generic [ref=e113]: 显示工具调用
- generic [ref=e114]: 在对话消息中显示模型的工具调用详情块。
- button [ref=e115]
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -0,0 +1,209 @@
# E2E 测试已知问题与修复指南
> 最后更新: 2026-03-17
> 测试通过率: 88% (65/74)
## 当前状态
### 测试结果摘要
- **总测试**: 74
- **通过**: 65
- **失败**: 9
### 快速运行测试命令
```bash
cd g:/ZClaw_openfang/desktop
# 运行全部测试
pnpm exec playwright test --config=tests/e2e/playwright.config.ts --reporter=list
# 仅运行 app-verification (全部通过)
pnpm exec playwright test --config=tests/e2e/playwright.config.ts tests/e2e/specs/app-verification.spec.ts --reporter=list
# 仅运行 functional-scenarios
pnpm exec playwright test --config=tests/e2e/playwright.config.ts tests/e2e/specs/functional-scenarios.spec.ts --reporter=list
```
---
## 问题 1: 聊天输入禁用问题
### 现象
测试在尝试填写聊天输入框时失败,因为 Agent 正在回复中 (`isStreaming=true`),导致输入框被禁用。
### 错误信息
```
locator resolved to <textarea rows="1" disabled placeholder="Agent 正在回复..." ...>
element is not enabled
```
### 影响的测试
- `10. 完整用户流程 10.2 完整聊天流程`
- `11. 性能与稳定性 11.4 长时间运行稳定性`
### 修复方案
#### 方案 A: 在测试中等待 streaming 完成
```typescript
// 在 functional-scenarios.spec.ts 中添加等待逻辑
async function waitForChatReady(page: Page) {
const chatInput = page.locator('textarea').first();
// 等待输入框可用
await page.waitForFunction(() => {
const textarea = document.querySelector('textarea');
return textarea && !textarea.disabled;
}, { timeout: 30000 });
}
```
#### 方案 B: 在组件中添加可中断的 streaming
修改 `ChatArea.tsx` 允许用户在 streaming 时中断并输入新消息。
### 相关文件
- `desktop/src/components/ChatArea.tsx:178` - `disabled={isStreaming || !input.trim() || !connected}`
- `desktop/tests/e2e/specs/functional-scenarios.spec.ts:1016` - 失败的测试行
---
## 问题 2: Hands 列表为空
### 现象
测试期望找到 Hands 卡片,但实际找到 0 个。API `/api/hands` 返回数据正常。
### 错误信息
```
Found 0 hand cards
```
### 影响的测试
- `3. Agent/分身管理 3.1 分身列表显示`
- `4. Hands 系统 4.1 Hands 列表显示`
### 根因分析
1. Hands 数据从 API 加载是异步的
2. 测试可能在数据加载完成前就检查 DOM
3. HandList 组件可能没有正确渲染数据
### 修复方案
#### 方案 A: 在测试中增加等待时间
```typescript
// 在 functional-scenarios.spec.ts 中修改
test('4.1 Hands 列表显示', async ({ page }) => {
await navigateToTab(page, 'Hands');
await page.waitForTimeout(2000); // 增加等待时间
await page.waitForSelector('button:has-text("Hand")', { timeout: 10000 });
// ... 继续测试
});
```
#### 方案 B: 在 HandList 组件中添加加载状态
确保组件在数据加载时显示 loading 状态,数据加载后正确渲染。
### 验证 API 返回数据
```bash
curl -s http://127.0.0.1:50051/api/hands | head -c 500
```
### 相关文件
- `desktop/src/components/HandList.tsx` - Hands 列表组件
- `desktop/src/store/gatewayStore.ts:1175` - loadHands 函数
- `desktop/tests/e2e/specs/functional-scenarios.spec.ts:406` - 失败的测试
---
## 问题 3: 模型配置测试失败
### 现象
测试在设置页面中找不到模型配置相关的 UI 元素。
### 影响的测试
- `8. 设置页面 8.3 模型配置`
### 修复方案
检查设置页面的模型配置部分是否存在,以及选择器是否正确。
### 相关文件
- `desktop/src/components/Settings/SettingsLayout.tsx`
- `desktop/tests/e2e/specs/functional-scenarios.spec.ts:729`
---
## 问题 4: 应用启动测试断言失败
### 现象
测试期望所有导航标签都存在,但可能某些标签未渲染。
### 影响的测试
- `1. 应用启动与初始化 1.1 应用正常启动并渲染所有核心组件`
### 修复方案
调整测试断言,使其更灵活地处理异步加载的组件。
---
## API 端点状态
### 正常工作的端点 (200)
- `/api/status` - Gateway 状态
- `/api/agents` - Agent 列表
- `/api/hands` - Hands 列表
- `/api/config` - 配置读取
- `/api/chat` - 聊天 (WebSocket streaming)
### 返回 404 的端点 (有 fallback 处理)
- `/api/workspace`
- `/api/stats/usage`
- `/api/plugins/status`
- `/api/scheduler/tasks`
- `/api/security/status`
这些 404 是预期行为,应用有 fallback 机制处理。
---
## 测试文件修改记录
### 已修复的选择器问题
1. **侧边栏导航** - 使用 `getByRole('tab', { name: '分身' })` 替代正则匹配
2. **发送按钮** - 使用 `getByRole('button', { name: '发送消息' })` 替代模糊匹配
3. **Strict mode 问题** - 修复多个 `.or()` 选择器导致的 strict mode violation
### 测试配置文件
- `desktop/tests/e2e/playwright.config.ts` - Playwright 配置
- `desktop/tests/e2e/specs/app-verification.spec.ts` - 基础验证测试 (28/28 通过)
- `desktop/tests/e2e/specs/functional-scenarios.spec.ts` - 功能场景测试 (37/46 通过)
---
## 截图位置
```
desktop/test-results/screenshots/
desktop/test-results/functional-scenarios-*-chromium/
```
## 下一步行动建议
1. **优先级 P0**: 修复聊天输入禁用问题 (影响多个测试)
2. **优先级 P1**: 修复 Hands 列表渲染问题
3. **优先级 P2**: 优化模型配置测试
4. **优先级 P3**: 清理长时间运行稳定性测试
---
## 新会话启动提示
在新会话中,可以使用以下提示快速开始:
```
我需要继续修复 ZCLAW 桌面应用的 E2E 测试问题。
当前状态:
- 测试通过率 88% (65/74)
- 已知问题记录在 desktop/tests/e2e/KNOWN_ISSUES.md
请帮我:
1. 阅读 KNOWN_ISSUES.md 了解详细问题
2. 从优先级 P0 (聊天输入禁用问题) 开始修复
3. 修复后运行测试验证
```

263
desktop/tests/e2e/debug.mjs Normal file
View File

@@ -0,0 +1,263 @@
/**
* ZCLAW 功能验证脚本 - Playwright CLI (改进版)
*/
import { chromium } from 'playwright';
const BASE_URL = 'http://localhost:1420';
const SCREENSHOT_DIR = 'test-results/screenshots';
const results = { passed: [], failed: [], warnings: [], screenshots: [] };
function log(msg) {
console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
}
async function screenshot(page, name) {
try {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
results.screenshots.push(name);
log(`📸 ${name}.png`);
} catch (e) {
log(`⚠️ 截图失败: ${name}`);
}
}
async function test(name, fn) {
try {
await fn();
results.passed.push(name);
log(`${name}`);
} catch (e) {
results.failed.push({ name, error: e.message });
log(`${name} - ${e.message}`);
}
}
async function main() {
log('🚀 ZCLAW 功能验证测试 (改进版)');
log(`📍 ${BASE_URL}`);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
const logs = [];
page.on('console', msg => logs.push(`[${msg.type()}] ${msg.text()}`));
page.on('pageerror', e => logs.push(`[error] ${e.message}`));
try {
// === 套件 1: 应用启动 ===
log('\n📋 应用启动与初始化');
await test('1.1 应用加载', async () => {
await page.goto(BASE_URL, { timeout: 10000 });
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(2000);
await screenshot(page, '01-app');
});
await test('1.2 左侧边栏', async () => {
const sidebar = page.locator('aside').first();
if (!await sidebar.isVisible()) throw new Error('左侧边栏不可见');
await screenshot(page, '02-sidebar');
});
await test('1.3 主区域', async () => {
if (!await page.locator('main').isVisible()) throw new Error('主区域不可见');
await screenshot(page, '03-main');
});
await test('1.4 右侧边栏', async () => {
const rightPanel = page.locator('aside').last();
if (!await rightPanel.isVisible()) throw new Error('右侧边栏不可见');
await screenshot(page, '04-right-panel');
});
// === 套件 2: 标签导航 ===
log('\n📋 标签导航');
await test('2.1 分身标签', async () => {
const tab = page.locator('button[role="tab"]').filter({ hasText: '分身' });
if (await tab.count() > 0) {
await tab.first().click();
await page.waitForTimeout(500);
await screenshot(page, 'tab-clones');
} else {
throw new Error('分身标签不可见');
}
});
await test('2.2 Hands 标签', async () => {
const tab = page.locator('button[role="tab"]').filter({ hasText: 'Hands' });
if (await tab.count() > 0) {
await tab.first().click();
await page.waitForTimeout(500);
await screenshot(page, 'tab-hands');
} else {
throw new Error('Hands 标签不可见');
}
});
await test('2.3 工作流标签', async () => {
const tab = page.locator('button[role="tab"]').filter({ hasText: '工作流' });
if (await tab.count() > 0) {
await tab.first().click();
await page.waitForTimeout(500);
await screenshot(page, 'tab-workflow');
} else {
throw new Error('工作流标签不可见');
}
});
await test('2.4 团队标签', async () => {
const tab = page.locator('button[role="tab"]').filter({ hasText: '团队' });
if (await tab.count() > 0) {
await tab.first().click();
await page.waitForTimeout(500);
await screenshot(page, 'tab-team');
} else {
throw new Error('团队标签不可见');
}
});
await test('2.5 协作标签', async () => {
const tab = page.locator('button[role="tab"]').filter({ hasText: '协作' });
if (await tab.count() > 0) {
await tab.first().click();
await page.waitForTimeout(500);
await screenshot(page, 'tab-swarm');
} else {
throw new Error('协作标签不可见');
}
});
// === 套件 3: Gateway 状态 ===
log('\n📋 Gateway 连接状态');
await test('3.1 连接状态显示', async () => {
const content = await page.content();
if (content.includes('Gateway 未连接') || content.includes('Connecting')) {
results.warnings.push('Gateway 未连接 - 需启动后端');
}
await screenshot(page, '05-gateway');
});
// === 套件 4: 设置 ===
log('\n📋 设置页面');
await test('4.1 打开设置', async () => {
const btn = page.getByRole('button', { name: /设置|Settings/i });
if (await btn.count() > 0) {
await btn.first().click();
await page.waitForTimeout(500);
await screenshot(page, '06-settings');
} else {
throw new Error('设置按钮不可见');
}
});
// === 套件 5: 聊天 ===
log('\n📋 聊天功能');
await test('5.1 聊天输入框', async () => {
await page.goto(BASE_URL, { timeout: 10000 });
await page.waitForTimeout(1000);
const input = page.locator('textarea');
if (await input.count() > 0) {
if (await input.first().isDisabled()) {
results.warnings.push('输入框已禁用 - 需 Gateway 连接');
}
} else {
throw new Error('聊天输入框不可见');
}
await screenshot(page, '07-chat');
});
await test('5.2 模型选择器', async () => {
const selector = page.locator('button').filter({ hasText: /模型|model/i });
if (await selector.count() > 0) {
await selector.first().click();
await page.waitForTimeout(300);
await screenshot(page, '08-model-selector');
}
});
// === 套件 6: 控制台错误 ===
log('\n📋 控制台错误');
await test('6.1 JS 错误检查', async () => {
const errors = logs.filter(l => l.includes('[error]'));
const criticalErrors = errors.filter(e =>
!e.includes('DevTools') &&
!e.includes('extension')
);
if (criticalErrors.length > 0) {
results.warnings.push(`发现 ${criticalErrors.length} 个 JS 错误`);
criticalErrors.slice(0, 5).forEach(e => log(` ${e.slice(0, 100)}...`));
}
});
// === 套件 7: 响应式 ===
log('\n📋 响应式布局');
await test('7.1 移动端', async () => {
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(500);
await screenshot(page, '09-mobile');
});
await test('7.2 平板', async () => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(500);
await screenshot(page, '10-tablet');
});
await test('7.3 桌面', async () => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForTimeout(500);
await screenshot(page, '11-desktop');
});
// === 套件 8: 性能 ===
log('\n📋 性能');
await test('8.1 加载时间', async () => {
const start = Date.now();
await page.goto(BASE_URL, { timeout: 10000 });
const time = Date.now() - start;
log(`加载时间: ${time}ms`);
if (time > 5000) results.warnings.push(`加载时间较长: ${time}ms`);
});
await test('8.2 DOM 数量', async () => {
const count = await page.evaluate(() => document.querySelectorAll('*').length);
log(`DOM 节点: ${count}`);
if (count > 3000) results.warnings.push(`DOM 过多: ${count}`);
});
} catch (e) {
log(`❌ 执行出错: ${e.message}`);
} finally {
await browser.close();
}
// 报告
console.log('\n' + '='.repeat(60));
console.log('📊 ZCLAW 功能验证报告');
console.log('='.repeat(60));
console.log(`\n✅ 通过: ${results.passed.length}`);
results.passed.forEach(n => console.log(` - ${n}`));
console.log(`\n❌ 失败: ${results.failed.length}`);
results.failed.forEach(f => console.log(` - ${f.name}: ${f.error}`));
console.log(`\n⚠️ 警告: ${results.warnings.length}`);
results.warnings.forEach(w => console.log(` - ${w}`));
console.log(`\n📸 截图: ${results.screenshots.length}`);
const total = results.passed.length + results.failed.length;
const rate = total > 0 ? ((results.passed.length / total) * 100).toFixed(1) : 0;
console.log(`\n总计: ${total} | 通过: ${results.passed.length} | 失败: ${results.failed.length} | 通过率: ${rate}%`);
process.exit(results.failed.length > 0 ? 1 : 0);
}
main().catch(console.error);

View File

@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:1420',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:1420',
reuseExistingServer: true,
},
});

View File

@@ -0,0 +1,211 @@
/**
* ZCLAW 快速功能验证 - Playwright CLI
*/
import { chromium } from 'playwright';
const BASE_URL = 'http://localhost:1420';
const SCREENSHOT_DIR = 'test-results/screenshots';
const results = { passed: [], failed: [], warnings: [], info: [] };
function log(msg) {
console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
}
async function screenshot(page, name) {
try {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
log(`📸 ${name}.png`);
} catch (e) {}
}
async function test(name, fn) {
try {
await fn();
results.passed.push(name);
log(`${name}`);
} catch (e) {
results.failed.push({ name, error: e.message.slice(0, 100) });
log(`${name}`);
}
}
async function main() {
log('🚀 ZCLAW 快速功能验证');
log(`📍 ${BASE_URL}`);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
const logs = [];
page.on('console', msg => logs.push(`[${msg.type()}] ${msg.text()}`));
page.on('pageerror', e => logs.push(`[error] ${e.message}`));
try {
// === 应用加载 ===
log('\n📋 应用加载');
await page.goto(BASE_URL, { timeout: 10000 });
await page.waitForTimeout(2000);
await screenshot(page, '01-app');
// === 页面结构检查 ===
log('\n📋 页面结构');
await test('主区域', async () => {
const main = page.locator('main');
if (!await main.isVisible()) throw new Error('主区域不可见');
});
await test('左侧边栏', async () => {
const sidebar = page.locator('aside').first();
if (!await sidebar.isVisible()) throw new Error('左侧边栏不可见');
});
await test('右侧边栏', async () => {
const rightPanel = page.locator('aside').last();
if (!await rightPanel.isVisible()) throw new Error('右侧边栏不可见');
});
// === 标签检查 ===
log('\n📋 标签导航');
const tabNames = ['分身', 'Hands', '工作流', '团队', '协作'];
for (const name of tabNames) {
await test(`标签: ${name}`, async () => {
const tab = page.getByRole('button', { name: new RegExp(name, 'i') });
if (await tab.count() === 0) throw new Error('标签不存在');
});
}
await screenshot(page, '02-tabs');
// === 标签切换测试 (只测前3个) ===
log('\n📋 标签切换');
for (const name of ['分身', 'Hands', '工作流']) {
await test(`切换: ${name}`, async () => {
const tab = page.getByRole('button', { name: new RegExp(name, 'i') }).first();
await tab.click({ timeout: 3000 });
await page.waitForTimeout(300);
});
}
await screenshot(page, '03-tab-switch');
// === 设置页面 ===
log('\n📋 设置');
await test('设置按钮', async () => {
const btn = page.getByRole('button', { name: /设置|Settings/i });
if (await btn.count() === 0) throw new Error('设置按钮不存在');
});
await test('打开设置', async () => {
const btn = page.getByRole('button', { name: /设置|Settings/i }).first();
await btn.click({ timeout: 3000 });
await page.waitForTimeout(500);
await screenshot(page, '04-settings');
});
// === 聊天功能 ===
log('\n📋 聊天');
await page.goto(BASE_URL, { timeout: 5000 });
await page.waitForTimeout(1000);
await test('聊天输入框', async () => {
const input = page.locator('textarea');
if (await input.count() === 0) throw new Error('输入框不存在');
if (await input.first().isDisabled()) {
results.warnings.push('输入框已禁用 - 需 Gateway 连接');
}
});
await test('发送按钮', async () => {
const btn = page.getByRole('button', { name: /发送|Send/i });
if (await btn.count() === 0) throw new Error('发送按钮不存在');
});
await test('模型选择器', async () => {
const selector = page.getByRole('button', { name: /模型|Model/i });
if (await selector.count() === 0) throw new Error('模型选择器不存在');
});
await screenshot(page, '05-chat');
// === Gateway 状态 ===
log('\n📋 Gateway 状态');
await test('连接状态显示', async () => {
const content = await page.content();
if (content.includes('Connecting') || content.includes('未连接')) {
results.info.push('Gateway 状态: 未连接/连接中');
} else if (content.includes('已连接')) {
results.info.push('Gateway 状态: 已连接');
}
});
// === 控制台错误 ===
log('\n📋 控制台检查');
const jsErrors = logs.filter(l => l.includes('[error]') && !l.includes('DevTools'));
if (jsErrors.length > 0) {
results.warnings.push(`JS 错误: ${jsErrors.length}`);
}
// === 响应式 ===
log('\n📋 响应式');
await test('移动端', async () => {
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(300);
});
await test('桌面', async () => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForTimeout(300);
});
await screenshot(page, '06-responsive');
// === 性能 ===
log('\n📋 性能');
await test('DOM 数量', async () => {
const count = await page.evaluate(() => document.querySelectorAll('*').length);
results.info.push(`DOM 节点: ${count}`);
if (count > 3000) results.warnings.push(`DOM 过多: ${count}`);
});
} catch (e) {
log(`❌ 执行出错: ${e.message}`);
} finally {
await browser.close();
}
// 报告
console.log('\n' + '='.repeat(60));
console.log('📊 ZCLAW 功能验证报告');
console.log('='.repeat(60));
console.log(`\n✅ 通过: ${results.passed.length}`);
results.passed.forEach(n => console.log(` - ${n}`));
console.log(`\n❌ 失败: ${results.failed.length}`);
results.failed.forEach(f => console.log(` - ${f.name}: ${f.error}`));
console.log(`\n⚠️ 警告: ${results.warnings.length}`);
results.warnings.forEach(w => console.log(` - ${w}`));
console.log(`\n 信息: ${results.info.length}`);
results.info.forEach(i => console.log(` - ${i}`));
const total = results.passed.length + results.failed.length;
const rate = total > 0 ? ((results.passed.length / total) * 100).toFixed(1) : 0;
console.log(`\n📈 通过率: ${rate}% (${results.passed.length}/${total})`);
process.exit(results.failed.length > 0 ? 1 : 0);
}
main().catch(console.error);

View File

@@ -0,0 +1,487 @@
/**
* ZCLAW 前端功能验证测试
*
* 验证所有核心功能的完整性和可用性
*/
import { test, expect, Page } from '@playwright/test';
// 测试超时配置
test.setTimeout(60000);
// 辅助函数:等待组件加载
async function waitForAppReady(page: Page) {
await page.waitForLoadState('networkidle');
// 等待主应用容器出现
await page.waitForSelector('.h-screen', { timeout: 10000 });
}
// 辅助函数:截图并保存
async function takeScreenshot(page: Page, name: string) {
await page.screenshot({
path: `test-results/screenshots/${name}.png`,
fullPage: true
});
}
test.describe('ZCLAW 前端功能验证', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForAppReady(page);
});
test.describe('1. 应用基础渲染', () => {
test('应用容器正确渲染', async ({ page }) => {
// 检查主容器存在
const appContainer = page.locator('.h-screen');
await expect(appContainer).toBeVisible();
// 检查三栏布局 - sidebar 和 main 都应该存在
const sidebar = page.locator('aside');
const mainContent = page.locator('main');
// 验证 sidebar 和 main 都存在
await expect(sidebar.first()).toBeVisible();
await expect(mainContent).toBeVisible();
await takeScreenshot(page, '01-app-layout');
});
test('页面标题正确', async ({ page }) => {
await expect(page).toHaveTitle(/ZCLAW/);
});
});
test.describe('2. Sidebar 侧边栏导航', () => {
test('侧边栏可见并包含导航项', async ({ page }) => {
// 验证侧边栏存在
const sidebar = page.locator('aside').first();
await expect(sidebar).toBeVisible();
// 检查导航按钮存在 - 使用 role="tab" 匹配
const cloneBtn = page.getByRole('tab', { name: '分身' });
const handsBtn = page.getByRole('tab', { name: 'Hands' });
const workflowBtn = page.getByRole('tab', { name: '工作流' });
const teamBtn = page.getByRole('tab', { name: '团队' });
const swarmBtn = page.getByRole('tab', { name: '协作' });
// 验证所有导航标签都存在
await expect(cloneBtn).toBeVisible();
await expect(handsBtn).toBeVisible();
await expect(workflowBtn).toBeVisible();
await expect(teamBtn).toBeVisible();
await expect(swarmBtn).toBeVisible();
await takeScreenshot(page, '02-sidebar-navigation');
});
test('导航切换功能', async ({ page }) => {
// 尝试点击不同的导航项
const navButtons = page.locator('button').filter({
has: page.locator('svg')
});
const count = await navButtons.count();
if (count > 1) {
await navButtons.nth(1).click();
await page.waitForTimeout(500);
// 验证视图切换
await takeScreenshot(page, '03-navigation-switch');
}
});
test('设置按钮可用', async ({ page }) => {
const settingsBtn = page.getByRole('button', { name: /settings|设置|⚙/i }).or(
page.locator('button').filter({ hasText: /设置|Settings/ })
);
if (await settingsBtn.isVisible()) {
await settingsBtn.click();
await page.waitForTimeout(300);
await takeScreenshot(page, '04-settings-access');
}
});
});
test.describe('3. ChatArea 聊天功能', () => {
test('聊天区域渲染', async ({ page }) => {
// 查找聊天输入框
const chatInput = page.locator('textarea').or(
page.locator('input[type="text"]')
).or(
page.locator('[contenteditable="true"]')
);
// 检查消息区域
const messageArea = page.locator('[class*="flex-1"]').filter({
has: page.locator('[class*="message"], [class*="chat"]')
});
await takeScreenshot(page, '05-chat-area');
// 记录聊天组件状态
const inputExists = await chatInput.count() > 0;
console.log(`Chat input found: ${inputExists}`);
});
test('消息发送功能', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
await chatInput.fill('测试消息');
const sendBtn = page.getByRole('button', { name: '发送消息' });
if (await sendBtn.isVisible()) {
await sendBtn.click();
await page.waitForTimeout(500);
} else {
// 可能支持回车发送
await chatInput.press('Enter');
}
await takeScreenshot(page, '06-message-send');
}
});
test('会话列表渲染', async ({ page }) => {
const conversationList = page.locator('[class*="conversation"]').or(
page.locator('[class*="session"]')
).or(
page.locator('ul, ol').filter({ has: page.locator('li') })
);
await takeScreenshot(page, '07-conversation-list');
});
});
test.describe('4. Hands 系统UI', () => {
test.beforeEach(async ({ page }) => {
// 导航到 Hands 视图
const handsBtn = page.getByRole('button', { name: 'Hands' });
if (await handsBtn.isVisible()) {
await handsBtn.click();
await page.waitForTimeout(1000); // 等待数据加载
}
});
test('Hands 列表渲染', async ({ page }) => {
const handsList = page.locator('[class*="hand"]').or(
page.locator('[class*="capability"]')
);
await takeScreenshot(page, '08-hands-list');
// 检查是否有 Hand 卡片
const handCards = page.locator('[class*="card"]').filter({
hasText: /Clip|Lead|Collector|Predictor|Researcher|Twitter|Browser/i
});
const cardCount = await handCards.count();
console.log(`Found ${cardCount} hand cards`);
});
test('Hand 触发按钮', async ({ page }) => {
const triggerBtn = page.getByRole('button', { name: /trigger|触发|执行|run/i });
if (await triggerBtn.first().isVisible()) {
await takeScreenshot(page, '09-hand-trigger');
}
});
});
test.describe('5. Workflow/Scheduler 面板', () => {
test.beforeEach(async ({ page }) => {
const workflowBtn = page.getByRole('button', { name: '工作流' });
if (await workflowBtn.isVisible()) {
await workflowBtn.click();
await page.waitForTimeout(500);
}
});
test('Scheduler 面板渲染', async ({ page }) => {
const schedulerPanel = page.locator('[class*="scheduler"]').or(
page.locator('[class*="workflow"]')
);
await takeScreenshot(page, '10-scheduler-panel');
// 检查定时任务列表
const taskList = page.locator('table, ul, [class*="list"]');
const hasTaskList = await taskList.count() > 0;
console.log(`Task list found: ${hasTaskList}`);
});
test('工作流编辑器', async ({ page }) => {
const workflowEditor = page.locator('[class*="editor"]').or(
page.locator('[class*="workflow-editor"]')
);
if (await workflowEditor.isVisible()) {
await takeScreenshot(page, '11-workflow-editor');
}
});
});
test.describe('6. Team 协作视图', () => {
test.beforeEach(async ({ page }) => {
const teamBtn = page.getByRole('button', { name: '团队' });
if (await teamBtn.isVisible()) {
await teamBtn.click();
await page.waitForTimeout(500);
}
});
test('Team 列表和创建', async ({ page }) => {
const teamList = page.locator('[class*="team"]').or(
page.locator('[class*="group"]')
);
const createBtn = page.getByRole('button', { name: /create|创建|new|新建|\+/i });
await takeScreenshot(page, '12-team-view');
if (await createBtn.first().isVisible()) {
console.log('Team create button available');
}
});
test('团队成员显示', async ({ page }) => {
const members = page.locator('[class*="member"]').or(
page.locator('[class*="agent"]')
);
const memberCount = await members.count();
console.log(`Found ${memberCount} team members`);
});
});
test.describe('7. Swarm Dashboard', () => {
test.beforeEach(async ({ page }) => {
const swarmBtn = page.getByRole('button', { name: '协作' });
if (await swarmBtn.isVisible()) {
await swarmBtn.click();
await page.waitForTimeout(500);
}
});
test('Swarm 仪表板渲染', async ({ page }) => {
const dashboard = page.locator('[class*="swarm"]').or(
page.locator('[class*="dashboard"]')
);
await takeScreenshot(page, '13-swarm-dashboard');
// 检查状态指示器
const statusIndicators = page.locator('[class*="status"]');
const statusCount = await statusIndicators.count();
console.log(`Found ${statusCount} status indicators`);
});
});
test.describe('8. Settings 设置页面', () => {
test.beforeEach(async ({ page }) => {
const settingsBtn = page.getByRole('button', { name: /settings|设置|⚙/i });
if (await settingsBtn.isVisible()) {
await settingsBtn.click();
await page.waitForTimeout(500);
}
});
test('设置页面渲染', async ({ page }) => {
const settingsLayout = page.locator('[class*="settings"]').or(
page.locator('form')
);
await takeScreenshot(page, '14-settings-page');
// 检查设置分类
const settingsTabs = page.locator('[role="tab"]').or(
page.locator('button').filter({ hasText: /General|通用|Security|安全|Model|模型/i })
);
const tabCount = await settingsTabs.count();
console.log(`Found ${tabCount} settings tabs`);
});
test('通用设置', async ({ page }) => {
const generalSettings = page.locator('[class*="general"]').or(
page.getByText(/general|通用设置/i)
);
if (await generalSettings.isVisible()) {
await takeScreenshot(page, '15-general-settings');
}
});
test('模型配置', async ({ page }) => {
// 检查设置页面是否有模型相关内容
const modelSection = page.getByRole('button', { name: /模型|Model/i }).or(
page.locator('text=/模型|Model/i')
);
// 这个测试是可选的,因为模型配置可能在不同的标签页
const isVisible = await modelSection.first().isVisible().catch(() => false);
if (isVisible) {
await takeScreenshot(page, '16-model-settings');
} else {
// 如果没有找到模型配置,跳过测试
console.log('Model settings section not found - may be in a different tab');
}
});
});
test.describe('9. RightPanel 右侧面板', () => {
test('右侧面板渲染', async ({ page }) => {
// 查找右侧面板
const rightPanel = page.locator('[class*="w-"][class*="border-l"]').or(
page.locator('aside').last()
);
if (await rightPanel.isVisible()) {
await takeScreenshot(page, '17-right-panel');
// 检查面板内容
const panelContent = rightPanel.locator('[class*="info"], [class*="detail"], [class*="context"]');
console.log(`Right panel content found: ${await panelContent.count() > 0}`);
}
});
});
test.describe('10. 错误处理和边界情况', () => {
test('网络错误处理', async ({ page }) => {
// 模拟离线
await page.context().setOffline(true);
await page.waitForTimeout(1000);
// 检查错误提示
const errorMessage = page.locator('[class*="error"]').or(
page.locator('[role="alert"]')
);
await takeScreenshot(page, '18-offline-error');
// 恢复网络
await page.context().setOffline(false);
});
test('空状态显示', async ({ page }) => {
// 检查空状态组件
const emptyState = page.locator('[class*="empty"]').or(
page.locator('[class*="no-data"]')
);
if (await emptyState.isVisible()) {
await takeScreenshot(page, '19-empty-state');
}
});
});
test.describe('11. 响应式布局', () => {
test('移动端布局', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(500);
await takeScreenshot(page, '20-mobile-layout');
// 检查移动端导航
const mobileMenu = page.locator('[class*="mobile"]').or(
page.locator('button[aria-label*="menu"]')
);
console.log(`Mobile menu found: ${await mobileMenu.count() > 0}`);
});
test('平板布局', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(500);
await takeScreenshot(page, '21-tablet-layout');
});
test('桌面布局', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForTimeout(500);
await takeScreenshot(page, '22-desktop-layout');
});
});
test.describe('12. 性能检查', () => {
test('页面加载性能', async ({ page }) => {
const startTime = Date.now();
await page.goto('/');
await waitForAppReady(page);
const loadTime = Date.now() - startTime;
console.log(`Page load time: ${loadTime}ms`);
// 页面加载时间应该小于 5 秒
expect(loadTime).toBeLessThan(5000);
});
test('内存使用检查', async ({ page }) => {
// 获取页面指标
const metrics = await page.evaluate(() => {
return {
memory: (performance as any).memory?.usedJSHeapSize || 0,
domNodes: document.querySelectorAll('*').length,
};
});
console.log(`DOM nodes: ${metrics.domNodes}`);
console.log(`Memory used: ${Math.round(metrics.memory / 1024 / 1024)}MB`);
});
});
});
test.describe('13. 控制台错误检查', () => {
test('无 JavaScript 错误', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', error => {
errors.push(error.message);
});
await page.goto('/');
await waitForAppReady(page);
// 执行一些交互
await page.click('body');
await page.waitForTimeout(1000);
// 检查是否有严重错误
const criticalErrors = errors.filter(e =>
!e.includes('Warning:') &&
!e.includes('DevTools') &&
!e.includes('extension')
);
console.log(`Console errors: ${criticalErrors.length}`);
criticalErrors.forEach(e => console.log(` - ${e}`));
// 允许少量非严重错误
expect(criticalErrors.length).toBeLessThan(5);
});
test('无网络请求失败', async ({ page }) => {
const failedRequests: string[] = [];
page.on('requestfailed', request => {
failedRequests.push(request.url());
});
await page.goto('/');
await waitForAppReady(page);
await page.waitForTimeout(2000);
console.log(`Failed requests: ${failedRequests.length}`);
failedRequests.forEach(r => console.log(` - ${r}`));
});
});

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -29,7 +29,8 @@ export default defineConfig(async () => ({
ignored: ["**/src-tauri/**"],
},
proxy: {
// Proxy /api requests to OpenFang (port 50051) or OpenClaw (port 18789)
// Proxy /api requests to OpenFang Kernel (port 50051)
// OpenFang is managed by Tauri app - started via gateway_start command
'/api': {
target: 'http://127.0.0.1:50051',
changeOrigin: true,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,547 @@
# Browser Hand UI 组件设计文档
> **日期**: 2026-03-17
> **状态**: Draft
> **作者**: Claude Code
## 一、概述
Browser Hand 是 ZCLAW 的浏览器自动化能力,基于 Fantoccini (Rust WebDriver) 实现。本设计文档描述其前端 UI 组件架构和集成方案。
### 设计目标
1. **双层触发机制**: 前端任务模板 + Agent 脚本驱动
2. **实时可视化**: 执行状态 + 截图预览
3. **完整工具集**: 支持所有浏览器自动化功能
4. **标准集成**: 作为 Hand 集成到现有 HandsPanel
### 范围
- 前端 UI 组件
- 状态管理 Store
- 任务模板系统
- 与现有 HandsPanel 集成
---
## 二、架构设计
### 2.1 组件结构
```
desktop/src/
├── components/
│ ├── HandsPanel.tsx # 现有,添加 Browser Hand 特殊处理
│ └── BrowserHand/
│ ├── index.ts # 模块导出
│ ├── BrowserHandCard.tsx # Browser Hand 专用卡片
│ ├── TaskTemplateModal.tsx # 任务模板选择模态框
│ ├── TaskRunner.tsx # 任务执行状态展示
│ ├── ScreenshotPreview.tsx # 截图预览组件
│ └── templates/
│ ├── index.ts # 模板注册
│ ├── types.ts # 模板类型定义
│ ├── basic.ts # 基础操作模板
│ ├── scraping.ts # 数据采集模板
│ └── automation.ts # 自动化流程模板
├── lib/
│ ├── browser-client.ts # 已完成 - Tauri API 封装
│ └── browser-templates.ts # 模板执行引擎
└── store/
└── browserHandStore.ts # Browser Hand 状态管理
```
### 2.2 数据流
```
┌─────────────────────────────────────────────────────────────────┐
│ 前端触发流程 │
├─────────────────────────────────────────────────────────────────┤
│ HandsPanel │
│ ↓ 检测 Browser Hand │
│ BrowserHandCard │
│ ↓ 点击"执行任务" │
│ TaskTemplateModal │
│ ↓ 选择模板 + 填参数 │
│ browserHandStore.executeTemplate() │
│ ↓ 调用 │
│ Tauri Commands (browser_*) │
│ ↓ WebDriver Protocol │
│ Fantoccini → ChromeDriver │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Agent 触发流程 │
├─────────────────────────────────────────────────────────────────┤
│ 用户对话 │
│ ↓ 理解意图 │
│ LLM 生成脚本 │
│ ↓ Agent 调用 │
│ browserHandStore.executeScript() │
│ ↓ 调用 │
│ Tauri Commands (browser_execute_script) │
│ ↓ WebDriver Protocol │
│ Fantoccini → ChromeDriver │
└─────────────────────────────────────────────────────────────────┘
```
### 2.3 与现有系统集成
```typescript
// HandsPanel.tsx 修改
import { BrowserHandCard } from './BrowserHand';
function HandsPanel() {
// 检测是否为 Browser Hand
const isBrowserHand = (hand: Hand) => hand.id === 'browser' || hand.name === 'Browser';
return (
<div className="grid gap-3">
{hands.map((hand) =>
isBrowserHand(hand) ? (
<BrowserHandCard key={hand.id} hand={hand} />
) : (
<HandCard key={hand.id} hand={hand} {...props} />
)
)}
</div>
);
}
```
---
## 三、状态管理
### 3.1 Store 结构
```typescript
// store/browserHandStore.ts
interface BrowserSession {
id: string;
name: string;
currentUrl: string | null;
title: string | null;
status: 'connecting' | 'connected' | 'active' | 'idle' | 'error';
createdAt: string;
lastActivity: string;
}
interface BrowserLog {
id: string;
timestamp: string;
level: 'info' | 'warn' | 'error' | 'action';
message: string;
details?: Record<string, unknown>;
}
interface ExecutionState {
isRunning: boolean;
currentAction: string | null;
currentUrl: string | null;
lastScreenshot: string | null;
progress: number; // 0-100
startTime: string | null;
}
interface RecentTask {
id: string;
templateId: string;
templateName: string;
params: Record<string, unknown>;
status: 'success' | 'failed' | 'cancelled';
executedAt: string;
duration: number;
result?: unknown;
}
interface BrowserHandState {
// 会话管理
sessions: BrowserSession[];
activeSessionId: string | null;
// 执行状态
execution: ExecutionState;
// 日志
logs: BrowserLog[];
// 模板
templates: TaskTemplate[];
recentTasks: RecentTask[];
// UI 状态
isTemplateModalOpen: boolean;
isLoading: boolean;
error: string | null;
}
interface BrowserHandActions {
// 会话管理
createSession: (options?: SessionOptions) => Promise<string>;
closeSession: (sessionId: string) => Promise<void>;
listSessions: () => Promise<void>;
// 模板执行
executeTemplate: (templateId: string, params: Record<string, unknown>) => Promise<unknown>;
executeScript: (script: string, args?: unknown[]) => Promise<unknown>;
// 状态更新
updateExecutionState: (state: Partial<ExecutionState>) => void;
addLog: (log: Omit<BrowserLog, 'id' | 'timestamp'>) => void;
// 截图
takeScreenshot: () => Promise<string>;
// UI 控制
openTemplateModal: () => void;
closeTemplateModal: () => void;
clearError: () => void;
}
```
### 3.2 Store 实现要点
```typescript
// 使用 Zustand 创建 store
export const useBrowserHandStore = create<BrowserHandState & BrowserHandActions>((set, get) => ({
// 初始状态
sessions: [],
activeSessionId: null,
execution: {
isRunning: false,
currentAction: null,
currentUrl: null,
lastScreenshot: null,
progress: 0,
startTime: null,
},
logs: [],
templates: BUILTIN_TEMPLATES,
recentTasks: [],
isTemplateModalOpen: false,
isLoading: false,
error: null,
// Actions 实现见详细设计
}));
```
---
## 四、任务模板系统
### 4.1 模板类型定义
```typescript
// components/BrowserHand/templates/types.ts
interface TaskTemplateParam {
key: string;
label: string;
type: 'text' | 'url' | 'number' | 'select' | 'textarea' | 'json';
required: boolean;
default?: unknown;
placeholder?: string;
options?: { value: string; label: string }[]; // for select type
description?: string;
}
interface TaskTemplate {
id: string;
name: string;
description: string;
category: 'basic' | 'scraping' | 'automation';
icon: string;
params: TaskTemplateParam[];
execute: (params: Record<string, unknown>, context: ExecutionContext) => Promise<unknown>;
}
interface ExecutionContext {
browser: Browser; // browser-client.ts 中的 Browser 类
onProgress: (action: string, progress: number) => void;
onLog: (level: BrowserLog['level'], message: string, details?: Record<string, unknown>) => void;
}
```
### 4.2 内置模板列表
#### 基础操作类 (Basic)
| ID | 名称 | 参数 | 说明 |
|----|------|------|------|
| `basic_navigate_screenshot` | 打开网页并截图 | `url` | 访问指定 URL 并截图 |
| `basic_fill_form` | 填写表单 | `url`, `fields[]` | 填写并可选提交表单 |
| `basic_click_navigate` | 点击导航 | `url`, `selector` | 点击指定元素 |
#### 数据采集类 (Scraping)
| ID | 名称 | 参数 | 说明 |
|----|------|------|------|
| `scrape_text` | 抓取页面文本 | `url`, `selectors[]` | 提取多个选择器的文本 |
| `scrape_list` | 提取列表数据 | `url`, `itemSelector`, `fieldMappings` | 批量提取结构化数据 |
| `scrape_images` | 下载图片 | `url`, `imageSelector`, `savePath` | 批量下载图片 |
#### 自动化流程类 (Automation)
| ID | 名称 | 参数 | 说明 |
|----|------|------|------|
| `auto_login_action` | 登录并操作 | `loginUrl`, `credentials`, `actions[]` | 登录后执行操作序列 |
| `auto_multi_page` | 多页面导航 | `urls[]`, `actions[]` | 遍历多个页面执行操作 |
| `auto_monitor` | 定时监控 | `url`, `checkCondition`, `interval` | 周期性检查页面状态 |
### 4.3 模板执行引擎
```typescript
// lib/browser-templates.ts
export async function executeTemplate(
template: TaskTemplate,
params: Record<string, unknown>,
browser: Browser,
onProgress: (action: string, progress: number) => void,
onLog: (level: BrowserLog['level'], message: string) => void
): Promise<unknown> {
const context: ExecutionContext = {
browser,
onProgress,
onLog: (level, message, details) => {
onLog(level, message);
console.log(`[BrowserHand] ${level}: ${message}`, details);
},
};
// 验证参数
validateParams(template.params, params);
// 执行模板
onProgress('启动浏览器', 0);
try {
const result = await template.execute(params, context);
onProgress('完成', 100);
return result;
} catch (error) {
onLog('error', `执行失败: ${error}`);
throw error;
}
}
```
---
## 五、UI 组件设计
### 5.1 BrowserHandCard
Browser Hand 的专用卡片组件,显示实时状态和截图预览。
```
┌────────────────────────────────────────────────────────────┐
│ 🌐 Browser Hand [●] 就绪/运行中 │
├────────────────────────────────────────────────────────────┤
│ 浏览器自动化能力 - 支持网页操作、数据采集、自动化流程 │
├────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 截 图 预 览 │ │
│ │ (最近一次截图) │ │
│ └────────────────────────────────────────────────────────┘ │
│ 当前: https://example.com/page │
│ 操作: 正在填写表单... ████████░░ 80% │
├────────────────────────────────────────────────────────────┤
│ [📋 执行任务] [📸 截图] [🔄 刷新] [⚙️ 设置] │
└────────────────────────────────────────────────────────────┘
```
**Props**:
```typescript
interface BrowserHandCardProps {
hand: Hand;
onOpenSettings?: () => void;
}
```
**状态显示**:
- `idle`: 显示"就绪",截图区域显示占位符
- `running`: 显示进度条、当前操作、最新截图
- `error`: 显示错误信息、重试按钮
### 5.2 TaskTemplateModal
任务模板选择和参数填写模态框。
```
┌────────────────────────────────────────────────────────────┐
│ 选择任务模板 ✕ │
├────────────────────────────────────────────────────────────┤
│ [基础操作] [数据采集] [自动化流程] │
├────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 📸 打开截图 │ │ 📝 填写表单 │ │ 🖱️ 点击导航 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 📄 抓取文本 │ │ 📊 提取列表 │ │
│ └──────────────┘ └──────────────┘ │
├────────────────────────────────────────────────────────────┤
│ 已选: 📸 打开网页并截图 │
│ │
│ 网页地址 * │
│ ┌────────────────────────────────────────────────────────┐│
│ │ https://example.com ││
│ └────────────────────────────────────────────────────────┘│
├────────────────────────────────────────────────────────────┤
│ [取消] [▶ 执行任务] │
└────────────────────────────────────────────────────────────┘
```
**Props**:
```typescript
interface TaskTemplateModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: TaskTemplate, params: Record<string, unknown>) => void;
}
```
### 5.3 TaskRunner
任务执行中的状态展示组件(可嵌入 BrowserHandCard 或独立显示)。
```
┌────────────────────────────────────────────────────────────┐
│ 执行中: 打开网页并截图 │
├────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 实 时 截 图 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 当前 URL: https://example.com/page │
│ 操作: 等待页面加载... │
│ │
│ ████████████████████░░░░░░░░░░ 65% │
│ │
│ ┌─ 操作日志 ─────────────────────────────────────────────┐│
│ │ 10:23:01 [info] 创建浏览器会话 ││
│ │ 10:23:02 [info] 导航到 https://example.com ││
│ │ 10:23:05 [action] 等待页面加载 ││
│ └────────────────────────────────────────────────────────┘│
├────────────────────────────────────────────────────────────┤
│ [⏹ 停止] [📋 查看结果] │
└────────────────────────────────────────────────────────────┘
```
### 5.4 ScreenshotPreview
截图预览组件,支持缩放和全屏查看。
```typescript
interface ScreenshotPreviewProps {
base64: string | null;
isLoading?: boolean;
onRefresh?: () => void;
onClick?: () => void; // 点击放大
}
```
---
## 六、错误处理
### 6.1 错误类型
```typescript
enum BrowserHandErrorType {
WEBDRIVER_NOT_AVAILABLE = 'webdriver_not_available',
SESSION_CREATION_FAILED = 'session_creation_failed',
NAVIGATION_FAILED = 'navigation_failed',
ELEMENT_NOT_FOUND = 'element_not_found',
TIMEOUT = 'timeout',
SCRIPT_ERROR = 'script_error',
PERMISSION_DENIED = 'permission_denied',
}
```
### 6.2 错误处理策略
| 错误类型 | 处理方式 |
|---------|---------|
| WEBDRIVER_NOT_AVAILABLE | 显示安装指引,提供下载链接 |
| SESSION_CREATION_FAILED | 重试按钮,显示详细错误 |
| NAVIGATION_FAILED | 显示 URL提供重试 |
| ELEMENT_NOT_FOUND | 显示选择器,建议检查页面 |
| TIMEOUT | 提供增加超时时间选项 |
| SCRIPT_ERROR | 显示脚本错误位置和消息 |
---
## 七、测试策略
### 7.1 单元测试
- `browserHandStore` 状态管理测试
- 模板参数验证测试
- 错误处理测试
### 7.2 集成测试
- 模板执行流程测试mock Tauri commands
- UI 组件渲染测试
- 与 HandsPanel 集成测试
### 7.3 E2E 测试
- 完整任务执行流程
- 多模板顺序执行
- 错误恢复流程
---
## 八、实现优先级
### Phase 1: 核心框架
1. `browserHandStore` 状态管理
2. `BrowserHandCard` 基础组件
3. 集成到 `HandsPanel`
### Phase 2: 模板系统
4. 模板类型定义和注册
5. `TaskTemplateModal` 组件
6. 基础操作类模板实现
### Phase 3: 完整功能
7. 数据采集类模板
8. 自动化流程类模板
9. `TaskRunner` 实时状态展示
10. 截图预览功能
---
## 九、依赖
- `browser-client.ts` (已完成)
- Tauri browser commands (已完成)
- Fantoccini WebDriver (已完成)
- Zustand (已有)
- Lucide React icons (已有)
- Tailwind CSS (已有)
---
## 十、风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| WebDriver 未安装 | 无法使用 | 提供安装检测和指引 |
| 大量截图消耗内存 | 性能下降 | 限制截图历史数量,压缩图片 |
| 脚本执行安全 | 潜在危险操作 | Agent 层审批机制 |
| 复杂模板难以调试 | 用户体验差 | 详细日志,步骤高亮 |
---
## 十一、未来扩展
- [ ] 录制回放功能
- [ ] 模板自定义创建
- [ ] 多浏览器并行
- [ ] Chrome DevTools Protocol 直接集成
- [ ] WebMCP 支持 (Chrome 146+)

View File

@@ -10,7 +10,14 @@
"test": "vitest run",
"gateway:start": "openfang gateway start",
"gateway:status": "openfang gateway status",
"gateway:doctor": "openfang doctor"
"gateway:doctor": "openfang doctor",
"start": "powershell -ExecutionPolicy Bypass -File ./start-all.ps1",
"start:dev": "powershell -ExecutionPolicy Bypass -File ./start-all.ps1 -Dev",
"start:desktop": "powershell -ExecutionPolicy Bypass -File ./start-all.ps1 -DesktopOnly",
"start:stop": "powershell -ExecutionPolicy Bypass -File ./start-all.ps1 -Stop",
"desktop": "cd desktop && pnpm tauri dev",
"desktop:build": "cd desktop && pnpm tauri build",
"chromedriver": "chromedriver --port=4444"
},
"keywords": [
"ai",

201
start-all.ps1 Normal file
View File

@@ -0,0 +1,201 @@
# ZCLAW Full Stack Start Script
# Starts: ChromeDriver (optional) -> Tauri Desktop (manages OpenFang internally)
#
# NOTE: OpenFang is bundled with Tauri and managed internally.
# The frontend uses Tauri commands (gateway_start/gateway_status) to control OpenFang.
# No external OpenFang CLI installation is required.
param(
[switch]$NoBrowser,
[switch]$Dev,
[switch]$Help,
[switch]$Stop,
[switch]$DesktopOnly
)
$ErrorActionPreference = "Continue"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Colors
function info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Cyan }
function ok { param($msg) Write-Host "[OK] $msg" -ForegroundColor Green }
function warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow }
function err { param($msg) Write-Host "[ERROR] $msg" -ForegroundColor Red }
if ($Help) {
Write-Host @"
ZCLAW Full Stack Start Script
==============================
Usage: .\start-all.ps1 [options]
Options:
-DesktopOnly Start desktop only (skip ChromeDriver)
-NoBrowser Skip ChromeDriver startup
-Dev Development mode (hot reload)
-Stop Stop all services
-Help Show this help
Note:
OpenFang is bundled with the Tauri app and managed internally.
The app will start OpenFang automatically via Tauri commands.
No external OpenFang CLI installation required.
Quick Commands:
pnpm start # Start all services
pnpm start:dev # Start in dev mode
pnpm start:desktop # Start desktop only (no browser)
"@
exit 0
}
# Stop all services
if ($Stop) {
info "Stopping all ZCLAW services..."
# Stop ChromeDriver
Get-Process -Name "chromedriver" -ErrorAction SilentlyContinue | Stop-Process -Force
ok "ChromeDriver stopped"
# Stop any process on port 4200 (OpenFang)
$port4200 = netstat -ano | Select-String ":4200.*LISTENING"
if ($port4200) {
$pid4200 = ($port4200 -split '\s+')[-1]
if ($pid4200 -match '^\d+$') {
Stop-Process -Id $pid4200 -Force -ErrorAction SilentlyContinue
ok "Stopped process on port 4200 (PID: $pid4200)"
}
}
# Stop Tauri/ZClaw
Get-Process -Name "ZClaw" -ErrorAction SilentlyContinue | Stop-Process -Force
Get-Process -Name "desktop" -ErrorAction SilentlyContinue | Stop-Process -Force
ok "ZCLAW Desktop stopped"
# Kill any process on port 1420 (Vite dev server)
$port1420 = netstat -ano | Select-String ":1420.*LISTENING"
if ($port1420) {
$pid1420 = ($port1420 -split '\s+')[-1]
if ($pid1420 -match '^\d+$') {
Stop-Process -Id $pid1420 -Force -ErrorAction SilentlyContinue
ok "Killed process on port 1420 (PID: $pid1420)"
}
}
ok "All services stopped"
exit 0
}
Write-Host ""
Write-Host "===============================================" -ForegroundColor Magenta
Write-Host " ZCLAW - OpenFang Desktop Client" -ForegroundColor Magenta
Write-Host "===============================================" -ForegroundColor Magenta
Write-Host ""
# Track processes for cleanup
$Jobs = @()
function Cleanup {
info "Cleaning up..."
foreach ($job in $Jobs) {
if ($job -and !$job.HasExited) {
info "Stopping $($job.ProcessName) (PID: $($job.Id))"
try { $job.Kill() } catch {}
}
}
}
trap { Cleanup; break }
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Cleanup } | Out-Null
# Skip ChromeDriver if DesktopOnly
if ($DesktopOnly) {
$NoBrowser = $true
}
# 1. ChromeDriver (optional - for Browser Hand automation)
if (-not $NoBrowser) {
info "Checking ChromeDriver..."
$existing = Get-Process -Name "chromedriver" -ErrorAction SilentlyContinue
if ($existing) {
ok "ChromeDriver already running (PID: $($existing.Id))"
} else {
$chromedriver = Get-Command chromedriver -ErrorAction SilentlyContinue
if ($chromedriver) {
ok "ChromeDriver found: $($chromedriver.Source)"
info "Starting ChromeDriver on port 4444..."
$proc = Start-Process -FilePath "chromedriver" -ArgumentList "--port=4444" -PassThru -WindowStyle Hidden
$Jobs += $proc
Start-Sleep -Milliseconds 500
if ($proc.HasExited) {
warn "ChromeDriver exited. Check if port 4444 is in use."
} else {
ok "ChromeDriver started (PID: $($proc.Id))"
}
} else {
warn "ChromeDriver not found. Browser automation disabled."
info "Download: https://chromedriver.chromium.org/downloads"
}
}
} else {
info "Skipping ChromeDriver"
}
Write-Host ""
# 2. Check OpenFang Runtime
info "Checking OpenFang runtime..."
$runtimePath = "$ScriptDir/desktop/src-tauri/resources/openfang-runtime"
if (Test-Path "$runtimePath/openfang.exe") {
ok "OpenFang runtime found (bundled)"
} elseif (Test-Path "$runtimePath/openfang") {
ok "OpenFang runtime found (bundled)"
} else {
warn "OpenFang runtime not found at $runtimePath"
info "Run: cd desktop && pnpm prepare:openfang-runtime"
}
Write-Host ""
# 3. Start Tauri Desktop
info "Starting ZCLAW Desktop..."
Set-Location "$ScriptDir/desktop"
# Check if port 1420 is in use
$port1420 = netstat -ano | Select-String ":1420.*LISTENING"
if ($port1420) {
$pid1420 = ($port1420 -split '\s+')[-1]
if ($pid1420 -match '^\d+$') {
warn "Port 1420 is in use by PID $pid1420. Killing..."
Stop-Process -Id $pid1420 -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
}
if ($Dev) {
info "Development mode enabled"
info "OpenFang will be started by the app via Tauri commands"
pnpm tauri dev
} else {
$exe = "src-tauri\target\release\ZClaw.exe"
if (Test-Path $exe) {
info "Starting built application..."
Start-Process $exe
ok "ZCLAW Desktop started"
} else {
info "Built app not found, using dev mode..."
pnpm tauri dev
}
}
if ($Dev) {
Write-Host ""
info "Press Ctrl+C to stop all services..."
try { while ($true) { Start-Sleep -Seconds 1 } }
finally { Cleanup }
}

183
start.ps1 Normal file
View File

@@ -0,0 +1,183 @@
# ZCLAW Unified Start Script for Windows
# Usage: .\start.ps1 [-NoBrowser] [-NoGateway] [-Dev]
param(
[switch]$NoBrowser, # Skip ChromeDriver
[switch]$NoGateway, # Skip OpenFang gateway
[switch]$Dev, # Start in development mode (with hot reload)
[switch]$Help
)
$ErrorActionPreference = "Continue"
# Colors for output
function Write-Info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Cyan }
function Write-Success { param($msg) Write-Host "[OK] $msg" -ForegroundColor Green }
function Write-Warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow }
function Write-Err { param($msg) Write-Host "[ERROR] $msg" -ForegroundColor Red }
if ($Help) {
Write-Host @"
ZCLAW Unified Start Script
Usage: .\start.ps1 [options]
Options:
-NoBrowser Skip starting ChromeDriver
-NoGateway Skip starting OpenFang gateway
-Dev Start in development mode with hot reload
-Help Show this help message
Examples:
.\start.ps1 # Start all services
.\start.ps1 -NoBrowser # Start without ChromeDriver
.\start.ps1 -Dev # Start in dev mode
"@
exit 0
}
Write-Host ""
Write-Host "═══════════════════════════════════════════" -ForegroundColor Magenta
Write-Host " 🦞 ZCLAW - OpenFang Desktop Client" -ForegroundColor Magenta
Write-Host "═══════════════════════════════════════════" -ForegroundColor Magenta
Write-Host ""
# Track started processes for cleanup
$startedProcesses = @()
# Cleanup function
function Cleanup {
Write-Info "Cleaning up..."
foreach ($proc in $startedProcesses) {
if ($proc -and !$proc.HasExited) {
Write-Info "Stopping process $($proc.ProcessName) (PID: $($proc.Id))"
try {
$proc.Kill()
} catch {
# Ignore errors
}
}
}
}
# Register cleanup
trap { Cleanup; break }
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Cleanup }
# 1. Check ChromeDriver
if (-not $NoBrowser) {
Write-Info "Checking ChromeDriver..."
$chromedriver = Get-Command chromedriver -ErrorAction SilentlyContinue
if ($chromedriver) {
Write-Success "ChromeDriver found: $($chromedriver.Source)"
# Start ChromeDriver
Write-Info "Starting ChromeDriver on port 4444..."
$chromedriverProc = Start-Process -FilePath "chromedriver" -ArgumentList "--port=4444" -PassThru -WindowStyle Hidden
$startedProcesses += $chromedriverProc
Start-Sleep -Milliseconds 500
if ($chromedriverProc.HasExited) {
Write-Warn "ChromeDriver exited immediately. It may already be running."
} else {
Write-Success "ChromeDriver started (PID: $($chromedriverProc.Id))"
}
} else {
Write-Warn "ChromeDriver not found. Browser automation will not work."
Write-Info "Install ChromeDriver: https://chromedriver.chromium.org/downloads"
}
} else {
Write-Info "Skipping ChromeDriver (-NoBrowser)"
}
Write-Host ""
# 2. Check/OpenFang Gateway
if (-not $NoGateway) {
Write-Info "Checking OpenFang Gateway..."
# Check if already running
$gatewayRunning = $false
try {
$response = Invoke-WebRequest -Uri "http://127.0.0.1:4200/health" -TimeoutSec 2 -ErrorAction SilentlyContinue
if ($response.StatusCode -eq 200) {
$gatewayRunning = $true
Write-Success "OpenFang Gateway already running on port 4200"
}
} catch {
# Not running
}
if (-not $gatewayRunning) {
# Try to start OpenFang
$openfang = Get-Command openfang -ErrorAction SilentlyContinue
if ($openfang) {
Write-Info "Starting OpenFang Gateway..."
$gatewayProc = Start-Process -FilePath "openfang" -ArgumentList "gateway", "start" -PassThru
$startedProcesses += $gatewayProc
# Wait for gateway to start
Write-Info "Waiting for gateway to be ready..."
$maxWait = 30
$waited = 0
while ($waited -lt $maxWait) {
try {
$response = Invoke-WebRequest -Uri "http://127.0.0.1:4200/health" -TimeoutSec 1 -ErrorAction SilentlyContinue
if ($response.StatusCode -eq 200) {
Write-Success "OpenFang Gateway started on port 4200"
break
}
} catch {}
Start-Sleep -Seconds 1
$waited++
Write-Host -NoNewline "."
}
Write-Host ""
if ($waited -ge $maxWait) {
Write-Warn "Gateway did not respond within ${maxWait}s. Check logs."
}
} else {
Write-Warn "OpenFang CLI not found. Gateway not started."
Write-Info "Install OpenFang: https://github.com/openfang/openfang"
}
}
} else {
Write-Info "Skipping OpenFang Gateway (-NoGateway)"
}
Write-Host ""
# 3. Start Tauri Desktop App
Write-Info "Starting ZCLAW Desktop..."
Set-Location desktop
if ($Dev) {
Write-Info "Starting in development mode..."
pnpm tauri dev
} else {
# Check if built version exists
$exePath = "src-tauri\target\release\ZClaw.exe"
if (Test-Path $exePath) {
Write-Info "Starting built application..."
Start-Process $exePath
Write-Success "ZCLAW Desktop started"
} else {
Write-Info "Built application not found, starting in dev mode..."
pnpm tauri dev
}
}
# Keep script running if in dev mode
if ($Dev) {
Write-Host ""
Write-Info "Press Ctrl+C to stop all services..."
try {
while ($true) {
Start-Sleep -Seconds 1
}
} finally {
Cleanup
}
}

160
start.sh Normal file
View File

@@ -0,0 +1,160 @@
#!/bin/bash
# ZCLAW Unified Start Script for macOS/Linux
# Starts: ChromeDriver (optional) -> Tauri Desktop (manages OpenFang internally)
#
# NOTE: OpenFang is bundled with Tauri and managed internally.
# The frontend uses Tauri commands (gateway_start/gateway_status) to control OpenFang.
# No external OpenFang CLI installation is required.
#
# Usage: ./start.sh [--no-browser] [--dev] [--help]
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# Parse arguments
NO_BROWSER=false
DEV_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--no-browser|-nb)
NO_BROWSER=true
shift
;;
--dev|-d)
DEV_MODE=true
shift
;;
--help|-h)
echo "ZCLAW Unified Start Script"
echo ""
echo "Usage: ./start.sh [options]"
echo ""
echo "Options:"
echo " --no-browser, -nb Skip starting ChromeDriver"
echo " --dev, -d Start in development mode"
echo " --help, -h Show this help message"
echo ""
echo "Note:"
echo " OpenFang is bundled with the Tauri app and managed internally."
echo " No external OpenFang CLI installation required."
echo ""
echo "Examples:"
echo " ./start.sh # Start all services"
echo " ./start.sh --no-browser # Start without ChromeDriver"
echo " ./start.sh --dev # Start in dev mode"
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
# Track PIDs for cleanup
PIDS=()
cleanup() {
echo -e "\n${CYAN}[INFO]${NC} Cleaning up..."
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo -e "${CYAN}[INFO]${NC} Stopping process $pid"
kill "$pid" 2>/dev/null || true
fi
done
}
trap cleanup EXIT INT TERM
echo ""
echo -e "${MAGENTA}═══════════════════════════════════════════${NC}"
echo -e "${MAGENTA} ZCLAW - OpenFang Desktop Client${NC}"
echo -e "${MAGENTA}═══════════════════════════════════════════${NC}"
echo ""
# 1. Check ChromeDriver
if [ "$NO_BROWSER" = false ]; then
echo -e "${CYAN}[INFO]${NC} Checking ChromeDriver..."
if command -v chromedriver &> /dev/null; then
CHROMEDRIVER_PATH=$(which chromedriver)
echo -e "${GREEN}[OK]${NC} ChromeDriver found: $CHROMEDRIVER_PATH"
# Check if already running
if ! pgrep -x "chromedriver" > /dev/null; then
echo -e "${CYAN}[INFO]${NC} Starting ChromeDriver on port 4444..."
chromedriver --port=4444 > /dev/null 2>&1 &
PIDS+=($!)
sleep 0.5
if kill -0 ${PIDS[-1]} 2>/dev/null; then
echo -e "${GREEN}[OK]${NC} ChromeDriver started (PID: ${PIDS[-1]})"
else
echo -e "${YELLOW}[WARN]${NC} ChromeDriver failed to start. It may already be running."
fi
else
echo -e "${GREEN}[OK]${NC} ChromeDriver already running"
fi
else
echo -e "${YELLOW}[WARN]${NC} ChromeDriver not found. Browser automation will not work."
echo -e "${CYAN}[INFO]${NC} Install ChromeDriver: https://chromedriver.chromium.org/downloads"
fi
else
echo -e "${CYAN}[INFO]${NC} Skipping ChromeDriver (--no-browser)"
fi
echo ""
# 2. Check OpenFang Runtime
echo -e "${CYAN}[INFO]${NC} Checking OpenFang runtime..."
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RUNTIME_PATH="$SCRIPT_DIR/desktop/src-tauri/resources/openfang-runtime"
if [ -f "$RUNTIME_PATH/openfang" ] || [ -f "$RUNTIME_PATH/openfang-x86_64-unknown-linux-gnu" ] || [ -f "$RUNTIME_PATH/openfang-aarch64-apple-darwin" ]; then
echo -e "${GREEN}[OK]${NC} OpenFang runtime found (bundled)"
else
echo -e "${YELLOW}[WARN]${NC} OpenFang runtime not found at $RUNTIME_PATH"
echo -e "${CYAN}[INFO]${NC} Run: cd desktop && pnpm prepare:openfang-runtime"
fi
echo ""
# 3. Start Tauri Desktop App
echo -e "${CYAN}[INFO]${NC} Starting ZCLAW Desktop..."
cd desktop
if [ "$DEV_MODE" = true ]; then
echo -e "${CYAN}[INFO]${NC} Starting in development mode..."
echo -e "${CYAN}[INFO]${NC} OpenFang will be started by the app via Tauri commands"
pnpm tauri dev
else
# Check if built version exists
if [ -f "src-tauri/target/release/ZClaw" ] || [ -f "src-tauri/target/release/ZClaw.app/Contents/MacOS/ZClaw" ]; then
echo -e "${CYAN}[INFO]${NC} Starting built application..."
if [ -f "src-tauri/target/release/ZClaw.app/Contents/MacOS/ZClaw" ]; then
open src-tauri/target/release/ZClaw.app
else
./src-tauri/target/release/ZClaw &
fi
echo -e "${GREEN}[OK]${NC} ZCLAW Desktop started"
else
echo -e "${CYAN}[INFO]${NC} Built application not found, starting in dev mode..."
pnpm tauri dev
fi
fi
# Keep script running if in dev mode
if [ "$DEV_MODE" = true ]; then
echo ""
echo -e "${CYAN}[INFO]${NC} Press Ctrl+C to stop all services..."
wait
fi

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