Files
prowler/ui/components/lighthouse/ai-elements/prompt-input.tsx

1180 lines
31 KiB
TypeScript

"use client";
import type { ChatStatus, FileUIPart } from "ai";
import {
ImageIcon,
Loader2Icon,
MicIcon,
PaperclipIcon,
PlusIcon,
SendIcon,
StopCircleIcon,
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,
useCallback,
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 = useCallback(
(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;
},
[accept],
);
const addLocal = useCallback(
(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);
});
},
[matchesAccept, maxFileSize, maxFiles, onError],
);
// useCallback required: used as useEffect dependency for drag-drop handlers
const add = useCallback(
(files: File[] | FileList) => {
if (usingProvider) {
controller.attachments.add(files);
} else {
addLocal(files);
}
},
[usingProvider, controller, 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(
files.map(async ({ id: _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[] = [];
for (const item of Array.from(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 = <StopCircleIcon className="size-5" />;
} 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) => void) | null;
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
onresult:
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void)
| null;
onerror:
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void)
| 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} />
);