diff --git a/.changeset/common-geese-fetch.md b/.changeset/common-geese-fetch.md new file mode 100644 index 000000000..dfc5853c5 --- /dev/null +++ b/.changeset/common-geese-fetch.md @@ -0,0 +1,81 @@ +--- +"@voltagent/resumable-streams": patch +"@voltagent/serverless-hono": patch +"@voltagent/server-core": patch +"@voltagent/server-hono": patch +"@voltagent/core": patch +--- + +feat: add resumable streaming support via @voltagent/resumable-streams, with server adapters that let clients reconnect to in-flight streams. + +```ts +import { openai } from "@ai-sdk/openai"; +import { Agent, VoltAgent } from "@voltagent/core"; +import { + createResumableStreamAdapter, + createResumableStreamRedisStore, +} from "@voltagent/resumable-streams"; +import { honoServer } from "@voltagent/server-hono"; + +const streamStore = await createResumableStreamRedisStore(); +const resumableStream = await createResumableStreamAdapter({ streamStore }); + +const agent = new Agent({ + id: "assistant", + name: "Resumable Stream Agent", + instructions: "You are a helpful assistant.", + model: openai("gpt-4o-mini"), +}); + +new VoltAgent({ + agents: { assistant: agent }, + server: honoServer({ + resumableStream: { adapter: resumableStream }, + }), +}); + +await fetch("http://localhost:3141/agents/assistant/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: `{"input":"Hello!","options":{"conversationId":"conv-1","userId":"user-1","resumableStream":true}}`, +}); + +// Resume the same stream after reconnect/refresh +const resumeResponse = await fetch( + "http://localhost:3141/agents/assistant/chat/conv-1/stream?userId=user-1" +); + +const reader = resumeResponse.body?.getReader(); +const decoder = new TextDecoder(); +while (reader) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + console.log(chunk); +} +``` + +AI SDK client (resume on refresh): + +```tsx +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; + +const { messages, sendMessage } = useChat({ + id: chatId, + messages: initialMessages, + resume: true, + transport: new DefaultChatTransport({ + api: "/api/chat", + prepareSendMessagesRequest: ({ id, messages }) => ({ + body: { + message: messages[messages.length - 1], + options: { conversationId: id, userId }, + }, + }), + prepareReconnectToStreamRequest: ({ id }) => ({ + api: `/api/chat/${id}/stream?userId=${encodeURIComponent(userId)}`, + }), + }), +}); +``` diff --git a/examples/README.md b/examples/README.md index d10c060a9..9f38c57fc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -113,6 +113,7 @@ Create a multi-agent research workflow where different AI agents collaborate to - [MCP Server](./with-mcp-server) — Implement and run a local MCP server that exposes custom tools. - [Netlify Functions](./with-netlify-functions) — Ship serverless agent APIs on Netlify. - [Next.js](./with-nextjs) — React UI with agent APIs and streaming responses. +- [Next.js + Resumable Streams](./with-nextjs-resumable-stream) — AI Elements chat UI with VoltAgent and resumable streams. - [Nuxt](./with-nuxt) — Vue/Nuxt front‑end talking to VoltAgent APIs. - [Offline Evals](./with-offline-evals) — Batch datasets and score outputs for regression testing. - [Peaka (MCP)](./with-peaka-mcp) — Integrate Peaka services via MCP tools. @@ -132,6 +133,8 @@ Create a multi-agent research workflow where different AI agents collaborate to - [Turso](./with-turso) — Persist memory on LibSQL/Turso with simple setup. - [Vector Search](./with-vector-search) — Semantic memory with embeddings and automatic recall during chats. - [Vercel AI](./with-vercel-ai) — VoltAgent with Vercel AI SDK provider and streaming. +- [Resumable Streams](./with-resumable-streams) — Persist and resume chat streams with Redis-backed SSE storage. +- [VoltOps Resumable Streams](./with-voltops-resumable-streams) — Persist and resume chat streams with VoltOps managed storage. - [ViteVal](./with-viteval) — Integrate ViteVal to evaluate agents and prompts. - [Voice (ElevenLabs)](./with-voice-elevenlabs) — Convert agent replies to speech using ElevenLabs TTS. - [Voice (OpenAI)](./with-voice-openai) — Speak responses with OpenAI’s TTS voices. diff --git a/examples/with-nextjs-resumable-stream/.env.example b/examples/with-nextjs-resumable-stream/.env.example new file mode 100644 index 000000000..2bc242a17 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/.env.example @@ -0,0 +1,2 @@ +OPENAI_API_KEY=sk-proj- +REDIS_URL=redis://localhost:6379 diff --git a/examples/with-nextjs-resumable-stream/.gitignore b/examples/with-nextjs-resumable-stream/.gitignore new file mode 100644 index 000000000..e72b4d6a4 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/with-nextjs-resumable-stream/README.md b/examples/with-nextjs-resumable-stream/README.md new file mode 100644 index 000000000..5de42ba0f --- /dev/null +++ b/examples/with-nextjs-resumable-stream/README.md @@ -0,0 +1,53 @@ +
+ +435380213-b6253409-8741-462b-a346-834cd18565a9 + + +
+
+ +
+ Home Page | + Documentation | + Examples | + Discord | + Blog +
+
+ +
+ +
+ VoltAgent is an open source TypeScript framework for building and orchestrating AI agents.
+Escape the limitations of no-code builders and the complexity of starting from scratch. +
+
+
+ +
+ +[![npm version](https://img.shields.io/npm/v/@voltagent/core.svg)](https://www.npmjs.com/package/@voltagent/core) +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)](CODE_OF_CONDUCT.md) +[![Discord](https://img.shields.io/discord/1361559153780195478.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://s.voltagent.dev/discord) +[![Twitter Follow](https://img.shields.io/twitter/follow/voltagent_dev?style=social)](https://twitter.com/voltagent_dev) + +
+ +
+ +
+ +VoltAgent Schema + + +
+ +## VoltAgent: Build AI Agents Fast and Flexibly + +VoltAgent is an open-source TypeScript framework for creating and managing AI agents. It provides modular components to build, customize, and scale agents with ease. From connecting to APIs and memory management to supporting multiple LLMs, VoltAgent simplifies the process of creating sophisticated AI systems. It enables fast development, maintains clean code, and offers flexibility to switch between models and tools without vendor lock-in. + +## Try Example + +```bash +npm create voltagent-app@latest -- --example with-nextjs-resumable-stream +``` diff --git a/examples/with-nextjs-resumable-stream/app/api/chat/[id]/stream/route.ts b/examples/with-nextjs-resumable-stream/app/api/chat/[id]/stream/route.ts new file mode 100644 index 000000000..c2d160dfb --- /dev/null +++ b/examples/with-nextjs-resumable-stream/app/api/chat/[id]/stream/route.ts @@ -0,0 +1,37 @@ +import { getResumableStreamAdapter } from "@/lib/resumable-stream"; +import { supervisorAgent } from "@/voltagent"; +import { safeStringify } from "@voltagent/internal/utils"; +import { createResumableChatSession } from "@voltagent/resumable-streams"; + +const jsonError = (status: number, message: string) => + new Response(safeStringify({ error: message, message }), { + status, + headers: { "Content-Type": "application/json" }, + }); + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + if (!id) { + return jsonError(400, "conversationId is required"); + } + + const userId = new URL(request.url).searchParams.get("userId"); + if (!userId) { + return jsonError(400, "userId is required"); + } + const agentId = supervisorAgent.id; + const resumableStream = await getResumableStreamAdapter(); + const session = createResumableChatSession({ + adapter: resumableStream, + conversationId: id, + userId, + agentId, + }); + + try { + return await session.resumeResponse(); + } catch (error) { + console.error("[API] Failed to resume stream:", error); + return new Response(null, { status: 204 }); + } +} diff --git a/examples/with-nextjs-resumable-stream/app/api/chat/route.ts b/examples/with-nextjs-resumable-stream/app/api/chat/route.ts new file mode 100644 index 000000000..897d8cf80 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/app/api/chat/route.ts @@ -0,0 +1,87 @@ +import { getResumableStreamAdapter } from "@/lib/resumable-stream"; +import { supervisorAgent } from "@/voltagent"; +import { setWaitUntil } from "@voltagent/core"; +import { safeStringify } from "@voltagent/internal/utils"; +import { createResumableChatSession } from "@voltagent/resumable-streams"; +import { after } from "next/server"; + +const jsonError = (status: number, message: string) => + new Response(safeStringify({ error: message, message }), { + status, + headers: { "Content-Type": "application/json" }, + }); + +export async function POST(req: Request) { + try { + const body = await req.json(); + const messages = Array.isArray(body?.messages) ? body.messages : []; + const message = body?.message; + const options = + body?.options && typeof body.options === "object" + ? (body.options as Record) + : undefined; + const conversationId = + typeof options?.conversationId === "string" ? options.conversationId : undefined; + const userId = typeof options?.userId === "string" ? options.userId : undefined; + const input = + message !== undefined ? (typeof message === "string" ? message : [message]) : messages; + + if (!conversationId) { + return jsonError(400, "options.conversationId is required"); + } + + if (!userId) { + return jsonError(400, "options.userId is required"); + } + + if (isEmptyInput(input)) { + return jsonError(400, "Message input is required"); + } + + // Enable non-blocking OTel export for Vercel/serverless + // This ensures spans are flushed in the background without blocking the response + setWaitUntil(after); + + const agentId = supervisorAgent.id; + const resumableStream = await getResumableStreamAdapter(); + const session = createResumableChatSession({ + adapter: resumableStream, + conversationId, + userId, + agentId, + }); + + try { + await session.clearActiveStream(); + } catch (error) { + console.error("[API] Failed to clear active resumable stream:", error); + } + + // Stream text from the supervisor agent with proper context + // The agent accepts UIMessage[] directly + const result = await supervisorAgent.streamText(input, { + userId, + conversationId, + }); + + return result.toUIMessageStreamResponse({ + consumeSseStream: session.consumeSseStream, + onFinish: session.onFinish, + }); + } catch (error) { + console.error("[API] Chat error:", error); + return jsonError(500, "Internal server error"); + } +} + +function isEmptyInput(input: unknown) { + if (input == null) { + return true; + } + + if (typeof input === "string") { + return input.trim().length === 0; + } + + return Array.isArray(input) && input.length === 0; +} diff --git a/examples/with-nextjs-resumable-stream/app/api/messages/route.ts b/examples/with-nextjs-resumable-stream/app/api/messages/route.ts new file mode 100644 index 000000000..cc5e85d4b --- /dev/null +++ b/examples/with-nextjs-resumable-stream/app/api/messages/route.ts @@ -0,0 +1,17 @@ +import { sharedMemory } from "@/voltagent/memory"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const conversationId = searchParams.get("conversationId"); + const userId = searchParams.get("userId"); + + if (!conversationId || !userId) { + return Response.json({ error: "conversationId and userId are required" }, { status: 400 }); + } + + const uiMessages = await sharedMemory.getMessages(userId, conversationId); + + return Response.json({ + data: uiMessages || [], + }); +} diff --git a/examples/with-nextjs-resumable-stream/app/globals.css b/examples/with-nextjs-resumable-stream/app/globals.css new file mode 100644 index 000000000..4546af1b7 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/app/globals.css @@ -0,0 +1,330 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --emerald-50: #ecfdf5; + --emerald-100: #d1fae5; + --emerald-500: #10b981; + --emerald-600: #059669; + --emerald-900: #064e3b; + --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); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +body { + font-family: var(--font-geist-sans), system-ui, -apple-system, sans-serif; +} + +/* Custom scrollbar for messages */ +.messages-container::-webkit-scrollbar { + width: 8px; +} + +.messages-container::-webkit-scrollbar-track { + background: rgba(16, 185, 129, 0.05); + border-radius: 4px; +} + +.messages-container::-webkit-scrollbar-thumb { + background: rgba(16, 185, 129, 0.2); + border-radius: 4px; +} + +.messages-container::-webkit-scrollbar-thumb:hover { + background: rgba(16, 185, 129, 0.3); +} + +/* Smooth animations */ +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-animate { + animation: slideInUp 0.3s ease-out; +} + +/* Code block styling */ +pre { + overflow-x: auto; + padding: 0.75rem; + border-radius: 0.5rem; + background: rgba(0, 0, 0, 0.2); + font-size: 0.875rem; + line-height: 1.5; +} + +code { + font-family: var(--font-geist-mono), monospace; +} + +/* Markdown content styling */ +.markdown-content { + font-size: 0.9375rem; + line-height: 1.7; + color: #e5e7eb; +} + +/* Code syntax highlighting overrides for dark theme */ +.markdown-content pre code.hljs { + background: transparent; + padding: 0; + display: block; +} + +.markdown-content .hljs { + color: #e5e7eb; + background: #0d1117; +} + +.markdown-content .hljs-comment, +.markdown-content .hljs-quote { + color: #8b949e; + font-style: italic; +} + +.markdown-content .hljs-keyword, +.markdown-content .hljs-selector-tag, +.markdown-content .hljs-subst { + color: #ff7b72; +} + +.markdown-content .hljs-number, +.markdown-content .hljs-literal, +.markdown-content .hljs-variable, +.markdown-content .hljs-template-variable, +.markdown-content .hljs-tag .hljs-attr { + color: #79c0ff; +} + +.markdown-content .hljs-string, +.markdown-content .hljs-doctag { + color: #a5d6ff; +} + +.markdown-content .hljs-title, +.markdown-content .hljs-section, +.markdown-content .hljs-selector-id { + color: #d2a8ff; + font-weight: bold; +} + +.markdown-content .hljs-subst { + font-weight: normal; +} + +.markdown-content .hljs-type, +.markdown-content .hljs-class .hljs-title { + color: #ffa657; + font-weight: bold; +} + +.markdown-content .hljs-tag, +.markdown-content .hljs-name, +.markdown-content .hljs-attribute { + color: #7ee787; + font-weight: normal; +} + +.markdown-content .hljs-regexp, +.markdown-content .hljs-link { + color: #a5d6ff; +} + +.markdown-content .hljs-symbol, +.markdown-content .hljs-bullet { + color: #ffa657; +} + +.markdown-content .hljs-built_in, +.markdown-content .hljs-builtin-name { + color: #ffa657; +} + +.markdown-content .hljs-meta { + color: #79c0ff; +} + +.markdown-content .hljs-deletion { + background: #490202; +} + +.markdown-content .hljs-addition { + background: #1c4220; +} + +.markdown-content .hljs-emphasis { + font-style: italic; +} + +.markdown-content .hljs-strong { + font-weight: bold; +} + +/* Ensure proper spacing in markdown lists */ +.markdown-content ul ul, +.markdown-content ol ul, +.markdown-content ul ol, +.markdown-content ol ol { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +/* Task lists */ +.markdown-content input[type="checkbox"] { + margin-right: 0.5rem; + cursor: pointer; +} + +.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); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground antialiased; + } +} + +/* Clean v0-style enhancements */ +@layer components { + /* Smooth transitions */ + * { + @apply transition-colors duration-200; + } + + /* Clean scrollbar */ + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.2); + border-radius: 3px; + } + + *::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.3); + } + + /* Clean focus states */ + :focus-visible { + @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background; + } +} diff --git a/examples/with-nextjs-resumable-stream/app/layout.tsx b/examples/with-nextjs-resumable-stream/app/layout.tsx new file mode 100644 index 000000000..240eaddb5 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { Toaster } from "sonner"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "VoltAgent AI Elements Chat", + description: "AI Elements chat UI with VoltAgent and Next.js", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/examples/with-nextjs-resumable-stream/app/page.tsx b/examples/with-nextjs-resumable-stream/app/page.tsx new file mode 100644 index 000000000..e53c13604 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/app/page.tsx @@ -0,0 +1,12 @@ +import { sharedMemory } from "@/voltagent/memory"; +import { ChatInterface } from "../components/chat-interface"; + +export default async function Home() { + const conversationId = "1"; + const userId = "1"; + const messages = (await sharedMemory.getMessages(userId, conversationId)) ?? []; + + return ( + + ); +} diff --git a/examples/with-nextjs-resumable-stream/components.json b/examples/with-nextjs-resumable-stream/components.json new file mode 100644 index 000000000..5d989a5d7 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components.json @@ -0,0 +1,24 @@ +{ + "$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" + }, + "registries": { + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" + } +} diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/actions.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/actions.tsx new file mode 100644 index 000000000..dbd19b11a --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/actions.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { ComponentProps } from "react"; + +export type ActionsProps = ComponentProps<"div">; + +export const Actions = ({ className, children, ...props }: ActionsProps) => ( +
+ {children} +
+); + +export type ActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const Action = ({ + tooltip, + children, + label, + className, + variant = "ghost", + size = "sm", + ...props +}: ActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/artifact.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/artifact.tsx new file mode 100644 index 000000000..898b6d403 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/artifact.tsx @@ -0,0 +1,118 @@ +"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/with-nextjs-resumable-stream/components/ai-elements/branch.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/branch.tsx new file mode 100644 index 000000000..06db0d8c1 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/branch.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { UIMessage } from "ai"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; + +type BranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const BranchContext = createContext(null); + +const useBranch = () => { + const context = useContext(BranchContext); + + if (!context) { + throw new Error("Branch components must be used within Branch"); + } + + return context; +}; + +export type BranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const Branch = ({ defaultBranch = 0, onBranchChange, className, ...props }: BranchProps) => { + 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: BranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0", className)} {...props} /> + + ); +}; + +export type BranchMessagesProps = HTMLAttributes; + +export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => { + const { currentBranch, setBranches, branches } = useBranch(); + const childrenArray = useMemo( + () => (Array.isArray(children) ? 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 BranchSelectorProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const BranchSelector = ({ className, from, ...props }: BranchSelectorProps) => { + const { totalBranches } = useBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( +
+ ); +}; + +export type BranchPreviousProps = ComponentProps; + +export const BranchPrevious = ({ className, children, ...props }: BranchPreviousProps) => { + const { goToPrevious, totalBranches } = useBranch(); + + return ( + + ); +}; + +export type BranchNextProps = ComponentProps; + +export const BranchNext = ({ className, children, ...props }: BranchNextProps) => { + const { goToNext, totalBranches } = useBranch(); + + return ( + + ); +}; + +export type BranchPageProps = HTMLAttributes; + +export const BranchPage = ({ className, ...props }: BranchPageProps) => { + const { currentBranch, totalBranches } = useBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/canvas.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/canvas.tsx new file mode 100644 index 000000000..e0466aef5 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/canvas.tsx @@ -0,0 +1,24 @@ +import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; +import type { ReactNode } from "react"; +import "@xyflow/react/dist/style.css"; +import { Controls } from "./controls"; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => ( + + + + {children} + +); diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/chain-of-thought.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 000000000..87bb307bf --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { BrainIcon, ChevronDownIcon, DotIcon, type LucideIcon } from "lucide-react"; +import type { ComponentProps } 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; + +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: string; + description?: string; + 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; + +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/with-nextjs-resumable-stream/components/ai-elements/code-block.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/code-block.tsx new file mode 100644 index 000000000..8b29ca1de --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/code-block.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { Element } from "hast"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { + type ComponentProps, + type HTMLAttributes, + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { type BundledLanguage, type ShikiTransformer, codeToHtml } 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: Element, line: number) { + 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/with-nextjs-resumable-stream/components/ai-elements/confirmation.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/confirmation.tsx new file mode 100644 index 000000000..addea92d6 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/confirmation.tsx @@ -0,0 +1,119 @@ +"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, type ReactNode, createContext, useContext } from "react"; + +type ConfirmationContextValue = { + approval: ToolUIPart["approval"]; + 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?: ToolUIPart["approval"]; + 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/with-nextjs-resumable-stream/components/ai-elements/controls.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/controls.tsx new file mode 100644 index 000000000..f994a634e --- /dev/null +++ b/examples/with-nextjs-resumable-stream/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/with-nextjs-resumable-stream/components/ai-elements/conversation.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/conversation.tsx new file mode 100644 index 000000000..411b124bc --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/conversation.tsx @@ -0,0 +1,87 @@ +"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; + +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/with-nextjs-resumable-stream/components/ai-elements/edge.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/edge.tsx new file mode 100644 index 000000000..1f4e4e2cd --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/edge.tsx @@ -0,0 +1,131 @@ +import { + BaseEdge, + type EdgeProps, + type InternalNode, + type Node, + Position, + getBezierPath, + getSimpleBezierPath, + 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/with-nextjs-resumable-stream/components/ai-elements/image.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/image.tsx new file mode 100644 index 000000000..0a9f62816 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/image.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; +import type { Experimental_GeneratedImage } from "ai"; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ base64, mediaType, ...props }: ImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element + {props.alt} +); diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/inline-citation.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/inline-citation.tsx new file mode 100644 index 000000000..f07f06583 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/inline-citation.tsx @@ -0,0 +1,255 @@ +"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; + } + + const updateCarouselState = () => { + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + }; + + updateCarouselState(); + + 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/with-nextjs-resumable-stream/components/ai-elements/loader.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/loader.tsx new file mode 100644 index 000000000..1259e64a7 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/loader.tsx @@ -0,0 +1,85 @@ +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/with-nextjs-resumable-stream/components/ai-elements/message.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/message.tsx new file mode 100644 index 000000000..8ce634b51 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/message.tsx @@ -0,0 +1,63 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import type { UIMessage } from "ai"; +import { type VariantProps, cva } from "class-variance-authority"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +const messageContentVariants = cva( + "is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm", + { + variants: { + variant: { + contained: [ + "max-w-[80%] px-4 py-3", + "group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground", + "group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground", + ], + flat: [ + "group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", + "group-[.is-assistant]:text-foreground", + ], + }, + }, + defaultVariants: { + variant: "contained", + }, + }, +); + +export type MessageContentProps = HTMLAttributes & + VariantProps; + +export const MessageContent = ({ children, className, variant, ...props }: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageAvatarProps = ComponentProps & { + src: string; + name?: string; +}; + +export const MessageAvatar = ({ src, name, className, ...props }: MessageAvatarProps) => ( + + + {name?.slice(0, 2) || "ME"} + +); diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/node.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/node.tsx new file mode 100644 index 000000000..ad9ed348b --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/node.tsx @@ -0,0 +1,63 @@ +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/with-nextjs-resumable-stream/components/ai-elements/open-in-chat.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/open-in-chat.tsx new file mode 100644 index 000000000..108b86f67 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/open-in-chat.tsx @@ -0,0 +1,333 @@ +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/with-nextjs-resumable-stream/components/ai-elements/panel.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/panel.tsx new file mode 100644 index 000000000..d8d7e2545 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/panel.tsx @@ -0,0 +1,12 @@ +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/with-nextjs-resumable-stream/components/ai-elements/plan.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/plan.tsx new file mode 100644 index 000000000..689efab18 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/plan.tsx @@ -0,0 +1,123 @@ +"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, "children"> & { + children: string; +}; + +export const PlanTitle = ({ children, ...props }: PlanTitleProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanDescriptionProps = Omit, "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/with-nextjs-resumable-stream/components/ai-elements/prompt-input.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/prompt-input.tsx new file mode 100644 index 000000000..081df4db9 --- /dev/null +++ b/examples/with-nextjs-resumable-stream/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1226 @@ +"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 { + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SendIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: (ref: RefObject, open: () => void) => void; +}; + +const PromptInputController = createContext(null); +const ProviderAttachmentsContext = createContext(null); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController().", + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments().", + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachements, setAttachements] = useState<(FileUIPart & { id: string })[]>([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) return; + + setAttachements((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })), + ), + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachements((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) URL.revokeObjectURL(found.url); + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachements((prev) => { + for (const f of prev) if (f.url) URL.revokeObjectURL(f.url); + return []; + }); + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachements, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachements, add, remove, clear, openFileDialog], + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [], + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput], + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider", + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ data, className, ...props }: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {filename +
+ )} +
+
+

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

+ {data.mediaType && ( +

{data.mediaType}

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