5b7b0ff1e4
- 添加 CSS 变量定义浅色和深色主题色板 - 扩展 Tailwind 配置支持语义化颜色 (surface-*, fg-*, line-*, code) - 创建 useTheme hook 管理主题状态和持久化 - 创建 ThemeToggle 组件支持三种模式 (light/dark/system) - 迁移所有组件从硬编码 gray-* 到语义化颜色 - 支持系统主题偏好检测 (prefers-color-scheme) - 添加主题初始化脚本防止闪烁 (FOUC)
641 lines
22 KiB
TypeScript
641 lines
22 KiB
TypeScript
/**
|
|
* AgentsPanel Component
|
|
*
|
|
* Agent 预设管理面板:列出所有 Agent、查看详情、创建/编辑/删除
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
X,
|
|
RefreshCw,
|
|
Bot,
|
|
Plus,
|
|
Settings,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Eye,
|
|
Copy,
|
|
Trash2,
|
|
Edit3,
|
|
Sparkles,
|
|
Cpu,
|
|
Layers,
|
|
Lock,
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { toast } from 'sonner';
|
|
import { cn } from '../utils/cn';
|
|
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
|
|
import { Button } from '../primitives/Button';
|
|
import { Skeleton } from './Skeleton';
|
|
import { AgentEditor } from './AgentEditor';
|
|
import { AgentDefaultsEditor } from './AgentDefaultsEditor';
|
|
import {
|
|
listAgents,
|
|
getAgent,
|
|
deleteAgent,
|
|
type AgentListItem,
|
|
type AgentDetail,
|
|
} from '../api/client.js';
|
|
|
|
interface AgentsPanelProps {
|
|
onClose: () => void;
|
|
/** 是否启用响应式布局 */
|
|
responsive?: boolean;
|
|
}
|
|
|
|
// 模式颜色映射
|
|
function getModeColor(mode: AgentListItem['mode']) {
|
|
switch (mode) {
|
|
case 'primary':
|
|
return 'bg-blue-500/20 text-blue-400';
|
|
case 'subagent':
|
|
return 'bg-purple-500/20 text-purple-400';
|
|
case 'all':
|
|
return 'bg-green-500/20 text-green-400';
|
|
case 'internal':
|
|
return 'bg-slate-500/20 text-slate-400';
|
|
default:
|
|
return 'bg-surface-muted/20 text-fg-muted';
|
|
}
|
|
}
|
|
|
|
// 模式文字
|
|
function getModeText(mode: AgentListItem['mode']) {
|
|
switch (mode) {
|
|
case 'primary':
|
|
return 'Primary';
|
|
case 'subagent':
|
|
return 'Subagent';
|
|
case 'all':
|
|
return 'Both';
|
|
case 'internal':
|
|
return 'Internal';
|
|
default:
|
|
return mode;
|
|
}
|
|
}
|
|
|
|
export function AgentsPanel({ onClose, responsive = false }: AgentsPanelProps) {
|
|
// 数据状态
|
|
const [agents, setAgents] = useState<AgentListItem[]>([]);
|
|
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
|
|
const [agentDetails, setAgentDetails] = useState<Record<string, AgentDetail>>({});
|
|
|
|
// UI 状态
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
|
|
// 编辑器状态
|
|
const [editingAgent, setEditingAgent] = useState<{ name: string; isNew: boolean } | null>(null);
|
|
const [showDefaultsEditor, setShowDefaultsEditor] = useState(false);
|
|
|
|
// 加载 Agent 列表
|
|
const loadAgents = useCallback(async (showToast = false) => {
|
|
try {
|
|
const result = await listAgents();
|
|
if (result.success) {
|
|
setAgents(result.data);
|
|
if (showToast) {
|
|
toast.success('Agents refreshed');
|
|
}
|
|
} else {
|
|
toast.error(result.error || 'Failed to load agents');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to load agents');
|
|
}
|
|
}, []);
|
|
|
|
// 初始加载
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
loadAgents().finally(() => setLoading(false));
|
|
}, [loadAgents]);
|
|
|
|
// 刷新
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
await loadAgents(true);
|
|
setRefreshing(false);
|
|
};
|
|
|
|
// 加载 Agent 详情
|
|
const loadAgentDetail = async (name: string) => {
|
|
if (agentDetails[name]) return agentDetails[name];
|
|
|
|
try {
|
|
const result = await getAgent(name);
|
|
if (result.success && result.data) {
|
|
setAgentDetails((prev) => ({ ...prev, [name]: result.data! }));
|
|
return result.data;
|
|
}
|
|
} catch (err) {
|
|
toast.error(`Failed to load agent details: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// 切换展开
|
|
const toggleExpanded = async (name: string) => {
|
|
const newExpanded = new Set(expandedAgents);
|
|
if (newExpanded.has(name)) {
|
|
newExpanded.delete(name);
|
|
} else {
|
|
newExpanded.add(name);
|
|
// 懒加载详情
|
|
await loadAgentDetail(name);
|
|
}
|
|
setExpandedAgents(newExpanded);
|
|
};
|
|
|
|
// 删除 Agent
|
|
const handleDelete = async (name: string) => {
|
|
if (!confirm(`Are you sure you want to delete agent "${name}"?`)) {
|
|
return;
|
|
}
|
|
|
|
setActionLoading(name);
|
|
try {
|
|
const result = await deleteAgent(name);
|
|
if (result.success) {
|
|
toast.success(`Agent "${name}" deleted`);
|
|
await loadAgents();
|
|
// 清除详情缓存
|
|
setAgentDetails((prev) => {
|
|
const newDetails = { ...prev };
|
|
delete newDetails[name];
|
|
return newDetails;
|
|
});
|
|
} else {
|
|
toast.error(result.error || 'Delete failed');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
// 复制 Agent(打开编辑器,以新名称创建)
|
|
const handleCopy = async (name: string) => {
|
|
const detail = await loadAgentDetail(name);
|
|
if (detail) {
|
|
setEditingAgent({ name: `${name}-copy`, isNew: true });
|
|
}
|
|
};
|
|
|
|
// 编辑器保存成功回调
|
|
const handleEditorSave = () => {
|
|
setEditingAgent(null);
|
|
loadAgents();
|
|
// 清除所有详情缓存以获取最新数据
|
|
setAgentDetails({});
|
|
};
|
|
|
|
// 统计 - 按分类过滤
|
|
const internalAgents = agents.filter((a) => a.mode === 'internal');
|
|
const presetAgents = agents.filter((a) => a.isPreset && a.mode !== 'internal');
|
|
const customAgents = agents.filter((a) => !a.isPreset && a.mode !== 'internal');
|
|
|
|
// Loading 骨架屏
|
|
const LoadingSkeleton = () => (
|
|
<div className="space-y-3 p-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
|
|
<Skeleton className="h-4 w-4" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-3 w-48" />
|
|
</div>
|
|
<Skeleton className="h-6 w-16 rounded-full" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Agent 项组件
|
|
const AgentItem = ({ agent }: { agent: AgentListItem }) => {
|
|
const isExpanded = expandedAgents.has(agent.name);
|
|
const isLoading = actionLoading === agent.name;
|
|
const detail = agentDetails[agent.name];
|
|
const isInternal = agent.mode === 'internal';
|
|
|
|
return (
|
|
<motion.div
|
|
layout
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="bg-surface-base/50 rounded-lg overflow-hidden"
|
|
>
|
|
{/* Agent Header */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-3 p-3',
|
|
'hover:bg-surface-base/80 transition-colors cursor-pointer'
|
|
)}
|
|
onClick={() => toggleExpanded(agent.name)}
|
|
>
|
|
{/* Expand Icon */}
|
|
<button className="text-fg-subtle hover:text-fg-secondary">
|
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</button>
|
|
|
|
{/* Icon */}
|
|
{isInternal ? (
|
|
<Lock size={16} className="text-slate-400" />
|
|
) : agent.isPreset ? (
|
|
<Sparkles size={16} className="text-yellow-400" />
|
|
) : (
|
|
<Bot size={16} className="text-primary-400" />
|
|
)}
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-fg-secondary">{agent.name}</span>
|
|
<span className={cn('text-xs px-2 py-0.5 rounded-full', getModeColor(agent.mode))}>
|
|
{getModeText(agent.mode)}
|
|
</span>
|
|
{agent.isCustomized && (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400">
|
|
Customized
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-fg-subtle truncate">{agent.description}</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
{isLoading ? (
|
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
|
|
) : (
|
|
<>
|
|
{/* View/Edit - Internal agents can be edited (model config) */}
|
|
{isInternal ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditingAgent({ name: agent.name, isNew: false })}
|
|
className="text-blue-400 hover:text-blue-300"
|
|
title="Edit Model Config"
|
|
>
|
|
<Edit3 size={14} />
|
|
</Button>
|
|
) : agent.isPreset && !agent.isCustomized ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => toggleExpanded(agent.name)}
|
|
className="text-fg-muted hover:text-fg-secondary"
|
|
title="View"
|
|
>
|
|
<Eye size={14} />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditingAgent({ name: agent.name, isNew: false })}
|
|
className="text-blue-400 hover:text-blue-300"
|
|
title="Edit"
|
|
>
|
|
<Edit3 size={14} />
|
|
</Button>
|
|
)}
|
|
|
|
{/* Copy - not for internal agents */}
|
|
{!isInternal && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleCopy(agent.name)}
|
|
className="text-fg-muted hover:text-fg-secondary"
|
|
title="Copy"
|
|
>
|
|
<Copy size={14} />
|
|
</Button>
|
|
)}
|
|
|
|
{/* Delete (only for custom agents, not internal) */}
|
|
{!agent.isPreset && !isInternal && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(agent.name)}
|
|
className="text-red-400 hover:text-red-300"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Content */}
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-4 pb-3 pt-1 border-t border-line/50 space-y-3">
|
|
{detail ? (
|
|
<>
|
|
{/* Model Info */}
|
|
{detail.model && (
|
|
<div className="flex items-start gap-2">
|
|
<Cpu size={14} className="text-fg-subtle mt-0.5" />
|
|
<div className="text-xs">
|
|
<span className="text-fg-muted">Model:</span>{' '}
|
|
<span className="text-fg-secondary">
|
|
{detail.model.provider && `${detail.model.provider}/`}
|
|
{detail.model.model || 'default'}
|
|
</span>
|
|
{detail.model.temperature !== undefined && (
|
|
<span className="text-fg-subtle ml-2">
|
|
temp: {detail.model.temperature}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tools Config */}
|
|
{detail.tools && (
|
|
<div className="flex items-start gap-2">
|
|
<Layers size={14} className="text-fg-subtle mt-0.5" />
|
|
<div className="text-xs">
|
|
<span className="text-fg-muted">Tools:</span>{' '}
|
|
{detail.tools.enabled ? (
|
|
<span className="text-green-400">
|
|
Only: {detail.tools.enabled.join(', ')}
|
|
</span>
|
|
) : detail.tools.disabled ? (
|
|
<span className="text-yellow-400">
|
|
Disabled: {detail.tools.disabled.join(', ')}
|
|
</span>
|
|
) : (
|
|
<span className="text-fg-secondary">All enabled</span>
|
|
)}
|
|
{detail.tools.noTask && (
|
|
<span className="text-red-400 ml-2">(No nested tasks)</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Max Steps */}
|
|
{detail.maxSteps && (
|
|
<div className="text-xs">
|
|
<span className="text-fg-muted">Max Steps:</span>{' '}
|
|
<span className="text-fg-secondary">{detail.maxSteps}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Prompt Preview */}
|
|
{detail.prompt && (
|
|
<div className="text-xs">
|
|
<span className="text-fg-muted">System Prompt:</span>
|
|
<pre className="mt-1 p-2 bg-surface-subtle/50 rounded text-fg-secondary overflow-x-auto max-h-32 text-[11px] leading-relaxed">
|
|
{detail.prompt.slice(0, 500)}
|
|
{detail.prompt.length > 500 && '...'}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center py-4">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
// 如果正在编辑 Agent
|
|
if (editingAgent) {
|
|
return (
|
|
<AgentEditor
|
|
agentName={editingAgent.isNew ? undefined : editingAgent.name}
|
|
defaultName={editingAgent.isNew ? editingAgent.name : undefined}
|
|
copyFrom={editingAgent.isNew ? editingAgent.name.replace(/-copy$/, '') : undefined}
|
|
onClose={() => setEditingAgent(null)}
|
|
onSave={handleEditorSave}
|
|
responsive={responsive}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 如果正在编辑全局默认配置
|
|
if (showDefaultsEditor) {
|
|
return (
|
|
<AgentDefaultsEditor
|
|
onClose={() => setShowDefaultsEditor(false)}
|
|
onSave={() => {
|
|
setShowDefaultsEditor(false);
|
|
loadAgents();
|
|
}}
|
|
responsive={responsive}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
variants={modalOverlay}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={{ duration: 0.2 }}
|
|
className={cn(
|
|
'fixed inset-0 bg-black/50 flex z-50',
|
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
|
)}
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
variants={modalContent}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={smoothTransition}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={cn(
|
|
'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col',
|
|
responsive
|
|
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
|
|
: 'rounded-lg w-full max-w-2xl mx-4'
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between border-b border-line',
|
|
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
|
)}
|
|
>
|
|
{responsive && (
|
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
|
|
)}
|
|
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<Bot size={20} className="text-primary-400" />
|
|
Agent Presets
|
|
</h2>
|
|
<p className="text-xs text-fg-subtle">
|
|
{agents.length} agents ({internalAgents.length} system, {presetAgents.length} preset, {customAgents.length} custom)
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setShowDefaultsEditor(true)}
|
|
title="Global Defaults"
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<Settings size={18} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setEditingAgent({ name: '', isNew: true })}
|
|
title="New Agent"
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<Plus size={18} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
title="Refresh"
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onClose}
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<X size={20} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Agent List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading ? (
|
|
<LoadingSkeleton />
|
|
) : agents.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
|
|
<Bot size={48} className="mb-4 opacity-50" />
|
|
<p className="text-center">No agents available</p>
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={() => setEditingAgent({ name: '', isNew: true })}
|
|
>
|
|
<Plus size={16} className="mr-2" />
|
|
Create Agent
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className={cn('space-y-4', responsive ? 'p-4' : 'p-4')}
|
|
>
|
|
{/* System Agents (Internal) */}
|
|
{internalAgents.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2 flex items-center gap-2">
|
|
<Lock size={12} />
|
|
System Agents
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{internalAgents.map((agent) => (
|
|
<AgentItem key={agent.name} agent={agent} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preset Agents */}
|
|
{presetAgents.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
|
|
<Sparkles size={12} />
|
|
Preset Agents
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{presetAgents.map((agent) => (
|
|
<AgentItem key={agent.name} agent={agent} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom Agents */}
|
|
<div>
|
|
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
|
|
<Bot size={12} />
|
|
Custom Agents
|
|
</h3>
|
|
{customAgents.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{customAgents.map((agent) => (
|
|
<AgentItem key={agent.name} agent={agent} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-6 text-fg-subtle text-sm">
|
|
<p>No custom agents yet</p>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-2"
|
|
onClick={() => setEditingAgent({ name: '', isNew: true })}
|
|
>
|
|
<Plus size={14} className="mr-1" />
|
|
Create one
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer Info */}
|
|
<div
|
|
className={cn(
|
|
'border-t border-line text-xs text-fg-subtle text-center',
|
|
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
|
)}
|
|
>
|
|
Config stored in{' '}
|
|
<code className="font-mono bg-surface-base px-1 rounded">.ai-assist/agents.json</code>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|