mirror of https://github.com/kortix-ai/suna.git
260 lines
7.6 KiB
TypeScript
260 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import React, {
|
|
useState,
|
|
useRef,
|
|
useEffect,
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
} from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Loader2, X } from 'lucide-react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { handleFiles } from './file-upload-handler';
|
|
import { MessageInput } from './message-input';
|
|
import { FileAttachment } from '../file-attachment';
|
|
import { AttachmentGroup } from '../attachment-group';
|
|
import { useModelSelection } from './_use-model-selection';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { toast } from 'sonner';
|
|
|
|
export interface ChatInputHandles {
|
|
getPendingFiles: () => File[];
|
|
clearPendingFiles: () => void;
|
|
}
|
|
|
|
export interface ChatInputProps {
|
|
onSubmit: (
|
|
message: string,
|
|
options?: { model_name?: string; enable_thinking?: boolean },
|
|
) => void;
|
|
placeholder?: string;
|
|
loading?: boolean;
|
|
disabled?: boolean;
|
|
isAgentRunning?: boolean;
|
|
onStopAgent?: () => void;
|
|
autoFocus?: boolean;
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
onFileBrowse?: () => void;
|
|
sandboxId?: string;
|
|
hideAttachments?: boolean;
|
|
}
|
|
|
|
export interface UploadedFile {
|
|
name: string;
|
|
path: string;
|
|
size: number;
|
|
type: string;
|
|
localUrl?: string;
|
|
}
|
|
|
|
export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|
(
|
|
{
|
|
onSubmit,
|
|
placeholder = 'Describe what you need help with...',
|
|
loading = false,
|
|
disabled = false,
|
|
isAgentRunning = false,
|
|
onStopAgent,
|
|
autoFocus = true,
|
|
value: controlledValue,
|
|
onChange: controlledOnChange,
|
|
onFileBrowse,
|
|
sandboxId,
|
|
hideAttachments = false,
|
|
},
|
|
ref,
|
|
) => {
|
|
const isControlled =
|
|
controlledValue !== undefined && controlledOnChange !== undefined;
|
|
|
|
const [uncontrolledValue, setUncontrolledValue] = useState('');
|
|
const value = isControlled ? controlledValue : uncontrolledValue;
|
|
|
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
|
|
|
const {
|
|
selectedModel,
|
|
setSelectedModel: handleModelChange,
|
|
subscriptionStatus,
|
|
allModels: modelOptions,
|
|
canAccessModel,
|
|
} = useModelSelection();
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getPendingFiles: () => pendingFiles,
|
|
clearPendingFiles: () => setPendingFiles([]),
|
|
}));
|
|
|
|
useEffect(() => {
|
|
if (autoFocus && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
}
|
|
}, [autoFocus]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (
|
|
(!value.trim() && uploadedFiles.length === 0) ||
|
|
loading ||
|
|
(disabled && !isAgentRunning)
|
|
)
|
|
return;
|
|
|
|
if (isAgentRunning && onStopAgent) {
|
|
onStopAgent();
|
|
return;
|
|
}
|
|
|
|
let message = value;
|
|
|
|
if (uploadedFiles.length > 0) {
|
|
const fileInfo = uploadedFiles
|
|
.map((file) => `[Uploaded File: ${file.path}]`)
|
|
.join('\n');
|
|
message = message ? `${message}\n\n${fileInfo}` : fileInfo;
|
|
}
|
|
|
|
let baseModelName = selectedModel;
|
|
let thinkingEnabled = false;
|
|
if (selectedModel.endsWith('-thinking')) {
|
|
baseModelName = selectedModel.replace(/-thinking$/, '');
|
|
thinkingEnabled = true;
|
|
}
|
|
|
|
onSubmit(message, {
|
|
model_name: baseModelName,
|
|
enable_thinking: thinkingEnabled,
|
|
});
|
|
|
|
if (!isControlled) {
|
|
setUncontrolledValue('');
|
|
}
|
|
|
|
setUploadedFiles([]);
|
|
};
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value;
|
|
if (isControlled) {
|
|
controlledOnChange(newValue);
|
|
} else {
|
|
setUncontrolledValue(newValue);
|
|
}
|
|
};
|
|
|
|
const removeUploadedFile = (index: number) => {
|
|
const fileToRemove = uploadedFiles[index];
|
|
if (fileToRemove.localUrl) {
|
|
URL.revokeObjectURL(fileToRemove.localUrl);
|
|
}
|
|
|
|
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
|
|
if (!sandboxId && pendingFiles.length > index) {
|
|
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingOver(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingOver(false);
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-4xl px-4">
|
|
<Card
|
|
className="shadow-none w-full max-w-4xl mx-auto bg-transparent border-none rounded-xl overflow-hidden"
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingOver(false);
|
|
|
|
if (fileInputRef.current && e.dataTransfer.files.length > 0) {
|
|
const files = Array.from(e.dataTransfer.files);
|
|
handleFiles(
|
|
files,
|
|
sandboxId,
|
|
setPendingFiles,
|
|
setUploadedFiles,
|
|
setIsUploading,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
|
<CardContent className="w-full p-1.5 pb-2 bg-sidebar rounded-2xl border">
|
|
<AttachmentGroup
|
|
files={uploadedFiles || []}
|
|
sandboxId={sandboxId}
|
|
onRemove={removeUploadedFile}
|
|
layout="inline"
|
|
maxHeight="216px"
|
|
showPreviews={true}
|
|
/>
|
|
|
|
<MessageInput
|
|
ref={textareaRef}
|
|
value={value}
|
|
onChange={handleChange}
|
|
onSubmit={handleSubmit}
|
|
placeholder={placeholder}
|
|
loading={loading}
|
|
disabled={disabled}
|
|
isAgentRunning={isAgentRunning}
|
|
onStopAgent={onStopAgent}
|
|
isDraggingOver={isDraggingOver}
|
|
uploadedFiles={uploadedFiles}
|
|
|
|
fileInputRef={fileInputRef}
|
|
isUploading={isUploading}
|
|
sandboxId={sandboxId}
|
|
setPendingFiles={setPendingFiles}
|
|
setUploadedFiles={setUploadedFiles}
|
|
setIsUploading={setIsUploading}
|
|
hideAttachments={hideAttachments}
|
|
|
|
selectedModel={selectedModel}
|
|
onModelChange={handleModelChange}
|
|
modelOptions={modelOptions}
|
|
subscriptionStatus={subscriptionStatus}
|
|
canAccessModel={canAccessModel}
|
|
/>
|
|
</CardContent>
|
|
</div>
|
|
</Card>
|
|
|
|
{isAgentRunning && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="pb-4 -mt-4 w-full flex items-center justify-center"
|
|
>
|
|
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
<span>Kortix Suna is working...</span>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
ChatInput.displayName = 'ChatInput'; |