Files
ai-terminal-assistant/packages/web/src/pages/Chat.tsx
T
kurihada ec3c7bccf9 feat(ui): 实现 Agent 模式切换和 Auto Edit 功能
- 添加 AgentModeSelector 组件,支持 Build/Plan 模式切换
- Build 模式下显示 Auto Edit 开关,自动授权文件写入/编辑
- 扩展 useChat hook 添加会话级别的 agentMode/autoApprove 状态
- 服务端支持解析和应用 Agent 模式配置
- 权限处理器实现 auto-approve 检查(仅 write/edit,不含 delete)
2025-12-15 19:42:51 +08:00

332 lines
11 KiB
TypeScript

/**
* Chat Page
*/
import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History, Server } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import {
useChat,
ChatMessage,
TypingIndicator,
ChatInput,
PermissionDialog,
ContextUsage,
} from '@ai-assistant/ui';
interface ChatPageProps {
sessionId: string;
onSessionNotFound?: () => void;
onSessionUpdated?: (sessionId: string, name: string) => void;
responsive?: boolean;
// 工具栏按钮
showFileBrowser?: boolean;
onToggleFileBrowser?: () => void;
onOpenConfig?: () => void;
onOpenCommands?: () => void;
onOpenMCP?: () => void;
onOpenHooks?: () => void;
onOpenAgents?: () => void;
onOpenCheckpoints?: () => void;
onOpenProviders?: () => void;
}
export function ChatPage({
sessionId,
onSessionNotFound,
onSessionUpdated,
responsive = false,
showFileBrowser,
onToggleFileBrowser,
onOpenConfig,
onOpenCommands,
onOpenMCP,
onOpenHooks,
onOpenAgents,
onOpenCheckpoints,
onOpenProviders,
}: ChatPageProps) {
const {
messages,
isConnected,
isLoading,
streamingMessage,
sendMessage,
cancelProcessing,
permissionRequest,
allowPermission,
denyPermission,
agentMode,
autoApprove,
setAgentMode,
setAutoApprove,
} = useChat({
sessionId,
onError: (error) => {
console.error('Chat error:', error);
},
onSessionNotFound,
onSessionUpdated,
onConfigError: (error) => {
toast.error(error.message, {
duration: 10000,
action: onOpenProviders
? {
label: '去配置',
onClick: onOpenProviders,
}
: undefined,
});
},
});
const messagesEndRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingMessage]);
// 空状态组件
const EmptyState = () => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center py-20"
>
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-gradient-to-br from-primary-500/20 to-primary-600/20 flex items-center justify-center">
<MessageSquare size={32} className="text-primary-400" />
</div>
<h2 className="text-2xl font-semibold mb-2 text-fg">
Start a conversation
</h2>
<p className="text-fg-muted mb-6 max-w-md mx-auto">
Ask me anything about coding, debugging, or software development.
</p>
<div className="flex flex-wrap justify-center gap-2">
{['Help me debug this code', 'Explain this function', 'Write a test'].map((suggestion) => (
<motion.button
key={suggestion}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => sendMessage(suggestion)}
className="px-3 py-1.5 bg-surface-subtle hover:bg-surface-muted rounded-full text-sm text-fg-secondary transition-colors"
>
"{suggestion}"
</motion.button>
))}
</div>
</motion.div>
);
// 连接状态指示器
const ConnectionStatus = () => (
<div className="flex items-center gap-1.5 text-sm">
{isConnected ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-1.5"
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-green-400 hidden sm:inline">Connected</span>
</motion.div>
) : (
<div className="flex items-center gap-1.5 text-red-400">
<WifiOff size={16} />
<span className="hidden sm:inline">Disconnected</span>
</div>
)}
</div>
);
return (
<div className="flex-1 flex flex-col h-screen">
{/* Header */}
<div className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-line bg-surface-subtle">
<h1 className="text-lg font-medium text-fg">Chat</h1>
<div className="flex items-center gap-3">
{/* 上下文使用情况 - 紧凑模式 */}
{sessionId && (
<ContextUsage
sessionId={sessionId}
compact
showCompressButton={false}
refreshInterval={30000}
/>
)}
{/* 连接状态 */}
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && (
<div className="flex items-center gap-1.5 border-l border-line-muted pl-3">
{/* Checkpoints 按钮 */}
{onOpenCheckpoints && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenCheckpoints}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Checkpoints"
>
<History size={20} />
</motion.button>
)}
{/* Providers 按钮 */}
{onOpenProviders && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenProviders}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Model Providers"
>
<Server size={20} />
</motion.button>
)}
{/* Agents 按钮 */}
{onOpenAgents && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenAgents}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Agent Presets"
>
<Bot size={20} />
</motion.button>
)}
{/* Hooks 按钮 */}
{onOpenHooks && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenHooks}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Hooks"
>
<Zap size={20} />
</motion.button>
)}
{/* MCP 按钮 */}
{onOpenMCP && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenMCP}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="MCP Servers"
>
<Plug size={20} />
</motion.button>
)}
{/* 命令按钮 */}
{onOpenCommands && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenCommands}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Commands"
>
<Terminal size={20} />
</motion.button>
)}
{/* 配置按钮 */}
{onOpenConfig && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenConfig}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Settings"
>
<Settings size={20} />
</motion.button>
)}
{/* 文件浏览器按钮 - 仅桌面端显示 */}
{onToggleFileBrowser && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onToggleFileBrowser}
className={`hidden md:block p-1.5 rounded-lg transition-colors ${
showFileBrowser
? 'text-blue-400 bg-blue-500/20'
: 'text-fg-muted hover:text-fg-secondary hover:bg-surface-muted'
}`}
title={showFileBrowser ? 'Hide Files' : 'Show Files'}
>
<FolderOpen size={20} />
</motion.button>
)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto space-y-4">
{messages.length === 0 && !isLoading && <EmptyState />}
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
</AnimatePresence>
{/* 流式消息 - 复用 ChatMessage 组件 */}
{streamingMessage && (
<ChatMessage message={streamingMessage} isStreaming />
)}
{isLoading && !streamingMessage && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<ChatInput
onSend={sendMessage}
onCancel={cancelProcessing}
isLoading={isLoading}
disabled={!isConnected}
responsive={responsive}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoApprove={autoApprove}
onAutoApproveChange={setAutoApprove}
/>
{/* Permission Dialog */}
{permissionRequest && (
<PermissionDialog
request={permissionRequest}
onAllow={(requestId, remember) => allowPermission(requestId, remember)}
onDeny={(requestId, remember) => denyPermission(requestId, remember)}
responsive={responsive}
/>
)}
</div>
);
}