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:
iven
2026-03-14 23:16:32 +08:00
parent 67e1da635d
commit 07079293f4
126 changed files with 36229 additions and 1035 deletions

View 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">&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>
)}
{/* 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;