mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-02 22:02:13 +08:00
* fix: remove unstable dependencies from speech recognition effect * fix: use refs to prevent stale closures in speech recognition * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1421 lines
37 KiB
TypeScript
1421 lines
37 KiB
TypeScript
"use client";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
CommandSeparator,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
} from "@/components/ui/hover-card";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupTextarea,
|
|
} from "@/components/ui/input-group";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
import type { ChatStatus, FileUIPart } from "ai";
|
|
import {
|
|
ArrowUpIcon,
|
|
ImageIcon,
|
|
Loader2Icon,
|
|
MicIcon,
|
|
PaperclipIcon,
|
|
PlusIcon,
|
|
SquareIcon,
|
|
UploadIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { nanoid } from "nanoid";
|
|
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,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
// ============================================================================
|
|
// 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 PromptInputControllerProps = {
|
|
textInput: TextInputContext;
|
|
attachments: AttachmentsContext;
|
|
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
|
__registerFileInput: (
|
|
ref: RefObject<HTMLInputElement | null>,
|
|
open: () => void,
|
|
) => void;
|
|
};
|
|
|
|
const PromptInputController = createContext<PromptInputControllerProps | null>(
|
|
null,
|
|
);
|
|
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
|
null,
|
|
);
|
|
|
|
export const usePromptInputController = () => {
|
|
const ctx = useContext(PromptInputController);
|
|
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 = () =>
|
|
useContext(PromptInputController);
|
|
|
|
export const useProviderAttachments = () => {
|
|
const ctx = useContext(ProviderAttachmentsContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().",
|
|
);
|
|
}
|
|
return ctx;
|
|
};
|
|
|
|
const useOptionalProviderAttachments = () =>
|
|
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 = useCallback(() => setTextInput(""), []);
|
|
|
|
// ----- attachments state (global when wrapped)
|
|
const [attachmentFiles, setAttachmentFiles] = useState<
|
|
(FileUIPart & { id: string })[]
|
|
>([]);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const openRef = useRef<() => void>(() => {});
|
|
|
|
const add = useCallback((files: File[] | FileList) => {
|
|
const incoming = Array.from(files);
|
|
if (incoming.length === 0) {
|
|
return;
|
|
}
|
|
|
|
setAttachmentFiles((prev) =>
|
|
prev.concat(
|
|
incoming.map((file) => ({
|
|
id: nanoid(),
|
|
type: "file" as const,
|
|
url: URL.createObjectURL(file),
|
|
mediaType: file.type,
|
|
filename: file.name,
|
|
})),
|
|
),
|
|
);
|
|
}, []);
|
|
|
|
const remove = useCallback((id: string) => {
|
|
setAttachmentFiles((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 = useCallback(() => {
|
|
setAttachmentFiles((prev) => {
|
|
for (const f of prev) {
|
|
if (f.url) {
|
|
URL.revokeObjectURL(f.url);
|
|
}
|
|
}
|
|
return [];
|
|
});
|
|
}, []);
|
|
|
|
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
|
const attachmentsRef = useRef(attachmentFiles);
|
|
attachmentsRef.current = attachmentFiles;
|
|
|
|
// Cleanup blob URLs on unmount to prevent memory leaks
|
|
useEffect(() => {
|
|
return () => {
|
|
for (const f of attachmentsRef.current) {
|
|
if (f.url) {
|
|
URL.revokeObjectURL(f.url);
|
|
}
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const openFileDialog = useCallback(() => {
|
|
openRef.current?.();
|
|
}, []);
|
|
|
|
const attachments = useMemo<AttachmentsContext>(
|
|
() => ({
|
|
files: attachmentFiles,
|
|
add,
|
|
remove,
|
|
clear,
|
|
openFileDialog,
|
|
fileInputRef,
|
|
}),
|
|
[attachmentFiles, add, remove, clear, openFileDialog],
|
|
);
|
|
|
|
const __registerFileInput = useCallback(
|
|
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
|
|
fileInputRef.current = ref.current;
|
|
openRef.current = open;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const controller = useMemo<PromptInputControllerProps>(
|
|
() => ({
|
|
textInput: {
|
|
value: textInput,
|
|
setInput: setTextInput,
|
|
clear: clearInput,
|
|
},
|
|
attachments,
|
|
__registerFileInput,
|
|
}),
|
|
[textInput, clearInput, attachments, __registerFileInput],
|
|
);
|
|
|
|
return (
|
|
<PromptInputController.Provider value={controller}>
|
|
<ProviderAttachmentsContext.Provider value={attachments}>
|
|
{children}
|
|
</ProviderAttachmentsContext.Provider>
|
|
</PromptInputController.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 filename = data.filename || "";
|
|
|
|
const mediaType =
|
|
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
|
const isImage = mediaType === "image";
|
|
|
|
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
|
|
|
return (
|
|
<PromptInputHoverCard>
|
|
<HoverCardTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
"group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none",
|
|
className,
|
|
)}
|
|
key={data.id}
|
|
{...props}
|
|
>
|
|
<div className="relative size-5 shrink-0">
|
|
<div className="bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0">
|
|
{isImage ? (
|
|
<img
|
|
alt={filename || "attachment"}
|
|
className="size-5 object-cover"
|
|
height={20}
|
|
src={data.url}
|
|
width={20}
|
|
/>
|
|
) : (
|
|
<div className="text-muted-foreground flex size-5 items-center justify-center">
|
|
<PaperclipIcon className="size-3" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
aria-label="Remove attachment"
|
|
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
attachments.remove(data.id);
|
|
}}
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<XIcon />
|
|
<span className="sr-only">Remove</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<span className="flex-1 truncate">{attachmentLabel}</span>
|
|
</div>
|
|
</HoverCardTrigger>
|
|
<PromptInputHoverCardContent className="w-auto p-2">
|
|
<div className="w-auto space-y-3">
|
|
{isImage && (
|
|
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
|
|
<img
|
|
alt={filename || "attachment preview"}
|
|
className="max-h-full max-w-full object-contain"
|
|
height={384}
|
|
src={data.url}
|
|
width={448}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="min-w-0 flex-1 space-y-1 px-0.5">
|
|
<h4 className="truncate text-sm leading-none font-semibold">
|
|
{filename || (isImage ? "Image" : "Attachment")}
|
|
</h4>
|
|
{data.mediaType && (
|
|
<p className="text-muted-foreground truncate font-mono text-xs">
|
|
{data.mediaType}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PromptInputHoverCardContent>
|
|
</PromptInputHoverCard>
|
|
);
|
|
}
|
|
|
|
export type PromptInputAttachmentsProps = Omit<
|
|
HTMLAttributes<HTMLDivElement>,
|
|
"children"
|
|
> & {
|
|
children: (attachment: FileUIPart & { id: string }) => ReactNode;
|
|
};
|
|
|
|
export function PromptInputAttachments({
|
|
children,
|
|
className,
|
|
...props
|
|
}: PromptInputAttachmentsProps) {
|
|
const attachments = usePromptInputAttachments();
|
|
|
|
if (!attachments.files.length) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
|
{...props}
|
|
>
|
|
{attachments.files.map((file) => (
|
|
<Fragment key={file.id}>
|
|
<div className="max-w-60">{children(file)}</div>
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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();
|
|
}}
|
|
>
|
|
<PaperclipIcon className="mr-2 size-4" /> {label}
|
|
</DropdownMenuItem>
|
|
);
|
|
};
|
|
|
|
export type PromptInputMessage = {
|
|
text: string;
|
|
files: FileUIPart[];
|
|
};
|
|
|
|
export type PromptInputProps = Omit<
|
|
HTMLAttributes<HTMLFormElement>,
|
|
"onSubmit" | "onError"
|
|
> & {
|
|
accept?: string; // e.g., "image/*" or leave undefined for any
|
|
disabled?: boolean;
|
|
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,
|
|
disabled,
|
|
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 formRef = useRef<HTMLFormElement | null>(null);
|
|
|
|
// ----- Local attachments (only used when no provider)
|
|
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
|
const files = usingProvider ? controller.attachments.files : items;
|
|
|
|
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
|
const filesRef = useRef(files);
|
|
filesRef.current = files;
|
|
|
|
const openFileDialogLocal = useCallback(() => {
|
|
inputRef.current?.click();
|
|
}, []);
|
|
|
|
const matchesAccept = useCallback(
|
|
(f: File) => {
|
|
if (!accept || accept.trim() === "") {
|
|
return true;
|
|
}
|
|
|
|
const patterns = accept
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
|
|
return patterns.some((pattern) => {
|
|
if (pattern.endsWith("/*")) {
|
|
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
|
return f.type.startsWith(prefix);
|
|
}
|
|
return f.type === pattern;
|
|
});
|
|
},
|
|
[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, maxFiles, maxFileSize, onError],
|
|
);
|
|
|
|
const removeLocal = useCallback(
|
|
(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 clearLocal = useCallback(
|
|
() =>
|
|
setItems((prev) => {
|
|
for (const file of prev) {
|
|
if (file.url) {
|
|
URL.revokeObjectURL(file.url);
|
|
}
|
|
}
|
|
return [];
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const add = usingProvider ? controller.attachments.add : addLocal;
|
|
const remove = usingProvider ? controller.attachments.remove : removeLocal;
|
|
const clear = usingProvider ? controller.attachments.clear : clearLocal;
|
|
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;
|
|
if (globalDrop) return; // when global drop is on, let the document-level handler own drops
|
|
|
|
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, globalDrop]);
|
|
|
|
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(
|
|
() => () => {
|
|
if (!usingProvider) {
|
|
for (const f of filesRef.current) {
|
|
if (f.url) URL.revokeObjectURL(f.url);
|
|
}
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
|
|
[usingProvider],
|
|
);
|
|
|
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
|
if (event.currentTarget.files) {
|
|
add(event.currentTarget.files);
|
|
}
|
|
// Reset input value to allow selecting files that were previously removed
|
|
event.currentTarget.value = "";
|
|
};
|
|
|
|
const convertBlobUrlToDataUrl = async (
|
|
url: string,
|
|
): Promise<string | null> => {
|
|
try {
|
|
const response = await fetch(url);
|
|
const blob = await response.blob();
|
|
return new Promise((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result as string);
|
|
reader.onerror = () => resolve(null);
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const ctx = useMemo<AttachmentsContext>(
|
|
() => ({
|
|
files: files.map((item) => ({ ...item, id: item.id })),
|
|
add,
|
|
remove,
|
|
clear,
|
|
openFileDialog,
|
|
fileInputRef: inputRef,
|
|
}),
|
|
[files, add, remove, clear, openFileDialog],
|
|
);
|
|
|
|
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, ...item }) => {
|
|
if (item.url && item.url.startsWith("blob:")) {
|
|
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
|
// If conversion failed, keep the original blob URL
|
|
return {
|
|
...item,
|
|
url: dataUrl ?? 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 {
|
|
// Don't clear on error - user may want to retry
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Don't clear on error - user may want to retry
|
|
});
|
|
};
|
|
|
|
// Render with or without local provider
|
|
const inner = (
|
|
<>
|
|
<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}
|
|
ref={formRef}
|
|
{...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 [isComposing, setIsComposing] = useState(false);
|
|
|
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
if (e.key === "Enter") {
|
|
if (isComposing || e.nativeEvent.isComposing) {
|
|
return;
|
|
}
|
|
if (e.shiftKey) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
|
|
// Check if the submit button is disabled before submitting
|
|
const form = e.currentTarget.form;
|
|
const submitButton = form?.querySelector(
|
|
'button[type="submit"]',
|
|
) as HTMLButtonElement | null;
|
|
if (submitButton?.disabled) {
|
|
return;
|
|
}
|
|
|
|
form?.requestSubmit();
|
|
}
|
|
|
|
// Remove last attachment when Backspace is pressed and textarea is empty
|
|
if (
|
|
e.key === "Backspace" &&
|
|
e.currentTarget.value === "" &&
|
|
attachments.files.length > 0
|
|
) {
|
|
e.preventDefault();
|
|
const lastAttachment = attachments.files.at(-1);
|
|
if (lastAttachment) {
|
|
attachments.remove(lastAttachment.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
|
const items = event.clipboardData?.items;
|
|
|
|
if (!items) {
|
|
return;
|
|
}
|
|
|
|
const files: File[] = [];
|
|
|
|
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"
|
|
onCompositionEnd={() => setIsComposing(false)}
|
|
onCompositionStart={() => setIsComposing(true)}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder={placeholder}
|
|
{...props}
|
|
{...controlledProps}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export type PromptInputHeaderProps = Omit<
|
|
ComponentProps<typeof InputGroupAddon>,
|
|
"align"
|
|
>;
|
|
|
|
export const PromptInputHeader = ({
|
|
className,
|
|
...props
|
|
}: PromptInputHeaderProps) => (
|
|
<InputGroupAddon
|
|
align="block-end"
|
|
className={cn("order-first flex-wrap gap-1", className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type PromptInputFooterProps = Omit<
|
|
ComponentProps<typeof InputGroupAddon>,
|
|
"align"
|
|
>;
|
|
|
|
export const PromptInputFooter = ({
|
|
className,
|
|
...props
|
|
}: PromptInputFooterProps) => (
|
|
<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) => {
|
|
return (
|
|
<InputGroupButton
|
|
className={cn(className)}
|
|
size="sm"
|
|
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={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputActionMenuItemProps = ComponentProps<
|
|
typeof DropdownMenuItem
|
|
>;
|
|
export const PromptInputActionMenuItem = ({
|
|
className,
|
|
...props
|
|
}: PromptInputActionMenuItemProps) => (
|
|
<DropdownMenuItem className={cn(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 = <ArrowUpIcon 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={cn(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;
|
|
resultIndex: number;
|
|
}
|
|
|
|
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);
|
|
const callbacksRef = useRef({ textareaRef, onTranscriptionChange });
|
|
callbacksRef.current = { textareaRef, onTranscriptionChange };
|
|
|
|
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 = event.resultIndex; i < event.results.length; i++) {
|
|
const result = event.results[i];
|
|
if (result?.isFinal) {
|
|
finalTranscript += result[0]?.transcript ?? "";
|
|
}
|
|
}
|
|
|
|
const currentTextareaRef = callbacksRef.current.textareaRef;
|
|
const currentOnTranscriptionChange = callbacksRef.current.onTranscriptionChange;
|
|
|
|
if (finalTranscript && currentTextareaRef?.current) {
|
|
const textarea = currentTextareaRef.current;
|
|
const currentValue = textarea.value;
|
|
const newValue =
|
|
currentValue + (currentValue ? " " : "") + finalTranscript;
|
|
|
|
textarea.value = newValue;
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
currentOnTranscriptionChange?.(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();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const toggleListening = useCallback(() => {
|
|
if (!recognition) {
|
|
return;
|
|
}
|
|
|
|
if (isListening) {
|
|
recognition.stop();
|
|
} else {
|
|
recognition.start();
|
|
}
|
|
}, [recognition, isListening]);
|
|
|
|
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 PromptInputSelectProps = ComponentProps<typeof Select>;
|
|
|
|
export const PromptInputSelect = (props: PromptInputSelectProps) => (
|
|
<Select {...props} />
|
|
);
|
|
|
|
export type PromptInputSelectTriggerProps = ComponentProps<
|
|
typeof SelectTrigger
|
|
>;
|
|
|
|
export const PromptInputSelectTrigger = ({
|
|
className,
|
|
...props
|
|
}: PromptInputSelectTriggerProps) => (
|
|
<SelectTrigger
|
|
className={cn(
|
|
"text-muted-foreground border-none bg-transparent font-medium shadow-none transition-colors",
|
|
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type PromptInputSelectContentProps = ComponentProps<
|
|
typeof SelectContent
|
|
>;
|
|
|
|
export const PromptInputSelectContent = ({
|
|
className,
|
|
...props
|
|
}: PromptInputSelectContentProps) => (
|
|
<SelectContent className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
|
|
|
|
export const PromptInputSelectItem = ({
|
|
className,
|
|
...props
|
|
}: PromptInputSelectItemProps) => (
|
|
<SelectItem className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
|
|
|
|
export const PromptInputSelectValue = ({
|
|
className,
|
|
...props
|
|
}: PromptInputSelectValueProps) => (
|
|
<SelectValue className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
|
|
|
|
export const PromptInputHoverCard = ({
|
|
openDelay = 0,
|
|
closeDelay = 0,
|
|
...props
|
|
}: PromptInputHoverCardProps) => (
|
|
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
|
);
|
|
|
|
export type PromptInputHoverCardTriggerProps = ComponentProps<
|
|
typeof HoverCardTrigger
|
|
>;
|
|
|
|
export const PromptInputHoverCardTrigger = (
|
|
props: PromptInputHoverCardTriggerProps,
|
|
) => <HoverCardTrigger {...props} />;
|
|
|
|
export type PromptInputHoverCardContentProps = ComponentProps<
|
|
typeof HoverCardContent
|
|
>;
|
|
|
|
export const PromptInputHoverCardContent = ({
|
|
align = "start",
|
|
...props
|
|
}: PromptInputHoverCardContentProps) => (
|
|
<HoverCardContent align={align} {...props} />
|
|
);
|
|
|
|
export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputTabsList = ({
|
|
className,
|
|
...props
|
|
}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
|
|
|
|
export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputTab = ({
|
|
className,
|
|
...props
|
|
}: PromptInputTabProps) => <div className={cn(className)} {...props} />;
|
|
|
|
export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
|
|
|
|
export const PromptInputTabLabel = ({
|
|
className,
|
|
...props
|
|
}: PromptInputTabLabelProps) => (
|
|
<h3
|
|
className={cn(
|
|
"text-muted-foreground mb-2 px-3 text-xs font-medium",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputTabBody = ({
|
|
className,
|
|
...props
|
|
}: PromptInputTabBodyProps) => (
|
|
<div className={cn("space-y-1", className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const PromptInputTabItem = ({
|
|
className,
|
|
...props
|
|
}: PromptInputTabItemProps) => (
|
|
<div
|
|
className={cn(
|
|
"hover:bg-accent flex items-center gap-2 px-3 py-2 text-xs",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type PromptInputCommandProps = ComponentProps<typeof Command>;
|
|
|
|
export const PromptInputCommand = ({
|
|
className,
|
|
...props
|
|
}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
|
|
|
|
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
|
|
|
|
export const PromptInputCommandInput = ({
|
|
className,
|
|
...props
|
|
}: PromptInputCommandInputProps) => (
|
|
<CommandInput className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
|
|
|
|
export const PromptInputCommandList = ({
|
|
className,
|
|
...props
|
|
}: PromptInputCommandListProps) => (
|
|
<CommandList className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
|
|
|
|
export const PromptInputCommandEmpty = ({
|
|
className,
|
|
...props
|
|
}: PromptInputCommandEmptyProps) => (
|
|
<CommandEmpty className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
|
|
|
|
export const PromptInputCommandGroup = ({
|
|
className,
|
|
...props
|
|
}: PromptInputCommandGroupProps) => (
|
|
<CommandGroup className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
|
|
|
|
export const PromptInputCommandItem = ({
|
|
className,
|
|
...props
|
|
}: PromptInputCommandItemProps) => (
|
|
<CommandItem className={cn(className)} {...props} />
|
|
);
|
|
|
|
export type PromptInputCommandSeparatorProps = ComponentProps<
|
|
typeof CommandSeparator
|
|
>;
|
|
|
|
export const PromptInputCommandSeparator = ({
|
|
className,
|
|
...props
|
|
}: PromptInputCommandSeparatorProps) => (
|
|
<CommandSeparator className={cn(className)} {...props} />
|
|
);
|