import { useState, useRef, useCallback, type KeyboardEvent, type ClipboardEvent } from 'react'; import { cn } from '../../src/lib/utils'; import { Send, Square, Paperclip, Slash } from 'lucide-react'; import type { ChatInputMessage, UserMessageImage } from '../../src/lib/types'; import type { AvailableCommand } from '../../src/acp/types'; import { CommandMenu } from './CommandMenu'; import imageCompression from 'browser-image-compression'; // 图片压缩配置 const IMAGE_COMPRESSION_OPTIONS = { maxSizeMB: 2, maxWidthOrHeight: 2048, useWebWorker: true, fileType: 'image/jpeg' as const, }; // ============================================================================= // Anthropic 风格聊天输入框 — 底部居中浮动卡片,橙色焦点环 // ============================================================================= interface ChatInputProps { onSubmit: (message: ChatInputMessage) => void; isLoading?: boolean; onInterrupt?: () => void; disabled?: boolean; placeholder?: string; /** 是否支持图片上传 */ supportsImages?: boolean; /** Agent 提供的可用 slash 命令 */ commands?: AvailableCommand[]; className?: string; } export function ChatInput({ onSubmit, isLoading = false, onInterrupt, disabled = false, placeholder = '给 Claude 发送消息…', supportsImages = false, commands, className, }: ChatInputProps) { const [text, setText] = useState(''); const [images, setImages] = useState([]); const [showCommandMenu, setShowCommandMenu] = useState(false); const [commandFilter, setCommandFilter] = useState(''); const textareaRef = useRef(null); const fileInputRef = useRef(null); const handleSubmit = useCallback(() => { const trimmed = text.trim(); if ((!trimmed && images.length === 0) || disabled) return; onSubmit({ text: trimmed, images: images.length > 0 ? images : undefined }); setText(''); setImages([]); setShowCommandMenu(false); setCommandFilter(''); // 重置 textarea 高度 if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }, [text, images, disabled, onSubmit]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (showCommandMenu) { if (e.key === 'Escape') { e.preventDefault(); setShowCommandMenu(false); return; } // Arrow keys and Enter are handled by CommandMenu via document-level listener // Don't submit or move cursor when menu is open if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') { e.preventDefault(); return; } if (e.key === 'Tab') { e.preventDefault(); setShowCommandMenu(false); return; } } if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (isLoading) { onInterrupt?.(); } else { handleSubmit(); } } }, [handleSubmit, isLoading, onInterrupt, showCommandMenu], ); const handleInput = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; setText(value); // 检测 slash 命令模式:仅在输入开头输入 / 时触发 if (value.startsWith('/') && commands && commands.length > 0) { setShowCommandMenu(true); setCommandFilter(value.slice(1).split(/\s/)[0] || ''); } else if (showCommandMenu) { setShowCommandMenu(false); setCommandFilter(''); } // 自动调整高度 const el = e.target; el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 200) + 'px'; }, [commands, showCommandMenu], ); // 粘贴图片 const handlePaste = useCallback( async (e: ClipboardEvent) => { if (!supportsImages) return; const files = Array.from(e.clipboardData.files).filter(f => f.type.startsWith('image/')); if (files.length === 0) return; e.preventDefault(); const newImages = await processImageFiles(files); setImages(prev => [...prev, ...newImages]); }, [supportsImages], ); // 选择文件 const handleFileSelect = useCallback(async () => { if (!fileInputRef.current) return; const files = fileInputRef.current.files; if (!files || files.length === 0) return; const newImages = await processImageFiles(Array.from(files)); setImages(prev => [...prev, ...newImages]); // 清空 input 以便重复选择 fileInputRef.current.value = ''; }, []); const removeImage = useCallback((index: number) => { setImages(prev => prev.filter((_, i) => i !== index)); }, []); const handleCommandSelect = useCallback((command: AvailableCommand) => { setText(`/${command.name} `); setShowCommandMenu(false); setCommandFilter(''); textareaRef.current?.focus(); }, []); const toggleCommandMenu = useCallback(() => { if (showCommandMenu) { setShowCommandMenu(false); setCommandFilter(''); } else { if (!text.startsWith('/')) { setText('/' + text); } setShowCommandMenu(true); setCommandFilter(text.startsWith('/') ? text.slice(1).split(/\s/)[0] || '' : ''); textareaRef.current?.focus(); } }, [showCommandMenu, text]); const canSend = (text.trim() || images.length > 0) && !disabled; return (
{/* Slash command menu — floating above input */} {showCommandMenu && commands && commands.length > 0 && ( { setShowCommandMenu(false); setCommandFilter(''); }} className="absolute bottom-full left-0 right-0 mb-1 z-50" /> )}
{/* 图片预览 */} {images.length > 0 && (
{images.map((img, i) => (
{`Attached
))}
)} {/* 输入区域 — Anthropic 单行紧凑布局 */}
{/* 左侧附件按钮 */} {supportsImages && ( <> )} {/* Slash 命令按钮 */} {commands && commands.length > 0 && ( )} {/* Textarea — Poppins font */}