Files
prowler/ui/components/lighthouse/ai-elements/prompt-input.tsx
2025-11-19 11:37:17 +01:00

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} />
);