Files
zclaw_openfang/desktop/src/components/HandsPanel.tsx
iven 834aa12076
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix: P0 panic风险修复 + P1编译warnings清零 + P2代码/文档清理
P0 安全性:
- account/handlers.rs: .unwrap() → .expect() 语义化错误信息
- relay/handlers.rs: SSE Response .unwrap() → .expect()

P1 编译质量 (6 warnings → 0):
- kernel.rs: 移除未使用的 Capability import 和 config_clone 变量
- pipeline_commands.rs: 未使用变量 id → _id
- db.rs: 移除多余括号
- relay/service.rs: 移除未使用的 StreamExt import
- telemetry/service.rs: 抑制 param_idx 未读赋值警告
- main.rs: TcpKeepalive::with_retries() Linux-only 条件编译

P2 代码清理:
- 移除 handStore/HandsPanel/HandTaskPanel/gateway-api/SchedulerPanel 调试 console.log
- SchedulerPanel: 修复 updateWorkflow 未解构导致 TS 编译错误
- 文档清理 zclaw-channels 已移除 crate 的引用
2026-03-30 11:33:47 +08:00

642 lines
23 KiB
TypeScript

/**
* HandsPanel - ZCLAW Hands Management UI
*
* Displays available ZCLAW Hands (autonomous capability packages)
* with detailed status, requirements, and activation controls.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play, Clock } from 'lucide-react';
import { BrowserHandCard } from './BrowserHand';
import type { HandParameter } from '../types/hands';
import { HAND_DEFINITIONS } from '../types/hands';
import { HandParamsForm } from './HandParamsForm';
import { ApprovalsPanel } from './ApprovalsPanel';
import { useToast } from './ui/Toast';
// === Tab Type ===
type TabType = 'hands' | 'approvals';
// === Status Badge Component ===
type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
// === Parameter Validation Helper ===
function validateAllParameters(
parameters: HandParameter[],
values: Record<string, unknown>
): Record<string, string> {
const errors: Record<string, string> = {};
parameters.forEach(param => {
if (param.required) {
const value = values[param.name];
if (value === undefined || value === null || value === '') {
errors[param.name] = `${param.label} is required`;
}
}
});
return errors;
}
interface StatusConfig {
label: string;
className: string;
dotClass: string;
}
const STATUS_CONFIG: Record<HandStatus, StatusConfig> = {
idle: {
label: '就绪',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
dotClass: 'bg-green-500',
},
running: {
label: '运行中',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
dotClass: 'bg-blue-500 animate-pulse',
},
needs_approval: {
label: '待审批',
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
dotClass: 'bg-yellow-500',
},
error: {
label: '错误',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
dotClass: 'bg-red-500',
},
unavailable: {
label: '不可用',
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
dotClass: 'bg-gray-400',
},
setup_needed: {
label: '需配置',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
dotClass: 'bg-orange-500',
},
};
function HandStatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status as HandStatus] || STATUS_CONFIG.unavailable;
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
{config.label}
</span>
);
}
// === Category Badge Component ===
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
productivity: { label: '生产力', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
data: { label: '数据', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
content: { label: '内容', className: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' },
communication: { label: '通信', className: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' },
};
function CategoryBadge({ category }: { category?: string }) {
if (!category) return null;
const config = CATEGORY_CONFIG[category] || { label: category, className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
return (
<span className={`px-2 py-0.5 rounded text-xs ${config.className}`}>
{config.label}
</span>
);
}
// === Requirement Item Component ===
function RequirementItem({ requirement }: { requirement: HandRequirement }) {
return (
<div className={`flex items-start gap-2 text-sm py-1 ${requirement.met ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
<span className="flex-shrink-0 mt-0.5">
{requirement.met ? (
<CheckCircle className="w-4 h-4" />
) : (
<XCircle className="w-4 h-4" />
)}
</span>
<div className="flex-1 min-w-0">
<span className="break-words">{requirement.description}</span>
{requirement.details && (
<span className="text-gray-400 dark:text-gray-500 text-xs ml-1">({requirement.details})</span>
)}
</div>
</div>
);
}
// === Hand Details Modal Component ===
interface HandDetailsModalProps {
hand: Hand;
isOpen: boolean;
onClose: () => void;
onActivate: (params?: Record<string, unknown>) => void;
isActivating: boolean;
}
function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: HandDetailsModalProps) {
// Get Hand parameters from definitions
const handDefinition = HAND_DEFINITIONS.find(h => h.id === hand.id);
const parameters: HandParameter[] = handDefinition?.parameters || [];
// Form state for parameters
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
const [showParamsForm, setShowParamsForm] = useState(false);
// Initialize default values
useEffect(() => {
if (parameters.length > 0) {
const defaults: Record<string, unknown> = {};
parameters.forEach(p => {
if (p.defaultValue !== undefined) {
defaults[p.name] = p.defaultValue;
}
});
setParamValues(defaults);
}
}, [parameters]);
// Reset form when modal opens/closes
useEffect(() => {
if (isOpen) {
setShowParamsForm(false);
setParamErrors({});
}
}, [isOpen]);
const handleActivateClick = useCallback(() => {
if (parameters.length > 0 && !showParamsForm) {
// Show params form first
setShowParamsForm(true);
return;
}
// Validate parameters if showing form
if (showParamsForm) {
const errors = validateAllParameters(parameters, paramValues);
setParamErrors(errors);
if (Object.keys(errors).length > 0) {
return;
}
// Pass parameters to onActivate
onActivate(paramValues);
} else {
onActivate();
}
}, [parameters, showParamsForm, paramValues, onActivate]);
if (!isOpen) return null;
const canActivate = hand.status === 'idle' || hand.status === 'setup_needed';
const hasUnmetRequirements = hand.requirements?.some(r => !r.met);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<span className="text-2xl">{hand.icon || '🤖'}</span>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{hand.name}</h2>
<HandStatusBadge status={hand.status} />
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<span className="text-xl">&times;</span>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400">{hand.description}</p>
{/* Agent Config */}
{(hand.provider || hand.model) && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
{hand.provider && (
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<p className="font-medium text-gray-900 dark:text-white">{hand.provider}</p>
</div>
)}
{hand.model && (
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<p className="font-medium text-gray-900 dark:text-white">{hand.model}</p>
</div>
)}
</div>
</div>
)}
{/* Requirements */}
{hand.requirements && hand.requirements.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
</h3>
<div className="space-y-1">
{hand.requirements.map((req, idx) => (
<RequirementItem key={idx} requirement={req} />
))}
</div>
</div>
)}
{/* Tools */}
{hand.tools && hand.tools.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
({hand.tools.length})
</h3>
<div className="flex flex-wrap gap-1.5">
{hand.tools.map((tool, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 font-mono"
>
{tool}
</span>
))}
</div>
</div>
)}
{/* Parameters Form (shown when activating) */}
{showParamsForm && parameters.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
</h3>
<HandParamsForm
parameters={parameters}
values={paramValues}
onChange={setParamValues}
errors={paramErrors}
disabled={isActivating}
presetKey={`hand-${hand.id}`}
/>
</div>
)}
{/* Dashboard Metrics */}
{hand.metrics && hand.metrics.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
({hand.metrics.length})
</h3>
<div className="grid grid-cols-3 gap-2">
{hand.metrics.map((metric, idx) => (
<div
key={idx}
className="bg-white dark:bg-gray-800 rounded p-2 text-center border border-gray-200 dark:border-gray-700"
>
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">{metric}</div>
<div className="text-lg font-semibold text-gray-400 dark:text-gray-500">-</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={showParamsForm ? () => setShowParamsForm(false) : onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
{showParamsForm ? '返回' : '关闭'}
</button>
<button
onClick={handleActivateClick}
disabled={!canActivate || hasUnmetRequirements || isActivating}
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isActivating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : hasUnmetRequirements ? (
<>
<Settings className="w-4 h-4" />
</>
) : showParamsForm ? (
<>
<Play className="w-4 h-4" />
</>
) : (
<>
<Zap className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Hand Card Component ===
interface HandCardProps {
hand: Hand;
onDetails: (hand: Hand) => void;
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
isActivating: boolean;
}
function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps) {
const canActivate = hand.status === 'idle';
const hasUnmetRequirements = hand.requirements_met === false;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl flex-shrink-0">{hand.icon || '🤖'}</span>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{hand.name}</h3>
</div>
<HandStatusBadge status={hand.status} />
</div>
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{hand.description}</p>
{/* Requirements Summary (if any unmet) */}
{hasUnmetRequirements && (
<div className="mb-3 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border border-orange-200 dark:border-orange-800">
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400 text-xs font-medium">
<AlertTriangle className="w-3.5 h-3.5" />
<span></span>
</div>
</div>
)}
{/* Meta Info */}
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mb-3">
{hand.toolCount !== undefined && (
<span>{hand.toolCount} </span>
)}
{hand.metricCount !== undefined && (
<span>{hand.metricCount} </span>
)}
{hand.category && (
<CategoryBadge category={hand.category} />
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => onDetails(hand)}
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 flex items-center gap-1"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onActivate(hand)}
disabled={!canActivate || hasUnmetRequirements || isActivating}
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
{isActivating ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : hand.status === 'running' ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : (
<>
<Zap className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
</div>
);
}
// === Main HandsPanel Component ===
export function HandsPanel() {
const { hands, loadHands, triggerHand, isLoading, error: storeError, getHandDetails } = useHandStore();
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>('hands');
const { toast } = useToast();
useEffect(() => {
loadHands();
}, [loadHands]);
const handleDetails = useCallback(async (hand: Hand) => {
// Load full details before showing modal
const details = await getHandDetails(hand.id);
setSelectedHand(details || hand);
setShowModal(true);
}, [getHandDetails]);
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
setActivatingHandId(hand.id);
try {
const result = await triggerHand(hand.id, params);
if (result) {
toast(`Hand "${hand.name}" 已成功激活`, 'success');
// Refresh hands after activation
await loadHands();
} else {
// Check if there's an error in the store
const errorMsg = storeError || '激活失败,请检查后端连接';
console.error(`[HandsPanel] Hand activation failed:`, errorMsg);
toast(`Hand "${hand.name}" 激活失败: ${errorMsg}`, 'error');
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error(`[HandsPanel] Hand activation error:`, errorMsg);
toast(`Hand "${hand.name}" 激活异常: ${errorMsg}`, 'error');
} finally {
setActivatingHandId(null);
}
}, [triggerHand, loadHands, toast, storeError]);
const handleCloseModal = useCallback(() => {
setShowModal(false);
setSelectedHand(null);
}, []);
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
if (!selectedHand) return;
setShowModal(false);
await handleActivate(selectedHand, params);
}, [selectedHand, handleActivate]);
if (isLoading && hands.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400"> Hands ...</p>
</div>
);
}
if (hands.length === 0) {
return (
<div className="p-4 text-center">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<Zap className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3"> Hands</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
ZCLAW
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Hands
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<button
onClick={() => loadHands()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('hands')}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'hands'
? 'text-orange-600 dark:text-orange-400 border-orange-500'
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<Zap className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('approvals')}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'approvals'
? 'text-orange-600 dark:text-orange-400 border-orange-500'
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<Clock className="w-4 h-4" />
</button>
</div>
{/* Tab Content */}
{activeTab === 'approvals' ? (
<ApprovalsPanel />
) : (
<>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
<span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
</span>
</div>
{/* Hand Cards Grid */}
<div className="grid gap-3">
{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}
onDetails={handleDetails}
onActivate={handleActivate}
isActivating={activatingHandId === hand.id}
/>
);
})}
</div>
{/* Details Modal */}
{selectedHand && (
<HandDetailsModal
hand={selectedHand}
isOpen={showModal}
onClose={handleCloseModal}
onActivate={handleModalActivate}
isActivating={activatingHandId === selectedHand.id}
/>
)}
</>
)}
</div>
);
}
export default HandsPanel;