ghostdrive1's picture
Upload folder using huggingface_hub
0b9dc2e verified
Raw
History Blame Contribute Delete
11.3 kB
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';