diff --git a/packages/core/src/core/agent.ts b/packages/core/src/core/agent.ts index 6fd90ca..79c2d7f 100644 --- a/packages/core/src/core/agent.ts +++ b/packages/core/src/core/agent.ts @@ -728,8 +728,31 @@ export class Agent { /** * 切换 Agent 模式 + * @param agent AgentInfo 对象或模式字符串 ('build'/'plan') */ - setAgentMode(agent: AgentInfo | null): void { + setAgentMode(agent: AgentInfo | 'build' | 'plan' | null): void { + // 如果是字符串模式,从 registry 获取预设 + if (typeof agent === 'string') { + const presetAgent = agentRegistry.get(agent); + if (presetAgent) { + this.currentAgentMode = presetAgent; + if (presetAgent.prompt) { + this.config = { + ...this.config, + systemPrompt: presetAgent.prompt, + }; + } + } else { + // 如果找不到预设,回退到默认模式 + this.currentAgentMode = null; + this.config = { + ...this.config, + systemPrompt: this.originalSystemPrompt, + }; + } + return; + } + this.currentAgentMode = agent; if (agent?.prompt) { @@ -747,6 +770,35 @@ export class Agent { } } + // ============================================================================ + // Auto-approve 功能(用于前端 Build 模式的自动授权) + // ============================================================================ + + /** 临时自动授权配置 */ + private autoApproveConfig: { file?: { write?: 'allow'; edit?: 'allow' } } | null = null; + + /** + * 设置自动授权配置 + * 仅影响 file write 和 file edit 操作(不包含 delete) + */ + setAutoApprove(config: { file?: { write?: 'allow'; edit?: 'allow' } }): void { + this.autoApproveConfig = config; + } + + /** + * 清除自动授权配置 + */ + clearAutoApprove(): void { + this.autoApproveConfig = null; + } + + /** + * 获取当前自动授权配置 + */ + getAutoApproveConfig(): { file?: { write?: 'allow'; edit?: 'allow' } } | null { + return this.autoApproveConfig; + } + /** * 获取当前 Agent 模式 */ diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index 86aa905..3fff349 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -11,7 +11,7 @@ import type { SessionStatus } from '../types.js'; import { getSessionManager } from '../session/manager.js'; import { broadcastToSession } from '../ws.js'; import { emitStatusEvent, emitLogEvent } from '../sse.js'; -import { createServerPermissionCallback } from '../permission/handler.js'; +import { createServerPermissionCallback, setSessionAutoApprove } from '../permission/handler.js'; // ============================================================================ // Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型) @@ -97,6 +97,16 @@ interface ChatOptions { abortSignal?: AbortSignal; } +/** + * Agent 模式选项 + */ +export interface AgentModeOptions { + /** Agent 模式 (build/plan) */ + agentMode?: 'build' | 'plan'; + /** 是否自动授权文件写入/编辑 */ + autoApprove?: boolean; +} + /** * Agent 实例接口 */ @@ -112,6 +122,9 @@ interface AgentInstance { shouldCompress(messages: unknown[]): boolean; }; getHistory(): unknown[]; + setAgentMode?(mode: 'build' | 'plan'): void; + setAutoApprove?(config: { file?: { write?: 'allow'; edit?: 'allow' } }): void; + clearAutoApprove?(): void; } /** @@ -343,7 +356,11 @@ export async function destroyAgent(sessionId: string): Promise { /** * 处理用户消息并流式返回响应 */ -export async function processMessage(sessionId: string, content: string): Promise { +export async function processMessage( + sessionId: string, + content: string, + options?: AgentModeOptions +): Promise { const sessionManager = getSessionManager(); // 取消之前可能存在的请求 @@ -405,6 +422,22 @@ export async function processMessage(sessionId: string, content: string): Promis return; } + // 应用 Agent 模式和自动授权配置 + if (options?.agentMode && agent.setAgentMode) { + agent.setAgentMode(options.agentMode); + } + + // autoApprove 仅对 build 模式生效,且只允许 write/edit(不含 delete) + if (options?.autoApprove && options?.agentMode !== 'plan') { + // 设置会话级别的 auto-approve 配置(用于权限回调) + setSessionAutoApprove(sessionId, { + file: { write: 'allow', edit: 'allow' }, + }); + } else { + // 清除 auto-approve 配置 + setSessionAutoApprove(sessionId, null); + } + try { // 调用 Agent 的 chat 方法,使用流式回调和 AbortSignal const result = await agent.chat(content, { diff --git a/packages/server/src/agent/index.ts b/packages/server/src/agent/index.ts index 3a80e7e..64faba2 100644 --- a/packages/server/src/agent/index.ts +++ b/packages/server/src/agent/index.ts @@ -19,4 +19,5 @@ export { type TokenUsage, type CompressionResult, type ContextUsageInfo, + type AgentModeOptions, } from './adapter.js'; diff --git a/packages/server/src/permission/handler.ts b/packages/server/src/permission/handler.ts index 4c81bb6..f308ad3 100644 --- a/packages/server/src/permission/handler.ts +++ b/packages/server/src/permission/handler.ts @@ -39,9 +39,56 @@ interface PendingRequest { const pendingRequests = new Map(); +// 会话级别的 auto-approve 配置 +// key: sessionId, value: auto-approve 配置 +const sessionAutoApprove = new Map(); + // 默认超时时间(60秒) const PERMISSION_TIMEOUT = 60000; +/** + * 设置会话的 auto-approve 配置 + */ +export function setSessionAutoApprove( + sessionId: string, + config: { file?: { write?: 'allow'; edit?: 'allow' } } | null +): void { + if (config) { + sessionAutoApprove.set(sessionId, config); + } else { + sessionAutoApprove.delete(sessionId); + } +} + +/** + * 获取会话的 auto-approve 配置 + */ +export function getSessionAutoApprove(sessionId: string): { file?: { write?: 'allow'; edit?: 'allow' } } | null { + return sessionAutoApprove.get(sessionId) ?? null; +} + +/** + * 检查操作是否被 auto-approve + */ +function isAutoApproved(sessionId: string, ctx: PermissionContext): boolean { + const config = sessionAutoApprove.get(sessionId); + if (!config) return false; + + const command = ctx.command.toLowerCase(); + + // 检查是否为文件写入操作 + if (command.startsWith('write ') && config.file?.write === 'allow') { + return true; + } + + // 检查是否为文件编辑操作 + if (command.startsWith('edit ') && config.file?.edit === 'allow') { + return true; + } + + return false; +} + /** * 从命令或上下文检测权限类型 */ @@ -117,6 +164,13 @@ function buildRequestContext(ctx: PermissionContext): PermissionRequestContext { export function createServerPermissionCallback(sessionId: string) { return async (ctx: unknown): Promise => { const permCtx = ctx as PermissionContext; + + // 检查 auto-approve 配置 + if (isAutoApproved(sessionId, permCtx)) { + console.log(`[Permission] Auto-approved: ${permCtx.command}`); + return { allow: true, remember: false }; + } + const requestId = randomUUID(); const permissionType = detectPermissionType(permCtx); const context = buildRequestContext(permCtx); diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 776d254..0fc2a88 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -87,6 +87,9 @@ export type Tool = z.infer; // ============ WebSocket 消息 ============ +// Agent 模式类型 +export type AgentModeType = 'build' | 'plan'; + // 客户端发送的消息 export interface ClientMessage { type: 'message' | 'cancel' | 'tool_response' | 'permission_response'; @@ -99,6 +102,9 @@ export interface ClientMessage { requestId?: string; allow?: boolean; remember?: boolean; + // Agent mode fields + agentMode?: AgentModeType; + autoApprove?: boolean; }; } diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index fca750c..9379544 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -106,6 +106,8 @@ export async function handleWebSocketMessage( case 'message': { // 用户发送消息 let content = message.payload?.content || ''; + const agentMode = message.payload?.agentMode as 'build' | 'plan' | undefined; + const autoApprove = message.payload?.autoApprove as boolean | undefined; // 将 @filepath 转换为 ./filepath 格式(方便 AI 识别为文件路径) content = content.replace(/@([\w./-]+)/g, './$1'); @@ -119,7 +121,7 @@ export async function handleWebSocketMessage( // 调用 Agent 处理消息(异步,不阻塞) // 消息存储由 Core Agent 负责 - processMessage(sessionId, content).catch((error) => { + processMessage(sessionId, content, { agentMode, autoApprove }).catch((error) => { console.error('[WS] Agent processing error:', error); }); break; diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 2fa6cfa..8f5fdd6 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -129,6 +129,8 @@ export type { // File search types FileSearchResult, FileSearchResponse, + // Agent mode types + AgentModeType, } from './types.js'; // API Configuration diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 14e2033..659e587 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -877,6 +877,11 @@ export interface FileSearchResponse { }; } +// ============ Agent 模式切换相关 ============ + +/** Agent 模式类型 (Build/Plan) */ +export type AgentModeType = 'build' | 'plan'; + // ============ 流式工具调用事件 ============ /** 工具开始事件 Payload */ diff --git a/packages/ui/src/components/AgentModeSelector.tsx b/packages/ui/src/components/AgentModeSelector.tsx new file mode 100644 index 0000000..a85118b --- /dev/null +++ b/packages/ui/src/components/AgentModeSelector.tsx @@ -0,0 +1,185 @@ +/** + * Agent Mode Selector Component + * + * 在 ChatInput 左侧显示,用于切换 Build/Plan 模式和控制 Auto-approve + */ + +import { useState, useRef, useEffect } from 'react'; +import { Hammer, FileSearch, ChevronDown, Check } from 'lucide-react'; +import clsx from 'clsx'; +import { Switch } from '../primitives/Switch.js'; + +export type AgentModeType = 'build' | 'plan'; + +interface AgentModeSelectorProps { + /** 当前模式 */ + mode: AgentModeType; + /** 模式变更回调 */ + onModeChange: (mode: AgentModeType) => void; + /** 是否自动授权文件写入/编辑 */ + autoApprove: boolean; + /** 自动授权变更回调 */ + onAutoApproveChange: (enabled: boolean) => void; + /** 是否禁用 */ + disabled?: boolean; +} + +const modeConfig = { + build: { + label: 'Build', + icon: Hammer, + color: 'text-blue-500', + bgColor: 'bg-blue-500/10', + description: '可执行代码修改', + }, + plan: { + label: 'Plan', + icon: FileSearch, + color: 'text-purple-500', + bgColor: 'bg-purple-500/10', + description: '只读模式,仅分析', + }, +}; + +export function AgentModeSelector({ + mode, + onModeChange, + autoApprove, + onAutoApproveChange, + disabled = false, +}: AgentModeSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const currentMode = modeConfig[mode]; + const ModeIcon = currentMode.icon; + + // 点击外部关闭下拉菜单 + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + // 键盘导航 + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (!isOpen) return; + + if (event.key === 'Escape') { + setIsOpen(false); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + // 切换模式 + onModeChange(mode === 'build' ? 'plan' : 'build'); + } else if (event.key === 'Enter') { + setIsOpen(false); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, mode, onModeChange]); + + const handleModeSelect = (newMode: AgentModeType) => { + onModeChange(newMode); + setIsOpen(false); + }; + + return ( +
+ {/* 模式选择器 */} +
+ + + {/* 下拉菜单 */} + {isOpen && ( +
+ {(Object.entries(modeConfig) as [AgentModeType, typeof modeConfig.build][]).map( + ([modeKey, config]) => { + const Icon = config.icon; + const isSelected = modeKey === mode; + + return ( + + ); + } + )} +
+ )} +
+ + {/* Auto-approve 开关 - 仅在 Build 模式下显示 */} + {mode === 'build' && ( +
+ + +
+ )} +
+ ); +} diff --git a/packages/ui/src/components/ChatInput.tsx b/packages/ui/src/components/ChatInput.tsx index e683df3..27ef9ab 100644 --- a/packages/ui/src/components/ChatInput.tsx +++ b/packages/ui/src/components/ChatInput.tsx @@ -12,6 +12,7 @@ import clsx from 'clsx'; import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; import { FileMenu, type FileMenuItem } from './FileMenu.js'; import { FileMentionTag } from './FileMentionTag.js'; +import { AgentModeSelector, type AgentModeType } from './AgentModeSelector.js'; import { useCommands } from '../hooks/useCommands.js'; import { useFileMention } from '../hooks/useFileMention.js'; @@ -26,6 +27,14 @@ interface ChatInputProps { enableCommands?: boolean; /** 是否启用文件提及 (@) */ enableFileMention?: boolean; + /** Agent 模式 (build/plan) */ + agentMode?: AgentModeType; + /** Agent 模式变更回调 */ + onAgentModeChange?: (mode: AgentModeType) => void; + /** 是否自动授权文件写入/编辑 */ + autoApprove?: boolean; + /** 自动授权变更回调 */ + onAutoApproveChange?: (enabled: boolean) => void; } export function ChatInput({ @@ -36,6 +45,10 @@ export function ChatInput({ responsive = false, enableCommands = true, enableFileMention = true, + agentMode = 'build', + onAgentModeChange, + autoApprove = false, + onAutoApproveChange, }: ChatInputProps) { const [input, setInput] = useState(''); const [showCommandMenu, setShowCommandMenu] = useState(false); @@ -281,7 +294,20 @@ export function ChatInput({ )} {/* 输入区域 */} -
+
+ {/* Agent 模式选择器 */} + {onAgentModeChange && ( +
+ {})} + disabled={disabled || isLoading} + /> +
+ )} +