ec3c7bccf9
- 添加 AgentModeSelector 组件,支持 Build/Plan 模式切换 - Build 模式下显示 Auto Edit 开关,自动授权文件写入/编辑 - 扩展 useChat hook 添加会话级别的 agentMode/autoApprove 状态 - 服务端支持解析和应用 Agent 模式配置 - 权限处理器实现 auto-approve 检查(仅 write/edit,不含 delete)
332 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|