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 */}