1479 lines
39 KiB
TypeScript
1479 lines
39 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 truncateFilename = (name: string, maxLen: number = 10) => {
|
||
if (name.length <= maxLen) return name;
|
||
const ext = name.slice(name.lastIndexOf("."));
|
||
const baseName = name.slice(0, name.lastIndexOf("."));
|
||
const truncated = baseName.slice(0, maxLen - ext.length - 3);
|
||
return truncated + "..." + ext;
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"group relative flex size-16 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-lg transition-all select-none",
|
||
isImage ? "p-0" : "bg-gray-100 dark:bg-gray-700",
|
||
className,
|
||
)}
|
||
key={data.id}
|
||
{...props}
|
||
>
|
||
{isImage ? (
|
||
<>
|
||
<img
|
||
alt={filename || "attachment"}
|
||
className="size-full object-cover"
|
||
src={data.url}
|
||
/>
|
||
{/* 悬浮遮罩层 */}
|
||
<div
|
||
className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||
style={{ borderRadius: "10px", background: "rgba(0, 0, 0, 0.60)" }}
|
||
>
|
||
{/* 眼睛图标 - 居中 */}
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 20 20"
|
||
fill="none"
|
||
>
|
||
<path
|
||
d="M10 4.75C13.3315 4.75 16.4669 6.61444 18.9805 9.88281C19.0335 9.95183 19.0335 10.0482 18.9805 10.1172C16.4669 13.3856 13.3315 15.25 10 15.25C6.66835 15.2499 3.53309 13.3857 1.01953 10.1172C0.966466 10.0482 0.966465 9.95182 1.01953 9.88281C3.53309 6.61435 6.66835 4.75014 10 4.75Z"
|
||
stroke="white"
|
||
strokeWidth="1.5"
|
||
/>
|
||
<path
|
||
d="M10 7.75C11.2426 7.75 12.25 8.75736 12.25 10C12.25 11.2426 11.2426 12.25 10 12.25C8.75736 12.25 7.75 11.2426 7.75 10C7.75 8.75736 8.75736 7.75 10 7.75Z"
|
||
stroke="white"
|
||
strokeWidth="1.5"
|
||
/>
|
||
</svg>
|
||
{/* 删除按钮 - 右上角 */}
|
||
<button
|
||
aria-label="Remove attachment"
|
||
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-white/20"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
attachments.remove(data.id);
|
||
}}
|
||
type="button"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="8"
|
||
height="8"
|
||
viewBox="0 0 8 8"
|
||
fill="none"
|
||
>
|
||
<path
|
||
d="M0.75 0.75L6.74995 6.74995"
|
||
stroke="white"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
/>
|
||
<path
|
||
d="M6.75 0.75L0.750025 6.74992"
|
||
stroke="white"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex flex-col items-center justify-center gap-1 px-1">
|
||
<PaperclipIcon className="size-6 text-gray-400" />
|
||
<span className="max-w-full truncate text-center text-[10px] text-gray-500">
|
||
{truncateFilename(filename)}
|
||
</span>
|
||
</div>
|
||
{/* 关闭按钮 - 右上角 */}
|
||
<button
|
||
aria-label="Remove attachment"
|
||
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-white/90 transition-colors hover:bg-white dark:bg-gray-800/90 dark:hover:bg-gray-800"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
attachments.remove(data.id);
|
||
}}
|
||
type="button"
|
||
>
|
||
<XIcon className="size-3 text-gray-600 dark:text-gray-300" />
|
||
<span className="sr-only">Remove</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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(
|
||
"inline-flex flex-row flex-nowrap items-center gap-2 rounded-xl p-2",
|
||
className,
|
||
)}
|
||
{...props}
|
||
>
|
||
{attachments.files.map((file) => (
|
||
<Fragment key={file.id}>{children(file)}</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>;
|
||
// className for InputGroup (passes through to inner InputGroup component)
|
||
inputGroupClassName?: string;
|
||
};
|
||
|
||
export const PromptInput = ({
|
||
className,
|
||
inputGroupClassName,
|
||
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 className={inputGroupClassName}>{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 = "sm",
|
||
status,
|
||
disabled,
|
||
children,
|
||
...props
|
||
}: PromptInputSubmitProps) => {
|
||
const controller = useOptionalPromptInputController();
|
||
|
||
// 判断是否有内容可发送
|
||
const hasContent = controller
|
||
? controller.textInput.value.trim().length > 0 ||
|
||
controller.attachments.files.length > 0
|
||
: false;
|
||
|
||
// 正在 streaming 时不允许发送
|
||
// const isStreaming = status === "streaming" || status === "submitted";
|
||
|
||
// const isDisabled = disabled || !hasContent || isStreaming;
|
||
|
||
let Icon = <ArrowUpIcon className="size-4" />;
|
||
|
||
let text: string = "发送";
|
||
|
||
if (status === "submitted") {
|
||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||
text = "生成中...";
|
||
} else if (status === "streaming") {
|
||
Icon = <SquareIcon className="size-4" />;
|
||
text = "停止";
|
||
} else if (status === "error") {
|
||
Icon = <XIcon className="size-4" />;
|
||
text = "错误";
|
||
}
|
||
|
||
return (
|
||
<InputGroupButton
|
||
aria-label="Submit"
|
||
// 被button{bgc:#fff}覆盖了,只能加"!"
|
||
className={cn(
|
||
"h-[40px] w-[140px] rounded-[10px] border-0 font-bold transition-all",
|
||
// isDisabled
|
||
// ? "cursor-not-allowed !bg-gray-200 text-gray-400":
|
||
"!bg-[#F0E8FB] text-[#8E47F0] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]",
|
||
className,
|
||
)}
|
||
size={size}
|
||
type="submit"
|
||
variant={variant}
|
||
// disabled={isDisabled}
|
||
{...props}
|
||
>
|
||
{/* {children ?? Icon} */}
|
||
{text}
|
||
</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);
|
||
|
||
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 ?? "";
|
||
}
|
||
}
|
||
|
||
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 = 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} />
|
||
);
|