Agentscoope / src /components /ChatInput.tsx
Nerdur's picture
Upload 3 files
1b132b9 verified
Raw
History Blame Contribute Delete
9.62 kB
import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type ChangeEvent } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Send, Square, Paperclip, X, FileText, Image as ImageIcon, Loader2 } from "lucide-react";
import type { UploadedAttachment } from "@/hooks/use-agentscope";
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50MB, matches the backend limit
interface PendingAttachment {
id: string;
file: File;
status: "uploading" | "done" | "error";
result?: UploadedAttachment;
error?: string;
}
interface ChatInputProps {
onSend: (text: string, attachments?: UploadedAttachment[]) => void;
onUpload: (file: File) => Promise<UploadedAttachment | null>;
onStop: () => void;
isStreaming: boolean;
isOnline: boolean;
hasEndpoint: boolean;
placeholder?: string;
}
export function ChatInput({
onSend,
onUpload,
onStop,
isStreaming,
isOnline,
hasEndpoint,
placeholder = "Type a message...",
}: ChatInputProps) {
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}, []);
useEffect(() => {
adjustHeight();
}, [input, adjustHeight]);
const isUploading = attachments.some((a) => a.status === "uploading");
const handleFilesSelected = useCallback(
(files: FileList | null) => {
if (!files || files.length === 0) return;
Array.from(files).forEach((file) => {
if (file.size > MAX_UPLOAD_BYTES) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setAttachments((prev) => [
...prev,
{ id, file, status: "error", error: "File is over the 50MB limit" },
]);
return;
}
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setAttachments((prev) => [...prev, { id, file, status: "uploading" }]);
onUpload(file).then((result) => {
setAttachments((prev) =>
prev.map((a) =>
a.id === id
? result
? { ...a, status: "done", result }
: { ...a, status: "error", error: "Upload failed" }
: a,
),
);
});
});
},
[onUpload],
);
const handleFileInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
handleFilesSelected(e.target.files);
e.target.value = ""; // allow re-selecting the same file later
},
[handleFilesSelected],
);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const handleSend = useCallback(() => {
const readyAttachments = attachments
.filter((a): a is PendingAttachment & { status: "done"; result: UploadedAttachment } =>
a.status === "done" && !!a.result,
)
.map((a) => a.result);
if ((!input.trim() && readyAttachments.length === 0) || isStreaming || isUploading) return;
onSend(input.trim(), readyAttachments);
setInput("");
setAttachments([]);
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [input, attachments, isStreaming, isUploading, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
const isDisabled = !hasEndpoint || !isOnline;
const canSend = (input.trim().length > 0 || attachments.some((a) => a.status === "done")) && !isUploading;
return (
<div className="border-t border-border bg-background/80 backdrop-blur-sm">
<div className="max-w-3xl mx-auto px-4 py-3">
{/* Status indicators */}
<div className="flex items-center gap-3 mb-2 px-1">
{!isOnline && (
<span className="flex items-center gap-1.5 text-xs text-destructive/80">
<span className="w-1.5 h-1.5 rounded-full bg-destructive" />
Offline
</span>
)}
{!hasEndpoint && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground" />
No endpoint configured
</span>
)}
{isStreaming && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-foreground/40 animate-pulse" />
Generating...
</span>
)}
</div>
{/* Attachment previews */}
<AnimatePresence initial={false}>
{attachments.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="flex flex-wrap gap-2 mb-2 px-1 overflow-hidden"
>
{attachments.map((att) => (
<div
key={att.id}
className={`flex items-center gap-1.5 rounded-lg border px-2 py-1.5 text-xs max-w-[220px] ${
att.status === "error"
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-border bg-secondary/50 text-foreground"
}`}
title={att.status === "error" ? att.error : att.file.name}
>
{att.status === "uploading" ? (
<Loader2 className="w-3.5 h-3.5 flex-shrink-0 animate-spin text-muted-foreground" />
) : att.file.type.startsWith("image/") ? (
<ImageIcon className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<FileText className="w-3.5 h-3.5 flex-shrink-0" />
)}
<span className="truncate">{att.file.name}</span>
<button
onClick={() => removeAttachment(att.id)}
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title="Remove"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</motion.div>
)}
</AnimatePresence>
{/* Input area */}
<div className="relative flex items-end gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInputChange}
disabled={isDisabled}
/>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => fileInputRef.current?.click()}
disabled={isDisabled}
className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary transition-all disabled:opacity-40 disabled:cursor-not-allowed"
title="Attach a file (up to 50MB)"
>
<Paperclip className="w-4 h-4" />
</motion.button>
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isDisabled
? !hasEndpoint
? "Configure API endpoint in Settings"
: "You are offline"
: placeholder
}
disabled={isDisabled}
rows={1}
className="w-full resize-none rounded-lg border border-border bg-secondary/50 px-4 py-3 pr-12 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-foreground/20 focus:border-foreground/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
/>
</div>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={isStreaming ? onStop : handleSend}
disabled={isDisabled && !isStreaming}
className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center transition-all ${
isStreaming
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: canSend
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-secondary text-muted-foreground cursor-not-allowed"
}`}
title={isStreaming ? "Stop generating" : "Send message"}
>
{isStreaming ? (
<Square className="w-4 h-4 fill-current" />
) : (
<Send className="w-4 h-4" />
)}
</motion.button>
</div>
{/* Hint */}
{!isStreaming && hasEndpoint && (
<p className="mt-1.5 text-[10px] text-muted-foreground/40 text-center">
Press Enter to send · Shift+Enter for new line · Attach files up to 50MB
</p>
)}
</div>
</div>
);
}