feat(ui): 实现 Agent 模式切换和 Auto Edit 功能

- 添加 AgentModeSelector 组件,支持 Build/Plan 模式切换
- Build 模式下显示 Auto Edit 开关,自动授权文件写入/编辑
- 扩展 useChat hook 添加会话级别的 agentMode/autoApprove 状态
- 服务端支持解析和应用 Agent 模式配置
- 权限处理器实现 auto-approve 检查(仅 write/edit,不含 delete)
This commit is contained in:
2025-12-15 19:42:51 +08:00
parent f09f8f2b03
commit ec3c7bccf9
13 changed files with 409 additions and 7 deletions
+53 -1
View File
@@ -728,8 +728,31 @@ export class Agent {
/** /**
* 切换 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; this.currentAgentMode = agent;
if (agent?.prompt) { 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 模式 * 获取当前 Agent 模式
*/ */
+35 -2
View File
@@ -11,7 +11,7 @@ import type { SessionStatus } from '../types.js';
import { getSessionManager } from '../session/manager.js'; import { getSessionManager } from '../session/manager.js';
import { broadcastToSession } from '../ws.js'; import { broadcastToSession } from '../ws.js';
import { emitStatusEvent, emitLogEvent } from '../sse.js'; import { emitStatusEvent, emitLogEvent } from '../sse.js';
import { createServerPermissionCallback } from '../permission/handler.js'; import { createServerPermissionCallback, setSessionAutoApprove } from '../permission/handler.js';
// ============================================================================ // ============================================================================
// Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型) // Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型)
@@ -97,6 +97,16 @@ interface ChatOptions {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
} }
/**
* Agent 模式选项
*/
export interface AgentModeOptions {
/** Agent 模式 (build/plan) */
agentMode?: 'build' | 'plan';
/** 是否自动授权文件写入/编辑 */
autoApprove?: boolean;
}
/** /**
* Agent 实例接口 * Agent 实例接口
*/ */
@@ -112,6 +122,9 @@ interface AgentInstance {
shouldCompress(messages: unknown[]): boolean; shouldCompress(messages: unknown[]): boolean;
}; };
getHistory(): unknown[]; 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<void> {
/** /**
* 处理用户消息并流式返回响应 * 处理用户消息并流式返回响应
*/ */
export async function processMessage(sessionId: string, content: string): Promise<void> { export async function processMessage(
sessionId: string,
content: string,
options?: AgentModeOptions
): Promise<void> {
const sessionManager = getSessionManager(); const sessionManager = getSessionManager();
// 取消之前可能存在的请求 // 取消之前可能存在的请求
@@ -405,6 +422,22 @@ export async function processMessage(sessionId: string, content: string): Promis
return; 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 { try {
// 调用 Agent 的 chat 方法,使用流式回调和 AbortSignal // 调用 Agent 的 chat 方法,使用流式回调和 AbortSignal
const result = await agent.chat(content, { const result = await agent.chat(content, {
+1
View File
@@ -19,4 +19,5 @@ export {
type TokenUsage, type TokenUsage,
type CompressionResult, type CompressionResult,
type ContextUsageInfo, type ContextUsageInfo,
type AgentModeOptions,
} from './adapter.js'; } from './adapter.js';
+54
View File
@@ -39,9 +39,56 @@ interface PendingRequest {
const pendingRequests = new Map<string, PendingRequest>(); const pendingRequests = new Map<string, PendingRequest>();
// 会话级别的 auto-approve 配置
// key: sessionId, value: auto-approve 配置
const sessionAutoApprove = new Map<string, { file?: { write?: 'allow'; edit?: 'allow' } }>();
// 默认超时时间(60秒) // 默认超时时间(60秒)
const PERMISSION_TIMEOUT = 60000; 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) { export function createServerPermissionCallback(sessionId: string) {
return async (ctx: unknown): Promise<PermissionDecision> => { return async (ctx: unknown): Promise<PermissionDecision> => {
const permCtx = ctx as PermissionContext; 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 requestId = randomUUID();
const permissionType = detectPermissionType(permCtx); const permissionType = detectPermissionType(permCtx);
const context = buildRequestContext(permCtx); const context = buildRequestContext(permCtx);
+6
View File
@@ -87,6 +87,9 @@ export type Tool = z.infer<typeof ToolSchema>;
// ============ WebSocket 消息 ============ // ============ WebSocket 消息 ============
// Agent 模式类型
export type AgentModeType = 'build' | 'plan';
// 客户端发送的消息 // 客户端发送的消息
export interface ClientMessage { export interface ClientMessage {
type: 'message' | 'cancel' | 'tool_response' | 'permission_response'; type: 'message' | 'cancel' | 'tool_response' | 'permission_response';
@@ -99,6 +102,9 @@ export interface ClientMessage {
requestId?: string; requestId?: string;
allow?: boolean; allow?: boolean;
remember?: boolean; remember?: boolean;
// Agent mode fields
agentMode?: AgentModeType;
autoApprove?: boolean;
}; };
} }
+3 -1
View File
@@ -106,6 +106,8 @@ export async function handleWebSocketMessage(
case 'message': { case 'message': {
// 用户发送消息 // 用户发送消息
let content = message.payload?.content || ''; 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 识别为文件路径) // 将 @filepath 转换为 ./filepath 格式(方便 AI 识别为文件路径)
content = content.replace(/@([\w./-]+)/g, './$1'); content = content.replace(/@([\w./-]+)/g, './$1');
@@ -119,7 +121,7 @@ export async function handleWebSocketMessage(
// 调用 Agent 处理消息(异步,不阻塞) // 调用 Agent 处理消息(异步,不阻塞)
// 消息存储由 Core Agent 负责 // 消息存储由 Core Agent 负责
processMessage(sessionId, content).catch((error) => { processMessage(sessionId, content, { agentMode, autoApprove }).catch((error) => {
console.error('[WS] Agent processing error:', error); console.error('[WS] Agent processing error:', error);
}); });
break; break;
+2
View File
@@ -129,6 +129,8 @@ export type {
// File search types // File search types
FileSearchResult, FileSearchResult,
FileSearchResponse, FileSearchResponse,
// Agent mode types
AgentModeType,
} from './types.js'; } from './types.js';
// API Configuration // API Configuration
+5
View File
@@ -877,6 +877,11 @@ export interface FileSearchResponse {
}; };
} }
// ============ Agent 模式切换相关 ============
/** Agent 模式类型 (Build/Plan) */
export type AgentModeType = 'build' | 'plan';
// ============ 流式工具调用事件 ============ // ============ 流式工具调用事件 ============
/** 工具开始事件 Payload */ /** 工具开始事件 Payload */
@@ -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<HTMLDivElement>(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 (
<div className="flex items-center gap-2">
{/* 模式选择器 */}
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={clsx(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-colors',
'text-sm font-medium',
disabled
? 'opacity-50 cursor-not-allowed border-line bg-surface-subtle'
: 'border-line hover:border-line-strong hover:bg-surface-subtle cursor-pointer',
currentMode.color
)}
>
<ModeIcon size={16} />
<span className="hidden sm:inline">{currentMode.label}</span>
<ChevronDown
size={14}
className={clsx(
'transition-transform text-fg-muted',
isOpen && 'rotate-180'
)}
/>
</button>
{/* 下拉菜单 */}
{isOpen && (
<div className="absolute bottom-full left-0 mb-1 w-48 py-1 bg-surface-base border border-line rounded-lg shadow-lg z-50">
{(Object.entries(modeConfig) as [AgentModeType, typeof modeConfig.build][]).map(
([modeKey, config]) => {
const Icon = config.icon;
const isSelected = modeKey === mode;
return (
<button
key={modeKey}
type="button"
onClick={() => handleModeSelect(modeKey)}
className={clsx(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
isSelected
? `${config.bgColor} ${config.color}`
: 'hover:bg-surface-subtle text-fg'
)}
>
<Icon size={16} className={config.color} />
<div className="flex-1">
<div className="text-sm font-medium">{config.label}</div>
<div className="text-xs text-fg-muted">{config.description}</div>
</div>
{isSelected && <Check size={16} className={config.color} />}
</button>
);
}
)}
</div>
)}
</div>
{/* Auto-approve 开关 - 仅在 Build 模式下显示 */}
{mode === 'build' && (
<div
className={clsx(
'flex items-center gap-1.5 px-2 py-1 rounded-lg transition-colors',
autoApprove ? 'bg-orange-500/10' : 'bg-transparent'
)}
title={autoApprove ? '自动授权已开启:文件写入/编辑无需确认' : '点击开启自动授权'}
>
<span
className={clsx(
'text-xs hidden sm:inline transition-colors',
autoApprove ? 'text-orange-500' : 'text-fg-muted'
)}
>
Auto Edit
</span>
<Switch
checked={autoApprove}
onCheckedChange={onAutoApproveChange}
disabled={disabled}
className="scale-75 origin-left"
/>
</div>
)}
</div>
);
}
+27 -1
View File
@@ -12,6 +12,7 @@ import clsx from 'clsx';
import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; import { CommandMenu, type CommandMenuItem } from './CommandMenu.js';
import { FileMenu, type FileMenuItem } from './FileMenu.js'; import { FileMenu, type FileMenuItem } from './FileMenu.js';
import { FileMentionTag } from './FileMentionTag.js'; import { FileMentionTag } from './FileMentionTag.js';
import { AgentModeSelector, type AgentModeType } from './AgentModeSelector.js';
import { useCommands } from '../hooks/useCommands.js'; import { useCommands } from '../hooks/useCommands.js';
import { useFileMention } from '../hooks/useFileMention.js'; import { useFileMention } from '../hooks/useFileMention.js';
@@ -26,6 +27,14 @@ interface ChatInputProps {
enableCommands?: boolean; enableCommands?: boolean;
/** 是否启用文件提及 (@) */ /** 是否启用文件提及 (@) */
enableFileMention?: boolean; enableFileMention?: boolean;
/** Agent 模式 (build/plan) */
agentMode?: AgentModeType;
/** Agent 模式变更回调 */
onAgentModeChange?: (mode: AgentModeType) => void;
/** 是否自动授权文件写入/编辑 */
autoApprove?: boolean;
/** 自动授权变更回调 */
onAutoApproveChange?: (enabled: boolean) => void;
} }
export function ChatInput({ export function ChatInput({
@@ -36,6 +45,10 @@ export function ChatInput({
responsive = false, responsive = false,
enableCommands = true, enableCommands = true,
enableFileMention = true, enableFileMention = true,
agentMode = 'build',
onAgentModeChange,
autoApprove = false,
onAutoApproveChange,
}: ChatInputProps) { }: ChatInputProps) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [showCommandMenu, setShowCommandMenu] = useState(false); const [showCommandMenu, setShowCommandMenu] = useState(false);
@@ -281,7 +294,20 @@ export function ChatInput({
)} )}
{/* 输入区域 */} {/* 输入区域 */}
<div className="flex gap-2"> <div className="flex gap-2 items-end">
{/* Agent 模式选择器 */}
{onAgentModeChange && (
<div className="flex-shrink-0 pb-1.5">
<AgentModeSelector
mode={agentMode}
onModeChange={onAgentModeChange}
autoApprove={autoApprove}
onAutoApproveChange={onAutoApproveChange ?? (() => {})}
disabled={disabled || isLoading}
/>
</div>
)}
<div className="flex-1 relative"> <div className="flex-1 relative">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
+27 -2
View File
@@ -13,6 +13,7 @@ import type {
ToolEndPayload, ToolEndPayload,
MessagePart, MessagePart,
ToolMessagePart, ToolMessagePart,
AgentModeType,
} from '../api/types.js'; } from '../api/types.js';
interface UseChatOptions { interface UseChatOptions {
@@ -31,6 +32,10 @@ interface ChatState {
/** 流式消息对象,复用 Message 结构 */ /** 流式消息对象,复用 Message 结构 */
streamingMessage: Message | null; streamingMessage: Message | null;
permissionRequest: PermissionRequest | null; permissionRequest: PermissionRequest | null;
/** Agent 模式 (会话级别) */
agentMode: AgentModeType;
/** 是否自动授权文件写入/编辑 (会话级别) */
autoApprove: boolean;
} }
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError }: UseChatOptions) { export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError }: UseChatOptions) {
@@ -40,6 +45,8 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
isLoading: false, isLoading: false,
streamingMessage: null, streamingMessage: null,
permissionRequest: null, permissionRequest: null,
agentMode: 'build',
autoApprove: false,
}); });
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -323,11 +330,15 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
JSON.stringify({ JSON.stringify({
type: 'message', type: 'message',
sessionId, sessionId,
payload: { content }, payload: {
content,
agentMode: state.agentMode,
autoApprove: state.autoApprove,
},
}) })
); );
}, },
[sessionId] [sessionId, state.agentMode, state.autoApprove]
); );
// 取消处理 // 取消处理
@@ -401,6 +412,16 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
[respondToPermission] [respondToPermission]
); );
// 设置 Agent 模式 (会话级别)
const setAgentMode = useCallback((mode: AgentModeType) => {
setState((prev) => ({ ...prev, agentMode: mode }));
}, []);
// 设置自动授权 (会话级别)
const setAutoApprove = useCallback((enabled: boolean) => {
setState((prev) => ({ ...prev, autoApprove: enabled }));
}, []);
// 初始化 // 初始化
useEffect(() => { useEffect(() => {
// 重置状态 // 重置状态
@@ -411,6 +432,8 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
isLoading: false, isLoading: false,
streamingMessage: null, streamingMessage: null,
permissionRequest: null, permissionRequest: null,
agentMode: 'build',
autoApprove: false,
}); });
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
@@ -448,5 +471,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
reload: loadMessages, reload: loadMessages,
allowPermission, allowPermission,
denyPermission, denyPermission,
setAgentMode,
setAutoApprove,
}; };
} }
+3
View File
@@ -174,6 +174,8 @@ export type {
// File Search types // File Search types
FileSearchResult, FileSearchResult,
FileSearchResponse, FileSearchResponse,
// Agent Mode types
AgentModeType,
} from './api/client.js'; } from './api/client.js';
// Primitives (shadcn/ui style) // Primitives (shadcn/ui style)
@@ -186,6 +188,7 @@ export * from './utils/animations.js';
// Components // Components
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js'; export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
export { ChatInput } from './components/ChatInput.js'; export { ChatInput } from './components/ChatInput.js';
export { AgentModeSelector } from './components/AgentModeSelector.js';
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js'; export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
export { FileMenu, type FileMenuItem } from './components/FileMenu.js'; export { FileMenu, type FileMenuItem } from './components/FileMenu.js';
export { export {
+8
View File
@@ -57,6 +57,10 @@ export function ChatPage({
permissionRequest, permissionRequest,
allowPermission, allowPermission,
denyPermission, denyPermission,
agentMode,
autoApprove,
setAgentMode,
setAutoApprove,
} = useChat({ } = useChat({
sessionId, sessionId,
onError: (error) => { onError: (error) => {
@@ -307,6 +311,10 @@ export function ChatPage({
isLoading={isLoading} isLoading={isLoading}
disabled={!isConnected} disabled={!isConnected}
responsive={responsive} responsive={responsive}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoApprove={autoApprove}
onAutoApproveChange={setAutoApprove}
/> />
{/* Permission Dialog */} {/* Permission Dialog */}