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:
2025-12-12 21:23:01 +08:00
parent 9365e07df1
commit a225e66ad7
13 changed files with 2447 additions and 5 deletions
+6
View File
@@ -10,6 +10,7 @@ import {
CommandPanel, CommandPanel,
MCPPanel, MCPPanel,
HooksPanel, HooksPanel,
AgentsPanel,
Toaster, Toaster,
listSessions, listSessions,
createSession, createSession,
@@ -25,6 +26,7 @@ export function App() {
const [showCommands, setShowCommands] = useState(false); const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false); const [showMCP, setShowMCP] = useState(false);
const [showHooks, setShowHooks] = useState(false); const [showHooks, setShowHooks] = useState(false);
const [showAgents, setShowAgents] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话 // 初始化:加载或创建会话
@@ -98,6 +100,7 @@ export function App() {
onOpenCommands={() => setShowCommands(true)} onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)} onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)} onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)}
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center h-full"> <div className="flex-1 flex items-center justify-center h-full">
@@ -130,6 +133,9 @@ export function App() {
{/* Hooks 面板 */} {/* Hooks 面板 */}
{showHooks && <HooksPanel onClose={() => setShowHooks(false)} />} {showHooks && <HooksPanel onClose={() => setShowHooks(false)} />}
{/* Agents 面板 */}
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} />}
{/* Toast 通知 */} {/* Toast 通知 */}
<Toaster /> <Toaster />
</div> </div>
+17 -2
View File
@@ -3,7 +3,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap } from 'lucide-react'; import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
useChat, useChat,
@@ -23,6 +23,7 @@ interface ChatPageProps {
onOpenCommands?: () => void; onOpenCommands?: () => void;
onOpenMCP?: () => void; onOpenMCP?: () => void;
onOpenHooks?: () => void; onOpenHooks?: () => void;
onOpenAgents?: () => void;
} }
export function ChatPage({ export function ChatPage({
@@ -34,6 +35,7 @@ export function ChatPage({
onOpenCommands, onOpenCommands,
onOpenMCP, onOpenMCP,
onOpenHooks, onOpenHooks,
onOpenAgents,
}: ChatPageProps) { }: ChatPageProps) {
const { const {
messages, messages,
@@ -127,8 +129,21 @@ export function ChatPage({
<ConnectionStatus /> <ConnectionStatus />
{/* 工具栏按钮 */} {/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks) && ( {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3"> <div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* Agents 按钮 */}
{onOpenAgents && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenAgents}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Agent Presets"
>
<Bot size={20} />
</motion.button>
)}
{/* Hooks 按钮 */} {/* Hooks 按钮 */}
{onOpenHooks && ( {onOpenHooks && (
<motion.button <motion.button
+2 -1
View File
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { createBunWebSocket } from 'hono/bun'; import { createBunWebSocket } from 'hono/bun';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter } from './routes/index.js'; import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter } from './routes/index.js';
import { import {
handleWebSocket, handleWebSocket,
handleWebSocketMessage, handleWebSocketMessage,
@@ -85,6 +85,7 @@ api.route('/files', filesRouter);
api.route('/commands', commandsRouter); api.route('/commands', commandsRouter);
api.route('/mcp', mcpRouter); api.route('/mcp', mcpRouter);
api.route('/hooks', hooksRouter); api.route('/hooks', hooksRouter);
api.route('/agents', agentsRouter);
// SSE 事件流 // SSE 事件流
api.get('/sessions/:id/events', handleSSE); api.get('/sessions/:id/events', handleSSE);
+602
View File
@@ -0,0 +1,602 @@
/**
* Agents API Routes
*
* 提供 Agent 预设管理的 REST API
*/
import { Hono } from 'hono';
import { getConfig } from './config.js';
// Agent 类型定义(与 Core 对应)
type AgentMode = 'primary' | 'subagent' | 'all';
interface AgentModelConfig {
provider?: 'anthropic' | 'deepseek' | 'openai';
model?: string;
temperature?: number;
topP?: number;
maxTokens?: number;
}
interface AgentToolConfig {
disabled?: string[];
enabled?: string[];
noTask?: boolean;
}
interface PermissionRule {
pattern: string;
action: 'allow' | 'deny';
}
interface AgentBashPermission {
rules?: PermissionRule[];
defaultAction?: 'allow' | 'deny';
}
interface AgentFilePermission {
read?: { rules?: PermissionRule[]; defaultAction?: 'allow' | 'deny' };
write?: { rules?: PermissionRule[]; defaultAction?: 'allow' | 'deny' };
}
interface AgentGitPermission {
commands?: string[];
allowPush?: boolean;
allowForce?: boolean;
}
interface AgentPermission {
bash?: AgentBashPermission;
file?: AgentFilePermission;
git?: AgentGitPermission;
}
interface AgentInfo {
name: string;
description: string;
mode: AgentMode;
prompt?: string;
model?: AgentModelConfig;
tools?: AgentToolConfig;
permission?: AgentPermission;
maxSteps?: number;
}
interface AgentConfigFile {
defaults?: {
maxSteps?: number;
model?: AgentModelConfig;
permission?: AgentPermission;
};
agents?: Record<string, Omit<AgentInfo, 'name'>>;
}
// Core Agent 模块类型
interface AgentModule {
agentRegistry: {
init: (workdir: string) => Promise<void>;
get: (name: string) => AgentInfo | undefined;
list: () => AgentInfo[];
};
loadAgentConfig: (workdir: string) => Promise<AgentConfigFile | null>;
saveAgentConfig: (workdir: string, config: AgentConfigFile, format?: 'json' | 'yaml') => Promise<void>;
presetAgents: Record<string, Omit<AgentInfo, 'name'>>;
isPresetAgent: (name: string) => boolean;
}
// API 响应类型
interface AgentListItem {
name: string;
description: string;
mode: AgentMode;
isPreset: boolean;
isCustomized: boolean;
model?: string;
maxSteps?: number;
}
interface AgentDefaults {
maxSteps?: number;
model?: AgentModelConfig;
permission?: AgentPermission;
}
export const agentsRouter = new Hono();
// Core 模块缓存
let agentModule: AgentModule | null = null;
let initialized = false;
/**
* 初始化 Agent 模块
*/
async function initAgentModule(): Promise<AgentModule | null> {
if (agentModule) return agentModule;
try {
const corePath = '@ai-assistant/core';
const core = (await import(corePath)) as Record<string, unknown>;
if (
!core.agentRegistry ||
typeof core.loadAgentConfig !== 'function' ||
typeof core.saveAgentConfig !== 'function' ||
!core.presetAgents ||
typeof core.isPresetAgent !== 'function'
) {
console.warn('[Agents] Core module missing Agent exports');
return null;
}
agentModule = {
agentRegistry: core.agentRegistry as AgentModule['agentRegistry'],
loadAgentConfig: core.loadAgentConfig as AgentModule['loadAgentConfig'],
saveAgentConfig: core.saveAgentConfig as AgentModule['saveAgentConfig'],
presetAgents: core.presetAgents as AgentModule['presetAgents'],
isPresetAgent: core.isPresetAgent as AgentModule['isPresetAgent'],
};
console.log('[Agents] Agent module initialized');
return agentModule;
} catch (error) {
console.warn('[Agents] Failed to load Agent module:', error);
return null;
}
}
/**
* 确保 AgentRegistry 已初始化
*/
async function ensureRegistryInitialized(): Promise<boolean> {
const module = await initAgentModule();
if (!module) return false;
if (!initialized) {
const config = getConfig();
await module.agentRegistry.init(config.workdir);
initialized = true;
}
return true;
}
/**
* 将 AgentInfo 转换为 AgentListItem
*/
function toListItem(agent: AgentInfo, module: AgentModule, customAgentNames: Set<string>): AgentListItem {
const isPreset = module.isPresetAgent(agent.name);
return {
name: agent.name,
description: agent.description,
mode: agent.mode,
isPreset,
isCustomized: customAgentNames.has(agent.name),
model: agent.model?.model,
maxSteps: agent.maxSteps,
};
}
/**
* GET /agents - 获取所有 Agent 列表
*/
agentsRouter.get('/', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
if (!(await ensureRegistryInitialized())) {
return c.json(
{
success: false,
error: 'Failed to initialize agent registry',
},
503
);
}
const config = getConfig();
const userConfig = await module.loadAgentConfig(config.workdir);
const customAgentNames = new Set(Object.keys(userConfig?.agents || {}));
const agents = module.agentRegistry.list();
const items = agents.map((agent) => toListItem(agent, module, customAgentNames));
return c.json({
success: true,
data: items,
});
});
/**
* GET /agents/presets - 获取预设 Agent 列表
*/
agentsRouter.get('/presets', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
const presets = Object.entries(module.presetAgents).map(([name, agent]) => ({
name,
description: agent.description,
mode: agent.mode,
isPreset: true,
isCustomized: false,
model: agent.model?.model,
maxSteps: agent.maxSteps,
}));
return c.json({
success: true,
data: presets,
});
});
/**
* GET /agents/defaults - 获取全局默认配置
*/
agentsRouter.get('/defaults', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
const config = getConfig();
const userConfig = await module.loadAgentConfig(config.workdir);
return c.json({
success: true,
data: userConfig?.defaults || {},
});
});
/**
* PUT /agents/defaults - 更新全局默认配置
*/
agentsRouter.put('/defaults', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
try {
const newDefaults = await c.req.json<AgentDefaults>();
const config = getConfig();
// 加载现有配置
let userConfig = await module.loadAgentConfig(config.workdir);
if (!userConfig) {
userConfig = {};
}
// 更新 defaults
userConfig.defaults = newDefaults;
// 保存配置
await module.saveAgentConfig(config.workdir, userConfig);
// 重新初始化 registry 以应用新配置
initialized = false;
await ensureRegistryInitialized();
return c.json({
success: true,
data: newDefaults,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update defaults',
},
500
);
}
});
/**
* GET /agents/:name - 获取单个 Agent 详情
*/
agentsRouter.get('/:name', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
if (!(await ensureRegistryInitialized())) {
return c.json(
{
success: false,
error: 'Failed to initialize agent registry',
},
503
);
}
const name = c.req.param('name');
const agent = module.agentRegistry.get(name);
if (!agent) {
return c.json(
{
success: false,
error: `Agent '${name}' not found`,
},
404
);
}
const config = getConfig();
const userConfig = await module.loadAgentConfig(config.workdir);
const isPreset = module.isPresetAgent(name);
const isCustomized = !!(userConfig?.agents && name in userConfig.agents);
return c.json({
success: true,
data: {
...agent,
isPreset,
isCustomized,
},
});
});
/**
* POST /agents - 创建新 Agent
*/
agentsRouter.post('/', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
try {
const body = await c.req.json<{ name: string } & Omit<AgentInfo, 'name'>>();
const { name, ...agentConfig } = body;
if (!name || typeof name !== 'string') {
return c.json(
{
success: false,
error: 'Agent name is required',
},
400
);
}
// 检查名称是否与预设冲突
if (module.isPresetAgent(name)) {
return c.json(
{
success: false,
error: `Cannot create agent with preset name '${name}'. Use PUT to customize a preset.`,
},
400
);
}
const config = getConfig();
let userConfig = await module.loadAgentConfig(config.workdir);
if (!userConfig) {
userConfig = {};
}
if (!userConfig.agents) {
userConfig.agents = {};
}
// 检查是否已存在
if (name in userConfig.agents) {
return c.json(
{
success: false,
error: `Agent '${name}' already exists`,
},
409
);
}
// 添加新 Agent
userConfig.agents[name] = agentConfig;
// 保存配置
await module.saveAgentConfig(config.workdir, userConfig);
// 重新初始化 registry
initialized = false;
await ensureRegistryInitialized();
const createdAgent = module.agentRegistry.get(name);
return c.json({
success: true,
data: createdAgent
? {
...createdAgent,
isPreset: false,
isCustomized: false,
}
: { name, ...agentConfig, isPreset: false, isCustomized: false },
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create agent',
},
500
);
}
});
/**
* PUT /agents/:name - 更新 Agent
*/
agentsRouter.put('/:name', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
try {
const name = c.req.param('name');
const agentConfig = await c.req.json<Omit<AgentInfo, 'name'>>();
const config = getConfig();
let userConfig = await module.loadAgentConfig(config.workdir);
if (!userConfig) {
userConfig = {};
}
if (!userConfig.agents) {
userConfig.agents = {};
}
// 更新 Agent(支持覆盖预设)
userConfig.agents[name] = agentConfig;
// 保存配置
await module.saveAgentConfig(config.workdir, userConfig);
// 重新初始化 registry
initialized = false;
await ensureRegistryInitialized();
const updatedAgent = module.agentRegistry.get(name);
const isPreset = module.isPresetAgent(name);
return c.json({
success: true,
data: updatedAgent
? {
...updatedAgent,
isPreset,
isCustomized: true,
}
: { name, ...agentConfig, isPreset, isCustomized: true },
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update agent',
},
500
);
}
});
/**
* DELETE /agents/:name - 删除 Agent
*/
agentsRouter.delete('/:name', async (c) => {
const module = await initAgentModule();
if (!module) {
return c.json(
{
success: false,
error: 'Agent module not available',
},
503
);
}
try {
const name = c.req.param('name');
const config = getConfig();
const userConfig = await module.loadAgentConfig(config.workdir);
if (!userConfig?.agents || !(name in userConfig.agents)) {
// 如果是预设 Agent,返回特定错误
if (module.isPresetAgent(name)) {
return c.json(
{
success: false,
error: `Cannot delete preset agent '${name}'`,
},
400
);
}
return c.json(
{
success: false,
error: `Agent '${name}' not found in user configuration`,
},
404
);
}
// 删除 Agent
delete userConfig.agents[name];
// 保存配置
await module.saveAgentConfig(config.workdir, userConfig);
// 重新初始化 registry
initialized = false;
await ensureRegistryInitialized();
return c.json({
success: true,
data: null,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to delete agent',
},
500
);
}
});
+1
View File
@@ -11,3 +11,4 @@ export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.j
export { commandsRouter } from './commands.js'; export { commandsRouter } from './commands.js';
export { mcpRouter } from './mcp.js'; export { mcpRouter } from './mcp.js';
export { hooksRouter } from './hooks.js'; export { hooksRouter } from './hooks.js';
export { agentsRouter } from './agents.js';
+112
View File
@@ -24,6 +24,10 @@ import type {
FileHookConfig, FileHookConfig,
ShellCommandConfig, ShellCommandConfig,
HookTestResult, HookTestResult,
AgentListItem,
AgentDetail,
AgentInput,
AgentDefaults,
} from './types.js'; } from './types.js';
// Re-export types // Re-export types
@@ -54,6 +58,19 @@ export type {
FileHookConfig, FileHookConfig,
ShellCommandConfig, ShellCommandConfig,
HookTestResult, HookTestResult,
// Agent types
AgentMode,
AgentModelConfig,
AgentToolConfig,
PermissionRule,
AgentBashPermission,
AgentFilePermission,
AgentGitPermission,
AgentPermission,
AgentListItem,
AgentDetail,
AgentInput,
AgentDefaults,
} from './types.js'; } from './types.js';
// API Configuration // API Configuration
@@ -476,3 +493,98 @@ export async function testHookCommand(command: ShellCommandConfig): Promise<{
}> { }> {
return request('POST', '/hooks/test', command); return request('POST', '/hooks/test', command);
} }
// ============ Agents API ============
/**
* 获取所有 Agent 列表
*/
export async function listAgents(): Promise<{
success: boolean;
data: AgentListItem[];
error?: string;
}> {
return request('GET', '/agents');
}
/**
* 获取单个 Agent 详情
*/
export async function getAgent(name: string): Promise<{
success: boolean;
data?: AgentDetail;
error?: string;
}> {
return request('GET', `/agents/${encodeURIComponent(name)}`);
}
/**
* 创建新 Agent
*/
export async function createAgent(
name: string,
config: AgentInput
): Promise<{
success: boolean;
data?: AgentDetail;
error?: string;
}> {
return request('POST', '/agents', { name, ...config });
}
/**
* 更新 Agent
*/
export async function updateAgent(
name: string,
config: AgentInput
): Promise<{
success: boolean;
data?: AgentDetail;
error?: string;
}> {
return request('PUT', `/agents/${encodeURIComponent(name)}`, config);
}
/**
* 删除 Agent
*/
export async function deleteAgent(name: string): Promise<{
success: boolean;
error?: string;
}> {
return request('DELETE', `/agents/${encodeURIComponent(name)}`);
}
/**
* 获取预设 Agent 列表
*/
export async function listPresetAgents(): Promise<{
success: boolean;
data: AgentListItem[];
error?: string;
}> {
return request('GET', '/agents/presets');
}
/**
* 获取全局默认配置
*/
export async function getAgentDefaults(): Promise<{
success: boolean;
data: AgentDefaults;
error?: string;
}> {
return request('GET', '/agents/defaults');
}
/**
* 更新全局默认配置
*/
export async function updateAgentDefaults(defaults: AgentDefaults): Promise<{
success: boolean;
data: AgentDefaults;
error?: string;
}> {
return request('PUT', '/agents/defaults', defaults);
}
+143
View File
@@ -281,3 +281,146 @@ export interface HookTestResult {
/** 执行时间(毫秒) */ /** 执行时间(毫秒) */
duration: number; duration: number;
} }
// ============ Agent 相关 ============
/** Agent 运行模式 */
export type AgentMode = 'primary' | 'subagent' | 'all';
/** Agent 模型配置 */
export interface AgentModelConfig {
/** 提供商 */
provider?: 'anthropic' | 'deepseek' | 'openai';
/** 模型名称 */
model?: string;
/** 温度参数 (0-1) */
temperature?: number;
/** Top-P 采样 */
topP?: number;
/** 最大 Token 数 */
maxTokens?: number;
}
/** Agent 工具配置 */
export interface AgentToolConfig {
/** 禁用的工具列表 */
disabled?: string[];
/** 只启用的工具列表(与 disabled 互斥) */
enabled?: string[];
/** 禁止嵌套 Task 调用 */
noTask?: boolean;
}
/** 权限规则 */
export interface PermissionRule {
/** 匹配模式(glob 或正则) */
pattern: string;
/** 操作:允许或拒绝 */
action: 'allow' | 'deny';
}
/** Bash 权限配置 */
export interface AgentBashPermission {
/** 规则列表 */
rules?: PermissionRule[];
/** 默认操作 */
defaultAction?: 'allow' | 'deny';
}
/** 文件权限配置 */
export interface AgentFilePermission {
/** 读取权限 */
read?: { rules?: PermissionRule[]; defaultAction?: 'allow' | 'deny' };
/** 写入权限 */
write?: { rules?: PermissionRule[]; defaultAction?: 'allow' | 'deny' };
}
/** Git 权限配置 */
export interface AgentGitPermission {
/** 允许的命令 */
commands?: string[];
/** 是否允许 push */
allowPush?: boolean;
/** 是否允许 force 操作 */
allowForce?: boolean;
}
/** Agent 权限配置 */
export interface AgentPermission {
/** Bash 命令权限 */
bash?: AgentBashPermission;
/** 文件操作权限 */
file?: AgentFilePermission;
/** Git 操作权限 */
git?: AgentGitPermission;
}
/** Agent 列表项 */
export interface AgentListItem {
/** Agent 名称 */
name: string;
/** 描述 */
description: string;
/** 运行模式 */
mode: AgentMode;
/** 是否为预设 Agent */
isPreset: boolean;
/** 是否被用户自定义覆盖 */
isCustomized: boolean;
/** 使用的模型 */
model?: string;
/** 最大执行步数 */
maxSteps?: number;
}
/** Agent 完整详情 */
export interface AgentDetail {
/** Agent 名称 */
name: string;
/** 描述 */
description: string;
/** 运行模式 */
mode: AgentMode;
/** 是否为预设 */
isPreset: boolean;
/** 是否被自定义 */
isCustomized?: boolean;
/** System Prompt */
prompt?: string;
/** 模型配置 */
model?: AgentModelConfig;
/** 工具配置 */
tools?: AgentToolConfig;
/** 权限配置 */
permission?: AgentPermission;
/** 最大执行步数 */
maxSteps?: number;
}
/** 创建/更新 Agent 输入 */
export interface AgentInput {
/** 描述 */
description: string;
/** 运行模式 */
mode: AgentMode;
/** System Prompt */
prompt?: string;
/** 模型配置 */
model?: AgentModelConfig;
/** 工具配置 */
tools?: AgentToolConfig;
/** 权限配置 */
permission?: AgentPermission;
/** 最大执行步数 */
maxSteps?: number;
}
/** 全局默认配置 */
export interface AgentDefaults {
/** 最大执行步数 */
maxSteps?: number;
/** 模型配置 */
model?: AgentModelConfig;
/** 权限配置 */
permission?: AgentPermission;
}
@@ -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>
);
}
+592
View File
@@ -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>
);
}
+604
View File
@@ -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>
);
}
+25
View File
@@ -54,6 +54,15 @@ export {
getSessionCompletedHooks, getSessionCompletedHooks,
updateSessionCompletedHooks, updateSessionCompletedHooks,
testHookCommand, testHookCommand,
// Agents API
listAgents,
getAgent,
createAgent,
updateAgent,
deleteAgent,
listPresetAgents,
getAgentDefaults,
updateAgentDefaults,
} from './api/client.js'; } from './api/client.js';
// Types // Types
@@ -87,6 +96,19 @@ export type {
FileHookConfig, FileHookConfig,
ShellCommandConfig, ShellCommandConfig,
HookTestResult, HookTestResult,
// Agent types
AgentMode,
AgentModelConfig,
AgentToolConfig,
PermissionRule,
AgentBashPermission,
AgentFilePermission,
AgentGitPermission,
AgentPermission,
AgentListItem,
AgentDetail,
AgentInput,
AgentDefaults,
} from './api/client.js'; } from './api/client.js';
// Primitives (shadcn/ui style) // Primitives (shadcn/ui style)
@@ -105,6 +127,9 @@ export { CommandEditor } from './components/CommandEditor.js';
export { MCPPanel } from './components/MCPPanel.js'; export { MCPPanel } from './components/MCPPanel.js';
export { HooksPanel } from './components/HooksPanel.js'; export { HooksPanel } from './components/HooksPanel.js';
export { HookEditor } from './components/HookEditor.js'; export { HookEditor } from './components/HookEditor.js';
export { AgentsPanel } from './components/AgentsPanel.js';
export { AgentEditor } from './components/AgentEditor.js';
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
export { Sidebar } from './components/Sidebar.js'; export { Sidebar } from './components/Sidebar.js';
export { FileBrowser } from './components/FileBrowser.js'; export { FileBrowser } from './components/FileBrowser.js';
export { ConfigPanel } from './components/ConfigPanel.js'; export { ConfigPanel } from './components/ConfigPanel.js';
+6
View File
@@ -12,6 +12,7 @@ import {
CommandPanel, CommandPanel,
MCPPanel, MCPPanel,
HooksPanel, HooksPanel,
AgentsPanel,
Toaster, Toaster,
listSessions, listSessions,
createSession, createSession,
@@ -27,6 +28,7 @@ export function App() {
const [showCommands, setShowCommands] = useState(false); const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false); const [showMCP, setShowMCP] = useState(false);
const [showHooks, setShowHooks] = useState(false); const [showHooks, setShowHooks] = useState(false);
const [showAgents, setShowAgents] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话 // 初始化:加载或创建会话
@@ -114,6 +116,7 @@ export function App() {
onOpenCommands={() => setShowCommands(true)} onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)} onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)} onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)}
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center h-full"> <div className="flex-1 flex items-center justify-center h-full">
@@ -171,6 +174,9 @@ export function App() {
{/* Hooks 面板 */} {/* Hooks 面板 */}
{showHooks && <HooksPanel onClose={() => setShowHooks(false)} responsive />} {showHooks && <HooksPanel onClose={() => setShowHooks(false)} responsive />}
{/* Agents 面板 */}
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} responsive />}
{/* 移动端底部文件按钮 */} {/* 移动端底部文件按钮 */}
<button <button
onClick={() => setShowFileBrowser(true)} onClick={() => setShowFileBrowser(true)}
+17 -2
View File
@@ -3,7 +3,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap } from 'lucide-react'; import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
useChat, useChat,
@@ -25,6 +25,7 @@ interface ChatPageProps {
onOpenCommands?: () => void; onOpenCommands?: () => void;
onOpenMCP?: () => void; onOpenMCP?: () => void;
onOpenHooks?: () => void; onOpenHooks?: () => void;
onOpenAgents?: () => void;
} }
export function ChatPage({ export function ChatPage({
@@ -38,6 +39,7 @@ export function ChatPage({
onOpenCommands, onOpenCommands,
onOpenMCP, onOpenMCP,
onOpenHooks, onOpenHooks,
onOpenAgents,
}: ChatPageProps) { }: ChatPageProps) {
const { const {
messages, messages,
@@ -132,8 +134,21 @@ export function ChatPage({
<ConnectionStatus /> <ConnectionStatus />
{/* 工具栏按钮 */} {/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks) && ( {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents) && (
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3"> <div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
{/* Agents 按钮 */}
{onOpenAgents && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenAgents}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Agent Presets"
>
<Bot size={20} />
</motion.button>
)}
{/* Hooks 按钮 */} {/* Hooks 按钮 */}
{onOpenHooks && ( {onOpenHooks && (
<motion.button <motion.button