| // src/components/multimodal/MultimodalMessageInput.tsx | |
| import { useRef, useState } from "react"; | |
| import type { ProcessedFile } from "@/types/multimodal"; | |
| import { FilePreviewCard } from "./FilePreviewCard"; | |
| import { Send, Paperclip, Mic, X, Sparkles, Loader2 } from "lucide-react"; | |
| import { VoiceMessageButton } from "@/components/voice/VoiceMessageButton"; | |
| interface MultimodalMessageInputProps { | |
| value: string; | |
| onChange: (val: string) => void; | |
| onSend: () => void; | |
| files: ProcessedFile[]; | |
| onFilesChange: (files: ProcessedFile[]) => void; | |
| onFileRemove: (id: string) => void; | |
| onAnalyzeFile: (file: ProcessedFile) => void; | |
| onTyping: () => void; | |
| sending: boolean; | |
| showAiButton?: boolean; | |
| onAiChat?: () => void; | |
| } | |
| export function MultimodalMessageInput({ | |
| value, onChange, onSend, files, onFilesChange, onFileRemove, | |
| onAnalyzeFile, onTyping, sending, showAiButton, onAiChat, | |
| }: MultimodalMessageInputProps) { | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const [pasted, setPasted] = useState(false); | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| onSend(); | |
| } | |
| }; | |
| // Handle paste (images from clipboard) | |
| const handlePaste = (e: React.ClipboardEvent) => { | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| for (const item of Array.from(items)) { | |
| if (item.type.startsWith("image/")) { | |
| const file = item.getAsFile(); | |
| if (file) { | |
| e.preventDefault(); | |
| // Convert to a pseudo‑FileList | |
| const dt = new DataTransfer(); | |
| dt.items.add(file); | |
| const fakeEvent = { | |
| target: { files: dt.files }, | |
| } as unknown as React.ChangeEvent<HTMLInputElement>; | |
| handleFileInput(fakeEvent); | |
| } | |
| } | |
| } | |
| }; | |
| const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const selectedFiles = e.target.files; | |
| if (selectedFiles?.length) { | |
| // Import dynamically to avoid circular deps – use the hook in parent instead | |
| // Here we just signal the parent; parent uses useFileProcessor | |
| window.dispatchEvent( | |
| new CustomEvent("multimodal-files-selected", { | |
| detail: { files: Array.from(selectedFiles) }, | |
| }) | |
| ); | |
| } | |
| // Reset input so same file can be re‑selected | |
| if (fileInputRef.current) fileInputRef.current.value = ""; | |
| }; | |
| return ( | |
| <div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700"> | |
| {/* File preview strip */} | |
| {files.length > 0 && ( | |
| <div className="px-3 pt-3 flex gap-2 overflow-x-auto pb-1"> | |
| {files.map((file) => ( | |
| <div key={file.id} className="shrink-0 w-40"> | |
| <FilePreviewCard | |
| file={file} | |
| onRemove={onFileRemove} | |
| onAnalyze={file.uploadStatus === "done" ? onAnalyzeFile : undefined} | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Input row */} | |
| <div className="flex items-end gap-2 p-3"> | |
| {/* Attach button */} | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| className="p-2 text-gray-400 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition" | |
| title="Attach file" | |
| > | |
| <Paperclip className="w-5 h-5" /> | |
| </button> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={handleFileInput} | |
| accept="image/*,video/*,.csv,.pdf,.json,.xml,.yaml,.yml,.py,.js,.ts,.jsx,.tsx,.java,.c,.cpp,.go,.rs,.rb,.tex,.mol,.sdf,.pdb,.cif" | |
| /> | |
| {/* Voice input */} | |
| <VoiceMessageButton | |
| onTranscript={(text) => onChange(value + " " + text)} | |
| /> | |
| {/* AI Chat button */} | |
| {showAiButton && ( | |
| <button | |
| onClick={onAiChat} | |
| className="p-2 text-purple-400 hover:text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition" | |
| title="Ask AI about files" | |
| > | |
| <Sparkles className="w-5 h-5" /> | |
| </button> | |
| )} | |
| {/* Text input */} | |
| <div className="flex-1 relative" onPaste={handlePaste}> | |
| <textarea | |
| value={value} | |
| onChange={(e) => onChange(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| onInput={onTyping} | |
| placeholder="Type a message or drop files here..." | |
| rows={1} | |
| className="w-full resize-none px-4 py-2.5 bg-gray-100 dark:bg-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary text-gray-800 dark:text-gray-100 placeholder-gray-400 text-sm" | |
| style={{ minHeight: "42px", maxHeight: "120px" }} | |
| /> | |
| {/* Paste indicator */} | |
| {pasted && ( | |
| <span className="absolute top-1 right-3 text-[10px] text-green-500"> | |
| Image pasted! | |
| </span> | |
| )} | |
| </div> | |
| {/* Send button */} | |
| <button | |
| onClick={onSend} | |
| disabled={(!value.trim() && files.length === 0) || sending} | |
| className="p-2.5 bg-primary text-white rounded-xl hover:bg-primary-dark disabled:opacity-50 transition shadow" | |
| > | |
| {sending ? ( | |
| <Loader2 className="w-5 h-5 animate-spin" /> | |
| ) : ( | |
| <Send className="w-5 h-5" /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 5.54 kB
- Xet hash:
- 145e784ff43c3a1f12dbf78c0fe665be876fb916135aee94f26da639dbd7e949
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.