From 700967af1286d6555a4ff6b42e6e25c5ab1bb132 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Tue, 16 Jun 2026 09:38:32 +0200 Subject: [PATCH 1/5] Run `PRAGMA optimize` periodically to refresh query-planner stats (PR 1784) Original commit: 75a71defb017b2bb3f6136c635a5a113b719b86e --- .../src/dev-server/db/BunSQLiteDriver.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts index f01f640da1..34f85ac195 100644 --- a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts +++ b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts @@ -632,6 +632,16 @@ export class BunSQLiteDriver implements IStorageDriver { "CREATE INDEX IF NOT EXISTS idx_feed_messages_feed_created ON feed_messages(feed_id, created_at DESC, message_id DESC)" ); + // Refresh query-planner statistics on every boot. The mask combines: + // 0x10000 — consider every table, not just ones used this session + // 0x00002 — run ANALYZE where it would help + // 0x00010 — cap ANALYZE via a temporary analysis_limit, so a large table + // can't turn startup into a slow full-index scan + // Together: "analyze on every boot" without "scan the world on every boot", + // and it also covers the CREATE INDEX statements above. + // See https://www.sqlite.org/pragma.html#pragma_optimize + db.run(`PRAGMA optimize=${0x10000 | 0x00002 | 0x00010}`); + this.db = db; } From 24f6704a28a7f9e5b4051fd23c1b44bfa608a02e Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Tue, 16 Jun 2026 11:34:03 +0100 Subject: [PATCH 2/5] Example: Vercel AI elements (#3524) Co-authored-by: Cursor --- .../nextjs-ai-elements-realtime/.env.example | 5 + .../nextjs-ai-elements-realtime/.gitignore | 12 + .../nextjs-ai-elements-realtime/README.md | 101 + .../nextjs-ai-elements-realtime/app/Chat.tsx | 564 ++ .../app/Providers.tsx | 26 + .../app/api/ai-reply/route.ts | 290 + .../app/api/liveblocks-auth/route.ts | 32 + .../app/api/users/route.ts | 16 + .../app/database.ts | 69 + .../app/globals.css | 121 + .../app/layout.tsx | 37 + .../nextjs-ai-elements-realtime/app/page.tsx | 24 + .../components.json | 21 + .../components/HelpButton.tsx | 331 + .../components/ai-elements/artifact.tsx | 147 + .../components/ai-elements/canvas.tsx | 22 + .../ai-elements/chain-of-thought.tsx | 231 + .../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 + .../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 | 1413 +++ .../components/ai-elements/queue.tsx | 274 + .../components/ai-elements/reasoning.tsx | 180 + .../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/alert.tsx | 66 + .../components/ui/badge.tsx | 48 + .../components/ui/button-group.tsx | 83 + .../components/ui/button.tsx | 64 + .../components/ui/card.tsx | 92 + .../components/ui/carousel.tsx | 241 + .../components/ui/collapsible.tsx | 33 + .../components/ui/command.tsx | 184 + .../components/ui/dialog.tsx | 158 + .../components/ui/dropdown-menu.tsx | 257 + .../components/ui/hover-card.tsx | 44 + .../components/ui/input-group.tsx | 170 + .../components/ui/input.tsx | 21 + .../components/ui/progress.tsx | 31 + .../components/ui/scroll-area.tsx | 58 + .../components/ui/select.tsx | 190 + .../components/ui/separator.tsx | 28 + .../components/ui/textarea.tsx | 18 + .../components/ui/tooltip.tsx | 57 + .../hooks/use-example-room-id.ts | 16 + .../nextjs-ai-elements-realtime/lib/utils.ts | 6 + .../liveblocks.config.ts | 63 + .../next.config.ts | 14 + .../package-lock.json | 7684 +++++++++++++++++ .../nextjs-ai-elements-realtime/package.json | 46 + .../postcss.config.mjs | 7 + .../nextjs-ai-elements-realtime/tsconfig.json | 34 + .../nextjs-ai-elements-realtime/vercel.json | 4 + 72 files changed, 17128 insertions(+) create mode 100644 examples/nextjs-ai-elements-realtime/.env.example create mode 100644 examples/nextjs-ai-elements-realtime/.gitignore create mode 100644 examples/nextjs-ai-elements-realtime/README.md create mode 100644 examples/nextjs-ai-elements-realtime/app/Chat.tsx create mode 100644 examples/nextjs-ai-elements-realtime/app/Providers.tsx create mode 100644 examples/nextjs-ai-elements-realtime/app/api/ai-reply/route.ts create mode 100644 examples/nextjs-ai-elements-realtime/app/api/liveblocks-auth/route.ts create mode 100644 examples/nextjs-ai-elements-realtime/app/api/users/route.ts create mode 100644 examples/nextjs-ai-elements-realtime/app/database.ts create mode 100644 examples/nextjs-ai-elements-realtime/app/globals.css create mode 100644 examples/nextjs-ai-elements-realtime/app/layout.tsx create mode 100644 examples/nextjs-ai-elements-realtime/app/page.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components.json create mode 100644 examples/nextjs-ai-elements-realtime/components/HelpButton.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/artifact.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/canvas.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/chain-of-thought.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/checkpoint.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/code-block.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/confirmation.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/connection.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/context.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/controls.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/conversation.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/edge.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/image.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/inline-citation.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/loader.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/message.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/model-selector.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/node.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/open-in-chat.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/panel.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/plan.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/prompt-input.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/queue.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/reasoning.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/shimmer.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/sources.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/suggestion.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/task.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/tool.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/toolbar.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ai-elements/web-preview.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/alert.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/badge.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/button-group.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/button.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/card.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/carousel.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/collapsible.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/command.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/dialog.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/dropdown-menu.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/hover-card.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/input-group.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/input.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/progress.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/scroll-area.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/select.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/separator.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/textarea.tsx create mode 100644 examples/nextjs-ai-elements-realtime/components/ui/tooltip.tsx create mode 100644 examples/nextjs-ai-elements-realtime/hooks/use-example-room-id.ts create mode 100644 examples/nextjs-ai-elements-realtime/lib/utils.ts create mode 100644 examples/nextjs-ai-elements-realtime/liveblocks.config.ts create mode 100644 examples/nextjs-ai-elements-realtime/next.config.ts create mode 100644 examples/nextjs-ai-elements-realtime/package-lock.json create mode 100644 examples/nextjs-ai-elements-realtime/package.json create mode 100644 examples/nextjs-ai-elements-realtime/postcss.config.mjs create mode 100644 examples/nextjs-ai-elements-realtime/tsconfig.json create mode 100644 examples/nextjs-ai-elements-realtime/vercel.json diff --git a/examples/nextjs-ai-elements-realtime/.env.example b/examples/nextjs-ai-elements-realtime/.env.example new file mode 100644 index 0000000000..3db4726723 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/.env.example @@ -0,0 +1,5 @@ +# https://liveblocks.io/dashboard/apikeys +LIVEBLOCKS_SECRET_KEY=sk_xxx + +# https://vercel.com/docs/ai-gateway +AI_GATEWAY_API_KEY= diff --git a/examples/nextjs-ai-elements-realtime/.gitignore b/examples/nextjs-ai-elements-realtime/.gitignore new file mode 100644 index 0000000000..3a68e0cfc9 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules +.env +.env.* +!.env.example +*.tsbuildinfo +.vercel +.next +out +next-env.d.ts +# Turborepo +.turbo diff --git a/examples/nextjs-ai-elements-realtime/README.md b/examples/nextjs-ai-elements-realtime/README.md new file mode 100644 index 0000000000..4a4a26850c --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/README.md @@ -0,0 +1,101 @@ +

