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:
@@ -129,6 +129,8 @@ export type {
|
||||
// File search types
|
||||
FileSearchResult,
|
||||
FileSearchResponse,
|
||||
// Agent mode types
|
||||
AgentModeType,
|
||||
} from './types.js';
|
||||
|
||||
// API Configuration
|
||||
|
||||
@@ -877,6 +877,11 @@ export interface FileSearchResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Agent 模式切换相关 ============
|
||||
|
||||
/** Agent 模式类型 (Build/Plan) */
|
||||
export type AgentModeType = 'build' | 'plan';
|
||||
|
||||
// ============ 流式工具调用事件 ============
|
||||
|
||||
/** 工具开始事件 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<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">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ToolEndPayload,
|
||||
MessagePart,
|
||||
ToolMessagePart,
|
||||
AgentModeType,
|
||||
} from '../api/types.js';
|
||||
|
||||
interface UseChatOptions {
|
||||
@@ -31,6 +32,10 @@ interface ChatState {
|
||||
/** 流式消息对象,复用 Message 结构 */
|
||||
streamingMessage: Message | null;
|
||||
permissionRequest: PermissionRequest | null;
|
||||
/** Agent 模式 (会话级别) */
|
||||
agentMode: AgentModeType;
|
||||
/** 是否自动授权文件写入/编辑 (会话级别) */
|
||||
autoApprove: boolean;
|
||||
}
|
||||
|
||||
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError }: UseChatOptions) {
|
||||
@@ -40,6 +45,8 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
isLoading: false,
|
||||
streamingMessage: null,
|
||||
permissionRequest: null,
|
||||
agentMode: 'build',
|
||||
autoApprove: false,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -323,11 +330,15 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
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]
|
||||
);
|
||||
|
||||
// 设置 Agent 模式 (会话级别)
|
||||
const setAgentMode = useCallback((mode: AgentModeType) => {
|
||||
setState((prev) => ({ ...prev, agentMode: mode }));
|
||||
}, []);
|
||||
|
||||
// 设置自动授权 (会话级别)
|
||||
const setAutoApprove = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({ ...prev, autoApprove: enabled }));
|
||||
}, []);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
@@ -411,6 +432,8 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
isLoading: false,
|
||||
streamingMessage: null,
|
||||
permissionRequest: null,
|
||||
agentMode: 'build',
|
||||
autoApprove: false,
|
||||
});
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
@@ -448,5 +471,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
reload: loadMessages,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
setAgentMode,
|
||||
setAutoApprove,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +174,8 @@ export type {
|
||||
// File Search types
|
||||
FileSearchResult,
|
||||
FileSearchResponse,
|
||||
// Agent Mode types
|
||||
AgentModeType,
|
||||
} from './api/client.js';
|
||||
|
||||
// Primitives (shadcn/ui style)
|
||||
@@ -186,6 +188,7 @@ export * from './utils/animations.js';
|
||||
// Components
|
||||
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
|
||||
export { ChatInput } from './components/ChatInput.js';
|
||||
export { AgentModeSelector } from './components/AgentModeSelector.js';
|
||||
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
||||
export { FileMenu, type FileMenuItem } from './components/FileMenu.js';
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user