import type { ContentBlock, TextBlock } from '@agentscope-ai/agentscope/message'; import { Paperclip, Send, Loader2, X } from 'lucide-react'; import React, { useState, useRef, useMemo, type KeyboardEvent, useImperativeHandle, forwardRef, } from 'react'; import { Button } from '../ui/button'; import { Kbd } from '../ui/kbd'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useTranslation } from '@/i18n/useI18n.ts'; import { cn } from '@/lib/utils'; import { isMac } from '@/utils/platform'; /** * Represents a file that has been selected and processed (or is being processed). */ interface ProcessedFile { /** Original file name for display */ name: string; /** Processing status */ status: 'processing' | 'done'; /** The resulting ContentBlock after processing (available when status === 'done') */ block: ContentBlock | null; } interface TextInputProps { onSend: (blocks: ContentBlock[]) => void; placeholder?: string; autoComplete?: (input: string) => string | null; disabled?: boolean; className?: string; /** * Controls which file types the file picker accepts. * Uses standard MIME types and file extensions, e.g.: * - Images: "image/*" or "image/jpeg", "image/png" * - Audio: "audio/*" or "audio/mpeg", "audio/wav" * - Video: "video/*" * - Plain text:"text/plain" * - PDF: "application/pdf" * - Word: ".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document" * - Excel: ".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" * * When undefined → no restriction (all files allowed). * When empty array [] → attachment button is disabled (model accepts no files). */ allowedInputTypes?: string[]; /** * Called immediately when a file is selected (at attach time, NOT at send time). * Should resolve to a ContentBlock to include in the message, or null to skip the file. * Runs concurrently for all selected files; the UI shows a loading state per file while processing. */ fileProcessor: (file: File) => Promise; } export interface TextInputRef { focus: () => void; } /** * A text input component with file attachment support and autocomplete functionality. * * @param root0 - The component props. * @param root0.onSend - Callback function to handle sending content blocks. * @param root0.placeholder - Placeholder text for the input field. * @param root0.autoComplete - Function to provide autocomplete suggestions. * @param root0.disabled - Whether the input is disabled. * @param root0.className - Additional CSS classes for styling. * @returns A TextInput component. */ export const TextInput = forwardRef( ( { onSend, placeholder, autoComplete, disabled = false, className, allowedInputTypes, fileProcessor, }, ref, ) => { const { t } = useTranslation(); const defaultPlaceholder = placeholder || t('chat.inputPlaceholder'); const [value, setValue] = useState(''); const [files, setFiles] = useState([]); const [isFocused, setIsFocused] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); const measureRef = useRef(null); // Derive the accept attribute for the hidden file input const acceptAttr = allowedInputTypes && allowedInputTypes.length > 0 ? allowedInputTypes.join(',') : undefined; // Attachment button is disabled when the model explicitly accepts no file types const attachDisabled = disabled || (allowedInputTypes !== undefined && allowedInputTypes.length === 0); // Whether any file is still being processed (block send until all done) const hasProcessing = files.some((f) => f.status === 'processing'); useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus(), })); // Calculate autocomplete suggestion using useMemo const suggestion = useMemo(() => { if (autoComplete && value && isFocused) { const result = autoComplete(value); // Only return the part after the cursor if (result && result.startsWith(value)) { return result.substring(value.length); } return result || ''; } return ''; }, [value, autoComplete, isFocused]); const handleKeyDown = (e: KeyboardEvent) => { // Tab key to select autocomplete if (e.key === 'Tab' && suggestion) { e.preventDefault(); setValue(value + suggestion); return; } // Enter to send message, Shift+Enter for new line if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); handleSend(); } }; const handleSend = () => { if (!value.trim() || disabled || hasProcessing) return; const blocks: ContentBlock[] = []; // Add text block if (value.trim()) { const textBlock: TextBlock = { id: crypto.randomUUID(), type: 'text', text: value.trim(), }; blocks.push(textBlock); } // Add processed file blocks (skip errored ones) files.forEach((f) => { if (f.status === 'done' && f.block) { blocks.push(f.block); } }); onSend?.(blocks); setValue(''); setFiles([]); }; const handleFileSelect = (e: React.ChangeEvent) => { if (!e.target.files) return; const selected = Array.from(e.target.files); // Reset input value so the same file can be re-selected e.target.value = ''; selected.forEach((file) => { // Insert a placeholder in processing state const placeholder: ProcessedFile = { name: file.name, status: 'processing', block: null, }; setFiles((prev) => [...prev, placeholder]); fileProcessor(file) .then((block) => { setFiles( (prev) => prev .map((f) => f.name === file.name && f.status === 'processing' ? block ? { ...f, status: 'done', block } : null : f, ) .filter(Boolean) as ProcessedFile[], ); }) .catch(() => { // Caller is responsible for error notification (e.g. toast). // Just silently remove the entry here. setFiles((prev) => prev.filter( (f) => !(f.name === file.name && f.status === 'processing'), ), ); }); }); }; return (
{/* File list */} {files.length > 0 && (
{files.map((file, index) => (
{file.status === 'processing' && ( )} {file.name}
))}
)} {/* Input area */}
{/* Hidden measurement element */} {value}