Spaces:
Paused
Paused
| 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<ContentBlock | null>; | |
| } | |
| 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<TextInputRef, TextInputProps>( | |
| ( | |
| { | |
| 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<ProcessedFile[]>([]); | |
| const [isFocused, setIsFocused] = useState(false); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const measureRef = useRef<HTMLSpanElement>(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<HTMLTextAreaElement>) => { | |
| // 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<HTMLInputElement>) => { | |
| 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 ( | |
| <div | |
| id="tour-chat-input" | |
| className={cn( | |
| 'flex flex-col gap-2 rounded-2xl border bg-background p-3', | |
| className, | |
| )} | |
| data-tour="chat-input" | |
| > | |
| {/* File list */} | |
| {files.length > 0 && ( | |
| <div className="flex flex-wrap gap-2"> | |
| {files.map((file, index) => ( | |
| <div | |
| key={index} | |
| className="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm" | |
| > | |
| {file.status === 'processing' && ( | |
| <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" /> | |
| )} | |
| <span className="max-w-[200px] truncate">{file.name}</span> | |
| <button | |
| onClick={() => setFiles(files.filter((_, i) => i !== index))} | |
| className="text-muted-foreground hover:text-foreground" | |
| > | |
| <X className="h-3 w-3" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Input area */} | |
| <div className="relative"> | |
| <div className="relative"> | |
| {/* Hidden measurement element */} | |
| <span | |
| ref={measureRef} | |
| className="invisible absolute whitespace-pre text-sm" | |
| style={{ | |
| font: 'inherit', | |
| padding: '0.5rem 0.75rem', | |
| lineHeight: '1.5em', | |
| }} | |
| > | |
| {value} | |
| </span> | |
| <textarea | |
| ref={textareaRef} | |
| value={value} | |
| onChange={(e) => setValue(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| onFocus={() => setIsFocused(true)} | |
| onBlur={() => setIsFocused(false)} | |
| placeholder={defaultPlaceholder} | |
| disabled={disabled} | |
| rows={1} | |
| className="w-full resize-none rounded-md border-0 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" | |
| style={{ | |
| maxHeight: 'calc(1.5em * 6)', | |
| lineHeight: '1.5em', | |
| overflowY: 'auto', | |
| }} | |
| autoFocus={true} | |
| /> | |
| {/* Autocomplete suggestion - using absolute positioning overlay */} | |
| {suggestion && isFocused && ( | |
| <div | |
| className="pointer-events-none absolute left-0 top-0 px-3 py-2 text-sm" | |
| style={{ | |
| lineHeight: '1.5em', | |
| whiteSpace: 'pre-wrap', | |
| wordWrap: 'break-word', | |
| }} | |
| > | |
| {/* Invisible input text */} | |
| <span className="invisible">{value}</span> | |
| {/* Suggestion text */} | |
| <span className="text-muted-foreground">{suggestion}</span> | |
| {/* Tab hint */} | |
| <span className="ml-2 text-xs text-muted-foreground/60"> | |
| <Kbd>Tab</Kbd> {t('textInput.toComplete')} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Button row */} | |
| <div className="mt-2 flex items-center justify-between"> | |
| <div> | |
| <span | |
| className={`text-muted-foreground text-sm ${!isFocused && 'hidden'}`} | |
| > | |
| <Kbd>{isMac() ? '⇧' : 'Shift'}</Kbd> + <Kbd>Enter</Kbd>{' '} | |
| {t('textInput.newLine')} | |
| </span> | |
| </div> | |
| <div className="flex gap-2"> | |
| {/* Attachment button */} | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="icon" | |
| onClick={() => fileInputRef.current?.click()} | |
| disabled={attachDisabled} | |
| className="shrink-0 rounded-full" | |
| > | |
| <Paperclip className="h-4 w-4" /> | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| {attachDisabled && allowedInputTypes?.length === 0 | |
| ? t('textInput.attachNotSupported') | |
| : t('textInput.attach')} | |
| </TooltipContent> | |
| </Tooltip> | |
| {/* Send button */} | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Button | |
| type="button" | |
| onClick={handleSend} | |
| disabled={disabled || !value.trim() || hasProcessing} | |
| size="icon" | |
| className="shrink-0 rounded-full" | |
| > | |
| <Send className="h-4 w-4" /> | |
| </Button> | |
| </TooltipTrigger> | |
| <TooltipContent>{t('textInput.send')}</TooltipContent> | |
| </Tooltip> | |
| {/* Hidden file input */} | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| accept={acceptAttr} | |
| onChange={handleFileSelect} | |
| className="hidden" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }, | |
| ); | |
| TextInput.displayName = 'TextInput'; | |