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
Batch 1 — User-facing fixes: - B1-1: Pipeline verified end-to-end (14 Rust commands, 8 frontend invoke, fully connected) - B1-2: MessageSearch restored to ChatArea with search button in DeerFlow header - B1-3: Viking cleanup — removed 5 orphan invokes (no Rust impl), added addWithMetadata + storeWithSummaries methods + summary generation UI - B1-4: api-fallbacks transparency — added _isFallback markers + console.warn to all 6 fallback functions Batch 2 — System health: - B2-1: Document drift calibration — TRUTH.md/README.md numbers verified and updated - B2-2: @reserved annotations on 15 SaaS handler functions with no frontend callers - B2-3: Scheduled Task Admin V2 — new service + page + route + sidebar navigation - B2-4: TRUTH.md Pipeline/Viking/ScheduledTask records corrected Batch 3 — Long-term quality: - B3-1: hand_run_status/hand_run_list verified as fully implemented (not stubs) - B3-2: Identity snapshot rollback UI added to RightPanel - B3-3: P2 code quality — 4 fixes (TODO comments, fire-and-forget notes, design notes, table name validation), 2 verified N/A, 1 upstream - B3-4: Config PATCH→PUT alignment (admin-v2 config.ts matched to SaaS backend)
968 lines
40 KiB
TypeScript
968 lines
40 KiB
TypeScript
import { ReactNode, useCallback, 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 { useConversationStore } from '../store/chat/conversationStore';
|
|
import { intelligenceClient, type IdentitySnapshot } from '../lib/intelligence-client';
|
|
import {
|
|
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
|
MessageSquare, Cpu, FileText, User, Activity, Brain,
|
|
Shield, Sparkles, List, Network, Dna, History,
|
|
ChevronDown, ChevronUp, RotateCcw, AlertCircle, Loader2,
|
|
} 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, setCurrentAgent } = useChatStore();
|
|
const currentModel = useConversationStore((s) => s.currentModel);
|
|
const currentAgent = useConversationStore((s) => s.currentAgent);
|
|
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);
|
|
|
|
// Identity snapshot state
|
|
const [snapshots, setSnapshots] = useState<IdentitySnapshot[]>([]);
|
|
const [snapshotsExpanded, setSnapshotsExpanded] = useState(false);
|
|
const [snapshotsLoading, setSnapshotsLoading] = useState(false);
|
|
const [snapshotsError, setSnapshotsError] = useState<string | null>(null);
|
|
const [restoringSnapshotId, setRestoringSnapshotId] = useState<string | null>(null);
|
|
const [confirmRestoreId, setConfirmRestoreId] = useState<string | 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 loadSnapshots = useCallback(async () => {
|
|
const agentId = currentAgent?.id;
|
|
if (!agentId) return;
|
|
setSnapshotsLoading(true);
|
|
setSnapshotsError(null);
|
|
try {
|
|
const result = await intelligenceClient.identity.getSnapshots(agentId, 20);
|
|
setSnapshots(result);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setSnapshotsError(`加载快照失败: ${msg}`);
|
|
} finally {
|
|
setSnapshotsLoading(false);
|
|
}
|
|
}, [currentAgent?.id]);
|
|
|
|
const handleRestoreSnapshot = useCallback(async (snapshotId: string) => {
|
|
const agentId = currentAgent?.id;
|
|
if (!agentId) return;
|
|
setRestoringSnapshotId(snapshotId);
|
|
setSnapshotsError(null);
|
|
setConfirmRestoreId(null);
|
|
try {
|
|
await intelligenceClient.identity.restoreSnapshot(agentId, snapshotId);
|
|
await loadSnapshots();
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setSnapshotsError(`回滚失败: ${msg}`);
|
|
} finally {
|
|
setRestoringSnapshotId(null);
|
|
}
|
|
}, [currentAgent?.id, loadSnapshots]);
|
|
|
|
// Load snapshots when agent tab is active and agent changes
|
|
useEffect(() => {
|
|
if (activeTab === 'agent' && currentAgent?.id) {
|
|
loadSnapshots();
|
|
}
|
|
}, [activeTab, currentAgent?.id, loadSnapshots]);
|
|
|
|
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"
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => { handleSaveAgent().catch(silentErrorHandler('RightPanel')); }}
|
|
aria-label="Save edit"
|
|
>
|
|
保存
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleStartEdit}
|
|
aria-label="Edit Agent"
|
|
>
|
|
编辑
|
|
</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">关于我</div>
|
|
{isEditingAgent && agentDraft ? (
|
|
<div className="space-y-2">
|
|
<AgentInput label="名称" value={agentDraft.name} onChange={(value) => setAgentDraft({ ...agentDraft, name: value })} />
|
|
<AgentInput label="角色" value={agentDraft.role} onChange={(value) => setAgentDraft({ ...agentDraft, role: value })} />
|
|
<AgentInput label="昵称" value={agentDraft.nickname} onChange={(value) => setAgentDraft({ ...agentDraft, nickname: value })} />
|
|
<AgentInput label="模型" value={agentDraft.model} onChange={(value) => setAgentDraft({ ...agentDraft, model: value })} />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 text-sm">
|
|
<AgentRow label="角色" value={selectedClone?.role || '全能型 AI 助手'} />
|
|
<AgentRow label="昵称" value={selectedClone?.nickname || '小龙'} />
|
|
<AgentRow label="模型" value={selectedClone?.model || currentModel} />
|
|
<AgentRow label="表情" 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">我眼中的你</div>
|
|
{isEditingAgent && agentDraft ? (
|
|
<div className="space-y-2">
|
|
<AgentInput label="你的名称" value={agentDraft.userName} onChange={(value) => setAgentDraft({ ...agentDraft, userName: value })} />
|
|
<AgentInput label="你的角色" value={agentDraft.userRole} onChange={(value) => setAgentDraft({ ...agentDraft, userRole: value })} />
|
|
<AgentInput label="场景" value={agentDraft.scenarios} onChange={(value) => setAgentDraft({ ...agentDraft, scenarios: value })} placeholder="编程, 研究" />
|
|
<AgentInput label="工作区" value={agentDraft.workspaceDir} onChange={(value) => setAgentDraft({ ...agentDraft, workspaceDir: value })} />
|
|
<AgentToggle label="文件限制" checked={agentDraft.restrictFiles} onChange={(value) => setAgentDraft({ ...agentDraft, restrictFiles: value })} />
|
|
<AgentToggle label="隐私计划" checked={agentDraft.privacyOptIn} onChange={(value) => setAgentDraft({ ...agentDraft, privacyOptIn: value })} />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 text-sm">
|
|
<AgentRow label="你的名称" value={userNameDisplay} />
|
|
<AgentRow label="称呼方式" value={userAddressing} />
|
|
<AgentRow label="时区" value={localTimezone} />
|
|
<div className="flex gap-4">
|
|
<div className="w-16 text-gray-500 dark:text-gray-400">专注</div>
|
|
<div className="flex-1 flex flex-wrap gap-2">
|
|
{focusAreas.map((item) => (
|
|
<Badge key={item} variant="default">{item}</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<AgentRow label="工作区" value={selectedClone?.workspaceDir || workspaceInfo?.path || '~/.zclaw/zclaw-workspace'} />
|
|
<AgentRow label="已解析" value={selectedClone?.workspaceResolvedPath || workspaceInfo?.resolvedPath || '-'} />
|
|
<AgentRow label="文件限制" value={selectedClone?.restrictFiles ? '已开启' : '已关闭'} />
|
|
<AgentRow label="隐私计划" value={selectedClone?.privacyOptIn ? '已加入' : '未加入'} />
|
|
</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">引导文件</div>
|
|
<Badge variant={selectedClone?.bootstrapReady ? 'success' : 'default'}>
|
|
{selectedClone?.bootstrapReady ? '已生成' : '未生成'}
|
|
</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 ? '已存在' : '缺失'}
|
|
</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">该 Agent 尚未生成引导文件。</p>
|
|
)}
|
|
</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"
|
|
>
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center justify-between mb-0"
|
|
onClick={() => setSnapshotsExpanded(!snapshotsExpanded)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<History className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">历史快照</span>
|
|
{snapshots.length > 0 && (
|
|
<Badge variant="default" className="text-xs">{snapshots.length}</Badge>
|
|
)}
|
|
</div>
|
|
{snapshotsExpanded ? (
|
|
<ChevronUp className="w-4 h-4 text-gray-400" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</button>
|
|
|
|
{snapshotsExpanded && (
|
|
<div className="mt-3 space-y-2">
|
|
{snapshotsError && (
|
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs">
|
|
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span>{snapshotsError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{snapshotsLoading ? (
|
|
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400 text-xs">
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
加载中...
|
|
</div>
|
|
) : snapshots.length === 0 ? (
|
|
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-xs bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700">
|
|
暂无快照记录
|
|
</div>
|
|
) : (
|
|
snapshots.map((snap) => {
|
|
const isRestoring = restoringSnapshotId === snap.id;
|
|
const isConfirming = confirmRestoreId === snap.id;
|
|
const timeLabel = formatSnapshotTime(snap.timestamp);
|
|
|
|
return (
|
|
<div
|
|
key={snap.id}
|
|
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700"
|
|
>
|
|
<div className="w-7 h-7 rounded-md bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
<History className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">{timeLabel}</span>
|
|
{isConfirming ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setConfirmRestoreId(null)}
|
|
disabled={isRestoring}
|
|
className="text-xs px-2 py-0.5 h-auto"
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => handleRestoreSnapshot(snap.id)}
|
|
disabled={isRestoring}
|
|
className="text-xs px-2 py-0.5 h-auto bg-orange-500 hover:bg-orange-600"
|
|
>
|
|
{isRestoring ? (
|
|
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="w-3 h-3 mr-1" />
|
|
)}
|
|
确认回滚
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setConfirmRestoreId(snap.id)}
|
|
disabled={restoringSnapshotId !== null}
|
|
className="text-xs text-gray-500 hover:text-orange-600 px-2 py-0.5 h-auto"
|
|
title="回滚到此版本"
|
|
>
|
|
<RotateCcw className="w-3 h-3 mr-1" />
|
|
回滚
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1 truncate" title={snap.reason}>
|
|
{snap.reason || '自动快照'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</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 ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
|
|
</Badge>
|
|
</div>
|
|
{connected && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => { loadUsageStats(); loadPluginStatus(); loadClones(); }}
|
|
className="p-1 text-gray-500 hover:text-orange-500"
|
|
title="刷新数据"
|
|
aria-label="刷新数据"
|
|
>
|
|
<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"
|
|
>
|
|
连接 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>
|
|
);
|
|
}
|
|
|
|
function formatSnapshotTime(timestamp: string): string {
|
|
const now = Date.now();
|
|
const then = new Date(timestamp).getTime();
|
|
const diff = now - then;
|
|
|
|
if (diff < 60000) return '刚刚';
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
|
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
|
|
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`;
|
|
return new Date(timestamp).toLocaleDateString('zh-CN');
|
|
}
|