feat(hands): restructure Hands UI with Chinese localization
Major changes: - Add HandList.tsx component for left sidebar - Add HandTaskPanel.tsx for middle content area - Restructure Sidebar tabs: 分身/HANDS/Workflow - Remove Hands tab from RightPanel - Localize all UI text to Chinese - Archive legacy OpenClaw documentation - Add Hands integration lessons document - Update feature checklist with new components UI improvements: - Left sidebar now shows Hands list with status icons - Middle area shows selected Hand's tasks and results - Consistent styling with Tailwind CSS - Chinese status labels and buttons Documentation: - Create docs/archive/openclaw-legacy/ for old docs - Add docs/knowledge-base/hands-integration-lessons.md - Update docs/knowledge-base/feature-checklist.md - Update docs/knowledge-base/README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
485
desktop/src/components/HandsPanel.tsx
Normal file
485
desktop/src/components/HandsPanel.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* HandsPanel - OpenFang Hands Management UI
|
||||
*
|
||||
* Displays available OpenFang Hands (autonomous capability packages)
|
||||
* with detailed status, requirements, and activation controls.
|
||||
*
|
||||
* Design based on OpenFang Dashboard v0.4.0
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// === Status Badge Component ===
|
||||
|
||||
type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
|
||||
|
||||
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: () => void;
|
||||
isActivating: boolean;
|
||||
}
|
||||
|
||||
function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: HandDetailsModalProps) {
|
||||
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">×</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>
|
||||
)}
|
||||
|
||||
{/* 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={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"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={!canActivate || hasUnmetRequirements || isActivating}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 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" />
|
||||
需要配置
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4" />
|
||||
激活
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Hand Card Component ===
|
||||
|
||||
interface HandCardProps {
|
||||
hand: Hand;
|
||||
onDetails: (hand: Hand) => void;
|
||||
onActivate: (hand: Hand) => 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-blue-600 text-white rounded-md hover:bg-blue-700 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 } = useGatewayStore();
|
||||
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
||||
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadHands();
|
||||
}, [loadHands]);
|
||||
|
||||
const handleDetails = useCallback(async (hand: Hand) => {
|
||||
// Load full details before showing modal
|
||||
const { getHandDetails } = useGatewayStore.getState();
|
||||
const details = await getHandDetails(hand.name);
|
||||
setSelectedHand(details || hand);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const handleActivate = useCallback(async (hand: Hand) => {
|
||||
setActivatingHandId(hand.id);
|
||||
try {
|
||||
await triggerHand(hand.name);
|
||||
// Refresh hands after activation
|
||||
await loadHands();
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
} finally {
|
||||
setActivatingHandId(null);
|
||||
}
|
||||
}, [triggerHand, loadHands]);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setSelectedHand(null);
|
||||
}, []);
|
||||
|
||||
const handleModalActivate = useCallback(async () => {
|
||||
if (!selectedHand) return;
|
||||
setShowModal(false);
|
||||
await handleActivate(selectedHand);
|
||||
}, [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">
|
||||
请连接到 OpenFang 以查看可用的自主能力包。
|
||||
</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>
|
||||
|
||||
{/* 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) => (
|
||||
<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;
|
||||
Reference in New Issue
Block a user