feat(agents): 添加 Agent 预设管理功能
- 创建 Server Agents API 路由 (CRUD + presets + defaults) - 添加 UI Agent 类型定义和 API 客户端函数 - 实现 AgentsPanel 组件 (预设/自定义 Agent 列表) - 实现 AgentEditor 组件 (创建/编辑 Agent) - 实现 AgentDefaultsEditor 组件 (全局默认配置) - 集成 AgentsPanel 到 Web 和 Desktop 应用
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* AgentDefaultsEditor Component
|
||||
*
|
||||
* 全局默认配置编辑器:配置所有 Agent 的默认参数
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Save,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
} 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 {
|
||||
getAgentDefaults,
|
||||
updateAgentDefaults,
|
||||
type AgentDefaults,
|
||||
type AgentModelConfig,
|
||||
} from '../api/client.js';
|
||||
|
||||
interface AgentDefaultsEditorProps {
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
/** 是否启用响应式布局 */
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
export function AgentDefaultsEditor({
|
||||
onClose,
|
||||
onSave,
|
||||
responsive = false,
|
||||
}: AgentDefaultsEditorProps) {
|
||||
// 表单状态
|
||||
const [maxSteps, setMaxSteps] = useState<number | undefined>(undefined);
|
||||
|
||||
// 模型配置
|
||||
const [modelProvider, setModelProvider] = useState<string>('');
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [temperature, setTemperature] = useState<number | undefined>(undefined);
|
||||
const [maxTokens, setMaxTokens] = useState<number | undefined>(undefined);
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 加载现有配置
|
||||
useEffect(() => {
|
||||
const loadDefaults = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getAgentDefaults();
|
||||
if (result.success && result.data) {
|
||||
const defaults = result.data;
|
||||
|
||||
setMaxSteps(defaults.maxSteps);
|
||||
|
||||
if (defaults.model) {
|
||||
setModelProvider(defaults.model.provider || '');
|
||||
setModelName(defaults.model.model || '');
|
||||
setTemperature(defaults.model.temperature);
|
||||
setMaxTokens(defaults.model.maxTokens);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load defaults');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDefaults();
|
||||
}, []);
|
||||
|
||||
// 构建配置对象
|
||||
const buildDefaults = (): AgentDefaults => {
|
||||
const defaults: AgentDefaults = {};
|
||||
|
||||
if (maxSteps !== undefined) {
|
||||
defaults.maxSteps = maxSteps;
|
||||
}
|
||||
|
||||
// 模型配置
|
||||
const model: AgentModelConfig = {};
|
||||
if (modelProvider) {
|
||||
model.provider = modelProvider as 'anthropic' | 'deepseek' | 'openai';
|
||||
}
|
||||
if (modelName) {
|
||||
model.model = modelName;
|
||||
}
|
||||
if (temperature !== undefined) {
|
||||
model.temperature = temperature;
|
||||
}
|
||||
if (maxTokens !== undefined) {
|
||||
model.maxTokens = maxTokens;
|
||||
}
|
||||
if (Object.keys(model).length > 0) {
|
||||
defaults.model = model;
|
||||
}
|
||||
|
||||
return defaults;
|
||||
};
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const defaults = buildDefaults();
|
||||
const result = await updateAgentDefaults(defaults);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Default settings saved');
|
||||
onSave();
|
||||
} else {
|
||||
setError(result.error || 'Save failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
|
||||
responsive
|
||||
? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
|
||||
: 'rounded-lg w-full max-w-lg mx-4'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-gray-700',
|
||||
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-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Settings size={20} className="text-primary-400" />
|
||||
Global Defaults
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
These settings apply to all agents unless overridden
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary-500" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Limits */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Execution Limits</h3>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Default Max Steps</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxSteps ?? ''}
|
||||
onChange={(e) =>
|
||||
setMaxSteps(e.target.value ? parseInt(e.target.value) : undefined)
|
||||
}
|
||||
placeholder="15"
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maximum number of tool call steps for all agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Default Model</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Provider</label>
|
||||
<select
|
||||
value={modelProvider}
|
||||
onChange={(e) => setModelProvider(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Temperature</label>
|
||||
<input
|
||||
type="number"
|
||||
value={temperature ?? ''}
|
||||
onChange={(e) =>
|
||||
setTemperature(e.target.value ? parseFloat(e.target.value) : undefined)
|
||||
}
|
||||
placeholder="0.7"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Tokens */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Max Tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTokens ?? ''}
|
||||
onChange={(e) =>
|
||||
setMaxTokens(e.target.value ? parseInt(e.target.value) : undefined)
|
||||
}
|
||||
placeholder="8192"
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-blue-400 text-xs">
|
||||
<p>
|
||||
These defaults will be applied to all agents. Individual agents can override
|
||||
these settings in their own configuration.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-gray-700 flex justify-end gap-2',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleSave} disabled={saving || loading}>
|
||||
<Save size={16} className="mr-1" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* AgentEditor Component
|
||||
*
|
||||
* Agent 创建/编辑器:配置 Agent 的各项参数
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Save,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
} 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 {
|
||||
getAgent,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
type AgentInput,
|
||||
type AgentMode,
|
||||
type AgentModelConfig,
|
||||
type AgentToolConfig,
|
||||
} from '../api/client.js';
|
||||
|
||||
interface AgentEditorProps {
|
||||
/** 编辑现有 Agent 的名称(新建时为 undefined) */
|
||||
agentName?: string;
|
||||
/** 新建时的默认名称 */
|
||||
defaultName?: string;
|
||||
/** 复制自哪个 Agent */
|
||||
copyFrom?: string;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
/** 是否启用响应式布局 */
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
// 可折叠区域组件
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between p-3 bg-gray-800/50 hover:bg-gray-800 transition-colors"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="p-4 border-t border-gray-700 space-y-4">{children}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentEditor({
|
||||
agentName,
|
||||
defaultName = '',
|
||||
copyFrom,
|
||||
onClose,
|
||||
onSave,
|
||||
responsive = false,
|
||||
}: AgentEditorProps) {
|
||||
const isNewAgent = !agentName;
|
||||
|
||||
// 表单状态
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [description, setDescription] = useState('');
|
||||
const [mode, setMode] = useState<AgentMode>('primary');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [maxSteps, setMaxSteps] = useState<number | undefined>(undefined);
|
||||
|
||||
// 模型配置
|
||||
const [modelProvider, setModelProvider] = useState<string>('');
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [temperature, setTemperature] = useState<number | undefined>(undefined);
|
||||
const [maxTokens, setMaxTokens] = useState<number | undefined>(undefined);
|
||||
|
||||
// 工具配置
|
||||
const [toolMode, setToolMode] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||
const [toolList, setToolList] = useState('');
|
||||
const [noTask, setNoTask] = useState(false);
|
||||
|
||||
// UI 状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 加载现有 Agent 数据
|
||||
useEffect(() => {
|
||||
const loadAgent = async () => {
|
||||
const targetName = agentName || copyFrom;
|
||||
if (!targetName) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getAgent(targetName);
|
||||
if (result.success && result.data) {
|
||||
const agent = result.data;
|
||||
|
||||
if (!agentName) {
|
||||
// 复制模式:不复制名称
|
||||
setName(defaultName);
|
||||
} else {
|
||||
setName(agent.name);
|
||||
}
|
||||
|
||||
setDescription(agent.description);
|
||||
setMode(agent.mode);
|
||||
setPrompt(agent.prompt || '');
|
||||
setMaxSteps(agent.maxSteps);
|
||||
|
||||
// 模型配置
|
||||
if (agent.model) {
|
||||
setModelProvider(agent.model.provider || '');
|
||||
setModelName(agent.model.model || '');
|
||||
setTemperature(agent.model.temperature);
|
||||
setMaxTokens(agent.model.maxTokens);
|
||||
}
|
||||
|
||||
// 工具配置
|
||||
if (agent.tools) {
|
||||
if (agent.tools.enabled && agent.tools.enabled.length > 0) {
|
||||
setToolMode('enabled');
|
||||
setToolList(agent.tools.enabled.join(', '));
|
||||
} else if (agent.tools.disabled && agent.tools.disabled.length > 0) {
|
||||
setToolMode('disabled');
|
||||
setToolList(agent.tools.disabled.join(', '));
|
||||
} else {
|
||||
setToolMode('all');
|
||||
}
|
||||
setNoTask(agent.tools.noTask || false);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to load agent');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load agent');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAgent();
|
||||
}, [agentName, copyFrom, defaultName]);
|
||||
|
||||
// 构建配置对象
|
||||
const buildConfig = (): AgentInput => {
|
||||
const config: AgentInput = {
|
||||
description,
|
||||
mode,
|
||||
};
|
||||
|
||||
if (prompt.trim()) {
|
||||
config.prompt = prompt;
|
||||
}
|
||||
|
||||
if (maxSteps !== undefined) {
|
||||
config.maxSteps = maxSteps;
|
||||
}
|
||||
|
||||
// 模型配置
|
||||
const model: AgentModelConfig = {};
|
||||
if (modelProvider) {
|
||||
model.provider = modelProvider as 'anthropic' | 'deepseek' | 'openai';
|
||||
}
|
||||
if (modelName) {
|
||||
model.model = modelName;
|
||||
}
|
||||
if (temperature !== undefined) {
|
||||
model.temperature = temperature;
|
||||
}
|
||||
if (maxTokens !== undefined) {
|
||||
model.maxTokens = maxTokens;
|
||||
}
|
||||
if (Object.keys(model).length > 0) {
|
||||
config.model = model;
|
||||
}
|
||||
|
||||
// 工具配置
|
||||
const tools: AgentToolConfig = {};
|
||||
if (toolMode === 'enabled' && toolList.trim()) {
|
||||
tools.enabled = toolList.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
} else if (toolMode === 'disabled' && toolList.trim()) {
|
||||
tools.disabled = toolList.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
if (noTask) {
|
||||
tools.noTask = true;
|
||||
}
|
||||
if (Object.keys(tools).length > 0) {
|
||||
config.tools = tools;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
// 验证
|
||||
if (!name.trim()) {
|
||||
setError('Agent name is required');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setError('Description is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const config = buildConfig();
|
||||
|
||||
let result;
|
||||
if (isNewAgent) {
|
||||
result = await createAgent(name.trim(), config);
|
||||
} else {
|
||||
result = await updateAgent(name.trim(), config);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(isNewAgent ? `Agent "${name}" created` : `Agent "${name}" updated`);
|
||||
onSave();
|
||||
} else {
|
||||
setError(result.error || 'Save failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-gray-800 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-gray-700',
|
||||
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-gray-600 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" />
|
||||
{isNewAgent ? 'Create Agent' : `Edit: ${agentName}`}
|
||||
</h2>
|
||||
{copyFrom && (
|
||||
<p className="text-xs text-gray-500">Copying from: {copyFrom}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
className={cn(responsive && 'min-h-[44px]')}
|
||||
>
|
||||
<Save size={16} className="mr-1" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary-500" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Basic Information</h3>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!isNewAgent}
|
||||
placeholder="my-agent"
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm',
|
||||
'focus:outline-none focus:border-primary-500',
|
||||
!isNewAgent && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
/>
|
||||
{!isNewAgent && (
|
||||
<p className="text-xs text-gray-500 mt-1">Name cannot be changed after creation</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Description *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="A helpful coding assistant"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Mode *</label>
|
||||
<div className="flex gap-2">
|
||||
{(['primary', 'subagent', 'all'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
mode === m
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-900 text-gray-400 hover:bg-gray-800'
|
||||
)}
|
||||
>
|
||||
{m === 'primary' ? 'Primary' : m === 'subagent' ? 'Subagent' : 'Both'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{mode === 'primary'
|
||||
? 'Can be used as the main agent'
|
||||
: mode === 'subagent'
|
||||
? 'Can only be spawned by other agents'
|
||||
: 'Can be used in both modes'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<CollapsibleSection title="System Prompt" defaultOpen={!!prompt}>
|
||||
<div>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm font-mono focus:outline-none focus:border-primary-500 resize-y"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Custom system prompt for this agent. Leave empty to use defaults.
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Model Configuration */}
|
||||
<CollapsibleSection title="Model Configuration" defaultOpen={!!modelProvider || !!modelName}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Provider</label>
|
||||
<select
|
||||
value={modelProvider}
|
||||
onChange={(e) => setModelProvider(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Temperature</label>
|
||||
<input
|
||||
type="number"
|
||||
value={temperature ?? ''}
|
||||
onChange={(e) =>
|
||||
setTemperature(e.target.value ? parseFloat(e.target.value) : undefined)
|
||||
}
|
||||
placeholder="0.7"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Tokens */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Max Tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTokens ?? ''}
|
||||
onChange={(e) =>
|
||||
setMaxTokens(e.target.value ? parseInt(e.target.value) : undefined)
|
||||
}
|
||||
placeholder="8192"
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Tool Configuration */}
|
||||
<CollapsibleSection title="Tool Configuration" defaultOpen={toolMode !== 'all' || noTask}>
|
||||
<div className="space-y-4">
|
||||
{/* Tool Mode */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Tool Access</label>
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'enabled', 'disabled'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setToolMode(m)}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
toolMode === m
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-900 text-gray-400 hover:bg-gray-800'
|
||||
)}
|
||||
>
|
||||
{m === 'all' ? 'All Tools' : m === 'enabled' ? 'Only These' : 'Except These'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool List */}
|
||||
{toolMode !== 'all' && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">
|
||||
{toolMode === 'enabled' ? 'Enabled Tools' : 'Disabled Tools'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toolList}
|
||||
onChange={(e) => setToolList(e.target.value)}
|
||||
placeholder="bash, read_file, write_file"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Comma-separated tool names</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Task */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="noTask"
|
||||
checked={noTask}
|
||||
onChange={(e) => setNoTask(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-gray-900 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="noTask" className="text-sm text-gray-300">
|
||||
Disable nested Task calls
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Execution Limits */}
|
||||
<CollapsibleSection title="Execution Limits" defaultOpen={maxSteps !== undefined}>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Max Steps</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxSteps ?? ''}
|
||||
onChange={(e) =>
|
||||
setMaxSteps(e.target.value ? parseInt(e.target.value) : undefined)
|
||||
}
|
||||
placeholder="15"
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maximum number of tool call steps. Leave empty for default.
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-gray-700 flex justify-end gap-2',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleSave} disabled={saving || loading}>
|
||||
<Save size={16} className="mr-1" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* 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,
|
||||
} 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';
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
// 模式文字
|
||||
function getModeText(mode: AgentListItem['mode']) {
|
||||
switch (mode) {
|
||||
case 'primary':
|
||||
return 'Primary';
|
||||
case 'subagent':
|
||||
return 'Subagent';
|
||||
case 'all':
|
||||
return 'Both';
|
||||
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 presetAgents = agents.filter((a) => a.isPreset);
|
||||
const customAgents = agents.filter((a) => !a.isPreset);
|
||||
|
||||
// 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-gray-900/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];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gray-900/50 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Agent Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3',
|
||||
'hover:bg-gray-900/80 transition-colors cursor-pointer'
|
||||
)}
|
||||
onClick={() => toggleExpanded(agent.name)}
|
||||
>
|
||||
{/* Expand Icon */}
|
||||
<button className="text-gray-500 hover:text-gray-300">
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Icon */}
|
||||
{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-gray-200">{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-gray-500 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 */}
|
||||
{agent.isPreset && !agent.isCustomized ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleExpanded(agent.name)}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
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 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(agent.name)}
|
||||
className="text-gray-400 hover:text-gray-300"
|
||||
title="Copy"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Delete (only for custom agents) */}
|
||||
{!agent.isPreset && (
|
||||
<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-gray-700/50 space-y-3">
|
||||
{detail ? (
|
||||
<>
|
||||
{/* Model Info */}
|
||||
{detail.model && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Cpu size={14} className="text-gray-500 mt-0.5" />
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-400">Model:</span>{' '}
|
||||
<span className="text-gray-300">
|
||||
{detail.model.provider && `${detail.model.provider}/`}
|
||||
{detail.model.model || 'default'}
|
||||
</span>
|
||||
{detail.model.temperature !== undefined && (
|
||||
<span className="text-gray-500 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-gray-500 mt-0.5" />
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-400">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-gray-300">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-gray-400">Max Steps:</span>{' '}
|
||||
<span className="text-gray-300">{detail.maxSteps}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt Preview */}
|
||||
{detail.prompt && (
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-400">System Prompt:</span>
|
||||
<pre className="mt-1 p-2 bg-gray-800/50 rounded text-gray-300 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-gray-800 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-gray-700',
|
||||
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-gray-600 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-gray-500">
|
||||
{agents.length} agents ({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-gray-500">
|
||||
<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')}
|
||||
>
|
||||
{/* Preset Agents */}
|
||||
{presetAgents.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-gray-400 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-gray-400 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-gray-500 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-gray-700 text-xs text-gray-500 text-center',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
Config stored in{' '}
|
||||
<code className="font-mono bg-gray-900 px-1 rounded">.ai-assist/agents.json</code>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user