Files
zclaw_openfang/desktop/src/components/RightPanel.tsx
iven 0d4fa96b82
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
refactor: 统一项目名称从OpenFang到ZCLAW
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括:
- 配置文件中的项目名称
- 代码注释和文档引用
- 环境变量和路径
- 类型定义和接口名称
- 测试用例和模拟数据

同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
2026-03-27 07:36:03 +08:00

791 lines
32 KiB
TypeScript

import { ReactNode, useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import { getStoredGatewayUrl } from '../lib/gateway-client';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore, type PluginStatus } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, FileText, User, Activity, Brain,
Shield, Sparkles, List, Network, Dna
} from 'lucide-react';
// === Helper to extract code blocks from markdown content ===
function extractCodeBlocksFromContent(content: string): CodeBlock[] {
const blocks: CodeBlock[] = [];
const regex = /```(\w*)\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(content)) !== null) {
const language = match[1] || 'text';
const codeContent = match[2].trim();
// Try to extract filename from first line comment
let filename: string | undefined;
let actualContent = codeContent;
// Check for filename patterns like "# filename.py" or "// filename.js"
const firstLine = codeContent.split('\n')[0];
const filenameMatch = firstLine.match(/^(?:#|\/\/|\/\*|<!--)\s*([^\s]+\.\w+)/);
if (filenameMatch) {
filename = filenameMatch[1];
actualContent = codeContent.split('\n').slice(1).join('\n').trim();
}
blocks.push({
language,
filename,
content: actualContent,
});
}
return blocks;
}
// === Tab Button Component ===
function TabButton({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: ReactNode;
label: string;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
active
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
{icon}
<span>{label}</span>
</button>
);
}
import { MemoryPanel } from './MemoryPanel';
import { MemoryGraph } from './MemoryGraph';
import { ReflectionLog } from './ReflectionLog';
import { AutonomyConfig } from './AutonomyConfig';
import { IdentityChangeProposalPanel } from './IdentityChangeProposal';
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
import { cardHover, defaultTransition } from '../lib/animations';
import { Button, Badge } from './ui';
import { getPersonalityById } from '../lib/personality-presets';
import { silentErrorHandler } from '../lib/error-utils';
export function RightPanel() {
// Connection store
const connectionState = useConnectionStore((s) => s.connectionState);
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
const error = useConnectionStore((s) => s.error);
const connect = useConnectionStore((s) => s.connect);
// Agent store
const clones = useAgentStore((s) => s.clones);
const usageStats = useAgentStore((s) => s.usageStats);
const pluginStatus = useAgentStore((s) => s.pluginStatus);
const loadClones = useAgentStore((s) => s.loadClones);
const loadUsageStats = useAgentStore((s) => s.loadUsageStats);
const loadPluginStatus = useAgentStore((s) => s.loadPluginStatus);
const updateClone = useAgentStore((s) => s.updateClone);
// Config store
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
const quickConfig = useConfigStore((s) => s.quickConfig);
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'evolution'>('status');
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
const connected = connectionState === 'connected';
const selectedClone = useMemo(
() => clones.find((clone) => clone.id === currentAgent?.id),
[clones, currentAgent?.id]
);
const focusAreas = selectedClone?.scenarios?.length ? selectedClone.scenarios : ['coding', 'writing', 'research', 'product', 'data'];
const bootstrapFiles = selectedClone?.bootstrapFiles || [];
const gatewayUrl = quickConfig.gatewayUrl || getStoredGatewayUrl();
useEffect(() => {
if (!selectedClone || isEditingAgent) return;
setAgentDraft(createAgentDraft(selectedClone, currentModel));
}, [selectedClone, currentModel, isEditingAgent]);
// Load data when connected
useEffect(() => {
if (connected) {
loadClones();
loadUsageStats();
loadPluginStatus();
}
}, [connected]);
const handleReconnect = () => {
connect().catch(silentErrorHandler('RightPanel'));
};
const handleStartEdit = () => {
if (!selectedClone) return;
setAgentDraft(createAgentDraft(selectedClone, currentModel));
setIsEditingAgent(true);
};
const handleCancelEdit = () => {
if (selectedClone) {
setAgentDraft(createAgentDraft(selectedClone, currentModel));
}
setIsEditingAgent(false);
};
const handleSaveAgent = async () => {
if (!selectedClone || !agentDraft || !agentDraft.name.trim()) return;
const updatedClone = await updateClone(selectedClone.id, {
name: agentDraft.name.trim(),
role: agentDraft.role.trim() || undefined,
nickname: agentDraft.nickname.trim() || undefined,
model: agentDraft.model.trim() || undefined,
scenarios: agentDraft.scenarios.split(',').map((item) => item.trim()).filter(Boolean),
workspaceDir: agentDraft.workspaceDir.trim() || undefined,
userName: agentDraft.userName.trim() || undefined,
userRole: agentDraft.userRole.trim() || undefined,
restrictFiles: agentDraft.restrictFiles,
privacyOptIn: agentDraft.privacyOptIn,
});
if (updatedClone) {
setCurrentAgent(toChatAgent(updatedClone));
setAgentDraft(createAgentDraft(updatedClone, updatedClone.model || currentModel));
setIsEditingAgent(false);
}
};
const userMsgCount = messages.filter(m => m.role === 'user').length;
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
const toolCallCount = messages.filter(m => m.role === 'tool').length;
const runtimeSummary = connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接';
const userNameDisplay = selectedClone?.userName || quickConfig.userName || 'User';
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || 'User';
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '系统时区';
// Extract code blocks from all messages (both from codeBlocks property and content parsing)
const codeSnippets = useMemo((): CodeSnippet[] => {
const snippets: CodeSnippet[] = [];
let globalIndex = 0;
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
const msg = messages[msgIdx];
// First, add any existing codeBlocks from the message
if (msg.codeBlocks && msg.codeBlocks.length > 0) {
for (const block of msg.codeBlocks) {
snippets.push({
id: `${msg.id}-codeblock-${globalIndex}`,
block,
messageIndex: msgIdx,
});
globalIndex++;
}
}
// Then, extract code blocks from the message content
if (msg.content) {
const extractedBlocks = extractCodeBlocksFromContent(msg.content);
for (const block of extractedBlocks) {
snippets.push({
id: `${msg.id}-extracted-${globalIndex}`,
block,
messageIndex: msgIdx,
});
globalIndex++;
}
}
}
return snippets;
}, [messages]);
return (
<aside className="w-full bg-white dark:bg-gray-900 flex flex-col">
{/* 顶部工具栏 - Tab 栏 */}
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
{/* 主 Tab 行 */}
<div className="flex items-center px-2 pt-2 gap-1">
<TabButton
active={activeTab === 'status'}
onClick={() => setActiveTab('status')}
icon={<Activity className="w-4 h-4" />}
label="状态"
/>
<TabButton
active={activeTab === 'agent'}
onClick={() => setActiveTab('agent')}
icon={<User className="w-4 h-4" />}
label="Agent"
/>
<TabButton
active={activeTab === 'files'}
onClick={() => setActiveTab('files')}
icon={<FileText className="w-4 h-4" />}
label="文件"
/>
<TabButton
active={activeTab === 'memory'}
onClick={() => setActiveTab('memory')}
icon={<Brain className="w-4 h-4" />}
label="记忆"
/>
</div>
{/* 第二行 Tab */}
<div className="flex items-center px-2 pb-2 gap-1">
<TabButton
active={activeTab === 'reflection'}
onClick={() => setActiveTab('reflection')}
icon={<Sparkles className="w-4 h-4" />}
label="反思"
/>
<TabButton
active={activeTab === 'autonomy'}
onClick={() => setActiveTab('autonomy')}
icon={<Shield className="w-4 h-4" />}
label="自主"
/>
<TabButton
active={activeTab === 'evolution'}
onClick={() => setActiveTab('evolution')}
icon={<Dna className="w-4 h-4" />}
label="演化"
/>
</div>
</div>
{/* 消息统计 */}
<div className="px-4 py-2 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<BarChart3 className="w-3.5 h-3.5" />
<span>{messages.length} </span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span>{userMsgCount} / {assistantMsgCount} </span>
</div>
<div className={`flex items-center gap-1 ${connected ? 'text-emerald-500' : 'text-gray-400'}`}>
{connected ? <Wifi className="w-3.5 h-3.5" /> : <WifiOff className="w-3.5 h-3.5" />}
<span>{runtimeSummary}</span>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
{activeTab === 'memory' ? (
<div className="space-y-3">
{/* 视图切换 */}
<div className="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
onClick={() => setMemoryViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
memoryViewMode === 'list'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<List className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setMemoryViewMode('graph')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
memoryViewMode === 'graph'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<Network className="w-3.5 h-3.5" />
</button>
</div>
{/* 内容区域 */}
{memoryViewMode === 'list' ? (
<MemoryPanel />
) : (
<div className="h-[400px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<MemoryGraph />
</div>
)}
</div>
) : activeTab === 'reflection' ? (
<ReflectionLog />
) : activeTab === 'autonomy' ? (
<AutonomyConfig />
) : activeTab === 'evolution' ? (
<IdentityChangeProposalPanel />
) : activeTab === 'agent' ? (
<div className="space-y-4">
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-orange-400 to-red-500 flex items-center justify-center text-white text-lg font-semibold">
{selectedClone?.emoji ? (
<span className="text-2xl">{selectedClone.emoji}</span>
) : (
<span>🦞</span>
)}
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
{selectedClone?.name || currentAgent?.name || '全能助手'}
{selectedClone?.personality ? (
<Badge variant="default" className="text-xs ml-1">
{getPersonalityById(selectedClone.personality)?.label || selectedClone.personality}
</Badge>
) : (
<Badge variant="default" className="text-xs ml-1">
</Badge>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{selectedClone?.role || '全能型 AI 助手'}</div>
</div>
</div>
{selectedClone ? (
isEditingAgent ? (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelEdit}
aria-label="Cancel edit"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={() => { handleSaveAgent().catch(silentErrorHandler('RightPanel')); }}
aria-label="Save edit"
>
Save
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={handleStartEdit}
aria-label="Edit Agent"
>
Edit
</Button>
)
) : null}
</div>
</motion.div>
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">About Me</div>
{isEditingAgent && agentDraft ? (
<div className="space-y-2">
<AgentInput label="Name" value={agentDraft.name} onChange={(value) => setAgentDraft({ ...agentDraft, name: value })} />
<AgentInput label="Role" value={agentDraft.role} onChange={(value) => setAgentDraft({ ...agentDraft, role: value })} />
<AgentInput label="Nickname" value={agentDraft.nickname} onChange={(value) => setAgentDraft({ ...agentDraft, nickname: value })} />
<AgentInput label="Model" value={agentDraft.model} onChange={(value) => setAgentDraft({ ...agentDraft, model: value })} />
</div>
) : (
<div className="space-y-3 text-sm">
<AgentRow label="Role" value={selectedClone?.role || '全能型 AI 助手'} />
<AgentRow label="Nickname" value={selectedClone?.nickname || '小龙'} />
<AgentRow label="Model" value={selectedClone?.model || currentModel} />
<AgentRow label="Emoji" value={selectedClone?.emoji || '🦞'} />
</div>
)}
</motion.div>
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">You in My Eyes</div>
{isEditingAgent && agentDraft ? (
<div className="space-y-2">
<AgentInput label="Name" value={agentDraft.userName} onChange={(value) => setAgentDraft({ ...agentDraft, userName: value })} />
<AgentInput label="Role" value={agentDraft.userRole} onChange={(value) => setAgentDraft({ ...agentDraft, userRole: value })} />
<AgentInput label="Scenarios" value={agentDraft.scenarios} onChange={(value) => setAgentDraft({ ...agentDraft, scenarios: value })} placeholder="coding, research" />
<AgentInput label="Workspace" value={agentDraft.workspaceDir} onChange={(value) => setAgentDraft({ ...agentDraft, workspaceDir: value })} />
<AgentToggle label="File Restriction" checked={agentDraft.restrictFiles} onChange={(value) => setAgentDraft({ ...agentDraft, restrictFiles: value })} />
<AgentToggle label="Opt-in Program" checked={agentDraft.privacyOptIn} onChange={(value) => setAgentDraft({ ...agentDraft, privacyOptIn: value })} />
</div>
) : (
<div className="space-y-3 text-sm">
<AgentRow label="Name" value={userNameDisplay} />
<AgentRow label="Addressing" value={userAddressing} />
<AgentRow label="Timezone" value={localTimezone} />
<div className="flex gap-4">
<div className="w-16 text-gray-500 dark:text-gray-400">Focus</div>
<div className="flex-1 flex flex-wrap gap-2">
{focusAreas.map((item) => (
<Badge key={item} variant="default">{item}</Badge>
))}
</div>
</div>
<AgentRow label="Workspace" value={selectedClone?.workspaceDir || workspaceInfo?.path || '~/.zclaw/zclaw-workspace'} />
<AgentRow label="Resolved" value={selectedClone?.workspaceResolvedPath || workspaceInfo?.resolvedPath || '-'} />
<AgentRow label="File Restriction" value={selectedClone?.restrictFiles ? 'Enabled' : 'Disabled'} />
<AgentRow label="Opt-in" value={selectedClone?.privacyOptIn ? 'Joined' : 'Not joined'} />
</div>
)}
</motion.div>
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Bootstrap Files</div>
<Badge variant={selectedClone?.bootstrapReady ? 'success' : 'default'}>
{selectedClone?.bootstrapReady ? 'Generated' : 'Not generated'}
</Badge>
</div>
<div className="space-y-2 text-sm">
{bootstrapFiles.length > 0 ? bootstrapFiles.map((file) => (
<div key={file.name} className="rounded-lg border border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-gray-800 dark:text-gray-200">{file.name}</span>
<Badge variant={file.exists ? 'success' : 'error'}>
{file.exists ? 'Exists' : 'Missing'}
</Badge>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 break-all">{file.path}</div>
</div>
)) : (
<p className="text-sm text-gray-500 dark:text-gray-400">No bootstrap files generated for this Agent.</p>
)}
</div>
</motion.div>
</div>
) : activeTab === 'files' ? (
<div className="p-4">
<CodeSnippetPanel snippets={codeSnippets} />
</div>
) : (
<>
{/* Gateway 连接状态 */}
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className={`rounded-lg border p-3 ${connected ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' : 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700'}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{connected ? (
<Wifi className="w-4 h-4 text-green-600 dark:text-green-400" />
) : (
<WifiOff className="w-4 h-4 text-gray-500 dark:text-gray-400" />
)}
<Badge variant={connected ? 'success' : 'default'}>
Gateway {connected ? 'Connected' : connectionState === 'connecting' ? 'Connecting...' : connectionState === 'reconnecting' ? 'Reconnecting...' : 'Disconnected'}
</Badge>
</div>
{connected && (
<Button
variant="ghost"
size="sm"
onClick={() => { loadUsageStats(); loadPluginStatus(); loadClones(); }}
className="p-1 text-gray-500 hover:text-orange-500"
title="Refresh data"
aria-label="Refresh data"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
)}
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700 font-mono">{gatewayUrl}</span>
</div>
{gatewayVersion && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">{gatewayVersion}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-orange-600 font-medium">{currentModel}</span>
</div>
</div>
{!connected && connectionState !== 'connecting' && (
<div className="mt-2">
<Button
variant="primary"
size="sm"
onClick={handleReconnect}
className="w-full"
>
Connect Gateway
</Button>
</div>
)}
{error && (
<p className="mt-2 text-xs text-red-500 truncate" title={error}>{error}</p>
)}
</motion.div>
{/* 当前会话 */}
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="bg-gray-50 rounded-lg border border-gray-100 p-3"
>
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<MessageSquare className="w-3.5 h-3.5" />
</h3>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{userMsgCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{assistantMsgCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{toolCallCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-orange-600">{messages.length}</span>
</div>
</div>
</motion.div>
{/* 分身 */}
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="bg-gray-50 rounded-lg border border-gray-100 p-3"
>
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5" />
</h3>
{clones.length > 0 ? (
<div className="space-y-1.5">
{clones.slice(0, 5).map(c => (
<div key={c.id} className="flex items-center gap-2 text-xs">
<div className="w-5 h-5 bg-gradient-to-br from-orange-400 to-red-500 rounded-md flex items-center justify-center text-white text-[10px]">
<Bot className="w-3 h-3" />
</div>
<span className="text-gray-700 truncate">{c.name}</span>
</div>
))}
{clones.length > 5 && (
<p className="text-xs text-gray-500">+{clones.length - 5} </p>
)}
</div>
) : (
<p className="text-xs text-gray-500">
{connected ? '暂无分身,在左侧栏创建' : '连接后可用'}
</p>
)}
</motion.div>
{/* 用量统计 */}
{usageStats && (
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="bg-gray-50 rounded-lg border border-gray-100 p-3"
>
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<BarChart3 className="w-3.5 h-3.5" />
</h3>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{usageStats.totalSessions}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{usageStats.totalMessages}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"> Token</span>
<span className="font-medium text-gray-900">{usageStats.totalTokens.toLocaleString()}</span>
</div>
</div>
</motion.div>
)}
{/* 插件状态 */}
{pluginStatus.length > 0 && (
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="bg-gray-50 rounded-lg border border-gray-100 p-3"
>
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Plug className="w-3.5 h-3.5" />
({pluginStatus.length})
</h3>
<div className="space-y-1 text-xs">
{pluginStatus.map((p: PluginStatus, i: number) => (
<div key={i} className="flex justify-between">
<span className="text-gray-600 truncate">{p.name || p.id}</span>
<span className={p.status === 'active' ? 'text-green-600' : 'text-gray-500'}>
{p.status === 'active' ? '运行中' : '已停止'}
</span>
</div>
))}
</div>
</motion.div>
)}
{/* 系统信息 */}
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="bg-gray-50 rounded-lg border border-gray-100 p-3"
>
<h3 className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5" />
</h3>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">{runtimeSummary}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Gateway </span>
<span className="text-gray-700">{gatewayVersion || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">{clones.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-700">{pluginStatus.length}</span>
</div>
</div>
</motion.div>
</>
)}
</div>
</aside>
);
}
function AgentRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-4">
<div className="w-16 text-gray-500">{label}</div>
<div className="flex-1 text-gray-700 break-all">{value}</div>
</div>
);
}
type AgentDraft = {
name: string;
role: string;
nickname: string;
model: string;
scenarios: string;
workspaceDir: string;
userName: string;
userRole: string;
restrictFiles: boolean;
privacyOptIn: boolean;
};
function createAgentDraft(
clone: {
name: string;
role?: string;
nickname?: string;
model?: string;
scenarios?: string[];
workspaceDir?: string;
userName?: string;
userRole?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
},
currentModel: string
): AgentDraft {
return {
name: clone.name || '',
role: clone.role || '',
nickname: clone.nickname || '',
model: clone.model || currentModel,
scenarios: clone.scenarios?.join(', ') || '',
workspaceDir: clone.workspaceDir || '~/.zclaw/zclaw-workspace',
userName: clone.userName || '',
userRole: clone.userRole || '',
restrictFiles: clone.restrictFiles ?? true,
privacyOptIn: clone.privacyOptIn ?? false,
};
}
function AgentInput({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<label className="block">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none"
/>
</label>
);
}
function AgentToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="flex items-center justify-between text-sm text-gray-700 border border-gray-100 rounded-lg px-3 py-2">
<span>{label}</span>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
</label>
);
}