feat(automation): complete unified automation system redesign

Phase 4 completion:
- Add ApprovalQueue component for managing pending approvals
- Add ExecutionResult component for displaying hand/workflow results
- Update Sidebar navigation to use unified AutomationPanel
- Replace separate 'hands' and 'workflow' tabs with single 'automation' tab
- Fix TypeScript type safety issues with unknown types in JSX expressions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-18 17:12:05 +08:00
parent 3a7631e035
commit 3518fc8ece
74 changed files with 4984 additions and 687 deletions

View File

@@ -1,19 +1,80 @@
import { useEffect, useMemo, useState } from 'react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import { getStoredGatewayUrl } from '../lib/gateway-client';
import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain,
Shield, Sparkles, GraduationCap
MessageSquare, Cpu, FileText, User, Activity, Brain,
Shield, Sparkles, GraduationCap, List, Network
} 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 { ActiveLearningPanel } from './ActiveLearningPanel';
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
import { cardHover, defaultTransition } from '../lib/animations';
import { Button, Badge, EmptyState } from './ui';
import { Button, Badge } from './ui';
import { getPersonalityById } from '../lib/personality-presets';
import { silentErrorHandler } from '../lib/error-utils';
@@ -24,6 +85,7 @@ export function RightPanel() {
} = useGatewayStore();
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'learning'>('status');
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
@@ -96,108 +158,149 @@ export function RightPanel() {
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || '未设置';
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-80 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 flex flex-col flex-shrink-0">
{/* 顶部工具栏 */}
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<BarChart3 className="w-4 h-4" />
<span className="font-medium">{messages.length}</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400" role="tablist">
<Button
variant={activeTab === 'status' ? 'secondary' : 'ghost'}
size="sm"
<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')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Status"
aria-label="Status"
aria-selected={activeTab === 'status'}
role="tab"
>
<Activity className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'files' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setActiveTab('files')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Files"
aria-label="Files"
aria-selected={activeTab === 'files'}
role="tab"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'agent' ? 'secondary' : 'ghost'}
size="sm"
icon={<Activity className="w-4 h-4" />}
label="状态"
/>
<TabButton
active={activeTab === 'agent'}
onClick={() => setActiveTab('agent')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Agent"
aria-label="Agent"
aria-selected={activeTab === 'agent'}
role="tab"
>
<User className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'memory' ? 'secondary' : 'ghost'}
size="sm"
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')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Memory"
aria-label="Memory"
aria-selected={activeTab === 'memory'}
role="tab"
>
<Brain className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'reflection' ? 'secondary' : 'ghost'}
size="sm"
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')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Reflection"
aria-label="Reflection"
aria-selected={activeTab === 'reflection'}
role="tab"
>
<Sparkles className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'autonomy' ? 'secondary' : 'ghost'}
size="sm"
icon={<Sparkles className="w-4 h-4" />}
label="反思"
/>
<TabButton
active={activeTab === 'autonomy'}
onClick={() => setActiveTab('autonomy')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Autonomy"
aria-label="Autonomy"
aria-selected={activeTab === 'autonomy'}
role="tab"
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'learning' ? 'secondary' : 'ghost'}
size="sm"
icon={<Shield className="w-4 h-4" />}
label="自主"
/>
<TabButton
active={activeTab === 'learning'}
onClick={() => setActiveTab('learning')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Learning"
aria-label="Learning"
aria-selected={activeTab === 'learning'}
role="tab"
>
<GraduationCap className="w-4 h-4" />
</Button>
icon={<GraduationCap 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' ? (
<MemoryPanel />
<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' ? (
@@ -354,90 +457,8 @@ export function RightPanel() {
</motion.div>
</div>
) : activeTab === 'files' ? (
<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-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<FileCode className="w-4 h-4" />
</h3>
</div>
{messages.filter(m => m.files && m.files.length > 0).length > 0 ? (
<div className="space-y-2">
{messages.filter(m => m.files && m.files.length > 0).map((msg, msgIdx) => (
<div key={msgIdx} className="space-y-1">
{msg.files!.map((file, fileIdx) => (
<div
key={`${msgIdx}-${fileIdx}`}
className="flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors"
title={file.path || file.name}
>
<FileText className="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-gray-700 dark:text-gray-200 truncate">{file.name}</div>
{file.path && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</div>
)}
</div>
{file.size && (
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
{file.size < 1024 ? `${file.size} B` :
file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` :
`${(file.size / (1024 * 1024)).toFixed(1)} MB`}
</span>
)}
</div>
))}
</div>
))}
</div>
) : (
<EmptyState
icon={<FileCode className="w-8 h-8" />}
title="No Output Files"
description="Files will appear here when AI uses tools"
className="py-4"
/>
)}
</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">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100"></h3>
</div>
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).length > 0 ? (
<div className="space-y-2">
{messages.filter(m => m.codeBlocks && m.codeBlocks.length > 0).flatMap((msg, msgIdx) =>
msg.codeBlocks!.map((block, blockIdx) => (
<div
key={`${msgIdx}-${blockIdx}`}
className="px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm"
>
<div className="flex items-center gap-2 mb-1">
<Badge variant="default">{block.language || 'code'}</Badge>
<span className="text-gray-700 dark:text-gray-200 truncate">{block.filename || 'Untitled'}</span>
</div>
<pre className="text-xs text-gray-500 dark:text-gray-400 overflow-x-auto max-h-20">
{block.content?.slice(0, 200)}{block.content && block.content.length > 200 ? '...' : ''}
</pre>
</div>
))
).slice(0, 5)}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">No code snippets in conversation</p>
)}
</motion.div>
<div className="p-4">
<CodeSnippetPanel snippets={codeSnippets} />
</div>
) : (
<>