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