+ + Liveblocks + + + Liveblocks + +

+ +# Realtime AI chat with AI Elements + +

+ + Live Preview + + + Open in CodeSandbox + + React + Next.js +

+ +This example shows how to build a realtime, multiplayer AI chat with +[Liveblocks Feeds](https://liveblocks.io/docs/collaboration-features/ai-collaboration), +[Next.js](https://nextjs.org/), and [AI Elements](https://ai-sdk.dev/elements). + +Each chat is a feed inside a Liveblocks room, so messages sync instantly to +everyone connected, complete with shared presence (avatar stack and a live "AI +is thinking…" status). The AI reply is generated on the server and streamed back +into the feed with `@liveblocks/node` (`createFeedMessage` + `updateFeedMessage`), +then rendered live for all users through the `useFeedMessages` hook — including +reasoning, chain of thought, tool calls, sources, and token usage via AI +Elements. + +## Getting started + +Run the following command to try this example locally: + +```bash +npx create-liveblocks-app@latest --example nextjs-ai-elements-realtime --api-key +``` + +This will download the example and ask permission to open your browser, enabling +you to automatically get your API key from your +[liveblocks.io](https://liveblocks.io) account. + +### Manual setup + +
Read more + +

+ +Alternatively, you can set up your project manually: + +- Install all dependencies with `npm install` +- Create an account on [liveblocks.io](https://liveblocks.io/dashboard) +- Copy your **secret** key from the + [dashboard](https://liveblocks.io/dashboard/apikeys) +- Create an `.env.local` file and add your **secret** key as the + `LIVEBLOCKS_SECRET_KEY` environment variable +- _(Optional)_ Add an `AI_GATEWAY_API_KEY` from the + [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to get real model + responses. Without it, the example uses a built-in mock assistant so it still + runs end to end. +- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) + +To see the realtime sync, open the page in two browser tabs and send a message +from one — it appears instantly in both, along with the AI's reply. + +
+ +### Deploy on Vercel + +
Read more + +

+ +To both deploy on [Vercel](https://vercel.com), and run the example locally, use +the following command: + +```bash +npx create-liveblocks-app@latest --example nextjs-ai-elements-realtime --vercel +``` + +This will download the example and ask permission to open your browser, enabling +you to deploy to Vercel. + +
+ +### Develop on CodeSandbox + +
Read more + +

+ +After forking +[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/nextjs-ai-elements-realtime) +on CodeSandbox, create the `LIVEBLOCKS_SECRET_KEY` environment variable as a +[secret](https://codesandbox.io/docs/secrets). + +
diff --git a/examples/nextjs-ai-elements-realtime/app/Chat.tsx b/examples/nextjs-ai-elements-realtime/app/Chat.tsx new file mode 100644 index 0000000000..5964357e3d --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/Chat.tsx @@ -0,0 +1,564 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { nanoid } from "nanoid"; +import { + ClientSideSuspense, + useCreateFeed, + useCreateFeedMessage, + useDeleteFeedMessage, + useFeedMessages, + useFeeds, + useOthers, + useSelf, + useUpdateMyPresence, +} from "@liveblocks/react/suspense"; +import { Avatar, AvatarStack } from "@liveblocks/react-ui"; +import { + CopyIcon, + HistoryIcon, + PlusIcon, + RefreshCcwIcon, + SparklesIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { + Message, + MessageAction, + MessageActions, + MessageContent, + MessageResponse, +} from "@/components/ai-elements/message"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtHeader, + ChainOfThoughtSearchResult, + ChainOfThoughtSearchResults, + ChainOfThoughtStep, +} from "@/components/ai-elements/chain-of-thought"; +import { + Tool, + ToolContent, + ToolHeader, + ToolInput, + ToolOutput, +} from "@/components/ai-elements/tool"; +import { Context, ContextTrigger } from "@/components/ai-elements/context"; +import { + Source, + Sources, + SourcesContent, + SourcesTrigger, +} from "@/components/ai-elements/sources"; +import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; +import { Loader } from "@/components/ai-elements/loader"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSelect, + PromptInputSelectContent, + PromptInputSelectItem, + PromptInputSelectTrigger, + PromptInputSelectValue, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { AI_USER_AVATAR, AI_USER_NAME } from "./database"; + +// Each chat is a feed in the room. Everyone connected reads and writes to the +// selected feed, so messages (and the AI's replies) appear live for all users. + +// Cheap, reasoning-capable models, resolved through the Vercel AI Gateway in +// the server route. +const MODELS = [ + { id: "openai/gpt-5.4-mini", name: "GPT-5.4 mini" }, + { id: "google/gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { id: "deepseek/deepseek-r1", name: "DeepSeek R1" }, +]; + +const STARTER_PROMPTS = [ + "Explain how Liveblocks Feeds work", + "Write a haiku about realtime collaboration", + "Ideas for an AI agent dashboard", + "Summarize the benefits of multiplayer apps", +]; + +export function Chat({ roomId }: { roomId: string }) { + const { feeds } = useFeeds(); + + // Chat history: every feed in the room, newest first. + const chats = useMemo( + () => [...feeds].sort((a, b) => b.createdAt - a.createdAt), + [feeds] + ); + + // The currently selected chat (feed). Defaults to the most recent one, or a + // stable default id for the first chat. (A render-time `nanoid()` here would + // change on every Suspense retry and never settle.) + const [feedId, setFeedId] = useState(() => chats[0]?.feedId ?? "main"); + const [model, setModel] = useState(MODELS[0].id); + + const newChat = useCallback(() => setFeedId(nanoid()), []); + + return ( + +
+
+
+ + + + + + + + Chat history + + {chats.length === 0 ? ( + No chats yet + ) : ( + chats.map((chat) => ( + setFeedId(chat.feedId)} + className={ + chat.feedId === feedId ? "bg-accent" : undefined + } + > + + {chat.metadata?.title || "Untitled chat"} + + + )) + )} + + +
+ + {/* Live presence: everyone currently in the room */} + +
+ + {/* The chat is suspense-wrapped on its own so switching chats only + shows a loader in the conversation area, not the whole screen. */} + + +
+ } + > + + + +
+ ); +} + +function ChatWindow({ + roomId, + feedId, + model, + setModel, +}: { + roomId: string; + feedId: string; + model: string; + setModel: (model: string) => void; +}) { + const { messages } = useFeedMessages(feedId); + const createFeed = useCreateFeed(); + const createFeedMessage = useCreateFeedMessage(); + const deleteFeedMessage = useDeleteFeedMessage(); + const self = useSelf(); + const updateMyPresence = useUpdateMyPresence(); + + // The AI "thinking" status is shared via presence (scoped to this chat), so + // everyone viewing this chat sees it — not just whoever triggered the reply. + const selfPrompting = self.presence.promptingFeedId === feedId; + const othersPrompting = useOthers((others) => + others.some((other) => other.presence.promptingFeedId === feedId) + ); + const aiThinking = selfPrompting || othersPrompting; + + // Ensure a feed exists before its first message is added. + const ensuredFeeds = useRef(new Set(messages.length > 0 ? [feedId] : [])); + const ensureFeed = useCallback( + async (id: string, title: string) => { + if (ensuredFeeds.current.has(id)) { + return; + } + ensuredFeeds.current.add(id); + try { + await createFeed(id, { metadata: { title } }); + } catch { + // Feed already exists (likely created by another user), ignore. + } + }, + [createFeed] + ); + + // Synchronous guard against double-sends (e.g. fast clicks on a suggestion). + const inFlight = useRef(false); + + const sorted = [...messages].sort((a, b) => a.createdAt - b.createdAt); + + const postReply = useCallback( + async (history: { role: "user" | "assistant"; content: string }[]) => { + await fetch("/api/ai-reply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId, feedId, model, messages: history }), + }); + }, + [roomId, feedId, model] + ); + + const send = useCallback( + async (text: string) => { + const content = text.trim(); + if (!content || inFlight.current) { + return; + } + + inFlight.current = true; + updateMyPresence({ promptingFeedId: feedId }); + try { + await ensureFeed(feedId, content.slice(0, 60)); + await createFeedMessage(feedId, { + role: "user", + content, + userId: self.id, + name: self.info.name, + avatar: self.info.avatar, + }); + + const history = [ + ...sorted.map((message) => ({ + role: message.data.role, + content: message.data.content, + })), + { role: "user" as const, content }, + ]; + await postReply(history); + } catch { + // Best-effort in this demo; errors are non-fatal to the UI. + } finally { + updateMyPresence({ promptingFeedId: null }); + inFlight.current = false; + } + }, + [ + feedId, + ensureFeed, + createFeedMessage, + self, + sorted, + postReply, + updateMyPresence, + ] + ); + + const regenerate = useCallback( + async (messageId: string) => { + const index = sorted.findIndex((message) => message.id === messageId); + if (index === -1 || inFlight.current) { + return; + } + + inFlight.current = true; + updateMyPresence({ promptingFeedId: feedId }); + try { + const history = sorted.slice(0, index).map((message) => ({ + role: message.data.role, + content: message.data.content, + })); + await deleteFeedMessage(feedId, messageId); + await postReply(history); + } catch { + // Best-effort in this demo; errors are non-fatal to the UI. + } finally { + updateMyPresence({ promptingFeedId: null }); + inFlight.current = false; + } + }, + [feedId, sorted, deleteFeedMessage, postReply, updateMyPresence] + ); + + // Total context-window usage across the conversation, shown in the composer. + const usedTokens = sorted.reduce( + (sum, message) => sum + (message.data.usedTokens ?? 0), + 0 + ); + + const lastMessage = sorted.at(-1); + const streamingInProgress = + lastMessage?.data.role === "assistant" && !!lastMessage.data.streaming; + const followUps = + !aiThinking && + lastMessage?.data.role === "assistant" && + !lastMessage.data.streaming + ? lastMessage.data.suggestions + : undefined; + + return ( + <> + + + {sorted.length === 0 ? ( + + +
+

Start the conversation

+

+ Messages sync live to everyone in this room. Replies are + generated by the AI and written into the shared feed. +

+
+ + {STARTER_PROMPTS.map((prompt) => ( + + ))} + +
+ ) : ( + sorted.map((message) => { + const { + role, + content, + reasoning, + sources, + name, + avatar, + streaming, + chainOfThought, + tool, + } = message.data; + const isAssistant = role === "assistant"; + + return ( + + +
+ + + {name ?? (isAssistant ? "Liveblocks AI" : "Someone")} + +
+ + {isAssistant && + chainOfThought && + chainOfThought.length > 0 ? ( + + + Chain of thought + + + {chainOfThought.map((step, index) => ( + + {step.search && step.search.length > 0 ? ( + + {step.search.map((term) => ( + + {term} + + ))} + + ) : null} + + ))} + + + ) : null} + + {isAssistant && reasoning ? ( + + + {reasoning} + + ) : null} + + {isAssistant && tool ? ( + + + + + + + + ) : null} + + {isAssistant && sources && sources.length > 0 ? ( + + + + {sources.map((source) => ( + + ))} + + + ) : null} + +
+ {content ? ( + {content} + ) : null} + + {isAssistant && streaming && !content && !reasoning ? ( + Thinking… + ) : null} +
+ + {isAssistant ? ( + + { + void navigator.clipboard + ?.writeText(content) + ?.catch(() => {}); + }} + disabled={!content} + > + + + regenerate(message.id)} + disabled={streaming} + > + + + + ) : null} +
+
+ ); + }) + )} +
+ +
+ +
+ {followUps && followUps.length > 0 ? ( + + {followUps.map((prompt) => ( + + ))} + + ) : null} + + send(message.text)} + > + + + + + + + + + + + {MODELS.map((m) => ( + + {m.name} + + ))} + + + +
+ {usedTokens > 0 ? ( + + + + ) : null} + +
+
+
+
+ + ); +} diff --git a/examples/nextjs-ai-elements-realtime/app/Providers.tsx b/examples/nextjs-ai-elements-realtime/app/Providers.tsx new file mode 100644 index 0000000000..ea590fd4d6 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/Providers.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react/suspense"; +import { PropsWithChildren } from "react"; + +export function Providers({ children }: PropsWithChildren) { + return ( + { + const search = new URLSearchParams( + userIds.map((userId) => ["userIds", userId]) + ); + const response = await fetch(`/api/users?${search}`); + if (!response.ok) { + throw new Error("Problem resolving users"); + } + return await response.json(); + }} + > + {children} + + ); +} diff --git a/examples/nextjs-ai-elements-realtime/app/api/ai-reply/route.ts b/examples/nextjs-ai-elements-realtime/app/api/ai-reply/route.ts new file mode 100644 index 0000000000..63a0fb31f0 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/api/ai-reply/route.ts @@ -0,0 +1,290 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import { AI_USER_AVATAR, AI_USER_ID, AI_USER_NAME } from "@/app/database"; + +/** + * Generates an assistant reply and streams it into the room's feed using + * `@liveblocks/node`. We create one assistant message, then repeatedly call + * `updateFeedMessage` as tokens (and reasoning) arrive. Every connected client + * sees the message fill in live through `useFeedMessages` — no SSE needed. + * + * This mimics a back-end AI workflow (n8n, LangChain, a custom agent, …) + * streaming into a Liveblocks feed. + */ + +type ChatMessage = { role: "user" | "assistant"; content: string }; +type Source = { title: string; url: string }; +type ChainStep = { + label: string; + description?: string; + status?: "complete" | "active" | "pending"; + search?: string[]; +}; +type ToolCall = { + name: string; + input: Record; + output?: string; +}; + +type AssistantUpdate = { + content: string; + reasoning?: string; + sources?: Source[]; + suggestions?: string[]; + chainOfThought?: ChainStep[]; + tool?: ToolCall; + usedTokens?: number; + maxTokens?: number; + streaming: boolean; +}; + +const MAX_TOKENS = 128_000; + +const AUTHOR = { + userId: AI_USER_ID, + name: AI_USER_NAME, + avatar: AI_USER_AVATAR, +} as const; + +const SYSTEM_PROMPT = + "You are a friendly, concise assistant inside a realtime collaborative chat " + + "powered by Liveblocks Feeds. Reply in clear Markdown. Keep answers short " + + "unless asked for detail. Separate ideas into proper paragraphs with a blank " + + "line between them — never use
tags or manual line breaks to space out " + + "text."; + +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + }); + + const { roomId, feedId, messages, model } = (await request.json()) as { + roomId: string; + feedId: string; + messages: ChatMessage[]; + model?: string; + }; + + // Only allow writing into this example's rooms. + if ( + !roomId?.startsWith("liveblocks:examples:nextjs-ai-elements-realtime") || + !feedId + ) { + return new NextResponse("Invalid room or feed", { status: 400 }); + } + + // Make sure the feed exists (idempotent safety net). + try { + await liveblocks.createFeed({ roomId, feedId, metadata: { title: "AI chat" } }); + } catch { + // Feed already exists, ignore. + } + + // Create the (empty) assistant message we'll stream into. + const created = await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { role: "assistant", content: "", streaming: true, model, ...AUTHOR }, + }); + const messageId = created.id; + + // Patches the streaming message. `updateFeedMessage` replaces the message + // data, so we always send the full object. + const update = (data: AssistantUpdate) => + liveblocks.updateFeedMessage({ + roomId, + feedId, + messageId, + data: { role: "assistant", model, ...AUTHOR, ...data }, + }); + + try { + if (process.env.AI_GATEWAY_API_KEY) { + await streamRealReply(messages, model, update); + } else { + await streamMockReply(messages, update); + } + } catch (error) { + // Make sure the message doesn't get stuck in the "streaming" state, and + // surface the reason (e.g. an unknown model id) to make debugging easy. + const reason = error instanceof Error ? error.message : "Unknown error"; + await update({ + content: + created.data.content || `Sorry, something went wrong.\n\n\`${reason}\``, + streaming: false, + }).catch(() => {}); + } + + return NextResponse.json({ ok: true }); +} + +type UpdateFn = (data: AssistantUpdate) => Promise; + +async function streamRealReply( + messages: ChatMessage[], + model: string | undefined, + update: UpdateFn +) { + // Imported lazily so the example still builds and runs without an API key. + const { streamText } = await import("ai"); + + // AI SDK v6 resolves bare string model ids (e.g. "openai/gpt-5.5") through + // the Vercel AI Gateway when AI_GATEWAY_API_KEY is set. + const result = streamText({ + model: model ?? "openai/gpt-5.4-mini", + system: SYSTEM_PROMPT, + messages, + // Turn reasoning on. Unknown options are ignored by providers that don't + // support them, so this works across whichever model is selected. + providerOptions: { + openai: { reasoningEffort: "low", reasoningSummary: "auto" }, + anthropic: { thinking: { type: "enabled", budgetTokens: 4096 } }, + google: { thinkingConfig: { includeThoughts: true } }, + }, + }); + + let content = ""; + let reasoning = ""; + let lastFlush = 0; + + const flush = async (force = false) => { + const now = Date.now(); + if (!force && now - lastFlush < 100) { + return; + } + lastFlush = now; + await update({ + content, + reasoning: reasoning || undefined, + streaming: true, + }); + }; + + for await (const part of result.fullStream) { + if (part.type === "text-delta") { + content += part.text; + await flush(); + } else if (part.type === "reasoning-delta") { + reasoning += part.text; + await flush(); + } + } + + // Fallback: some models return reasoning only at the end rather than as + // streamed deltas. + if (!reasoning) { + reasoning = (await result.reasoningText) ?? ""; + } + + const sources = (await result.sources) + .filter((source) => source.sourceType === "url") + .map((source) => ({ title: source.title || source.url, url: source.url })); + + const usage = await result.usage; + + await update({ + content, + reasoning: reasoning || undefined, + sources: sources.length > 0 ? sources : undefined, + usedTokens: usage.totalTokens ?? 0, + maxTokens: MAX_TOKENS, + streaming: false, + }); +} + +// Simulates a streamed, reasoning-capable reply so the example fully works +// (and visibly streams via `updateFeedMessage`) without an AI provider key. +async function streamMockReply(messages: ChatMessage[], update: UpdateFn) { + const lastUserMessage = + [...messages].reverse().find((message) => message.role === "user") + ?.content ?? "your message"; + + const reasoningText = + "No AI provider key is set, so I'm streaming a canned response. " + + "Each chunk is written on the server with updateFeedMessage — this is " + + "where a real model's live reasoning would stream in."; + + const contentText = [ + `Here's a streamed mock reply to **"${lastUserMessage}"**.`, + "", + "Every chunk you see was written into the same feed message with `updateFeedMessage`, so it streams live to everyone in the room via `useFeedMessages`.", + "", + "Add an `AI_GATEWAY_API_KEY` to `.env.local` for real, reasoning-capable model responses.", + ].join("\n"); + + // Constant for the lifetime of this message — shown (collapsed) while the + // text streams in. + const chainOfThought: ChainStep[] = [ + { + label: "Understand the request", + description: "Parse what the user is asking for.", + status: "complete", + }, + { + label: "Search the Liveblocks docs", + status: "complete", + search: ["Feeds", "useFeedMessages", "createFeedMessage"], + }, + { + label: "Write a concise, streamed answer", + status: "complete", + }, + ]; + + const tool: ToolCall = { + name: "searchDocs", + input: { query: lastUserMessage, limit: 3 }, + output: "Found 3 relevant documentation sections about Liveblocks Feeds.", + }; + + const base = { chainOfThought, tool, maxTokens: MAX_TOKENS } as const; + + // Stream the reasoning first… + let reasoning = ""; + for (const chunk of chunkText(reasoningText)) { + reasoning += chunk; + await update({ content: "", reasoning, ...base, streaming: true }); + await sleep(40); + } + + // …then the answer, word by word. + let content = ""; + for (const chunk of chunkText(contentText)) { + content += chunk; + await update({ content, reasoning, ...base, streaming: true }); + await sleep(55); + } + + await update({ + content, + reasoning, + ...base, + usedTokens: Math.round(content.length / 4) + 320, + sources: [ + { + title: "AI collaboration — Liveblocks Docs", + url: "https://liveblocks.io/docs/collaboration-features/ai-collaboration", + }, + { title: "Feeds API reference", url: "https://liveblocks.io/docs" }, + ], + suggestions: [ + "How do I trigger this from a backend?", + "Show me the useFeedMessages hook", + "What else can Feeds do?", + ], + streaming: false, + }); +} + +// Splits text into single words (keeping trailing whitespace) for streaming. +function chunkText(text: string): string[] { + return text.match(/\S+\s*/g) ?? [text]; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/examples/nextjs-ai-elements-realtime/app/api/liveblocks-auth/route.ts b/examples/nextjs-ai-elements-realtime/app/api/liveblocks-auth/route.ts new file mode 100644 index 0000000000..b01cc4d015 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/api/liveblocks-auth/route.ts @@ -0,0 +1,32 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import { getRandomUser } from "@/app/database"; + +/** + * Authenticating your Liveblocks application + * https://liveblocks.io/docs/authentication + */ + +export async function POST(_request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + }); + + // Pick a random example user so each connection has a name and avatar that + // resolve through `resolveUsers` (used by AvatarStack and presence UI). + const user = getRandomUser(); + + const session = liveblocks.prepareSession(`${user.id}`, { + userInfo: user.info, + }); + + // Use a naming pattern to allow access to rooms with a wildcard + session.allow(`liveblocks:examples:*`, session.FULL_ACCESS); + + const { status, body } = await session.authorize(); + return new NextResponse(body, { status }); +} diff --git a/examples/nextjs-ai-elements-realtime/app/api/users/route.ts b/examples/nextjs-ai-elements-realtime/app/api/users/route.ts new file mode 100644 index 0000000000..15bd38e741 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/api/users/route.ts @@ -0,0 +1,16 @@ +import { getUser } from "@/app/database"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const userIds = searchParams.getAll("userIds"); + + if (!userIds || !Array.isArray(userIds)) { + return new NextResponse("Missing or invalid userIds", { status: 400 }); + } + + return NextResponse.json( + userIds.map((userId) => getUser(userId)?.info || null), + { status: 200 } + ); +} diff --git a/examples/nextjs-ai-elements-realtime/app/database.ts b/examples/nextjs-ai-elements-realtime/app/database.ts new file mode 100644 index 0000000000..56870d7bd0 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/database.ts @@ -0,0 +1,69 @@ +export const AI_USER_ID = "ai-assistant"; +export const AI_USER_NAME = "Liveblocks AI"; +export const AI_USER_AVATAR = + "https://liveblocks.io/api/avatar?u=ai-assistant&agent=true"; + +// A mock database with example users +const USER_INFO: Liveblocks["UserMeta"][] = [ + { + id: "charlie.layne@example.com", + info: { + name: "Charlie Layne", + color: "#D583F0", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "mislav.abha@example.com", + info: { + name: "Mislav Abha", + color: "#F08385", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, + { + id: "tatum.paolo@example.com", + info: { + name: "Tatum Paolo", + color: "#F0D885", + avatar: "https://liveblocks.io/avatars/avatar-3.png", + }, + }, + { + id: "anjali.wanda@example.com", + info: { + name: "Anjali Wanda", + color: "#85EED6", + avatar: "https://liveblocks.io/avatars/avatar-4.png", + }, + }, + { + id: "quinn.elton@example.com", + info: { + name: "Quinn Elton", + color: "#87EE85", + avatar: "https://liveblocks.io/avatars/avatar-8.png", + }, + }, + { + id: AI_USER_ID, + info: { + name: AI_USER_NAME, + color: "#000000", + avatar: AI_USER_AVATAR, + }, + }, +]; + +export function getRandomUser() { + const humans = USER_INFO.filter((user) => user.id !== AI_USER_ID); + return humans[Math.floor(Math.random() * humans.length)]; +} + +export function getUser(id: string) { + return USER_INFO.find((u) => u.id === id) || undefined; +} + +export function getUsers() { + return USER_INFO; +} diff --git a/examples/nextjs-ai-elements-realtime/app/globals.css b/examples/nextjs-ai-elements-realtime/app/globals.css new file mode 100644 index 0000000000..8a3e54e803 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/globals.css @@ -0,0 +1,121 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "@liveblocks/react-ui/styles.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/examples/nextjs-ai-elements-realtime/app/layout.tsx b/examples/nextjs-ai-elements-realtime/app/layout.tsx new file mode 100644 index 0000000000..58130bb1c6 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/layout.tsx @@ -0,0 +1,37 @@ +import "./globals.css"; +import { ReactNode, Suspense } from "react"; +import { Providers } from "./Providers"; +import { HelpButton } from "@/components/HelpButton"; + +export const metadata = { + title: "Liveblocks", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + + + + + + {children} + + + + + ); +} diff --git a/examples/nextjs-ai-elements-realtime/app/page.tsx b/examples/nextjs-ai-elements-realtime/app/page.tsx new file mode 100644 index 0000000000..c88603396d --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/app/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { ClientSideSuspense, RoomProvider } from "@liveblocks/react/suspense"; +import { useExampleRoomId } from "@/hooks/use-example-room-id"; +import { Chat } from "./Chat"; +import { Loader } from "@/components/ai-elements/loader"; + +export default function Page() { + const roomId = useExampleRoomId(); + + return ( + + + + + } + > + + + + ); +} diff --git a/examples/nextjs-ai-elements-realtime/components.json b/examples/nextjs-ai-elements-realtime/components.json new file mode 100644 index 0000000000..26ad91e754 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/examples/nextjs-ai-elements-realtime/components/HelpButton.tsx b/examples/nextjs-ai-elements-realtime/components/HelpButton.tsx new file mode 100644 index 0000000000..5e303fea2e --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/components/HelpButton.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { CSSProperties, ReactNode, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +const EXAMPLE_NAME = "Realtime AI chat with AI Elements"; +const EXAMPLE_URL = + "https://liveblocks.io/examples/nextjs-ai-elements-realtime"; + +type Feature = { + icon: ReactNode; + title: string; + description: ReactNode; +}; + +const FEATURES: Feature[] = [ + { + icon: , + title: "Realtime AI chat", + description: + "Messages live in a Liveblocks feed and sync instantly to everyone connected to the room.", + }, + { + icon: , + title: "Shared across users", + description: + "Open this example in two tabs — every message and AI reply appears for both at once.", + }, + { + icon: , + title: "Replies from the server", + description: + "The AI answer is generated server-side and written into the feed with @liveblocks/node.", + }, + { + icon: , + title: "Built with AI Elements", + description: ( + <> + The UI uses Vercel{" "} + + AI Elements + {" "} + rendered from feed data. + + ), + }, +]; + +const styles: Record = { + button: { + position: "fixed", + bottom: 16, + left: 16, + zIndex: 2147483000, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 36, + height: 36, + background: "#ffffff", + border: "1px solid #e5e5e5", + borderRadius: 9999, + boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + color: "#737373", + cursor: "pointer", + }, + backdrop: { + position: "fixed", + inset: 0, + zIndex: 2147483000, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 16, + background: "rgba(23, 23, 23, 0.2)", + }, + panel: { + background: "#ffffff", + border: "1px solid #e5e5e5", + borderRadius: 8, + boxShadow: + "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + width: "100%", + maxWidth: 448, + maxHeight: "80vh", + overflowY: "auto", + }, + header: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 16, + padding: 20, + borderBottom: "1px solid #e5e5e5", + }, + title: { fontSize: 14, fontWeight: 600, color: "#171717", margin: 0 }, + titleLink: { color: "inherit", textDecoration: "none" }, + desc: { fontSize: 14, color: "#737373", marginTop: 4, marginBottom: 0 }, + close: { + flexShrink: 0, + marginTop: -4, + marginRight: -4, + padding: 6, + borderRadius: 4, + border: "none", + background: "transparent", + color: "#737373", + cursor: "pointer", + lineHeight: 0, + }, + list: { + listStyle: "none", + margin: 0, + padding: 20, + display: "flex", + flexDirection: "column", + gap: 16, + }, + item: { display: "flex", alignItems: "flex-start", gap: 16 }, + iconWrap: { + flexShrink: 0, + marginTop: 2, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 28, + height: 28, + borderRadius: 4, + background: "#f5f5f5", + color: "#404040", + }, + featureTitle: { fontSize: 14, fontWeight: 500, color: "#171717", margin: 0 }, + featureDesc: { fontSize: 14, color: "#737373", marginTop: 2, marginBottom: 0 }, +}; + +const HOVER_CSS = ` +.lb-help-button:hover { background:#fafafa !important; color:#171717 !important; } +.lb-help-title-link:hover { text-decoration: underline !important; } +.lb-help-close:hover { background:#f5f5f5 !important; color:#171717 !important; } +.lb-help-link { color:#404040 !important; text-decoration: underline !important; } +.lb-help-link:hover { color:#171717 !important; } +.lb-help, .lb-help * { box-sizing: border-box; } +`; + +export function HelpButton() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (!isOpen) { + return; + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setIsOpen(false); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isOpen]); + + return ( + <> + + + + {isOpen && typeof document !== "undefined" + ? createPortal( +
setIsOpen(false)} + > +
event.stopPropagation()} + > +
+
+

