img]:bg-background dark:[&>img]:bg-foreground flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1",
- className,
- )}
- {...props}
- />
-);
-
-export type ModelSelectorNameProps = ComponentProps<"span">;
-
-export const ModelSelectorName = ({
- className,
- ...props
-}: ModelSelectorNameProps) => (
-
-);
+import {
+ Command,
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "@/components/ui/command";
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+import type { ComponentProps, ReactNode } from "react";
+
+export type ModelSelectorProps = ComponentProps
;
+
+export const ModelSelector = (props: ModelSelectorProps) => (
+
+);
+
+export type ModelSelectorTriggerProps = ComponentProps;
+
+export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
+
+);
+
+export type ModelSelectorContentProps = ComponentProps & {
+ title?: ReactNode;
+};
+
+export const ModelSelectorContent = ({
+ className,
+ children,
+ title = "Model Selector",
+ ...props
+}: ModelSelectorContentProps) => (
+
+ {title}
+
+ {children}
+
+
+);
+
+export type ModelSelectorDialogProps = ComponentProps;
+
+export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
+
+);
+
+export type ModelSelectorInputProps = ComponentProps;
+
+export const ModelSelectorInput = ({
+ className,
+ ...props
+}: ModelSelectorInputProps) => (
+
+);
+
+export type ModelSelectorListProps = ComponentProps;
+
+export const ModelSelectorList = (props: ModelSelectorListProps) => (
+
+);
+
+export type ModelSelectorEmptyProps = ComponentProps;
+
+export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
+
+);
+
+export type ModelSelectorGroupProps = ComponentProps;
+
+export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
+
+);
+
+export type ModelSelectorItemProps = ComponentProps;
+
+export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
+
+);
+
+export type ModelSelectorShortcutProps = ComponentProps;
+
+export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
+
+);
+
+export type ModelSelectorSeparatorProps = ComponentProps<
+ typeof CommandSeparator
+>;
+
+export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
+
+);
+
+export type ModelSelectorLogoProps = Omit<
+ ComponentProps<"img">,
+ "src" | "alt"
+> & {
+ provider:
+ | "moonshotai-cn"
+ | "lucidquery"
+ | "moonshotai"
+ | "zai-coding-plan"
+ | "alibaba"
+ | "xai"
+ | "vultr"
+ | "nvidia"
+ | "upstage"
+ | "groq"
+ | "github-copilot"
+ | "mistral"
+ | "vercel"
+ | "nebius"
+ | "deepseek"
+ | "alibaba-cn"
+ | "google-vertex-anthropic"
+ | "venice"
+ | "chutes"
+ | "cortecs"
+ | "github-models"
+ | "togetherai"
+ | "azure"
+ | "baseten"
+ | "huggingface"
+ | "opencode"
+ | "fastrouter"
+ | "google"
+ | "google-vertex"
+ | "cloudflare-workers-ai"
+ | "inception"
+ | "wandb"
+ | "openai"
+ | "zhipuai-coding-plan"
+ | "perplexity"
+ | "openrouter"
+ | "zenmux"
+ | "v0"
+ | "iflowcn"
+ | "synthetic"
+ | "deepinfra"
+ | "zhipuai"
+ | "submodel"
+ | "zai"
+ | "inference"
+ | "requesty"
+ | "morph"
+ | "lmstudio"
+ | "anthropic"
+ | "aihubmix"
+ | "fireworks-ai"
+ | "modelscope"
+ | "llama"
+ | "scaleway"
+ | "amazon-bedrock"
+ | "cerebras"
+ | (string & {});
+};
+
+export const ModelSelectorLogo = ({
+ provider,
+ className,
+ ...props
+}: ModelSelectorLogoProps) => (
+
+);
+
+export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
+
+export const ModelSelectorLogoGroup = ({
+ className,
+ ...props
+}: ModelSelectorLogoGroupProps) => (
+ img]:bg-background dark:[&>img]:bg-foreground flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1",
+ className,
+ )}
+ {...props}
+ />
+);
+
+export type ModelSelectorNameProps = ComponentProps<"span">;
+
+export const ModelSelectorName = ({
+ className,
+ ...props
+}: ModelSelectorNameProps) => (
+
+);
diff --git a/frontend-web/src/components/ai-elements/node.tsx b/frontend-web/src/components/ai-elements/node.tsx
index f329a8b3..e4b48c8d 100644
--- a/frontend-web/src/components/ai-elements/node.tsx
+++ b/frontend-web/src/components/ai-elements/node.tsx
@@ -1,71 +1,71 @@
-import {
- Card,
- CardAction,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { cn } from "@/lib/utils";
-import { Handle, Position } from "@xyflow/react";
-import type { ComponentProps } from "react";
-
-export type NodeProps = ComponentProps
& {
- handles: {
- target: boolean;
- source: boolean;
- };
-};
-
-export const Node = ({ handles, className, ...props }: NodeProps) => (
-
- {handles.target && }
- {handles.source && }
- {props.children}
-
-);
-
-export type NodeHeaderProps = ComponentProps;
-
-export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
-
-);
-
-export type NodeTitleProps = ComponentProps;
-
-export const NodeTitle = (props: NodeTitleProps) => ;
-
-export type NodeDescriptionProps = ComponentProps;
-
-export const NodeDescription = (props: NodeDescriptionProps) => (
-
-);
-
-export type NodeActionProps = ComponentProps;
-
-export const NodeAction = (props: NodeActionProps) => ;
-
-export type NodeContentProps = ComponentProps;
-
-export const NodeContent = ({ className, ...props }: NodeContentProps) => (
-
-);
-
-export type NodeFooterProps = ComponentProps;
-
-export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
-
-);
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import { Handle, Position } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+export type NodeProps = ComponentProps & {
+ handles: {
+ target: boolean;
+ source: boolean;
+ };
+};
+
+export const Node = ({ handles, className, ...props }: NodeProps) => (
+
+ {handles.target && }
+ {handles.source && }
+ {props.children}
+
+);
+
+export type NodeHeaderProps = ComponentProps;
+
+export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
+
+);
+
+export type NodeTitleProps = ComponentProps;
+
+export const NodeTitle = (props: NodeTitleProps) => ;
+
+export type NodeDescriptionProps = ComponentProps;
+
+export const NodeDescription = (props: NodeDescriptionProps) => (
+
+);
+
+export type NodeActionProps = ComponentProps;
+
+export const NodeAction = (props: NodeActionProps) => ;
+
+export type NodeContentProps = ComponentProps;
+
+export const NodeContent = ({ className, ...props }: NodeContentProps) => (
+
+);
+
+export type NodeFooterProps = ComponentProps;
+
+export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
+
+);
diff --git a/frontend-web/src/components/ai-elements/open-in-chat.tsx b/frontend-web/src/components/ai-elements/open-in-chat.tsx
index b86e93db..baab778a 100644
--- a/frontend-web/src/components/ai-elements/open-in-chat.tsx
+++ b/frontend-web/src/components/ai-elements/open-in-chat.tsx
@@ -1,365 +1,365 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { cn } from "@/lib/utils";
-import {
- ChevronDownIcon,
- ExternalLinkIcon,
- MessageCircleIcon,
-} from "lucide-react";
-import { type ComponentProps, createContext, useContext } from "react";
-
-const providers = {
- github: {
- title: "Open in GitHub",
- createUrl: (url: string) => url,
- icon: (
-
- ),
- },
- scira: {
- title: "Open in Scira",
- createUrl: (q: string) =>
- `https://scira.ai/?${new URLSearchParams({
- q,
- })}`,
- icon: (
-
- ),
- },
- chatgpt: {
- title: "Open in ChatGPT",
- createUrl: (prompt: string) =>
- `https://chatgpt.com/?${new URLSearchParams({
- hints: "search",
- prompt,
- })}`,
- icon: (
-
- ),
- },
- claude: {
- title: "Open in Claude",
- createUrl: (q: string) =>
- `https://claude.ai/new?${new URLSearchParams({
- q,
- })}`,
- icon: (
-
- ),
- },
- t3: {
- title: "Open in T3 Chat",
- createUrl: (q: string) =>
- `https://t3.chat/new?${new URLSearchParams({
- q,
- })}`,
- icon: ,
- },
- v0: {
- title: "Open in v0",
- createUrl: (q: string) =>
- `https://v0.app?${new URLSearchParams({
- q,
- })}`,
- icon: (
-
- ),
- },
- cursor: {
- title: "Open in Cursor",
- createUrl: (text: string) => {
- const url = new URL("https://cursor.com/link/prompt");
- url.searchParams.set("text", text);
- return url.toString();
- },
- icon: (
-
- ),
- },
-};
-
-const OpenInContext = createContext<{ query: string } | undefined>(undefined);
-
-const useOpenInContext = () => {
- const context = useContext(OpenInContext);
- if (!context) {
- throw new Error("OpenIn components must be used within an OpenIn provider");
- }
- return context;
-};
-
-export type OpenInProps = ComponentProps & {
- query: string;
-};
-
-export const OpenIn = ({ query, ...props }: OpenInProps) => (
-
-
-
-);
-
-export type OpenInContentProps = ComponentProps;
-
-export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
-
-);
-
-export type OpenInItemProps = ComponentProps;
-
-export const OpenInItem = (props: OpenInItemProps) => (
-
-);
-
-export type OpenInLabelProps = ComponentProps;
-
-export const OpenInLabel = (props: OpenInLabelProps) => (
-
-);
-
-export type OpenInSeparatorProps = ComponentProps;
-
-export const OpenInSeparator = (props: OpenInSeparatorProps) => (
-
-);
-
-export type OpenInTriggerProps = ComponentProps;
-
-export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
-
- {children ?? (
-
- )}
-
-);
-
-export type OpenInChatGPTProps = ComponentProps;
-
-export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
- const { query } = useOpenInContext();
- return (
-
-
- {providers.chatgpt.icon}
- {providers.chatgpt.title}
-
-
-
- );
-};
-
-export type OpenInClaudeProps = ComponentProps;
-
-export const OpenInClaude = (props: OpenInClaudeProps) => {
- const { query } = useOpenInContext();
- return (
-
-
- {providers.claude.icon}
- {providers.claude.title}
-
-
-
- );
-};
-
-export type OpenInT3Props = ComponentProps;
-
-export const OpenInT3 = (props: OpenInT3Props) => {
- const { query } = useOpenInContext();
- return (
-
-
- {providers.t3.icon}
- {providers.t3.title}
-
-
-
- );
-};
-
-export type OpenInSciraProps = ComponentProps;
-
-export const OpenInScira = (props: OpenInSciraProps) => {
- const { query } = useOpenInContext();
- return (
-
-
- {providers.scira.icon}
- {providers.scira.title}
-
-
-
- );
-};
-
-export type OpenInv0Props = ComponentProps;
-
-export const OpenInv0 = (props: OpenInv0Props) => {
- const { query } = useOpenInContext();
- return (
-
-
- {providers.v0.icon}
- {providers.v0.title}
-
-
-
- );
-};
-
-export type OpenInCursorProps = ComponentProps;
-
-export const OpenInCursor = (props: OpenInCursorProps) => {
- const { query } = useOpenInContext();
- return (
-
-
- {providers.cursor.icon}
- {providers.cursor.title}
-
-
-
- );
-};
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+import {
+ ChevronDownIcon,
+ ExternalLinkIcon,
+ MessageCircleIcon,
+} from "lucide-react";
+import { type ComponentProps, createContext, useContext } from "react";
+
+const providers = {
+ github: {
+ title: "Open in GitHub",
+ createUrl: (url: string) => url,
+ icon: (
+
+ ),
+ },
+ scira: {
+ title: "Open in Scira",
+ createUrl: (q: string) =>
+ `https://scira.ai/?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ chatgpt: {
+ title: "Open in ChatGPT",
+ createUrl: (prompt: string) =>
+ `https://chatgpt.com/?${new URLSearchParams({
+ hints: "search",
+ prompt,
+ })}`,
+ icon: (
+
+ ),
+ },
+ claude: {
+ title: "Open in Claude",
+ createUrl: (q: string) =>
+ `https://claude.ai/new?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ t3: {
+ title: "Open in T3 Chat",
+ createUrl: (q: string) =>
+ `https://t3.chat/new?${new URLSearchParams({
+ q,
+ })}`,
+ icon: ,
+ },
+ v0: {
+ title: "Open in v0",
+ createUrl: (q: string) =>
+ `https://v0.app?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ cursor: {
+ title: "Open in Cursor",
+ createUrl: (text: string) => {
+ const url = new URL("https://cursor.com/link/prompt");
+ url.searchParams.set("text", text);
+ return url.toString();
+ },
+ icon: (
+
+ ),
+ },
+};
+
+const OpenInContext = createContext<{ query: string } | undefined>(undefined);
+
+const useOpenInContext = () => {
+ const context = useContext(OpenInContext);
+ if (!context) {
+ throw new Error("OpenIn components must be used within an OpenIn provider");
+ }
+ return context;
+};
+
+export type OpenInProps = ComponentProps & {
+ query: string;
+};
+
+export const OpenIn = ({ query, ...props }: OpenInProps) => (
+
+
+
+);
+
+export type OpenInContentProps = ComponentProps;
+
+export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
+
+);
+
+export type OpenInItemProps = ComponentProps;
+
+export const OpenInItem = (props: OpenInItemProps) => (
+
+);
+
+export type OpenInLabelProps = ComponentProps;
+
+export const OpenInLabel = (props: OpenInLabelProps) => (
+
+);
+
+export type OpenInSeparatorProps = ComponentProps;
+
+export const OpenInSeparator = (props: OpenInSeparatorProps) => (
+
+);
+
+export type OpenInTriggerProps = ComponentProps;
+
+export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
+
+ {children ?? (
+
+ )}
+
+);
+
+export type OpenInChatGPTProps = ComponentProps;
+
+export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.chatgpt.icon}
+ {providers.chatgpt.title}
+
+
+
+ );
+};
+
+export type OpenInClaudeProps = ComponentProps;
+
+export const OpenInClaude = (props: OpenInClaudeProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.claude.icon}
+ {providers.claude.title}
+
+
+
+ );
+};
+
+export type OpenInT3Props = ComponentProps;
+
+export const OpenInT3 = (props: OpenInT3Props) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.t3.icon}
+ {providers.t3.title}
+
+
+
+ );
+};
+
+export type OpenInSciraProps = ComponentProps;
+
+export const OpenInScira = (props: OpenInSciraProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.scira.icon}
+ {providers.scira.title}
+
+
+
+ );
+};
+
+export type OpenInv0Props = ComponentProps;
+
+export const OpenInv0 = (props: OpenInv0Props) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.v0.icon}
+ {providers.v0.title}
+
+
+
+ );
+};
+
+export type OpenInCursorProps = ComponentProps;
+
+export const OpenInCursor = (props: OpenInCursorProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.cursor.icon}
+ {providers.cursor.title}
+
+
+
+ );
+};
diff --git a/frontend-web/src/components/ai-elements/panel.tsx b/frontend-web/src/components/ai-elements/panel.tsx
index a0b8c1d7..ba0babc2 100644
--- a/frontend-web/src/components/ai-elements/panel.tsx
+++ b/frontend-web/src/components/ai-elements/panel.tsx
@@ -1,15 +1,15 @@
-import { cn } from "@/lib/utils";
-import { Panel as PanelPrimitive } from "@xyflow/react";
-import type { ComponentProps } from "react";
-
-type PanelProps = ComponentProps;
-
-export const Panel = ({ className, ...props }: PanelProps) => (
-
-);
+import { cn } from "@/lib/utils";
+import { Panel as PanelPrimitive } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+type PanelProps = ComponentProps;
+
+export const Panel = ({ className, ...props }: PanelProps) => (
+
+);
diff --git a/frontend-web/src/components/ai-elements/plan.tsx b/frontend-web/src/components/ai-elements/plan.tsx
index be04d883..b63a47bd 100644
--- a/frontend-web/src/components/ai-elements/plan.tsx
+++ b/frontend-web/src/components/ai-elements/plan.tsx
@@ -1,142 +1,142 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardAction,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
-import { cn } from "@/lib/utils";
-import { ChevronsUpDownIcon } from "lucide-react";
-import type { ComponentProps } from "react";
-import { createContext, useContext } from "react";
-import { Shimmer } from "./shimmer";
-
-type PlanContextValue = {
- isStreaming: boolean;
-};
-
-const PlanContext = createContext(null);
-
-const usePlan = () => {
- const context = useContext(PlanContext);
- if (!context) {
- throw new Error("Plan components must be used within Plan");
- }
- return context;
-};
-
-export type PlanProps = ComponentProps & {
- isStreaming?: boolean;
-};
-
-export const Plan = ({
- className,
- isStreaming = false,
- children,
- ...props
-}: PlanProps) => (
-
-
- {children}
-
-
-);
-
-export type PlanHeaderProps = ComponentProps;
-
-export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
-
-);
-
-export type PlanTitleProps = Omit<
- ComponentProps,
- "children"
-> & {
- children: string;
-};
-
-export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
- const { isStreaming } = usePlan();
-
- return (
-
- {isStreaming ? {children} : children}
-
- );
-};
-
-export type PlanDescriptionProps = Omit<
- ComponentProps,
- "children"
-> & {
- children: string;
-};
-
-export const PlanDescription = ({
- className,
- children,
- ...props
-}: PlanDescriptionProps) => {
- const { isStreaming } = usePlan();
-
- return (
-
- {isStreaming ? {children} : children}
-
- );
-};
-
-export type PlanActionProps = ComponentProps;
-
-export const PlanAction = (props: PlanActionProps) => (
-
-);
-
-export type PlanContentProps = ComponentProps;
-
-export const PlanContent = (props: PlanContentProps) => (
-
-
-
-);
-
-export type PlanFooterProps = ComponentProps<"div">;
-
-export const PlanFooter = (props: PlanFooterProps) => (
-
-);
-
-export type PlanTriggerProps = ComponentProps;
-
-export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
-
-
-
-);
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { ChevronsUpDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+import { createContext, useContext } from "react";
+import { Shimmer } from "./shimmer";
+
+type PlanContextValue = {
+ isStreaming: boolean;
+};
+
+const PlanContext = createContext(null);
+
+const usePlan = () => {
+ const context = useContext(PlanContext);
+ if (!context) {
+ throw new Error("Plan components must be used within Plan");
+ }
+ return context;
+};
+
+export type PlanProps = ComponentProps & {
+ isStreaming?: boolean;
+};
+
+export const Plan = ({
+ className,
+ isStreaming = false,
+ children,
+ ...props
+}: PlanProps) => (
+
+
+ {children}
+
+
+);
+
+export type PlanHeaderProps = ComponentProps;
+
+export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
+
+);
+
+export type PlanTitleProps = Omit<
+ ComponentProps,
+ "children"
+> & {
+ children: string;
+};
+
+export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ {isStreaming ? {children} : children}
+
+ );
+};
+
+export type PlanDescriptionProps = Omit<
+ ComponentProps,
+ "children"
+> & {
+ children: string;
+};
+
+export const PlanDescription = ({
+ className,
+ children,
+ ...props
+}: PlanDescriptionProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ {isStreaming ? {children} : children}
+
+ );
+};
+
+export type PlanActionProps = ComponentProps;
+
+export const PlanAction = (props: PlanActionProps) => (
+
+);
+
+export type PlanContentProps = ComponentProps;
+
+export const PlanContent = (props: PlanContentProps) => (
+
+
+
+);
+
+export type PlanFooterProps = ComponentProps<"div">;
+
+export const PlanFooter = (props: PlanFooterProps) => (
+
+);
+
+export type PlanTriggerProps = ComponentProps;
+
+export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
+
+
+
+);
diff --git a/frontend-web/src/components/ai-elements/prompt-input.tsx b/frontend-web/src/components/ai-elements/prompt-input.tsx
index 0970fbbf..50065afb 100644
--- a/frontend-web/src/components/ai-elements/prompt-input.tsx
+++ b/frontend-web/src/components/ai-elements/prompt-input.tsx
@@ -1,1474 +1,1474 @@
-"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 type { PromptInputFilePart } from "@/core/uploads";
-import { splitUnsupportedUploadFiles } from "@/core/uploads";
-import { isIMEComposing } from "@/lib/ime";
-import { cn } from "@/lib/utils";
-import type { ChatStatus } 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";
-import { toast } from "sonner";
-
-// ============================================================================
-// Provider Context & Types
-// ============================================================================
-
-export type AttachmentsContext = {
- files: (PromptInputFilePart & { id: string })[];
- add: (files: File[] | FileList) => void;
- remove: (id: string) => void;
- clear: () => void;
- openFileDialog: () => void;
- fileInputRef: RefObject;
-};
-
-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,
- open: () => void,
- ) => void;
-};
-
-const PromptInputController = createContext(
- null,
-);
-const ProviderAttachmentsContext = createContext(
- null,
-);
-const PromptInputValidationContext = createContext<
- ((files: File[] | FileList) => File[]) | null
->(null);
-
-export const usePromptInputController = () => {
- const ctx = useContext(PromptInputController);
- if (!ctx) {
- throw new Error(
- "Wrap your component inside 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 to use useProviderAttachments().",
- );
- }
- return ctx;
-};
-
-const useOptionalProviderAttachments = () =>
- useContext(ProviderAttachmentsContext);
-const usePromptInputValidation = () => useContext(PromptInputValidationContext);
-
-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<
- (PromptInputFilePart & { id: string })[]
- >([]);
- const fileInputRef = useRef(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,
- file,
- })),
- ),
- );
- }, []);
-
- 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(
- () => ({
- files: attachmentFiles,
- add,
- remove,
- clear,
- openFileDialog,
- fileInputRef,
- }),
- [attachmentFiles, add, remove, clear, openFileDialog],
- );
-
- const __registerFileInput = useCallback(
- (ref: RefObject, open: () => void) => {
- fileInputRef.current = ref.current;
- openRef.current = open;
- },
- [],
- );
-
- const controller = useMemo(
- () => ({
- textInput: {
- value: textInput,
- setInput: setTextInput,
- clear: clearInput,
- },
- attachments,
- __registerFileInput,
- }),
- [textInput, clearInput, attachments, __registerFileInput],
- );
-
- return (
-
-
- {children}
-
-
- );
-}
-
-// ============================================================================
-// Component Context & Hooks
-// ============================================================================
-
-const LocalAttachmentsContext = createContext(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 & {
- data: PromptInputFilePart & { 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 (
-
-
-
-
-
- {isImage ? (
-

- ) : (
-
- )}
-
-
-
-
-
{attachmentLabel}
-
-
-
-
- {isImage && (
-
-

-
- )}
-
-
-
- {filename || (isImage ? "Image" : "Attachment")}
-
- {data.mediaType && (
-
- {data.mediaType}
-
- )}
-
-
-
-
-
- );
-}
-
-export type PromptInputAttachmentsProps = Omit<
- HTMLAttributes,
- "children"
-> & {
- children: (attachment: PromptInputFilePart & { id: string }) => ReactNode;
-};
-
-export function PromptInputAttachments({
- children,
- className,
- ...props
-}: PromptInputAttachmentsProps) {
- const attachments = usePromptInputAttachments();
-
- if (!attachments.files.length) {
- return null;
- }
-
- return (
-
- {attachments.files.map((file) => (
-
- {children(file)}
-
- ))}
-
- );
-}
-
-export type PromptInputActionAddAttachmentsProps = ComponentProps<
- typeof DropdownMenuItem
-> & {
- label?: string;
-};
-
-export const PromptInputActionAddAttachments = ({
- label = "Add photos or files",
- ...props
-}: PromptInputActionAddAttachmentsProps) => {
- const attachments = usePromptInputAttachments();
-
- return (
- {
- e.preventDefault();
- attachments.openFileDialog();
- }}
- >
- {label}
-
- );
-};
-
-export type PromptInputMessage = {
- text: string;
- files: PromptInputFilePart[];
-};
-
-export type PromptInputProps = Omit<
- HTMLAttributes,
- "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" | "unsupported_package";
- message: string;
- }) => void;
- onSubmit: (
- message: PromptInputMessage,
- event: FormEvent,
- ) => void | Promise;
-};
-
-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(null);
- const formRef = useRef(null);
-
- // ----- Local attachments (only used when no provider)
- const [items, setItems] = useState<(PromptInputFilePart & { 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: (PromptInputFilePart & { id: string })[] = [];
- for (const file of capped) {
- next.push({
- id: nanoid(),
- type: "file",
- url: URL.createObjectURL(file),
- mediaType: file.type,
- filename: file.name,
- file,
- });
- }
- 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;
-
- const sanitizeIncomingFiles = useCallback(
- (fileList: File[] | FileList) => {
- const { accepted, message } = splitUnsupportedUploadFiles(fileList);
- if (message) {
- onError?.({
- code: "unsupported_package",
- message,
- });
- if (!onError) {
- toast.error(message);
- }
- }
- return accepted;
- },
- [onError],
- );
-
- // 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) {
- const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
- if (accepted.length > 0) {
- add(accepted);
- }
- }
- };
- form.addEventListener("dragover", onDragOver);
- form.addEventListener("drop", onDrop);
- return () => {
- form.removeEventListener("dragover", onDragOver);
- form.removeEventListener("drop", onDrop);
- };
- }, [add, globalDrop, sanitizeIncomingFiles]);
-
- 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) {
- const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
- if (accepted.length > 0) {
- add(accepted);
- }
- }
- };
- document.addEventListener("dragover", onDragOver);
- document.addEventListener("drop", onDrop);
- return () => {
- document.removeEventListener("dragover", onDragOver);
- document.removeEventListener("drop", onDrop);
- };
- }, [add, globalDrop, sanitizeIncomingFiles]);
-
- 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 = (event) => {
- if (event.currentTarget.files) {
- const accepted = sanitizeIncomingFiles(event.currentTarget.files);
- if (accepted.length > 0) {
- add(accepted);
- }
- }
- // Reset input value to allow selecting files that were previously removed
- event.currentTarget.value = "";
- };
-
- const convertBlobUrlToDataUrl = async (
- url: string,
- ): Promise => {
- 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(
- () => ({
- files: files.map((item) => ({ ...item, id: item.id })),
- add,
- remove,
- clear,
- openFileDialog,
- fileInputRef: inputRef,
- }),
- [files, add, remove, clear, openFileDialog],
- );
-
- const handleSubmit: FormEventHandler = (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.file instanceof File) {
- // Downstream upload prep reads the preserved File directly.
- return 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: PromptInputFilePart[]) => {
- 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 = (
-
-
-
-
- );
-
- return usingProvider ? (
- inner
- ) : (
-
- {inner}
-
- );
-};
-
-export type PromptInputBodyProps = HTMLAttributes;
-
-export const PromptInputBody = ({
- className,
- ...props
-}: PromptInputBodyProps) => (
-
-);
-
-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 sanitizeIncomingFiles = usePromptInputValidation();
- const [isComposing, setIsComposing] = useState(false);
-
- const handleKeyDown: KeyboardEventHandler = (e) => {
- if (e.key === "Enter") {
- if (isIMEComposing(e, 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 = (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();
- const accepted = sanitizeIncomingFiles
- ? sanitizeIncomingFiles(files)
- : files;
- if (accepted.length > 0) {
- attachments.add(accepted);
- }
- }
- };
-
- const controlledProps = controller
- ? {
- value: controller.textInput.value,
- onChange: (e: ChangeEvent) => {
- controller.textInput.setInput(e.currentTarget.value);
- onChange?.(e);
- },
- }
- : {
- onChange,
- };
-
- return (
- setIsComposing(false)}
- onCompositionStart={() => setIsComposing(true)}
- onKeyDown={handleKeyDown}
- onPaste={handlePaste}
- placeholder={placeholder}
- {...props}
- {...controlledProps}
- />
- );
-};
-
-export type PromptInputHeaderProps = Omit<
- ComponentProps,
- "align"
->;
-
-export const PromptInputHeader = ({
- className,
- ...props
-}: PromptInputHeaderProps) => (
-
-);
-
-export type PromptInputFooterProps = Omit<
- ComponentProps,
- "align"
->;
-
-export const PromptInputFooter = ({
- className,
- ...props
-}: PromptInputFooterProps) => (
-
-);
-
-export type PromptInputToolsProps = HTMLAttributes;
-
-export const PromptInputTools = ({
- className,
- ...props
-}: PromptInputToolsProps) => (
-
-);
-
-export type PromptInputButtonProps = ComponentProps;
-
-export const PromptInputButton = ({
- variant = "ghost",
- className,
- size,
- ...props
-}: PromptInputButtonProps) => {
- return (
-
- );
-};
-
-export type PromptInputActionMenuProps = ComponentProps;
-export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
-
-);
-
-export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
-
-export const PromptInputActionMenuTrigger = ({
- className,
- children,
- ...props
-}: PromptInputActionMenuTriggerProps) => (
-
-
- {children ?? }
-
-
-);
-
-export type PromptInputActionMenuContentProps = ComponentProps<
- typeof DropdownMenuContent
->;
-export const PromptInputActionMenuContent = ({
- className,
- ...props
-}: PromptInputActionMenuContentProps) => (
-
-);
-
-export type PromptInputActionMenuItemProps = ComponentProps<
- typeof DropdownMenuItem
->;
-export const PromptInputActionMenuItem = ({
- className,
- ...props
-}: PromptInputActionMenuItemProps) => (
-
-);
-
-// 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 & {
- status?: ChatStatus;
-};
-
-export const PromptInputSubmit = ({
- className,
- variant = "default",
- size = "icon-sm",
- status,
- children,
- ...props
-}: PromptInputSubmitProps) => {
- let Icon = ;
-
- if (status === "submitted") {
- Icon = ;
- } else if (status === "streaming") {
- Icon = ;
- } else if (status === "error") {
- Icon = ;
- }
-
- return (
-
- {children ?? Icon}
-
- );
-};
-
-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;
- onTranscriptionChange?: (text: string) => void;
-};
-
-export const PromptInputSpeechButton = ({
- className,
- textareaRef,
- onTranscriptionChange,
- ...props
-}: PromptInputSpeechButtonProps) => {
- const [isListening, setIsListening] = useState(false);
- const [recognition, setRecognition] = useState(
- null,
- );
- const recognitionRef = useRef(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 (
-
-
-
- );
-};
-
-export type PromptInputSelectProps = ComponentProps;
-
-export const PromptInputSelect = (props: PromptInputSelectProps) => (
-
-);
-
-export type PromptInputSelectTriggerProps = ComponentProps<
- typeof SelectTrigger
->;
-
-export const PromptInputSelectTrigger = ({
- className,
- ...props
-}: PromptInputSelectTriggerProps) => (
-
-);
-
-export type PromptInputSelectContentProps = ComponentProps<
- typeof SelectContent
->;
-
-export const PromptInputSelectContent = ({
- className,
- ...props
-}: PromptInputSelectContentProps) => (
-
-);
-
-export type PromptInputSelectItemProps = ComponentProps;
-
-export const PromptInputSelectItem = ({
- className,
- ...props
-}: PromptInputSelectItemProps) => (
-
-);
-
-export type PromptInputSelectValueProps = ComponentProps;
-
-export const PromptInputSelectValue = ({
- className,
- ...props
-}: PromptInputSelectValueProps) => (
-
-);
-
-export type PromptInputHoverCardProps = ComponentProps;
-
-export const PromptInputHoverCard = ({
- openDelay = 0,
- closeDelay = 0,
- ...props
-}: PromptInputHoverCardProps) => (
-
-);
-
-export type PromptInputHoverCardTriggerProps = ComponentProps<
- typeof HoverCardTrigger
->;
-
-export const PromptInputHoverCardTrigger = (
- props: PromptInputHoverCardTriggerProps,
-) => ;
-
-export type PromptInputHoverCardContentProps = ComponentProps<
- typeof HoverCardContent
->;
-
-export const PromptInputHoverCardContent = ({
- align = "start",
- ...props
-}: PromptInputHoverCardContentProps) => (
-
-);
-
-export type PromptInputTabsListProps = HTMLAttributes;
-
-export const PromptInputTabsList = ({
- className,
- ...props
-}: PromptInputTabsListProps) => ;
-
-export type PromptInputTabProps = HTMLAttributes;
-
-export const PromptInputTab = ({
- className,
- ...props
-}: PromptInputTabProps) => ;
-
-export type PromptInputTabLabelProps = HTMLAttributes;
-
-export const PromptInputTabLabel = ({
- className,
- ...props
-}: PromptInputTabLabelProps) => (
-
-);
-
-export type PromptInputTabBodyProps = HTMLAttributes;
-
-export const PromptInputTabBody = ({
- className,
- ...props
-}: PromptInputTabBodyProps) => (
-
-);
-
-export type PromptInputTabItemProps = HTMLAttributes;
-
-export const PromptInputTabItem = ({
- className,
- ...props
-}: PromptInputTabItemProps) => (
-
-);
-
-export type PromptInputCommandProps = ComponentProps;
-
-export const PromptInputCommand = ({
- className,
- ...props
-}: PromptInputCommandProps) => ;
-
-export type PromptInputCommandInputProps = ComponentProps;
-
-export const PromptInputCommandInput = ({
- className,
- ...props
-}: PromptInputCommandInputProps) => (
-
-);
-
-export type PromptInputCommandListProps = ComponentProps;
-
-export const PromptInputCommandList = ({
- className,
- ...props
-}: PromptInputCommandListProps) => (
-
-);
-
-export type PromptInputCommandEmptyProps = ComponentProps;
-
-export const PromptInputCommandEmpty = ({
- className,
- ...props
-}: PromptInputCommandEmptyProps) => (
-
-);
-
-export type PromptInputCommandGroupProps = ComponentProps;
-
-export const PromptInputCommandGroup = ({
- className,
- ...props
-}: PromptInputCommandGroupProps) => (
-
-);
-
-export type PromptInputCommandItemProps = ComponentProps;
-
-export const PromptInputCommandItem = ({
- className,
- ...props
-}: PromptInputCommandItemProps) => (
-
-);
-
-export type PromptInputCommandSeparatorProps = ComponentProps<
- typeof CommandSeparator
->;
-
-export const PromptInputCommandSeparator = ({
- className,
- ...props
-}: PromptInputCommandSeparatorProps) => (
-
-);
+"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 type { PromptInputFilePart } from "@/core/uploads";
+import { splitUnsupportedUploadFiles } from "@/core/uploads";
+import { isIMEComposing } from "@/lib/ime";
+import { cn } from "@/lib/utils";
+import type { ChatStatus } 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";
+import { toast } from "sonner";
+
+// ============================================================================
+// Provider Context & Types
+// ============================================================================
+
+export type AttachmentsContext = {
+ files: (PromptInputFilePart & { id: string })[];
+ add: (files: File[] | FileList) => void;
+ remove: (id: string) => void;
+ clear: () => void;
+ openFileDialog: () => void;
+ fileInputRef: RefObject;
+};
+
+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,
+ open: () => void,
+ ) => void;
+};
+
+const PromptInputController = createContext(
+ null,
+);
+const ProviderAttachmentsContext = createContext(
+ null,
+);
+const PromptInputValidationContext = createContext<
+ ((files: File[] | FileList) => File[]) | null
+>(null);
+
+export const usePromptInputController = () => {
+ const ctx = useContext(PromptInputController);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside 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 to use useProviderAttachments().",
+ );
+ }
+ return ctx;
+};
+
+const useOptionalProviderAttachments = () =>
+ useContext(ProviderAttachmentsContext);
+const usePromptInputValidation = () => useContext(PromptInputValidationContext);
+
+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<
+ (PromptInputFilePart & { id: string })[]
+ >([]);
+ const fileInputRef = useRef(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,
+ file,
+ })),
+ ),
+ );
+ }, []);
+
+ 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(
+ () => ({
+ files: attachmentFiles,
+ add,
+ remove,
+ clear,
+ openFileDialog,
+ fileInputRef,
+ }),
+ [attachmentFiles, add, remove, clear, openFileDialog],
+ );
+
+ const __registerFileInput = useCallback(
+ (ref: RefObject, open: () => void) => {
+ fileInputRef.current = ref.current;
+ openRef.current = open;
+ },
+ [],
+ );
+
+ const controller = useMemo(
+ () => ({
+ textInput: {
+ value: textInput,
+ setInput: setTextInput,
+ clear: clearInput,
+ },
+ attachments,
+ __registerFileInput,
+ }),
+ [textInput, clearInput, attachments, __registerFileInput],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext(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 & {
+ data: PromptInputFilePart & { 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 (
+
+
+
+
+
+ {isImage ? (
+

+ ) : (
+
+ )}
+
+
+
+
+
{attachmentLabel}
+
+
+
+
+ {isImage && (
+
+

+
+ )}
+
+
+
+ {filename || (isImage ? "Image" : "Attachment")}
+
+ {data.mediaType && (
+
+ {data.mediaType}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export type PromptInputAttachmentsProps = Omit<
+ HTMLAttributes,
+ "children"
+> & {
+ children: (attachment: PromptInputFilePart & { id: string }) => ReactNode;
+};
+
+export function PromptInputAttachments({
+ children,
+ className,
+ ...props
+}: PromptInputAttachmentsProps) {
+ const attachments = usePromptInputAttachments();
+
+ if (!attachments.files.length) {
+ return null;
+ }
+
+ return (
+
+ {attachments.files.map((file) => (
+
+ {children(file)}
+
+ ))}
+
+ );
+}
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+ typeof DropdownMenuItem
+> & {
+ label?: string;
+};
+
+export const PromptInputActionAddAttachments = ({
+ label = "Add photos or files",
+ ...props
+}: PromptInputActionAddAttachmentsProps) => {
+ const attachments = usePromptInputAttachments();
+
+ return (
+ {
+ e.preventDefault();
+ attachments.openFileDialog();
+ }}
+ >
+ {label}
+
+ );
+};
+
+export type PromptInputMessage = {
+ text: string;
+ files: PromptInputFilePart[];
+};
+
+export type PromptInputProps = Omit<
+ HTMLAttributes,
+ "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" | "unsupported_package";
+ message: string;
+ }) => void;
+ onSubmit: (
+ message: PromptInputMessage,
+ event: FormEvent,
+ ) => void | Promise;
+};
+
+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(null);
+ const formRef = useRef(null);
+
+ // ----- Local attachments (only used when no provider)
+ const [items, setItems] = useState<(PromptInputFilePart & { 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: (PromptInputFilePart & { id: string })[] = [];
+ for (const file of capped) {
+ next.push({
+ id: nanoid(),
+ type: "file",
+ url: URL.createObjectURL(file),
+ mediaType: file.type,
+ filename: file.name,
+ file,
+ });
+ }
+ 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;
+
+ const sanitizeIncomingFiles = useCallback(
+ (fileList: File[] | FileList) => {
+ const { accepted, message } = splitUnsupportedUploadFiles(fileList);
+ if (message) {
+ onError?.({
+ code: "unsupported_package",
+ message,
+ });
+ if (!onError) {
+ toast.error(message);
+ }
+ }
+ return accepted;
+ },
+ [onError],
+ );
+
+ // 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) {
+ const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
+ if (accepted.length > 0) {
+ add(accepted);
+ }
+ }
+ };
+ form.addEventListener("dragover", onDragOver);
+ form.addEventListener("drop", onDrop);
+ return () => {
+ form.removeEventListener("dragover", onDragOver);
+ form.removeEventListener("drop", onDrop);
+ };
+ }, [add, globalDrop, sanitizeIncomingFiles]);
+
+ 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) {
+ const accepted = sanitizeIncomingFiles(e.dataTransfer.files);
+ if (accepted.length > 0) {
+ add(accepted);
+ }
+ }
+ };
+ document.addEventListener("dragover", onDragOver);
+ document.addEventListener("drop", onDrop);
+ return () => {
+ document.removeEventListener("dragover", onDragOver);
+ document.removeEventListener("drop", onDrop);
+ };
+ }, [add, globalDrop, sanitizeIncomingFiles]);
+
+ 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 = (event) => {
+ if (event.currentTarget.files) {
+ const accepted = sanitizeIncomingFiles(event.currentTarget.files);
+ if (accepted.length > 0) {
+ add(accepted);
+ }
+ }
+ // Reset input value to allow selecting files that were previously removed
+ event.currentTarget.value = "";
+ };
+
+ const convertBlobUrlToDataUrl = async (
+ url: string,
+ ): Promise => {
+ 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(
+ () => ({
+ files: files.map((item) => ({ ...item, id: item.id })),
+ add,
+ remove,
+ clear,
+ openFileDialog,
+ fileInputRef: inputRef,
+ }),
+ [files, add, remove, clear, openFileDialog],
+ );
+
+ const handleSubmit: FormEventHandler = (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.file instanceof File) {
+ // Downstream upload prep reads the preserved File directly.
+ return 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: PromptInputFilePart[]) => {
+ 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 = (
+
+
+
+
+ );
+
+ return usingProvider ? (
+ inner
+ ) : (
+
+ {inner}
+
+ );
+};
+
+export type PromptInputBodyProps = HTMLAttributes;
+
+export const PromptInputBody = ({
+ className,
+ ...props
+}: PromptInputBodyProps) => (
+
+);
+
+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 sanitizeIncomingFiles = usePromptInputValidation();
+ const [isComposing, setIsComposing] = useState(false);
+
+ const handleKeyDown: KeyboardEventHandler = (e) => {
+ if (e.key === "Enter") {
+ if (isIMEComposing(e, 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 = (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();
+ const accepted = sanitizeIncomingFiles
+ ? sanitizeIncomingFiles(files)
+ : files;
+ if (accepted.length > 0) {
+ attachments.add(accepted);
+ }
+ }
+ };
+
+ const controlledProps = controller
+ ? {
+ value: controller.textInput.value,
+ onChange: (e: ChangeEvent) => {
+ controller.textInput.setInput(e.currentTarget.value);
+ onChange?.(e);
+ },
+ }
+ : {
+ onChange,
+ };
+
+ return (
+ setIsComposing(false)}
+ onCompositionStart={() => setIsComposing(true)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ placeholder={placeholder}
+ {...props}
+ {...controlledProps}
+ />
+ );
+};
+
+export type PromptInputHeaderProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputHeader = ({
+ className,
+ ...props
+}: PromptInputHeaderProps) => (
+
+);
+
+export type PromptInputFooterProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputFooter = ({
+ className,
+ ...props
+}: PromptInputFooterProps) => (
+
+);
+
+export type PromptInputToolsProps = HTMLAttributes;
+
+export const PromptInputTools = ({
+ className,
+ ...props
+}: PromptInputToolsProps) => (
+
+);
+
+export type PromptInputButtonProps = ComponentProps;
+
+export const PromptInputButton = ({
+ variant = "ghost",
+ className,
+ size,
+ ...props
+}: PromptInputButtonProps) => {
+ return (
+
+ );
+};
+
+export type PromptInputActionMenuProps = ComponentProps;
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+
+);
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
+
+export const PromptInputActionMenuTrigger = ({
+ className,
+ children,
+ ...props
+}: PromptInputActionMenuTriggerProps) => (
+
+
+ {children ??