better-chatbot / src /components /prompt-input.tsx
Bot
Initial commit for HF Spaces
05c5ed5
Raw
History Blame Contribute Delete
29.9 kB
"use client";
import {
AudioWaveformIcon,
ChevronDown,
CornerRightUp,
FileIcon,
FileTextIcon,
ImagesIcon,
Loader2,
PaperclipIcon,
PlusIcon,
Square,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "ui/button";
import { UIMessage, UseChatHelpers } from "@ai-sdk/react";
import { SelectModel } from "./select-model";
import { appStore, UploadedFile } from "@/app/store";
import { useShallow } from "zustand/shallow";
import { ChatMention, ChatModel } from "app-types/chat";
import dynamic from "next/dynamic";
import { ToolModeDropdown } from "./tool-mode-dropdown";
import { ToolSelectDropdown } from "./tool-select-dropdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip";
import { useTranslations } from "next-intl";
import { Editor } from "@tiptap/react";
import { WorkflowSummary } from "app-types/workflow";
import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar";
import equal from "lib/equal";
import { MCPIcon } from "ui/mcp-icon";
import { DefaultToolName } from "lib/ai/tools";
import { DefaultToolIcon } from "./default-tool-icon";
import { OpenAIIcon } from "ui/openai-icon";
import { GrokIcon } from "ui/grok-icon";
import { ClaudeIcon } from "ui/claude-icon";
import { GeminiIcon } from "ui/gemini-icon";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useThreadFileUploader } from "@/hooks/use-thread-file-uploader";
import { EMOJI_DATA } from "lib/const";
import { AgentSummary } from "app-types/agent";
import { FileUIPart, TextUIPart } from "ai";
import { toast } from "sonner";
import { isFilePartSupported, isIngestSupported } from "@/lib/ai/file-support";
import { useChatModels } from "@/hooks/queries/use-chat-models";
interface PromptInputProps {
placeholder?: string;
setInput: (value: string) => void;
input: string;
onStop: () => void;
sendMessage: UseChatHelpers<UIMessage>["sendMessage"];
toolDisabled?: boolean;
isLoading?: boolean;
model?: ChatModel;
setModel?: (model: ChatModel) => void;
voiceDisabled?: boolean;
threadId?: string;
disabledMention?: boolean;
onFocus?: () => void;
}
const ChatMentionInput = dynamic(() => import("./chat-mention-input"), {
ssr: false,
loading() {
return <div className="h-[2rem] w-full animate-pulse"></div>;
},
});
export default function PromptInput({
placeholder,
sendMessage,
model,
setModel,
input,
onFocus,
setInput,
onStop,
isLoading,
toolDisabled,
voiceDisabled,
threadId,
disabledMention,
}: PromptInputProps) {
const t = useTranslations("Chat");
const [isUploadDropdownOpen, setIsUploadDropdownOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { uploadFiles } = useThreadFileUploader(threadId);
const { data: providers } = useChatModels();
const [
globalModel,
threadMentions,
threadFiles,
threadImageToolModel,
appStoreMutate,
] = appStore(
useShallow((state) => [
state.chatModel,
state.threadMentions,
state.threadFiles,
state.threadImageToolModel,
state.mutate,
]),
);
const modelInfo = useMemo(() => {
const provider = providers?.find(
(provider) => provider.provider === globalModel?.provider,
);
const model = provider?.models.find(
(model) => model.name === globalModel?.model,
);
return model;
}, [providers, globalModel]);
const supportedFileMimeTypes = modelInfo?.supportedFileMimeTypes;
const canUploadImages =
supportedFileMimeTypes?.some((mime) => mime.startsWith("image/")) ?? true;
const mentions = useMemo<ChatMention[]>(() => {
if (!threadId) return [];
return threadMentions[threadId!] ?? [];
}, [threadMentions, threadId]);
const uploadedFiles = useMemo<UploadedFile[]>(() => {
if (!threadId) return [];
return threadFiles[threadId] ?? [];
}, [threadFiles, threadId]);
const imageToolModel = useMemo(() => {
if (!threadId) return undefined;
return threadImageToolModel[threadId];
}, [threadImageToolModel, threadId]);
const chatModel = useMemo(() => {
return model ?? globalModel;
}, [model, globalModel]);
const editorRef = useRef<Editor | null>(null);
const setChatModel = useCallback(
(model: ChatModel) => {
if (setModel) {
setModel(model);
} else {
appStoreMutate({ chatModel: model });
}
},
[setModel, appStoreMutate],
);
const deleteMention = useCallback(
(mention: ChatMention) => {
if (!threadId) return;
appStoreMutate((prev) => {
const newMentions = mentions.filter((m) => !equal(m, mention));
return {
threadMentions: {
...prev.threadMentions,
[threadId!]: newMentions,
},
};
});
},
[mentions, threadId],
);
const deleteFile = useCallback(
(fileId: string) => {
if (!threadId) return;
// Find file and abort if uploading
const file = uploadedFiles.find((f) => f.id === fileId);
if (file?.isUploading && file.abortController) {
file.abortController.abort();
}
// Cleanup preview URL if exists
if (file?.previewUrl) {
URL.revokeObjectURL(file.previewUrl);
}
appStoreMutate((prev) => {
const newFiles = uploadedFiles.filter((f) => f.id !== fileId);
return {
threadFiles: {
...prev.threadFiles,
[threadId]: newFiles,
},
};
});
},
[uploadedFiles, threadId, appStoreMutate],
);
// uploadFiles handled by hook
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files;
if (!list) return;
await uploadFiles(Array.from(list));
// Reset input
if (fileInputRef.current) fileInputRef.current.value = "";
setIsUploadDropdownOpen(false);
},
[uploadFiles],
);
const handleGenerateImage = useCallback(
(provider?: "google" | "openai") => {
if (!provider) {
appStoreMutate({
threadImageToolModel: {},
});
}
if (!threadId) return;
setIsUploadDropdownOpen(false);
appStoreMutate((prev) => ({
threadImageToolModel: {
...prev.threadImageToolModel,
[threadId]: provider,
},
}));
// Focus on the input
editorRef.current?.commands.focus();
},
[threadId, editorRef],
);
const addMention = useCallback(
(mention: ChatMention) => {
if (!threadId) return;
appStoreMutate((prev) => {
if (mentions.some((m) => equal(m, mention))) return prev;
const newMentions =
mention.type == "agent"
? [...mentions.filter((m) => m.type !== "agent"), mention]
: [...mentions, mention];
return {
threadMentions: {
...prev.threadMentions,
[threadId!]: newMentions,
},
};
});
},
[mentions, threadId],
);
const onSelectWorkflow = useCallback(
(workflow: WorkflowSummary) => {
addMention({
type: "workflow",
name: workflow.name,
icon: workflow.icon,
workflowId: workflow.id,
description: workflow.description,
});
},
[addMention],
);
const onSelectAgent = useCallback(
(agent: AgentSummary) => {
appStoreMutate((prev) => {
return {
threadMentions: {
...prev.threadMentions,
[threadId!]: [
{
type: "agent",
name: agent.name,
icon: agent.icon,
description: agent.description,
agentId: agent.id,
},
],
},
};
});
},
[mentions, threadId],
);
const onChangeMention = useCallback(
(mentions: ChatMention[]) => {
let hasAgent = false;
[...mentions]
.reverse()
.filter((m) => {
if (m.type == "agent") {
if (hasAgent) return false;
hasAgent = true;
}
return true;
})
.reverse()
.forEach(addMention);
},
[addMention],
);
const submit = () => {
if (isLoading) return;
if (uploadedFiles.some((file) => file.isUploading)) {
toast.error("Please wait for files to finish uploading before sending.");
return;
}
const userMessage = input?.trim() || "";
if (userMessage.length === 0) return;
setInput("");
const attachmentParts = uploadedFiles.reduce<
Array<FileUIPart | TextUIPart | any>
>((acc, file) => {
const isFileSupported = isFilePartSupported(
file.mimeType,
supportedFileMimeTypes,
);
const link = file.url || file.dataUrl || "";
if (!link) return acc;
if (isFileSupported) {
acc.push({
type: "file",
url: link,
mediaType: file.mimeType,
filename: file.name,
} as FileUIPart);
} else {
// Use a rich UI part for unsupported file types; will be filtered out for model input
acc.push({
type: "source-url",
url: link,
title: file.name,
mediaType: file.mimeType,
} as any);
}
return acc;
}, []);
if (attachmentParts.length) {
const summary = uploadedFiles
.map((file, index) => {
const type = file.mimeType || "unknown";
return `${index + 1}. ${file.name} (${type})`;
})
.join("\n");
attachmentParts.unshift({
type: "text",
text: `Attached files:\n${summary}`,
ingestionPreview: true,
});
}
sendMessage({
role: "user",
parts: [...attachmentParts, { type: "text", text: userMessage }],
});
appStoreMutate((prev) => ({
threadFiles: {
...prev.threadFiles,
[threadId!]: [],
},
}));
};
// Handle ESC key to clear mentions
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
threadId &&
(mentions.length > 0 || imageToolModel)
) {
e.preventDefault();
e.stopPropagation();
appStoreMutate(() => ({
threadMentions: {},
agentId: undefined,
threadImageToolModel: {},
}));
editorRef.current?.commands.focus();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mentions.length, threadId, appStoreMutate, imageToolModel]);
// Drag overlay handled globally in ChatBot
return (
<div className="max-w-3xl mx-auto fade-in animate-in">
<div className="z-10 mx-auto w-full max-w-3xl relative">
<fieldset className="flex w-full min-w-0 max-w-full flex-col px-4">
<div className="shadow-lg overflow-hidden rounded-4xl backdrop-blur-sm transition-all duration-200 bg-muted/60 relative flex w-full flex-col cursor-text z-10 items-stretch focus-within:bg-muted hover:bg-muted focus-within:ring-muted hover:ring-muted">
{mentions.length > 0 && (
<div className="bg-input rounded-b-sm rounded-t-3xl p-3 flex flex-col gap-4 mx-2 my-2">
{mentions.map((mention, i) => {
return (
<div key={i} className="flex items-center gap-2">
{mention.type === "workflow" ||
mention.type === "agent" ? (
<Avatar
className="size-6 p-1 ring ring-border rounded-full flex-shrink-0"
style={mention.icon?.style}
>
<AvatarImage
src={
mention.icon?.value ||
EMOJI_DATA[i % EMOJI_DATA.length]
}
/>
<AvatarFallback>
{mention.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
) : (
<Button className="size-6 flex items-center justify-center ring ring-border rounded-full flex-shrink-0 p-0.5">
{mention.type == "mcpServer" ? (
<MCPIcon className="size-3.5" />
) : (
<DefaultToolIcon
name={mention.name as DefaultToolName}
className="size-3.5"
/>
)}
</Button>
)}
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-semibold truncate">
{mention.name}
</span>
{mention.description ? (
<span className="text-muted-foreground text-xs truncate">
{mention.description}
</span>
) : null}
</div>
<Button
variant={"ghost"}
size={"icon"}
disabled={!threadId}
className="rounded-full hover:bg-input! flex-shrink-0"
onClick={() => {
deleteMention(mention);
}}
>
<XIcon />
</Button>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-3.5 px-5 pt-2 pb-4">
<div className="relative min-h-[2rem]">
<ChatMentionInput
input={input}
onChange={setInput}
onChangeMention={onChangeMention}
onEnter={submit}
placeholder={placeholder ?? t("placeholder")}
ref={editorRef}
disabledMention={disabledMention}
onFocus={onFocus}
/>
</div>
<div className="flex w-full items-center z-30">
<input
ref={fileInputRef}
type="file"
accept="image/*,.pdf,.txt,.md,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.7z,.tar,.gz,.mp3,.wav,.m4a,.ogg,.mp4,.webm,.mov"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={!threadId}
/>
<DropdownMenu
open={isUploadDropdownOpen}
onOpenChange={setIsUploadDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant={"ghost"}
size={"sm"}
className="rounded-full hover:bg-input! p-2! data-[state=open]:bg-input!"
disabled={!threadId}
>
<PlusIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem
className="cursor-pointer"
disabled={
modelInfo?.isImageInputUnsupported || !canUploadImages
}
onClick={() => fileInputRef.current?.click()}
>
<PaperclipIcon className="mr-2 size-4" />
{t("uploadImage")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<ImagesIcon className="mr-4 size-4 text-muted-foreground" />
<span className="mr-4">{t("generateImage")}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
disabled={modelInfo?.isToolCallUnsupported}
onClick={() => handleGenerateImage("google")}
className="cursor-pointer"
>
<GeminiIcon className="mr-2 size-4" />
Gemini (Nano Banana)
</DropdownMenuItem>
<DropdownMenuItem
disabled={modelInfo?.isToolCallUnsupported}
onClick={() => handleGenerateImage("openai")}
className="cursor-pointer"
>
<OpenAIIcon className="mr-2 size-4" />
OpenAI
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
{!toolDisabled &&
(imageToolModel ? (
<Button
variant={"ghost"}
size={"sm"}
className="rounded-full hover:bg-input! p-2! group/image-generator text-primary"
onClick={() => handleGenerateImage()}
>
<ImagesIcon className="size-3.5" />
{t("generateImage")}
<XIcon className="size-3 group-hover/image-generator:opacity-100 opacity-0 transition-opacity duration-200" />
</Button>
) : (
<>
<ToolModeDropdown />
<ToolSelectDropdown
className="mx-1"
align="start"
side="top"
onSelectWorkflow={onSelectWorkflow}
onSelectAgent={onSelectAgent}
onGenerateImage={handleGenerateImage}
mentions={mentions}
/>
</>
))}
<div className="flex-1" />
<SelectModel onSelect={setChatModel} currentModel={chatModel}>
<Button
variant={"ghost"}
size={"sm"}
className="rounded-full group data-[state=open]:bg-input! hover:bg-input! mr-1"
data-testid="model-selector-button"
>
{chatModel?.model ? (
<>
{chatModel.provider === "openai" ? (
<OpenAIIcon className="size-3 opacity-0 group-data-[state=open]:opacity-100 group-hover:opacity-100" />
) : chatModel.provider === "xai" ? (
<GrokIcon className="size-3 opacity-0 group-data-[state=open]:opacity-100 group-hover:opacity-100" />
) : chatModel.provider === "anthropic" ? (
<ClaudeIcon className="size-3 opacity-0 group-data-[state=open]:opacity-100 group-hover:opacity-100" />
) : chatModel.provider === "google" ? (
<GeminiIcon className="size-3 opacity-0 group-data-[state=open]:opacity-100 group-hover:opacity-100" />
) : null}
<span
className="text-foreground group-data-[state=open]:text-foreground "
data-testid="selected-model-name"
>
{chatModel.model}
</span>
</>
) : (
<span className="text-muted-foreground">model</span>
)}
<ChevronDown className="size-3" />
</Button>
</SelectModel>
{!isLoading && !input.length && !voiceDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size={"sm"}
onClick={() => {
appStoreMutate((state) => ({
voiceChat: {
...state.voiceChat,
isOpen: true,
agentId: undefined,
},
}));
}}
className="rounded-full p-2!"
>
<AudioWaveformIcon size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("VoiceChat.title")}</TooltipContent>
</Tooltip>
) : (
<div
onClick={() => {
if (isLoading) {
onStop();
} else {
submit();
}
}}
className="fade-in animate-in cursor-pointer text-muted-foreground rounded-full p-2 bg-secondary hover:bg-accent-foreground hover:text-accent transition-all duration-200"
>
{isLoading ? (
<Square
size={16}
className="fill-muted-foreground text-muted-foreground"
/>
) : (
<CornerRightUp size={16} />
)}
</div>
)}
</div>
{/* Uploaded Files Preview - Below Input */}
{uploadedFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
{uploadedFiles.map((file) => {
const isImage = file.mimeType.startsWith("image/");
const imageSrc =
file.previewUrl || file.url || file.dataUrl || "";
const displayName = file.name;
const displayExt =
file.name.split(".").pop()?.toUpperCase() || "FILE";
const isSummarizable = isIngestSupported(file.mimeType);
return (
<div
key={file.id}
className="relative group rounded-lg overflow-hidden border-2 border-border hover:border-primary transition-all"
>
{isImage ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={imageSrc}
alt={file.name}
className="w-24 h-24 object-cover"
/>
) : (
<div className="w-32 h-28 flex flex-col items-center justify-center bg-muted px-2 py-3 text-center">
<FileIcon className="size-8 text-muted-foreground mb-1" />
<span className="text-xs font-medium text-muted-foreground line-clamp-2 w-full">
{displayName}
</span>
<span className="text-[11px] text-muted-foreground/80">
{displayExt}
</span>
</div>
)}
{/* Upload Progress Overlay */}
{file.isUploading && (
<div className="absolute inset-0 bg-background/90 flex rounded-lg flex-col items-center justify-center backdrop-blur-sm">
<Loader2 className="size-6 animate-spin text-foreground mb-2" />
<div className="w-16 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${file.progress || 0}%` }}
/>
</div>
<span className="text-foreground text-xs mt-1">
{file.progress || 0}%
</span>
</div>
)}
{/* Hover Actions */}
<div
className={cn(
"absolute inset-0 bg-background/80 backdrop-blur-sm transition-opacity flex items-center justify-center rounded-lg",
file.isUploading
? "opacity-0"
: "opacity-0 group-hover:opacity-100",
)}
>
<div className="flex gap-2 items-center">
{isSummarizable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="rounded-full"
onClick={async () => {
try {
const url = file.url || file.dataUrl;
if (!url) {
toast.error("No file URL available");
return;
}
const res = await fetch(
"/api/storage/ingest",
{
method: "POST",
headers: {
"Content-Type":
"application/json",
},
body: JSON.stringify({ url }),
},
);
if (!res.ok) {
const e = await res
.json()
.catch(() => ({}));
toast.error(
e.error || "Failed to ingest file",
);
return;
}
const data = await res.json();
// Append preview text to input for the user to send
setInput(
`${input ? input + "\n\n" : ""}${data.text}`,
);
} catch (_err) {
toast.error("Failed to ingest file");
}
}}
>
<FileTextIcon className="size-4" />
<span className="sr-only">Summarize</span>
</Button>
</TooltipTrigger>
<TooltipContent>Summarize</TooltipContent>
</Tooltip>
)}
<Button
variant="ghost"
size="icon"
className="rounded-full bg-background/80 hover:bg-background"
onClick={() => deleteFile(file.id)}
disabled={file.isUploading}
>
<XIcon className="size-4" />
</Button>
</div>
</div>
{/* Cancel Upload Button (Top Right) */}
{file.isUploading && (
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 size-6 rounded-full bg-background/60 hover:bg-background/80 backdrop-blur-sm"
onClick={() => deleteFile(file.id)}
>
<XIcon className="size-3" />
</Button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</fieldset>
</div>
</div>
);
}