+ + {EXAMPLE_NAME} + +

+

How to use this example

+
+ +
+ +
    + {FEATURES.map((feature) => ( +
  • + {feature.icon} +
    +

    {feature.title}

    +

    {feature.description}

    +
    +
  • + ))} +
+
+
, + document.body + ) + : null} + + ); +} + +function HelpIcon() { + return ( + + + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +function FeatureIconBase({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function CommentIcon() { + return ( + + + + ); +} + +function SparklesIcon() { + return ( + + + + + ); +} + +function UsersIcon() { + return ( + + + + + + ); +} + +function ZapIcon() { + return ( + + + + ); +} diff --git a/examples/nextjs-ai-elements-realtime/components/ai-elements/artifact.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/artifact.tsx new file mode 100644 index 0000000000..c90cb5fe3d --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/canvas.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/canvas.tsx new file mode 100644 index 0000000000..5aa83cb5e7 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/chain-of-thought.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000000..195c465c7c --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,231 @@ +"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/examples/nextjs-ai-elements-realtime/components/ai-elements/checkpoint.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/checkpoint.tsx new file mode 100644 index 0000000000..d9a5d326c8 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/code-block.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/code-block.tsx new file mode 100644 index 0000000000..b6865f0dc4 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/confirmation.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/confirmation.tsx new file mode 100644 index 0000000000..2ec0aab578 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/controls.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/controls.tsx new file mode 100644 index 0000000000..770a8262aa --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/conversation.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/conversation.tsx new file mode 100644 index 0000000000..aa380f573f --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/edge.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/edge.tsx new file mode 100644 index 0000000000..3cec409d10 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/image.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/image.tsx new file mode 100644 index 0000000000..542812a328 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/inline-citation.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/inline-citation.tsx new file mode 100644 index 0000000000..5977081bb4 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/loader.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/loader.tsx new file mode 100644 index 0000000000..5f0cfce400 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/message.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/message.tsx new file mode 100644 index 0000000000..73d6997efe --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/model-selector.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/model-selector.tsx new file mode 100644 index 0000000000..ef6ebd7e8b --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/node.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/node.tsx new file mode 100644 index 0000000000..75ac59a153 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/open-in-chat.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/open-in-chat.tsx new file mode 100644 index 0000000000..0c62a6ac4a --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/panel.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/panel.tsx new file mode 100644 index 0000000000..059cb7ac21 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/plan.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/plan.tsx new file mode 100644 index 0000000000..be04d883be --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/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/examples/nextjs-ai-elements-realtime/components/ai-elements/prompt-input.tsx b/examples/nextjs-ai-elements-realtime/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000000..5ede475f52 --- /dev/null +++ b/examples/nextjs-ai-elements-realtime/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1413 @@ +"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 [attachmentFiles, setAttachmentFiles] = 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; + } + + setAttachmentFiles((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })) + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + attachmentsRef.current = attachmentFiles; + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }; + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + 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: 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 formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + filesRef.current = files; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [] + ); + + const clearLocal = useCallback( + () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [] + ); + + const add = usingProvider ? controller.attachments.add : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const clear = usingProvider ? controller.attachments.clear : clearLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + if (globalDrop) return // when global drop is on, let the document-level handler own drops + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider] + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }; + + const convertBlobUrlToDataUrl = async ( + url: string + ): Promise => { + 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.url && item.url.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }) + ) + .then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + }; + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + 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 [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const form = e.currentTarget.form; + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }; + + const handlePaste: ClipboardEventHandler = (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }; + + const controlledProps = controller + ? { + value: controller.textInput.value, + onChange: (e: ChangeEvent) => { + 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) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + 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); + + useEffect(() => { + if ( + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) + ) { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + const speechRecognition = new SpeechRecognition(); + + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + speechRecognition.lang = "en-US"; + + speechRecognition.onstart = () => { + setIsListening(true); + }; + + speechRecognition.onend = () => { + setIsListening(false); + }; + + speechRecognition.onresult = (event) => { + let finalTranscript = ""; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + finalTranscript += result[0]?.transcript ?? ""; + } + } + + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; + const currentValue = textarea.value; + const newValue = + currentValue + (currentValue ? " " : "") + finalTranscript; + + textarea.value = newValue; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + onTranscriptionChange?.(newValue); + } + }; + + speechRecognition.onerror = (event) => { + console.error("Speech recognition error:", event.error); + setIsListening(false); + }; + + recognitionRef.current = speechRecognition; + setRecognition(speechRecognition); + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [textareaRef, onTranscriptionChange]); + + const toggleListening = useCallback(() => { + if (!recognition) { + return; + } + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [recognition, isListening]); + + return ( + + + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( + + ); +}; + +export type WebPreviewBodyProps = ComponentProps<"iframe"> & { + loading?: ReactNode; +}; + +export const WebPreviewBody = ({ + className, + loading, + src, + ...props +}: WebPreviewBodyProps) => { + const { url } = useWebPreview(); + + return ( +
+