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:
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user