From 811c8a44e1126bab620237b6a894e1699e8c4429 Mon Sep 17 00:00:00 2001 From: Aneesh Date: Tue, 18 Nov 2025 21:50:39 +0530 Subject: [PATCH 1/9] designed Sidebar component --- components/app-sidebar.tsx | 104 +++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 components/app-sidebar.tsx diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx new file mode 100644 index 0000000..f836279 --- /dev/null +++ b/components/app-sidebar.tsx @@ -0,0 +1,104 @@ +import { Plus } from 'lucide-react'; +import { Trash } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarFooter, + SidebarGroupAction, +} from '@/components/ui/sidebar'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ChevronUp } from 'lucide-react'; +import { User2 } from 'lucide-react'; + +// Sample projects. +const projects = [ + { + name: 'Testing Chatbot', + url: '#', + }, + { name: 'Another chat', url: '#' }, +]; + +export function AppSidebar() { + return ( + + + + Chatbot + + New Chat + + + + + {projects.map((project) => ( + +
+ + + {project.name} + + + + +
+
+ ))} +
+
+
+
+ + + + + + + Username + + + + + + Account + + + Billing + + + Sign out + + + + + + +
+ ); +} From 10939c61e282a3ed679808cc97bd7884c5067f48 Mon Sep 17 00:00:00 2001 From: Aneesh Date: Tue, 18 Nov 2025 21:51:31 +0530 Subject: [PATCH 2/9] Added ChatSidebar component --- components/chat/ChatSidebar.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 components/chat/ChatSidebar.tsx diff --git a/components/chat/ChatSidebar.tsx b/components/chat/ChatSidebar.tsx new file mode 100644 index 0000000..d84703e --- /dev/null +++ b/components/chat/ChatSidebar.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" +import { AppSidebar } from "@/components/app-sidebar" + +export default function ChatSidebar() { + return ( + + + + + + ) +} \ No newline at end of file From 527579d46d772351a6862147f2edbc71b7bffb7f Mon Sep 17 00:00:00 2001 From: Aneesh Date: Tue, 18 Nov 2025 21:52:50 +0530 Subject: [PATCH 3/9] added chat page --- app/chat/page.tsx | 220 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 app/chat/page.tsx diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000..cf3ebed --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,220 @@ +'use client'; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from '@/components/ai-elements/conversation'; +import { + Message, + MessageContent, + MessageResponse, + MessageActions, + MessageAction, +} from '@/components/ai-elements/message'; +import { + PromptInput, + PromptInputActionAddAttachments, + PromptInputActionMenu, + PromptInputActionMenuContent, + PromptInputActionMenuTrigger, + PromptInputAttachment, + PromptInputAttachments, + PromptInputBody, + PromptInputButton, + PromptInputHeader, + type PromptInputMessage, + PromptInputSelect, + PromptInputSelectContent, + PromptInputSelectItem, + PromptInputSelectTrigger, + PromptInputSelectValue, + PromptInputSubmit, + PromptInputTextarea, + PromptInputFooter, + PromptInputTools, +} from '@/components/ai-elements/prompt-input'; +import { useState } from 'react'; +import { useChat } from '@ai-sdk/react'; +import { CopyIcon, GlobeIcon, RefreshCcwIcon } from 'lucide-react'; +import { + Source, + Sources, + SourcesContent, + SourcesTrigger, +} from '@/components/ai-elements/sources'; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from '@/components/ai-elements/reasoning'; +import { Loader } from '@/components/ai-elements/loader'; +const models = [ + { + name: 'GPT 4o', + value: 'openai/gpt-4o', + }, + { + name: 'Deepseek R1', + value: 'deepseek/deepseek-r1', + }, + { + name: 'Gemini Pro', + value: 'google/gemini-pro', + }, +]; +const ChatBotDemo = () => { + const [input, setInput] = useState(''); + const [model, setModel] = useState(models[0].value); + const [webSearch, setWebSearch] = useState(false); + const { messages, sendMessage, status, regenerate } = useChat(); + const handleSubmit = (message: PromptInputMessage) => { + const hasText = Boolean(message.text); + const hasAttachments = Boolean(message.files?.length); + if (!(hasText || hasAttachments)) { + return; + } + sendMessage( + { + text: message.text || 'Sent with attachments', + files: message.files + }, + { + body: { + model: model, + webSearch: webSearch, + }, + }, + ); + setInput(''); + }; + return ( +
+
+ + + {messages.map((message) => ( +
+ {message.role === 'assistant' && message.parts.filter((part) => part.type === 'source-url').length > 0 && ( + + part.type === 'source-url', + ).length + } + /> + {message.parts.filter((part) => part.type === 'source-url').map((part, i) => ( + + + + ))} + + )} + {message.parts.map((part, i) => { + switch (part.type) { + case 'text': + return ( + + + + {part.text} + + + {message.role === 'assistant' && i === messages.length - 1 && ( + + regenerate()} + label="Retry" + > + + + + navigator.clipboard.writeText(part.text) + } + label="Copy" + > + + + + )} + + ); + case 'reasoning': + return ( + + + {part.text} + + ); + default: + return null; + } + })} +
+ ))} + {status === 'submitted' && } +
+ +
+ + + + {(attachment) => } + + + + setInput(e.target.value)} + value={input} + /> + + + + + + + + + + setWebSearch(!webSearch)} + > + + Search + + { + setModel(value); + }} + value={model} + > + + + + + {models.map((model) => ( + + {model.name} + + ))} + + + + + + +
+
+ ); +}; +export default ChatBotDemo; \ No newline at end of file From 4e0424e7235e6e4ccc55599ee11bd2cb5e4b86ee Mon Sep 17 00:00:00 2001 From: Aneesh Date: Tue, 18 Nov 2025 21:53:08 +0530 Subject: [PATCH 4/9] Fix chat layout structure --- app/chat/layout.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 app/chat/layout.tsx diff --git a/app/chat/layout.tsx b/app/chat/layout.tsx new file mode 100644 index 0000000..a417c25 --- /dev/null +++ b/app/chat/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import ChatSidebar from "@/components/chat/ChatSidebar"; + +export default function ChatLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+ +
+ {children} +
+
+ ); +} From 4a2108f7f1818f3d6a3579eff5aeae10db604545 Mon Sep 17 00:00:00 2001 From: Aneesh Date: Tue, 18 Nov 2025 21:53:59 +0530 Subject: [PATCH 5/9] chat route handler --- app/api/chat/route.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 app/api/chat/route.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..46b4167 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,25 @@ +import { streamText, UIMessage, convertToModelMessages } from 'ai'; +// Allow streaming responses up to 30 seconds +export const maxDuration = 30; +export async function POST(req: Request) { + const { + messages, + model, + webSearch, + }: { + messages: UIMessage[]; + model: string; + webSearch: boolean; + } = await req.json(); + const result = streamText({ + model: webSearch ? 'perplexity/sonar' : model, + messages: convertToModelMessages(messages), + system: + 'You are a helpful assistant that can answer questions and help with tasks', + }); + // send sources and reasoning back to the client + return result.toUIMessageStreamResponse({ + sendSources: true, + sendReasoning: true, + }); +} \ No newline at end of file From 6a0fb60fbc717430597e4323c0ef84c08b4cd2e4 Mon Sep 17 00:00:00 2001 From: Aneesh Date: Thu, 4 Dec 2025 08:29:58 +0530 Subject: [PATCH 6/9] Fixed that Gateway issue --- app/api/chat/provider.ts | 10 + app/api/chat/route.ts | 42 +- app/chat/page.tsx | 70 +- components/ai-elements/artifact.tsx | 147 + components/ai-elements/canvas.tsx | 22 + components/ai-elements/chain-of-thought.tsx | 228 + components/ai-elements/checkpoint.tsx | 68 + components/ai-elements/code-block.tsx | 178 + components/ai-elements/confirmation.tsx | 176 + components/ai-elements/connection.tsx | 28 + components/ai-elements/context.tsx | 408 ++ components/ai-elements/controls.tsx | 18 + components/ai-elements/conversation.tsx | 100 + components/ai-elements/edge.tsx | 140 + components/ai-elements/image.tsx | 24 + components/ai-elements/inline-citation.tsx | 287 ++ components/ai-elements/loader.tsx | 96 + components/ai-elements/message.tsx | 448 ++ components/ai-elements/model-selector.tsx | 205 + components/ai-elements/node.tsx | 71 + components/ai-elements/open-in-chat.tsx | 365 ++ components/ai-elements/panel.tsx | 15 + components/ai-elements/plan.tsx | 142 + components/ai-elements/prompt-input.tsx | 1378 +++++ components/ai-elements/queue.tsx | 274 + components/ai-elements/reasoning.tsx | 178 + components/ai-elements/shimmer.tsx | 64 + components/ai-elements/sources.tsx | 77 + components/ai-elements/suggestion.tsx | 56 + components/ai-elements/task.tsx | 87 + components/ai-elements/tool.tsx | 163 + components/ai-elements/toolbar.tsx | 16 + components/ai-elements/web-preview.tsx | 263 + components/ui/button-group.tsx | 83 + components/ui/input-group.tsx | 170 + components/ui/sidebar.tsx | 4 +- package-lock.json | 4980 ++++++++++++++++--- package.json | 39 +- 38 files changed, 10271 insertions(+), 849 deletions(-) create mode 100644 app/api/chat/provider.ts create mode 100644 components/ai-elements/artifact.tsx create mode 100644 components/ai-elements/canvas.tsx create mode 100644 components/ai-elements/chain-of-thought.tsx create mode 100644 components/ai-elements/checkpoint.tsx create mode 100644 components/ai-elements/code-block.tsx create mode 100644 components/ai-elements/confirmation.tsx create mode 100644 components/ai-elements/connection.tsx create mode 100644 components/ai-elements/context.tsx create mode 100644 components/ai-elements/controls.tsx create mode 100644 components/ai-elements/conversation.tsx create mode 100644 components/ai-elements/edge.tsx create mode 100644 components/ai-elements/image.tsx create mode 100644 components/ai-elements/inline-citation.tsx create mode 100644 components/ai-elements/loader.tsx create mode 100644 components/ai-elements/message.tsx create mode 100644 components/ai-elements/model-selector.tsx create mode 100644 components/ai-elements/node.tsx create mode 100644 components/ai-elements/open-in-chat.tsx create mode 100644 components/ai-elements/panel.tsx create mode 100644 components/ai-elements/plan.tsx create mode 100644 components/ai-elements/prompt-input.tsx create mode 100644 components/ai-elements/queue.tsx create mode 100644 components/ai-elements/reasoning.tsx create mode 100644 components/ai-elements/shimmer.tsx create mode 100644 components/ai-elements/sources.tsx create mode 100644 components/ai-elements/suggestion.tsx create mode 100644 components/ai-elements/task.tsx create mode 100644 components/ai-elements/tool.tsx create mode 100644 components/ai-elements/toolbar.tsx create mode 100644 components/ai-elements/web-preview.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/input-group.tsx diff --git a/app/api/chat/provider.ts b/app/api/chat/provider.ts new file mode 100644 index 0000000..f4540b6 --- /dev/null +++ b/app/api/chat/provider.ts @@ -0,0 +1,10 @@ +import { openai } from '@ai-sdk/openai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { google } from '@ai-sdk/google'; + +export const providers = { + openai: openai('gpt-4-turbo'), + claude: anthropic('claude-3-5-sonnet-20241022'), + gemini: google('gemini-2.0-flash'), + gemini_pro: google('gemini-2.0-flash-lite'), +}; diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 46b4167..991e741 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,25 +1,23 @@ -import { streamText, UIMessage, convertToModelMessages } from 'ai'; -// Allow streaming responses up to 30 seconds -export const maxDuration = 30; -export async function POST(req: Request) { - const { - messages, +import { streamText, convertToModelMessages } from 'ai'; +import { providers } from './provider'; +import { NextRequest } from 'next/server'; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { messages, modelKey } = body; + + const model = providers[modelKey as keyof typeof providers]; + if (!model) { + return new Response(JSON.stringify({ error: 'Unknown modelKey' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const result = await streamText({ model, - webSearch, - }: { - messages: UIMessage[]; - model: string; - webSearch: boolean; - } = await req.json(); - const result = streamText({ - model: webSearch ? 'perplexity/sonar' : model, messages: convertToModelMessages(messages), - system: - 'You are a helpful assistant that can answer questions and help with tasks', }); - // send sources and reasoning back to the client - return result.toUIMessageStreamResponse({ - sendSources: true, - sendReasoning: true, - }); -} \ No newline at end of file + + return result.toUIMessageStreamResponse(); +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx index cf3ebed..986c719 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -22,7 +22,6 @@ import { PromptInputBody, PromptInputButton, PromptInputHeader, - type PromptInputMessage, PromptInputSelect, PromptInputSelectContent, PromptInputSelectItem, @@ -49,44 +48,43 @@ import { } from '@/components/ai-elements/reasoning'; import { Loader } from '@/components/ai-elements/loader'; const models = [ - { - name: 'GPT 4o', - value: 'openai/gpt-4o', - }, - { - name: 'Deepseek R1', - value: 'deepseek/deepseek-r1', - }, - { - name: 'Gemini Pro', - value: 'google/gemini-pro', - }, + { name: 'Gemini (flash)', value: 'gemini' }, + { name: 'Gemini (lite)', value: 'gemini_flash_lite' }, + { name: 'Gemini (pro)', value: 'gemini_pro' }, + { name: 'GPT-4 Turbo', value: 'openai' }, + { name: 'Claude 3.5', value: 'claude' }, ]; + const ChatBotDemo = () => { + const [model, setModel] = useState(models[0].value); const [input, setInput] = useState(''); - const [model, setModel] = useState(models[0].value); const [webSearch, setWebSearch] = useState(false); - const { messages, sendMessage, status, regenerate } = useChat(); - const handleSubmit = (message: PromptInputMessage) => { - const hasText = Boolean(message.text); - const hasAttachments = Boolean(message.files?.length); - if (!(hasText || hasAttachments)) { - return; - } - sendMessage( - { - text: message.text || 'Sent with attachments', - files: message.files + + const { messages, sendMessage, status, regenerate } = useChat({}); + + const handleSubmit = async (message?: { text?: string }, e?: React.FormEvent) => { + e?.preventDefault(); + const text = message?.text ?? input; + if (!text?.trim()) return; + + await sendMessage( + { + text, }, { body: { - model: model, - webSearch: webSearch, + modelKey: model, + webSearch, }, - }, + } ); setInput(''); }; + + function handleInputChange(event: React.ChangeEvent): void { + setInput(event.target.value); + } + return (
@@ -124,7 +122,7 @@ const ChatBotDemo = () => { {part.text} - {message.role === 'assistant' && i === messages.length - 1 && ( + {message.role === 'assistant' && i === message.parts.length - 1 && ( regenerate()} @@ -165,7 +163,12 @@ const ChatBotDemo = () => { - + {(attachment) => } @@ -173,7 +176,7 @@ const ChatBotDemo = () => { setInput(e.target.value)} + onChange={handleInputChange} value={input} /> @@ -210,11 +213,12 @@ const ChatBotDemo = () => { - +
); }; -export default ChatBotDemo; \ No newline at end of file + +export default ChatBotDemo; diff --git a/components/ai-elements/artifact.tsx b/components/ai-elements/artifact.tsx new file mode 100644 index 0000000..c90cb5f --- /dev/null +++ b/components/ai-elements/artifact.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { type LucideIcon, XIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/components/ai-elements/canvas.tsx b/components/ai-elements/canvas.tsx new file mode 100644 index 0000000..5aa83cb --- /dev/null +++ b/components/ai-elements/canvas.tsx @@ -0,0 +1,22 @@ +import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; +import type { ReactNode } from "react"; +import "@xyflow/react/dist/style.css"; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => ( + + + {children} + +); diff --git a/components/ai-elements/chain-of-thought.tsx b/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000..457186f --- /dev/null +++ b/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + BrainIcon, + ChevronDownIcon, + DotIcon, + type LucideIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, memo, useContext, useMemo } from "react"; + +type ChainOfThoughtContextValue = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const ChainOfThoughtContext = createContext( + null +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + "ChainOfThought components must be used within ChainOfThought" + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const chainOfThoughtContext = useMemo( + () => ({ isOpen, setIsOpen }), + [isOpen, setIsOpen] + ); + + return ( + +
+ {children} +
+
+ ); + } +); + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen, setIsOpen } = useChainOfThought(); + + return ( + + + + + {children ?? "Chain of Thought"} + + + + + ); + } +); + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: "complete" | "active" | "pending"; +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = "complete", + children, + ...props + }: ChainOfThoughtStepProps) => { + const statusStyles = { + complete: "text-muted-foreground", + active: "text-foreground", + pending: "text-muted-foreground/50", + }; + + return ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); + } +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ) +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ) +); + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children} + + + ); + } +); + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ) +); + +ChainOfThought.displayName = "ChainOfThought"; +ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; +ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; +ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; +ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; +ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; +ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/components/ai-elements/checkpoint.tsx b/components/ai-elements/checkpoint.tsx new file mode 100644 index 0000000..d9a5d32 --- /dev/null +++ b/components/ai-elements/checkpoint.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { BookmarkIcon, type LucideProps } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type CheckpointProps = HTMLAttributes; + +export const Checkpoint = ({ + className, + children, + ...props +}: CheckpointProps) => ( +
+ {children} + +
+); + +export type CheckpointIconProps = LucideProps; + +export const CheckpointIcon = ({ + className, + children, + ...props +}: CheckpointIconProps) => + children ?? ( + + ); + +export type CheckpointTriggerProps = ComponentProps & { + tooltip?: string; +}; + +export const CheckpointTrigger = ({ + children, + className, + variant = "ghost", + size = "sm", + tooltip, + ...props +}: CheckpointTriggerProps) => + tooltip ? ( + + + + + + {tooltip} + + + ) : ( + + ); diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx new file mode 100644 index 0000000..6fce420 --- /dev/null +++ b/components/ai-elements/code-block.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; + +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + code: "", +}); + +const lineNumberTransformer: ShikiTransformer = { + name: "line-numbers", + line(node, line) { + node.children.unshift({ + type: "element", + tagName: "span", + properties: { + className: [ + "inline-block", + "min-w-10", + "mr-4", + "text-right", + "select-none", + "text-muted-foreground", + ], + }, + children: [{ type: "text", value: String(line) }], + }); + }, +}; + +export async function highlightCode( + code: string, + language: BundledLanguage, + showLineNumbers = false +) { + const transformers: ShikiTransformer[] = showLineNumbers + ? [lineNumberTransformer] + : []; + + return await Promise.all([ + codeToHtml(code, { + lang: language, + theme: "one-light", + transformers, + }), + codeToHtml(code, { + lang: language, + theme: "one-dark-pro", + transformers, + }), + ]); +} + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const [html, setHtml] = useState(""); + const [darkHtml, setDarkHtml] = useState(""); + const mounted = useRef(false); + + useEffect(() => { + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { + if (!mounted.current) { + setHtml(light); + setDarkHtml(dark); + mounted.current = true; + } + }); + + return () => { + mounted.current = false; + }; + }, [code, language, showLineNumbers]); + + return ( + +
+
+
+
+ {children && ( +
+ {children} +
+ )} +
+
+ + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + setTimeout(() => setIsCopied(false), timeout); + } catch (error) { + onError?.(error as Error); + } + }; + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; diff --git a/components/ai-elements/confirmation.tsx b/components/ai-elements/confirmation.tsx new file mode 100644 index 0000000..2ec0aab --- /dev/null +++ b/components/ai-elements/confirmation.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ToolUIPart } from "ai"; +import { + type ComponentProps, + createContext, + type ReactNode, + useContext, +} from "react"; + +type ToolUIPartApproval = + | { + id: string; + approved?: never; + reason?: never; + } + | { + id: string; + approved: boolean; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: false; + reason?: string; + } + | undefined; + +type ConfirmationContextValue = { + approval: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +const ConfirmationContext = createContext( + null +); + +const useConfirmation = () => { + const context = useContext(ConfirmationContext); + + if (!context) { + throw new Error("Confirmation components must be used within Confirmation"); + } + + return context; +}; + +export type ConfirmationProps = ComponentProps & { + approval?: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +export const Confirmation = ({ + className, + approval, + state, + ...props +}: ConfirmationProps) => { + if (!approval || state === "input-streaming" || state === "input-available") { + return null; + } + + return ( + + + + ); +}; + +export type ConfirmationTitleProps = ComponentProps; + +export const ConfirmationTitle = ({ + className, + ...props +}: ConfirmationTitleProps) => ( + +); + +export type ConfirmationRequestProps = { + children?: ReactNode; +}; + +export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + if (state !== "approval-requested") { + return null; + } + + return children; +}; + +export type ConfirmationAcceptedProps = { + children?: ReactNode; +}; + +export const ConfirmationAccepted = ({ + children, +}: ConfirmationAcceptedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when approved and in response states + if ( + !approval?.approved || + (state !== "approval-responded" && + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationRejectedProps = { + children?: ReactNode; +}; + +export const ConfirmationRejected = ({ + children, +}: ConfirmationRejectedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when rejected and in response states + if ( + approval?.approved !== false || + (state !== "approval-responded" && + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationActionsProps = ComponentProps<"div">; + +export const ConfirmationActions = ({ + className, + ...props +}: ConfirmationActionsProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + if (state !== "approval-requested") { + return null; + } + + return ( +
+ ); +}; + +export type ConfirmationActionProps = ComponentProps; + +export const ConfirmationAction = (props: ConfirmationActionProps) => ( + + )} + + ); +}; + +export type ContextContentProps = ComponentProps; + +export const ContextContent = ({ + className, + ...props +}: ContextContentProps) => ( + +); + +export type ContextContentHeaderProps = ComponentProps<"div">; + +export const ContextContentHeader = ({ + children, + className, + ...props +}: ContextContentHeaderProps) => { + const { usedTokens, maxTokens } = useContextValue(); + const usedPercent = usedTokens / maxTokens; + const displayPct = new Intl.NumberFormat("en-US", { + style: "percent", + maximumFractionDigits: 1, + }).format(usedPercent); + const used = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(usedTokens); + const total = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(maxTokens); + + return ( +
+ {children ?? ( + <> +
+

{displayPct}

+

+ {used} / {total} +

+
+
+ +
+ + )} +
+ ); +}; + +export type ContextContentBodyProps = ComponentProps<"div">; + +export const ContextContentBody = ({ + children, + className, + ...props +}: ContextContentBodyProps) => ( +
+ {children} +
+); + +export type ContextContentFooterProps = ComponentProps<"div">; + +export const ContextContentFooter = ({ + children, + className, + ...props +}: ContextContentFooterProps) => { + const { modelId, usage } = useContextValue(); + const costUSD = modelId + ? getUsage({ + modelId, + usage: { + input: usage?.inputTokens ?? 0, + output: usage?.outputTokens ?? 0, + }, + }).costUSD?.totalUSD + : undefined; + const totalCost = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(costUSD ?? 0); + + return ( +
+ {children ?? ( + <> + Total cost + {totalCost} + + )} +
+ ); +}; + +export type ContextInputUsageProps = ComponentProps<"div">; + +export const ContextInputUsage = ({ + className, + children, + ...props +}: ContextInputUsageProps) => { + const { usage, modelId } = useContextValue(); + const inputTokens = usage?.inputTokens ?? 0; + + if (children) { + return children; + } + + if (!inputTokens) { + return null; + } + + const inputCost = modelId + ? getUsage({ + modelId, + usage: { input: inputTokens, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const inputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(inputCost ?? 0); + + return ( +
+ Input + +
+ ); +}; + +export type ContextOutputUsageProps = ComponentProps<"div">; + +export const ContextOutputUsage = ({ + className, + children, + ...props +}: ContextOutputUsageProps) => { + const { usage, modelId } = useContextValue(); + const outputTokens = usage?.outputTokens ?? 0; + + if (children) { + return children; + } + + if (!outputTokens) { + return null; + } + + const outputCost = modelId + ? getUsage({ + modelId, + usage: { input: 0, output: outputTokens }, + }).costUSD?.totalUSD + : undefined; + const outputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(outputCost ?? 0); + + return ( +
+ Output + +
+ ); +}; + +export type ContextReasoningUsageProps = ComponentProps<"div">; + +export const ContextReasoningUsage = ({ + className, + children, + ...props +}: ContextReasoningUsageProps) => { + const { usage, modelId } = useContextValue(); + const reasoningTokens = usage?.reasoningTokens ?? 0; + + if (children) { + return children; + } + + if (!reasoningTokens) { + return null; + } + + const reasoningCost = modelId + ? getUsage({ + modelId, + usage: { reasoningTokens }, + }).costUSD?.totalUSD + : undefined; + const reasoningCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(reasoningCost ?? 0); + + return ( +
+ Reasoning + +
+ ); +}; + +export type ContextCacheUsageProps = ComponentProps<"div">; + +export const ContextCacheUsage = ({ + className, + children, + ...props +}: ContextCacheUsageProps) => { + const { usage, modelId } = useContextValue(); + const cacheTokens = usage?.cachedInputTokens ?? 0; + + if (children) { + return children; + } + + if (!cacheTokens) { + return null; + } + + const cacheCost = modelId + ? getUsage({ + modelId, + usage: { cacheReads: cacheTokens, input: 0, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const cacheCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cacheCost ?? 0); + + return ( +
+ Cache + +
+ ); +}; + +const TokensWithCost = ({ + tokens, + costText, +}: { + tokens?: number; + costText?: string; +}) => ( + + {tokens === undefined + ? "—" + : new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(tokens)} + {costText ? ( + • {costText} + ) : null} + +); diff --git a/components/ai-elements/controls.tsx b/components/ai-elements/controls.tsx new file mode 100644 index 0000000..770a826 --- /dev/null +++ b/components/ai-elements/controls.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Controls as ControlsPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type ControlsProps = ComponentProps; + +export const Controls = ({ className, ...props }: ControlsProps) => ( + button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!", + className + )} + {...props} + /> +); diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..aa380f5 --- /dev/null +++ b/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/components/ai-elements/edge.tsx b/components/ai-elements/edge.tsx new file mode 100644 index 0000000..3cec409 --- /dev/null +++ b/components/ai-elements/edge.tsx @@ -0,0 +1,140 @@ +import { + BaseEdge, + type EdgeProps, + getBezierPath, + getSimpleBezierPath, + type InternalNode, + type Node, + Position, + useInternalNode, +} from "@xyflow/react"; + +const Temporary = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: EdgeProps) => { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +const getHandleCoordsByPosition = ( + node: InternalNode, + handlePosition: Position +) => { + // Choose the handle type based on position - Left is for target, Right is for source + const handleType = handlePosition === Position.Left ? "target" : "source"; + + const handle = node.internals.handleBounds?.[handleType]?.find( + (h) => h.position === handlePosition + ); + + if (!handle) { + return [0, 0] as const; + } + + let offsetX = handle.width / 2; + let offsetY = handle.height / 2; + + // this is a tiny detail to make the markerEnd of an edge visible. + // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset + // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position + switch (handlePosition) { + case Position.Left: + offsetX = 0; + break; + case Position.Right: + offsetX = handle.width; + break; + case Position.Top: + offsetY = 0; + break; + case Position.Bottom: + offsetY = handle.height; + break; + default: + throw new Error(`Invalid handle position: ${handlePosition}`); + } + + const x = node.internals.positionAbsolute.x + handle.x + offsetX; + const y = node.internals.positionAbsolute.y + handle.y + offsetY; + + return [x, y] as const; +}; + +const getEdgeParams = ( + source: InternalNode, + target: InternalNode +) => { + const sourcePos = Position.Right; + const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); + const targetPos = Position.Left; + const [tx, ty] = getHandleCoordsByPosition(target, targetPos); + + return { + sx, + sy, + tx, + ty, + sourcePos, + targetPos, + }; +}; + +const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!(sourceNode && targetNode)) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetX: tx, + targetY: ty, + targetPosition: targetPos, + }); + + return ( + <> + + + + + + ); +}; + +export const Edge = { + Temporary, + Animated, +}; diff --git a/components/ai-elements/image.tsx b/components/ai-elements/image.tsx new file mode 100644 index 0000000..542812a --- /dev/null +++ b/components/ai-elements/image.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/lib/utils"; +import type { Experimental_GeneratedImage } from "ai"; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ + base64, + uint8Array, + mediaType, + ...props +}: ImageProps) => ( + {props.alt} +); diff --git a/components/ai-elements/inline-citation.tsx b/components/ai-elements/inline-citation.tsx new file mode 100644 index 0000000..5977081 --- /dev/null +++ b/components/ai-elements/inline-citation.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +export type InlineCitationProps = ComponentProps<"span">; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<"span">; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; +}; + +export const InlineCitationCardTrigger = ({ + sources, + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {sources[0] ? ( + <> + {new URL(sources[0]).hostname}{" "} + {sources.length > 1 && `+${sources.length - 1}`} + + ) : ( + "unknown" + )} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<"div">; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<"div">; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<"div">; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<"div">; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on("select", () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +export type InlineCitationCarouselPrevProps = ComponentProps<"button">; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<"button">; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<"div"> & { + title?: string; + url?: string; + description?: string; +}; + +export const InlineCitationSource = ({ + title, + url, + description, + className, + children, + ...props +}: InlineCitationSourceProps) => ( +
+ {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {description && ( +

+ {description} +

+ )} + {children} +
+); + +export type InlineCitationQuoteProps = ComponentProps<"blockquote">; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/components/ai-elements/loader.tsx b/components/ai-elements/loader.tsx new file mode 100644 index 0000000..5f0cfce --- /dev/null +++ b/components/ai-elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from "@/lib/utils"; +import type { HTMLAttributes } from "react"; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx new file mode 100644 index 0000000..5c164c7 --- /dev/null +++ b/components/ai-elements/message.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "@/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, UIMessage } from "ai"; +import { + ChevronLeftIcon, + ChevronRightIcon, + PaperclipIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { createContext, memo, useContext, useEffect, useState } from "react"; +import { Streamdown } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon-sm", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +type MessageBranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + "MessageBranch components must be used within MessageBranch" + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: MessageBranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = Array.isArray(children) ? children : [children]; + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden" + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const MessageBranchSelector = ({ + className, + from, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + className, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageAttachmentProps = HTMLAttributes & { + data: FileUIPart; + className?: string; + onRemove?: () => void; +}; + +export function MessageAttachment({ + data, + className, + onRemove, + ...props +}: MessageAttachmentProps) { + 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 ? ( + <> + {filename + {onRemove && ( + + )} + + ) : ( + <> + + +
+ +
+
+ +

{attachmentLabel}

+
+
+ {onRemove && ( + + )} + + )} +
+ ); +} + +export type MessageAttachmentsProps = ComponentProps<"div">; + +export function MessageAttachments({ + children, + className, + ...props +}: MessageAttachmentsProps) { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx new file mode 100644 index 0000000..ef6ebd7 --- /dev/null +++ b/components/ai-elements/model-selector.tsx @@ -0,0 +1,205 @@ +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) => ( + {`${provider} +); + +export type ModelSelectorLogoGroupProps = ComponentProps<"div">; + +export const ModelSelectorLogoGroup = ({ + className, + ...props +}: ModelSelectorLogoGroupProps) => ( +
img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground", + className + )} + {...props} + /> +); + +export type ModelSelectorNameProps = ComponentProps<"span">; + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +); diff --git a/components/ai-elements/node.tsx b/components/ai-elements/node.tsx new file mode 100644 index 0000000..75ac59a --- /dev/null +++ b/components/ai-elements/node.tsx @@ -0,0 +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) => ( + +); diff --git a/components/ai-elements/open-in-chat.tsx b/components/ai-elements/open-in-chat.tsx new file mode 100644 index 0000000..0c62a6a --- /dev/null +++ b/components/ai-elements/open-in-chat.tsx @@ -0,0 +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: ( + + GitHub + + + ), + }, + scira: { + title: "Open in Scira", + createUrl: (q: string) => + `https://scira.ai/?${new URLSearchParams({ + q, + })}`, + icon: ( + + Scira AI + + + + + + + + + ), + }, + chatgpt: { + title: "Open in ChatGPT", + createUrl: (prompt: string) => + `https://chatgpt.com/?${new URLSearchParams({ + hints: "search", + prompt, + })}`, + icon: ( + + OpenAI + + + ), + }, + claude: { + title: "Open in Claude", + createUrl: (q: string) => + `https://claude.ai/new?${new URLSearchParams({ + q, + })}`, + icon: ( + + Claude + + + ), + }, + 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: ( + + v0 + + + + ), + }, + 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: ( + + Cursor + + + ), + }, +}; + +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/components/ai-elements/panel.tsx b/components/ai-elements/panel.tsx new file mode 100644 index 0000000..059cb7a --- /dev/null +++ b/components/ai-elements/panel.tsx @@ -0,0 +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) => ( + +); diff --git a/components/ai-elements/plan.tsx b/components/ai-elements/plan.tsx new file mode 100644 index 0000000..be04d88 --- /dev/null +++ b/components/ai-elements/plan.tsx @@ -0,0 +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) => ( + + + +); diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..9c94197 --- /dev/null +++ b/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1378 @@ +"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 { + CornerDownLeftIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + 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; +}; + +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 +); + +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); + +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 [attachements, setAttachements] = useState< + (FileUIPart & { 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; + } + + setAttachements((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })) + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachements((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachements((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachements, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachements, 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: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { 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: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const anchorRef = useRef(null); + const formRef = useRef(null); + + // Find nearest form to scope drag & drop + useEffect(() => { + const root = anchorRef.current?.closest("form"); + if (root instanceof HTMLFormElement) { + formRef.current = root; + } + }, []); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + if (accept.includes("image/*")) { + return f.type.startsWith("image/"); + } + // NOTE: keep simple; expand as needed + return true; + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const add = usingProvider + ? (files: File[] | FileList) => controller.attachments.add(files) + : addLocal; + + const remove = usingProvider + ? (id: string) => controller.attachments.remove(id) + : (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }); + + const clear = usingProvider + ? () => controller.attachments.clear() + : () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }); + + const openFileDialog = usingProvider + ? () => controller.attachments.openFileDialog() + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of files) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + [usingProvider, files] + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + }; + + const convertBlobUrlToDataUrl = async (url: string): Promise => { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }; + + const ctx = 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.url && item.url.startsWith("blob:")) { + return { + ...item, + url: await convertBlobUrlToDataUrl(item.url), + }; + } + return item; + }) + ).then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch (error) { + // Don't clear on error - user may want to retry + } + }); + }; + + // Render with or without local provider + const inner = ( + <> +