mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
1167 lines
30 KiB
TypeScript
1167 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import type { ChatStatus, FileUIPart } from "ai";
|
|
import {
|
|
ImageIcon,
|
|
Loader2Icon,
|
|
MicIcon,
|
|
PaperclipIcon,
|
|
PlusIcon,
|
|
SendIcon,
|
|
SquareIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { nanoid } from "nanoid";
|
|
import Image from "next/image";
|
|
import {
|
|
type ChangeEvent,
|
|
type ChangeEventHandler,
|
|
Children,
|
|
type ClipboardEventHandler,
|
|
type ComponentProps,
|
|
createContext,
|
|
type FormEvent,
|
|
type FormEventHandler,
|
|
Fragment,
|
|
type HTMLAttributes,
|
|
type KeyboardEventHandler,
|
|
type PropsWithChildren,
|
|
type ReactNode,
|
|
type RefObject,
|
|
useContext,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import { Button } from "@/components/shadcn/button/button";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "./dropdown-menu";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupTextarea,
|
|
} from "./input-group";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "./select";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
|
|
// ============================================================================
|
|
// Provider Context & Types
|
|
// ============================================================================
|
|
|
|
export type AttachmentsContext = {
|
|
files: (FileUIPart & { id: string })[];
|
|
add: (files: File[] | FileList) => void;
|
|
remove: (id: string) => void;
|
|
clear: () => void;
|
|
openFileDialog: () => void;
|
|
fileInputRef: RefObject<HTMLInputElement | null>;
|
|
};
|
|
|
|
export type TextInputContext = {
|
|
value: string;
|
|
setInput: (v: string) => void;
|
|
clear: () => void;
|
|
};
|
|
|
|
export type PromptInputController = {
|
|
textInput: TextInputContext;
|
|
attachments: AttachmentsContext;
|
|
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
|
__registerFileInput: (
|
|
ref: RefObject<HTMLInputElement | null>,
|
|
open: () => void,
|
|
) => void;
|
|
};
|
|
|
|
const PromptInputContext = createContext<PromptInputController | null>(null);
|
|
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
|
null,
|
|
);
|
|
|
|
export const usePromptInputController = () => {
|
|
const ctx = useContext(PromptInputContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
"Wrap your component inside <PromptInputProvider> to use usePromptInputController().",
|
|
);
|
|
}
|
|
return ctx;
|
|
};
|
|
|
|
// Optional variants (do NOT throw). Useful for dual-mode components.
|
|
const useOptionalPromptInputController = () => {
|
|
return useContext(PromptInputContext);
|
|
};
|
|
|
|
export const useProviderAttachments = () => {
|
|
const ctx = useContext(ProviderAttachmentsContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
|
|
);
|
|
}
|
|
return ctx;
|
|
};
|
|
|
|
const useOptionalProviderAttachments = () => {
|
|
return useContext(ProviderAttachmentsContext);
|
|
};
|
|
|
|
export type PromptInputProviderProps = PropsWithChildren<{
|
|
initialInput?: string;
|
|
}>;
|
|
|
|
/**
|
|
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
|
* If you don't use it, PromptInput stays fully self-managed.
|
|
*/
|
|
export function PromptInputProvider({
|
|
initialInput: initialTextInput = "",
|
|
children,
|
|
}: PromptInputProviderProps) {
|
|
// ----- textInput state
|
|
const [textInput, setTextInput] = useState(initialTextInput);
|
|
const clearInput = () => setTextInput("");
|
|
|
|
// ----- attachments state (global when wrapped)
|
|
const [attachements, setAttachements] = useState<
|
|
(FileUIPart & { id: string })[]
|
|
>([]);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const openRef = useRef<() => void>(() => {});
|
|
|
|
const add = (files: File[] | FileList) => {
|
|
const incoming = Array.from(files);
|
|
if (incoming.length === 0) return;
|
|
|
|
setAttachements((prev) =>
|
|
prev.concat(
|
|
incoming.map((file) => ({
|
|
id: nanoid(),
|
|
type: "file" as const,
|
|
url: URL.createObjectURL(file),
|
|
mediaType: file.type,
|
|
filename: file.name,
|
|
})),
|
|
),
|
|
);
|
|
};
|
|
|
|
const remove = (id: string) => {
|
|
setAttachements((prev) => {
|
|
const found = prev.find((f) => f.id === id);
|
|
if (found?.url) URL.revokeObjectURL(found.url);
|
|
return prev.filter((f) => f.id !== id);
|
|
});
|
|
};
|
|
|
|
const clear = () => {
|
|
setAttachements((prev) => {
|
|
for (const f of prev) if (f.url) URL.revokeObjectURL(f.url);
|
|
return [];
|
|
});
|
|
};
|
|
|
|
const openFileDialog = () => {
|
|
openRef.current?.();
|
|
};
|
|
|
|
const attachments: AttachmentsContext = {
|
|
files: attachements,
|
|
add,
|
|
remove,
|
|
clear,
|
|
openFileDialog,
|
|
fileInputRef,
|
|
};
|
|
|
|
const __registerFileInput = (
|
|
ref: RefObject<HTMLInputElement | null>,
|
|
open: () => void,
|
|
) => {
|
|
fileInputRef.current = ref.current;
|
|
openRef.current = open;
|
|
};
|
|
|
|
const controller: PromptInputController = {
|
|
textInput: {
|
|
value: textInput,
|
|
setInput: setTextInput,
|
|
clear: clearInput,
|
|
},
|
|
attachments,
|
|
__registerFileInput,
|
|
};
|
|
|
|
return (
|
|
<PromptInputContext.Provider value={controller}>
|
|
<ProviderAttachmentsContext.Provider value={attachments}>
|
|
{children}
|
|
</ProviderAttachmentsContext.Provider>
|
|
</PromptInputContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Component Context & Hooks
|
|
// ============================================================================
|
|
|
|
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
|
|
|
export const usePromptInputAttachments = () => {
|
|
// Dual-mode: prefer provider if present, otherwise use local
|
|
const provider = useOptionalProviderAttachments();
|
|
const local = useContext(LocalAttachmentsContext);
|
|
const context = provider ?? local;
|
|
if (!context) {
|
|
throw new Error(
|
|
"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
|
|
);
|
|
}
|
|
return context;
|
|
};
|
|
|
|
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
|
data: FileUIPart & { id: string };
|
|
className?: string;
|
|
};
|
|
|
|
export function PromptInputAttachment({
|
|
data,
|
|
className,
|
|
...props
|
|
}: PromptInputAttachmentProps) {
|
|
const attachments = usePromptInputAttachments();
|
|
|
|
const mediaType =
|
|
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"group relative h-14 w-14 rounded-md border",
|
|
className,
|
|
mediaType === "image" ? "h-14 w-14" : "h-8 w-auto max-w-full",
|
|
)}
|
|
key={data.id}
|
|
{...props}
|
|
>
|
|
{mediaType === "image" ? (
|
|
<Image
|
|
alt={data.filename || "attachment"}
|
|
className="size-full rounded-md object-cover"
|
|
height={56}
|
|
src={data.url}
|
|
width={56}
|
|
unoptimized
|
|
/>
|
|
) : (
|
|
<div className="text-muted-foreground flex size-full max-w-full cursor-pointer items-center justify-start gap-2 overflow-hidden px-2">
|
|
<PaperclipIcon className="size-4 shrink-0" />
|
|
<Tooltip delayDuration={400}>
|
|
<TooltipTrigger className="min-w-0 flex-1">
|
|
<h4 className="w-full truncate text-left text-sm font-medium">
|
|
{data.filename || "Unknown file"}
|
|
</h4>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<div className="text-muted-foreground text-xs">
|
|
<h4 className="max-w-[240px] overflow-hidden text-left text-sm font-semibold break-words whitespace-normal">
|
|
{data.filename || "Unknown file"}
|
|
</h4>
|
|
{data.mediaType && <div>{data.mediaType}</div>}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
<Button
|
|
aria-label="Remove attachment"
|
|
className="absolute -top-1.5 -right-1.5 h-6 w-6 rounded-full opacity-0 group-hover:opacity-100"
|
|
onClick={() => attachments.remove(data.id)}
|
|
size="icon"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type PromptInputAttachmentsProps = Omit<
|
|
HTMLAttributes<HTMLDivElement>,
|
|
"children"
|
|
> & {
|
|
children: (attachment: FileUIPart & { id: string }) => ReactNode;
|
|
};
|
|
|
|
export function PromptInputAttachments({
|
|
className,
|
|
children,
|
|
...props
|
|
}: PromptInputAttachmentsProps) {
|
|
const attachments = usePromptInputAttachments();
|
|
const [height, setHeight] = useState(0);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = contentRef.current;
|
|
if (!el) {
|
|
return;
|
|
}
|
|
const ro = new ResizeObserver(() => {
|
|
setHeight(el.getBoundingClientRect().height);
|
|
});
|
|
ro.observe(el);
|
|
setHeight(el.getBoundingClientRect().height);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Force height measurement when attachments change
|
|
useLayoutEffect(() => {
|
|
const el = contentRef.current;
|
|
if (!el) {
|
|
return;
|
|
}
|
|
setHeight(el.getBoundingClientRect().height);
|
|
}, [attachments.files.length]);
|
|
|
|
if (attachments.files.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<InputGroupAddon
|
|
align="block-start"
|
|
aria-live="polite"
|
|
className={cn(
|
|
"overflow-hidden transition-[height] duration-200 ease-out",
|
|
className,
|
|
)}
|
|
style={{ height: attachments.files.length ? height : 0 }}
|
|
{...props}
|
|
>
|
|
<div className="space-y-2 py-1" ref={contentRef}>
|
|
<div className="flex flex-wrap gap-2">
|
|
{attachments.files
|
|
.filter((f) => !(f.mediaType?.startsWith("image/") && f.url))
|
|
.map((file) => (
|
|
<Fragment key={file.id}>{children(file)}</Fragment>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{attachments.files
|
|
.filter((f) => f.mediaType?.startsWith("image/") && f.url)
|
|
.map((file) => (
|
|
<Fragment key={file.id}>{children(file)}</Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</InputGroupAddon>
|
|
);
|
|
}
|
|
|
|
export type PromptInputActionAddAttachmentsProps = ComponentProps<
|
|
typeof DropdownMenuItem
|
|
> & {
|
|
label?: string;
|
|
};
|
|
|
|
export const PromptInputActionAddAttachments = ({
|
|
label = "Add photos or files",
|
|
...props
|
|
}: PromptInputActionAddAttachmentsProps) => {
|
|
const attachments = usePromptInputAttachments();
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
{...props}
|
|
onSelect={(e) => {
|
|
e.preventDefault();
|
|
attachments.openFileDialog();
|
|
}}
|
|
>
|
|
<ImageIcon className="mr-2 size-4" /> {label}
|
|
</DropdownMenuItem>
|
|
);
|
|
};
|
|
|
|
export type PromptInputMessage = {
|
|
text?: string;
|
|
files?: FileUIPart[];
|
|
};
|
|
|
|
export type PromptInputProps = Omit<
|
|
HTMLAttributes<HTMLFormElement>,
|
|
"onSubmit"
|
|
> & {
|
|
accept?: string; // e.g., "image/*" or leave undefined for any
|
|
multiple?: boolean;
|
|
// When true, accepts drops anywhere on document. Default false (opt-in).
|
|
globalDrop?: boolean;
|
|
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
|
syncHiddenInput?: boolean;
|
|
// Minimal constraints
|
|
maxFiles?: number;
|
|
maxFileSize?: number; // bytes
|
|
onError?: (err: {
|
|
code: "max_files" | "max_file_size" | "accept";
|
|
message: string;
|
|
}) => void;
|
|
onSubmit: (
|
|
message: PromptInputMessage,
|
|
event: FormEvent<HTMLFormElement>,
|
|
) => void | Promise<void>;
|
|
};
|
|
|
|
export const PromptInput = ({
|
|
className,
|
|
accept,
|
|
multiple,
|
|
globalDrop,
|
|
syncHiddenInput,
|
|
maxFiles,
|
|
maxFileSize,
|
|
onError,
|
|
onSubmit,
|
|
children,
|
|
...props
|
|
}: PromptInputProps) => {
|
|
// Try to use a provider controller if present
|
|
const controller = useOptionalPromptInputController();
|
|
const usingProvider = !!controller;
|
|
|
|
// Refs
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const anchorRef = useRef<HTMLSpanElement>(null);
|
|
const formRef = useRef<HTMLFormElement | null>(null);
|
|
|
|
// Find nearest form to scope drag & drop
|
|
useEffect(() => {
|
|
const root = anchorRef.current?.closest("form");
|
|
if (root instanceof HTMLFormElement) {
|
|
formRef.current = root;
|
|
}
|
|
}, []);
|
|
|
|
// ----- Local attachments (only used when no provider)
|
|
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
|
const files = usingProvider ? controller.attachments.files : items;
|
|
|
|
const openFileDialogLocal = () => {
|
|
inputRef.current?.click();
|
|
};
|
|
|
|
const matchesAccept = (f: File) => {
|
|
if (!accept || accept.trim() === "") {
|
|
return true;
|
|
}
|
|
if (accept.includes("image/*")) {
|
|
return f.type.startsWith("image/");
|
|
}
|
|
// NOTE: keep simple; expand as needed
|
|
return true;
|
|
};
|
|
|
|
const addLocal = (fileList: File[] | FileList) => {
|
|
const incoming = Array.from(fileList);
|
|
const accepted = incoming.filter((f) => matchesAccept(f));
|
|
if (incoming.length && accepted.length === 0) {
|
|
onError?.({
|
|
code: "accept",
|
|
message: "No files match the accepted types.",
|
|
});
|
|
return;
|
|
}
|
|
const withinSize = (f: File) =>
|
|
maxFileSize ? f.size <= maxFileSize : true;
|
|
const sized = accepted.filter(withinSize);
|
|
if (accepted.length > 0 && sized.length === 0) {
|
|
onError?.({
|
|
code: "max_file_size",
|
|
message: "All files exceed the maximum size.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setItems((prev) => {
|
|
const capacity =
|
|
typeof maxFiles === "number"
|
|
? Math.max(0, maxFiles - prev.length)
|
|
: undefined;
|
|
const capped =
|
|
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
|
if (typeof capacity === "number" && sized.length > capacity) {
|
|
onError?.({
|
|
code: "max_files",
|
|
message: "Too many files. Some were not added.",
|
|
});
|
|
}
|
|
const next: (FileUIPart & { id: string })[] = [];
|
|
for (const file of capped) {
|
|
next.push({
|
|
id: nanoid(),
|
|
type: "file",
|
|
url: URL.createObjectURL(file),
|
|
mediaType: file.type,
|
|
filename: file.name,
|
|
});
|
|
}
|
|
return prev.concat(next);
|
|
});
|
|
};
|
|
|
|
const add = usingProvider
|
|
? (files: File[] | FileList) => controller.attachments.add(files)
|
|
: addLocal;
|
|
|
|
const remove = usingProvider
|
|
? (id: string) => controller.attachments.remove(id)
|
|
: (id: string) =>
|
|
setItems((prev) => {
|
|
const found = prev.find((file) => file.id === id);
|
|
if (found?.url) {
|
|
URL.revokeObjectURL(found.url);
|
|
}
|
|
return prev.filter((file) => file.id !== id);
|
|
});
|
|
|
|
const clear = usingProvider
|
|
? () => controller.attachments.clear()
|
|
: () =>
|
|
setItems((prev) => {
|
|
for (const file of prev) {
|
|
if (file.url) {
|
|
URL.revokeObjectURL(file.url);
|
|
}
|
|
}
|
|
return [];
|
|
});
|
|
|
|
const openFileDialog = usingProvider
|
|
? () => controller.attachments.openFileDialog()
|
|
: openFileDialogLocal;
|
|
|
|
// Let provider know about our hidden file input so external menus can call openFileDialog()
|
|
useEffect(() => {
|
|
if (!usingProvider) return;
|
|
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
|
|
}, [usingProvider, controller]);
|
|
|
|
// Note: File input cannot be programmatically set for security reasons
|
|
// The syncHiddenInput prop is no longer functional
|
|
useEffect(() => {
|
|
if (syncHiddenInput && inputRef.current && files.length === 0) {
|
|
inputRef.current.value = "";
|
|
}
|
|
}, [files, syncHiddenInput]);
|
|
|
|
// Attach drop handlers on nearest form and document (opt-in)
|
|
useEffect(() => {
|
|
const form = formRef.current;
|
|
if (!form) return;
|
|
|
|
const onDragOver = (e: DragEvent) => {
|
|
if (e.dataTransfer?.types?.includes("Files")) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
const onDrop = (e: DragEvent) => {
|
|
if (e.dataTransfer?.types?.includes("Files")) {
|
|
e.preventDefault();
|
|
}
|
|
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
add(e.dataTransfer.files);
|
|
}
|
|
};
|
|
form.addEventListener("dragover", onDragOver);
|
|
form.addEventListener("drop", onDrop);
|
|
return () => {
|
|
form.removeEventListener("dragover", onDragOver);
|
|
form.removeEventListener("drop", onDrop);
|
|
};
|
|
}, [add]);
|
|
|
|
useEffect(() => {
|
|
if (!globalDrop) return;
|
|
|
|
const onDragOver = (e: DragEvent) => {
|
|
if (e.dataTransfer?.types?.includes("Files")) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
const onDrop = (e: DragEvent) => {
|
|
if (e.dataTransfer?.types?.includes("Files")) {
|
|
e.preventDefault();
|
|
}
|
|
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
add(e.dataTransfer.files);
|
|
}
|
|
};
|
|
document.addEventListener("dragover", onDragOver);
|
|
document.addEventListener("drop", onDrop);
|
|
return () => {
|
|
document.removeEventListener("dragover", onDragOver);
|
|
document.removeEventListener("drop", onDrop);
|
|
};
|
|
}, [add, globalDrop]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (!usingProvider) {
|
|
for (const f of files) {
|
|
if (f.url) URL.revokeObjectURL(f.url);
|
|
}
|
|
}
|
|
};
|
|
}, [usingProvider, files]);
|
|
|
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
|
if (event.currentTarget.files) {
|
|
add(event.currentTarget.files);
|
|
}
|
|
};
|
|
|
|
const convertBlobUrlToDataUrl = async (url: string): Promise<string> => {
|
|
const response = await fetch(url);
|
|
const blob = await response.blob();
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
};
|
|
|
|
const ctx: AttachmentsContext = {
|
|
files: files.map((item) => ({ ...item, id: item.id })),
|
|
add,
|
|
remove,
|
|
clear,
|
|
openFileDialog,
|
|
fileInputRef: inputRef,
|
|
};
|
|
|
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
|
event.preventDefault();
|
|
|
|
const form = event.currentTarget;
|
|
const text = usingProvider
|
|
? controller.textInput.value
|
|
: (() => {
|
|
const formData = new FormData(form);
|
|
return (formData.get("message") as string) || "";
|
|
})();
|
|
|
|
// Reset form immediately after capturing text to avoid race condition
|
|
// where user input during async blob conversion would be lost
|
|
if (!usingProvider) {
|
|
form.reset();
|
|
}
|
|
|
|
// Convert blob URLs to data URLs asynchronously
|
|
Promise.all(
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
files.map(async ({ id, ...item }) => {
|
|
if (item.url && item.url.startsWith("blob:")) {
|
|
return {
|
|
...item,
|
|
url: await convertBlobUrlToDataUrl(item.url),
|
|
};
|
|
}
|
|
return item;
|
|
}),
|
|
).then((convertedFiles: FileUIPart[]) => {
|
|
try {
|
|
const result = onSubmit({ text, files: convertedFiles }, event);
|
|
|
|
// Handle both sync and async onSubmit
|
|
if (result instanceof Promise) {
|
|
result
|
|
.then(() => {
|
|
clear();
|
|
if (usingProvider) {
|
|
controller.textInput.clear();
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Don't clear on error - user may want to retry
|
|
});
|
|
} else {
|
|
// Sync function completed without throwing, clear attachments
|
|
clear();
|
|
if (usingProvider) {
|
|
controller.textInput.clear();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Don't clear on error - user may want to retry
|
|
}
|
|
});
|
|
};
|
|
|
|
// Render with or without local provider
|
|
const inner = (
|
|
<>
|
|
<span aria-hidden="true" className="hidden" ref={anchorRef} />
|
|
<input
|
|
accept={accept}
|
|
aria-label="Upload files"
|
|
className="hidden"
|
|
multiple={multiple}
|
|
onChange={handleChange}
|
|
ref={inputRef}
|
|
title="Upload files"
|
|
type="file"
|
|
/>
|
|
<form
|
|
className={cn("w-full", className)}
|
|
onSubmit={handleSubmit}
|
|
{...props}
|
|
>
|
|
<InputGroup>{children}</InputGroup>
|
|
</form>
|
|
</>
|
|
);
|
|
|
|
return usingProvider ? (
|
|
inner
|
|
) : (
|
|
<LocalAttachmentsContext.Provider value={ctx}>
|
|
{inner}
|
|
</LocalAttachmentsContext.Provider>
|
|
);
|
|
};
|
|
|
|
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputBody = ({
|
|
className,
|
|
...props
|
|
}: PromptInputBodyProps) => (
|
|
<div className={cn("contents", className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputTextareaProps = ComponentProps<
|
|
typeof InputGroupTextarea
|
|
>;
|
|
|
|
export const PromptInputTextarea = ({
|
|
onChange,
|
|
className,
|
|
placeholder = "What would you like to know?",
|
|
...props
|
|
}: PromptInputTextareaProps) => {
|
|
const controller = useOptionalPromptInputController();
|
|
const attachments = usePromptInputAttachments();
|
|
|
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
if (e.key === "Enter") {
|
|
if (e.nativeEvent.isComposing) return;
|
|
if (e.shiftKey) return;
|
|
e.preventDefault();
|
|
e.currentTarget.form?.requestSubmit();
|
|
}
|
|
};
|
|
|
|
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
|
const items = event.clipboardData?.items;
|
|
|
|
if (!items) {
|
|
return;
|
|
}
|
|
|
|
const files: File[] = [];
|
|
|
|
// @ts-expect-error - Code from AI Elements library
|
|
for (const item of items) {
|
|
if (item.kind === "file") {
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
files.push(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (files.length > 0) {
|
|
event.preventDefault();
|
|
attachments.add(files);
|
|
}
|
|
};
|
|
|
|
const controlledProps = controller
|
|
? {
|
|
value: controller.textInput.value,
|
|
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
controller.textInput.setInput(e.currentTarget.value);
|
|
onChange?.(e);
|
|
},
|
|
}
|
|
: {
|
|
onChange,
|
|
};
|
|
|
|
return (
|
|
<InputGroupTextarea
|
|
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
|
name="message"
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder={placeholder}
|
|
{...props}
|
|
{...controlledProps}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export type PromptInputToolbarProps = Omit<
|
|
ComponentProps<typeof InputGroupAddon>,
|
|
"align"
|
|
>;
|
|
|
|
export const PromptInputToolbar = ({
|
|
className,
|
|
...props
|
|
}: PromptInputToolbarProps) => (
|
|
<InputGroupAddon
|
|
align="block-end"
|
|
className={cn("justify-between gap-1", className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputTools = ({
|
|
className,
|
|
...props
|
|
}: PromptInputToolsProps) => (
|
|
<div className={cn("flex items-center gap-1", className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
|
|
|
export const PromptInputButton = ({
|
|
variant = "ghost",
|
|
className,
|
|
size,
|
|
...props
|
|
}: PromptInputButtonProps) => {
|
|
const newSize =
|
|
size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
|
|
|
|
return (
|
|
<InputGroupButton
|
|
className={className}
|
|
size={newSize}
|
|
type="button"
|
|
variant={variant}
|
|
{...props}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
|
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
|
<DropdownMenu {...props} />
|
|
);
|
|
|
|
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
|
|
|
|
export const PromptInputActionMenuTrigger = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: PromptInputActionMenuTriggerProps) => (
|
|
<DropdownMenuTrigger asChild>
|
|
<PromptInputButton className={className} {...props}>
|
|
{children ?? <PlusIcon className="size-4" />}
|
|
</PromptInputButton>
|
|
</DropdownMenuTrigger>
|
|
);
|
|
|
|
export type PromptInputActionMenuContentProps = ComponentProps<
|
|
typeof DropdownMenuContent
|
|
>;
|
|
export const PromptInputActionMenuContent = ({
|
|
className,
|
|
...props
|
|
}: PromptInputActionMenuContentProps) => (
|
|
<DropdownMenuContent align="start" className={className} {...props} />
|
|
);
|
|
|
|
export type PromptInputActionMenuItemProps = ComponentProps<
|
|
typeof DropdownMenuItem
|
|
>;
|
|
export const PromptInputActionMenuItem = ({
|
|
className,
|
|
...props
|
|
}: PromptInputActionMenuItemProps) => (
|
|
<DropdownMenuItem className={className} {...props} />
|
|
);
|
|
|
|
// Note: Actions that perform side-effects (like opening a file dialog)
|
|
// are provided in opt-in modules (e.g., prompt-input-attachments).
|
|
|
|
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
|
status?: ChatStatus;
|
|
};
|
|
|
|
export const PromptInputSubmit = ({
|
|
className,
|
|
variant = "default",
|
|
size = "icon-sm",
|
|
status,
|
|
children,
|
|
...props
|
|
}: PromptInputSubmitProps) => {
|
|
let Icon = <SendIcon className="size-4" />;
|
|
|
|
if (status === "submitted") {
|
|
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
|
} else if (status === "streaming") {
|
|
Icon = <SquareIcon className="size-4" />;
|
|
} else if (status === "error") {
|
|
Icon = <XIcon className="size-4" />;
|
|
}
|
|
|
|
return (
|
|
<InputGroupButton
|
|
aria-label="Submit"
|
|
className={className}
|
|
size={size}
|
|
type="submit"
|
|
variant={variant}
|
|
{...props}
|
|
>
|
|
{children ?? Icon}
|
|
</InputGroupButton>
|
|
);
|
|
};
|
|
|
|
interface SpeechRecognition extends EventTarget {
|
|
continuous: boolean;
|
|
interimResults: boolean;
|
|
lang: string;
|
|
start(): void;
|
|
stop(): void;
|
|
onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
|
|
onend: ((this: SpeechRecognition, ev: Event) => any) | null;
|
|
onresult:
|
|
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
|
|
| null;
|
|
onerror:
|
|
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
|
|
| null;
|
|
}
|
|
|
|
interface SpeechRecognitionEvent extends Event {
|
|
results: SpeechRecognitionResultList;
|
|
}
|
|
|
|
type SpeechRecognitionResultList = {
|
|
readonly length: number;
|
|
item(index: number): SpeechRecognitionResult;
|
|
[index: number]: SpeechRecognitionResult;
|
|
};
|
|
|
|
type SpeechRecognitionResult = {
|
|
readonly length: number;
|
|
item(index: number): SpeechRecognitionAlternative;
|
|
[index: number]: SpeechRecognitionAlternative;
|
|
isFinal: boolean;
|
|
};
|
|
|
|
type SpeechRecognitionAlternative = {
|
|
transcript: string;
|
|
confidence: number;
|
|
};
|
|
|
|
interface SpeechRecognitionErrorEvent extends Event {
|
|
error: string;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
SpeechRecognition: {
|
|
new (): SpeechRecognition;
|
|
};
|
|
webkitSpeechRecognition: {
|
|
new (): SpeechRecognition;
|
|
};
|
|
}
|
|
}
|
|
|
|
export type PromptInputSpeechButtonProps = ComponentProps<
|
|
typeof PromptInputButton
|
|
> & {
|
|
textareaRef?: RefObject<HTMLTextAreaElement | null>;
|
|
onTranscriptionChange?: (text: string) => void;
|
|
};
|
|
|
|
export const PromptInputSpeechButton = ({
|
|
className,
|
|
textareaRef,
|
|
onTranscriptionChange,
|
|
...props
|
|
}: PromptInputSpeechButtonProps) => {
|
|
const [isListening, setIsListening] = useState(false);
|
|
const [recognition, setRecognition] = useState<SpeechRecognition | null>(
|
|
null,
|
|
);
|
|
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
typeof window !== "undefined" &&
|
|
("SpeechRecognition" in window || "webkitSpeechRecognition" in window)
|
|
) {
|
|
const SpeechRecognition =
|
|
window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
const speechRecognition = new SpeechRecognition();
|
|
|
|
speechRecognition.continuous = true;
|
|
speechRecognition.interimResults = true;
|
|
speechRecognition.lang = "en-US";
|
|
|
|
speechRecognition.onstart = () => {
|
|
setIsListening(true);
|
|
};
|
|
|
|
speechRecognition.onend = () => {
|
|
setIsListening(false);
|
|
};
|
|
|
|
speechRecognition.onresult = (event) => {
|
|
let finalTranscript = "";
|
|
|
|
for (let i = 0; i < event.results.length; i++) {
|
|
if (event.results[i].isFinal) {
|
|
finalTranscript += event.results[i][0].transcript;
|
|
}
|
|
}
|
|
|
|
if (finalTranscript && textareaRef?.current) {
|
|
const textarea = textareaRef.current;
|
|
const currentValue = textarea.value;
|
|
const newValue =
|
|
currentValue + (currentValue ? " " : "") + finalTranscript;
|
|
|
|
textarea.value = newValue;
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
onTranscriptionChange?.(newValue);
|
|
}
|
|
};
|
|
|
|
speechRecognition.onerror = (event) => {
|
|
console.error("Speech recognition error:", event.error);
|
|
setIsListening(false);
|
|
};
|
|
|
|
recognitionRef.current = speechRecognition;
|
|
setRecognition(speechRecognition);
|
|
}
|
|
|
|
return () => {
|
|
if (recognitionRef.current) {
|
|
recognitionRef.current.stop();
|
|
}
|
|
};
|
|
}, [textareaRef, onTranscriptionChange]);
|
|
|
|
const toggleListening = () => {
|
|
if (!recognition) return;
|
|
|
|
if (isListening) {
|
|
recognition.stop();
|
|
} else {
|
|
recognition.start();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PromptInputButton
|
|
className={cn(
|
|
"relative transition-all duration-200",
|
|
isListening && "bg-accent text-accent-foreground animate-pulse",
|
|
className,
|
|
)}
|
|
disabled={!recognition}
|
|
onClick={toggleListening}
|
|
{...props}
|
|
>
|
|
<MicIcon className="size-4" />
|
|
</PromptInputButton>
|
|
);
|
|
};
|
|
|
|
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
|
|
|
|
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
|
|
<Select {...props} />
|
|
);
|
|
|
|
export type PromptInputModelSelectTriggerProps = ComponentProps<
|
|
typeof SelectTrigger
|
|
>;
|
|
|
|
export const PromptInputModelSelectTrigger = ({
|
|
className,
|
|
...props
|
|
}: PromptInputModelSelectTriggerProps) => (
|
|
<SelectTrigger
|
|
className={cn(
|
|
"text-muted-foreground border-none bg-transparent font-medium shadow-none transition-colors",
|
|
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type PromptInputModelSelectContentProps = ComponentProps<
|
|
typeof SelectContent
|
|
>;
|
|
|
|
export const PromptInputModelSelectContent = ({
|
|
className,
|
|
...props
|
|
}: PromptInputModelSelectContentProps) => (
|
|
<SelectContent className={className} {...props} />
|
|
);
|
|
|
|
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
|
|
|
|
export const PromptInputModelSelectItem = ({
|
|
className,
|
|
...props
|
|
}: PromptInputModelSelectItemProps) => (
|
|
<SelectItem className={className} {...props} />
|
|
);
|
|
|
|
export type PromptInputModelSelectValueProps = ComponentProps<
|
|
typeof SelectValue
|
|
>;
|
|
|
|
export const PromptInputModelSelectValue = ({
|
|
className,
|
|
...props
|
|
}: PromptInputModelSelectValueProps) => (
|
|
<SelectValue className={className} {...props} />
|
|
);
|