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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+[](https://www.npmjs.com/package/@voltagent/core)
+[](CODE_OF_CONDUCT.md)
+[](https://s.voltagent.dev/discord)
+[](https://twitter.com/voltagent_dev)
+
+
+
+
+
+
+
+## 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 = (
+
+ {children}
+ {label || tooltip}
+
+ );
+
+ 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) => (
+
+ {children ?? }
+ Close
+
+);
+
+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 = (
+
+ {Icon ? : children}
+ {label || tooltip}
+
+ );
+
+ 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 (
+
+ {children ?? }
+
+ );
+};
+
+export type BranchNextProps = ComponentProps;
+
+export const BranchNext = ({ className, children, ...props }: BranchNextProps) => {
+ const { goToNext, totalBranches } = useBranch();
+
+ return (
+
+ {children ?? }
+
+ );
+};
+
+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 (
+
+ {children ?? }
+
+ );
+};
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) => (
+
+);
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/connection.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/connection.tsx
new file mode 100644
index 000000000..8a64eb9f9
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/connection.tsx
@@ -0,0 +1,16 @@
+import type { ConnectionLineComponent } from "@xyflow/react";
+
+const HALF = 0.5;
+
+export const Connection: ConnectionLineComponent = ({ fromX, fromY, toX, toY }) => (
+
+
+
+
+);
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/context.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/context.tsx
new file mode 100644
index 000000000..4c86d0a12
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/context.tsx
@@ -0,0 +1,360 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
+import { Progress } from "@/components/ui/progress";
+import { cn } from "@/lib/utils";
+import type { LanguageModelUsage } from "ai";
+import { type ComponentProps, createContext, useContext } from "react";
+import { getUsage } from "tokenlens";
+
+const PERCENT_MAX = 100;
+const ICON_RADIUS = 10;
+const ICON_VIEWBOX = 24;
+const ICON_CENTER = 12;
+const ICON_STROKE_WIDTH = 2;
+
+type ModelId = string;
+
+type ContextSchema = {
+ usedTokens: number;
+ maxTokens: number;
+ usage?: LanguageModelUsage;
+ modelId?: ModelId;
+};
+
+const ContextContext = createContext(null);
+
+const useContextValue = () => {
+ const context = useContext(ContextContext);
+
+ if (!context) {
+ throw new Error("Context components must be used within Context");
+ }
+
+ return context;
+};
+
+export type ContextProps = ComponentProps & ContextSchema;
+
+export const Context = ({ usedTokens, maxTokens, usage, modelId, ...props }: ContextProps) => (
+
+
+
+);
+
+const ContextIcon = () => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const circumference = 2 * Math.PI * ICON_RADIUS;
+ const usedPercent = usedTokens / maxTokens;
+ const dashOffset = circumference * (1 - usedPercent);
+
+ return (
+
+
+
+
+ );
+};
+
+export type ContextTriggerProps = ComponentProps;
+
+export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const usedPercent = usedTokens / maxTokens;
+ const renderedPercent = new Intl.NumberFormat("en-US", {
+ style: "percent",
+ maximumFractionDigits: 1,
+ }).format(usedPercent);
+
+ return (
+
+ {children ?? (
+
+ {renderedPercent}
+
+
+ )}
+
+ );
+};
+
+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
+
+);
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 ?? (
+
+ Open in chat
+
+
+ )}
+
+);
+
+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) => (
+
+
+
+ Toggle plan
+
+
+);
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
+
+ ) : (
+
+ )}
+
+
{
+ e.stopPropagation();
+ attachments.remove(data.id);
+ }}
+ type="button"
+ variant="ghost"
+ >
+
+ Remove
+
+
+
+
{attachmentLabel}
+
+
+
+
+ {isImage && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ )}
+
+
+
+ {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 = (
+ <>
+
+
+
+ >
+ );
+
+ return usingProvider ? (
+ inner
+ ) : (
+ {inner}
+ );
+};
+
+export type PromptInputBodyProps = HTMLAttributes;
+
+export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => (
+
+);
+
+export type PromptInputTextareaProps = ComponentProps;
+
+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();
+ e.currentTarget.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, "align">;
+
+export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => (
+
+);
+
+export type PromptInputFooterProps = Omit, "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;
+export const PromptInputActionMenuContent = ({
+ className,
+ ...props
+}: PromptInputActionMenuContentProps) => (
+
+);
+
+export type PromptInputActionMenuItemProps = ComponentProps;
+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) => unknown) | null;
+ onend: ((this: SpeechRecognition, ev: Event) => unknown) | null;
+ onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => unknown) | null;
+ onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => unknown) | null;
+}
+
+interface SpeechRecognitionEvent extends Event {
+ results: SpeechRecognitionResultList;
+}
+
+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 & {
+ 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 = "";
+
+ const results = Array.from(event.results);
+
+ for (const result of results) {
+ 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;
+ // Initialize recognition state after effect
+ Promise.resolve().then(() => 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 PromptInputModelSelectProps = ComponentProps;
+
+export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => ;
+
+export type PromptInputModelSelectTriggerProps = ComponentProps;
+
+export const PromptInputModelSelectTrigger = ({
+ className,
+ ...props
+}: PromptInputModelSelectTriggerProps) => (
+
+);
+
+export type PromptInputModelSelectContentProps = ComponentProps;
+
+export const PromptInputModelSelectContent = ({
+ className,
+ ...props
+}: PromptInputModelSelectContentProps) => ;
+
+export type PromptInputModelSelectItemProps = ComponentProps;
+
+export const PromptInputModelSelectItem = ({
+ className,
+ ...props
+}: PromptInputModelSelectItemProps) => ;
+
+export type PromptInputModelSelectValueProps = ComponentProps;
+
+export const PromptInputModelSelectValue = ({
+ className,
+ ...props
+}: PromptInputModelSelectValueProps) => ;
+
+export type PromptInputHoverCardProps = ComponentProps;
+
+export const PromptInputHoverCard = ({
+ openDelay = 0,
+ closeDelay = 0,
+ ...props
+}: PromptInputHoverCardProps) => (
+
+);
+
+export type PromptInputHoverCardTriggerProps = ComponentProps;
+
+export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => (
+
+);
+
+export type PromptInputHoverCardContentProps = ComponentProps;
+
+export const PromptInputHoverCardContent = ({
+ align = "start",
+ ...props
+}: PromptInputHoverCardContentProps) => ;
+
+export type PromptInputTabsListProps = HTMLAttributes;
+
+export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => (
+
+);
+
+export type PromptInputTabProps = HTMLAttributes;
+
+export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => (
+
+);
+
+export type PromptInputTabLabelProps = HTMLAttributes;
+
+export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
+
+);
+
+export type PromptInputTabBodyProps = HTMLAttributes;
+
+export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => (
+
+);
+
+export type PromptInputTabItemProps = HTMLAttributes;
+
+export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
+
+);
+
+export type PromptInputCommandProps = ComponentProps;
+
+export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => (
+
+);
+
+export type PromptInputCommandInputProps = ComponentProps;
+
+export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => (
+
+);
+
+export type PromptInputCommandListProps = ComponentProps;
+
+export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => (
+
+);
+
+export type PromptInputCommandEmptyProps = ComponentProps;
+
+export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => (
+
+);
+
+export type PromptInputCommandGroupProps = ComponentProps;
+
+export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => (
+
+);
+
+export type PromptInputCommandItemProps = ComponentProps;
+
+export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => (
+
+);
+
+export type PromptInputCommandSeparatorProps = ComponentProps;
+
+export const PromptInputCommandSeparator = ({
+ className,
+ ...props
+}: PromptInputCommandSeparatorProps) => ;
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/queue.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/queue.tsx
new file mode 100644
index 000000000..c2d91bc62
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/queue.tsx
@@ -0,0 +1,232 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type QueueMessagePart = {
+ type: string;
+ text?: string;
+ url?: string;
+ filename?: string;
+ mediaType?: string;
+};
+
+export type QueueMessage = {
+ id: string;
+ parts: QueueMessagePart[];
+};
+
+export type QueueTodo = {
+ id: string;
+ title: string;
+ description?: string;
+ status?: "pending" | "completed";
+};
+
+export type QueueItemProps = ComponentProps<"li">;
+
+export const QueueItem = ({ className, ...props }: QueueItemProps) => (
+
+);
+
+export type QueueItemIndicatorProps = ComponentProps<"span"> & {
+ completed?: boolean;
+};
+
+export const QueueItemIndicator = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemIndicatorProps) => (
+
+);
+
+export type QueueItemContentProps = ComponentProps<"span"> & {
+ completed?: boolean;
+};
+
+export const QueueItemContent = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemContentProps) => (
+
+);
+
+export type QueueItemDescriptionProps = ComponentProps<"div"> & {
+ completed?: boolean;
+};
+
+export const QueueItemDescription = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemDescriptionProps) => (
+
+);
+
+export type QueueItemActionsProps = ComponentProps<"div">;
+
+export const QueueItemActions = ({ className, ...props }: QueueItemActionsProps) => (
+
+);
+
+export type QueueItemActionProps = Omit, "variant" | "size">;
+
+export const QueueItemAction = ({ className, ...props }: QueueItemActionProps) => (
+
+);
+
+export type QueueItemAttachmentProps = ComponentProps<"div">;
+
+export const QueueItemAttachment = ({ className, ...props }: QueueItemAttachmentProps) => (
+
+);
+
+export type QueueItemImageProps = ComponentProps<"img">;
+
+export const QueueItemImage = ({ className, ...props }: QueueItemImageProps) => (
+ // eslint-disable-next-line @next/next/no-img-element
+
+);
+
+export type QueueItemFileProps = ComponentProps<"span">;
+
+export const QueueItemFile = ({ children, className, ...props }: QueueItemFileProps) => (
+
+
+ {children}
+
+);
+
+export type QueueListProps = ComponentProps;
+
+export const QueueList = ({ children, className, ...props }: QueueListProps) => (
+
+
+
+);
+
+// QueueSection - collapsible section container
+export type QueueSectionProps = ComponentProps;
+
+export const QueueSection = ({ className, defaultOpen = true, ...props }: QueueSectionProps) => (
+
+);
+
+// QueueSectionTrigger - section header/trigger
+export type QueueSectionTriggerProps = ComponentProps<"button">;
+
+export const QueueSectionTrigger = ({
+ children,
+ className,
+ ...props
+}: QueueSectionTriggerProps) => (
+
+
+ {children}
+
+
+);
+
+// QueueSectionLabel - label content with icon and count
+export type QueueSectionLabelProps = ComponentProps<"span"> & {
+ count?: number;
+ label: string;
+ icon?: React.ReactNode;
+};
+
+export const QueueSectionLabel = ({
+ count,
+ label,
+ icon,
+ className,
+ ...props
+}: QueueSectionLabelProps) => (
+
+
+ {icon}
+
+ {count} {label}
+
+
+);
+
+// QueueSectionContent - collapsible content area
+export type QueueSectionContentProps = ComponentProps;
+
+export const QueueSectionContent = ({ className, ...props }: QueueSectionContentProps) => (
+
+);
+
+export type QueueProps = ComponentProps<"div">;
+
+export const Queue = ({ className, ...props }: QueueProps) => (
+
+);
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/reasoning.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/reasoning.tsx
new file mode 100644
index 000000000..5e2529800
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/reasoning.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+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 } from "lucide-react";
+import type { ComponentProps } from "react";
+import { createContext, memo, useContext, useEffect, useState } from "react";
+import { Response } from "./response";
+import { Shimmer } from "./shimmer";
+
+type ReasoningContextValue = {
+ isStreaming: boolean;
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+ duration: number;
+};
+
+const ReasoningContext = createContext(null);
+
+const useReasoning = () => {
+ const context = useContext(ReasoningContext);
+ if (!context) {
+ throw new Error("Reasoning components must be used within Reasoning");
+ }
+ return context;
+};
+
+export type ReasoningProps = ComponentProps & {
+ isStreaming?: boolean;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ duration?: number;
+};
+
+const AUTO_CLOSE_DELAY = 1000;
+const MS_IN_S = 1000;
+
+export const Reasoning = memo(
+ ({
+ className,
+ isStreaming = false,
+ open,
+ defaultOpen = true,
+ onOpenChange,
+ duration: durationProp,
+ children,
+ ...props
+ }: ReasoningProps) => {
+ const [isOpen, setIsOpen] = useControllableState({
+ prop: open,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+ const [duration, setDuration] = useControllableState({
+ prop: durationProp,
+ defaultProp: 0,
+ });
+
+ const [hasAutoClosed, setHasAutoClosed] = useState(false);
+ const [startTime, setStartTime] = useState(null);
+
+ // Track duration when streaming starts and ends
+ useEffect(() => {
+ if (isStreaming) {
+ if (startTime === null) {
+ Promise.resolve().then(() => setStartTime(Date.now()));
+ }
+ } else if (startTime !== null) {
+ const calculatedDuration = Math.ceil((Date.now() - startTime) / MS_IN_S);
+ Promise.resolve().then(() => {
+ setDuration(calculatedDuration);
+ setStartTime(null);
+ });
+ }
+ }, [isStreaming, startTime, setDuration]);
+
+ // Auto-open when streaming starts, auto-close when streaming ends (once only)
+ useEffect(() => {
+ if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
+ // Add a small delay before closing to allow user to see the content
+ const timer = setTimeout(() => {
+ setIsOpen(false);
+ setHasAutoClosed(true);
+ }, AUTO_CLOSE_DELAY);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
+
+ const handleOpenChange = (newOpen: boolean) => {
+ setIsOpen(newOpen);
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
+
+export type ReasoningTriggerProps = ComponentProps;
+
+const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
+ if (isStreaming || duration === 0) {
+ return Thinking... ;
+ }
+ if (duration === undefined) {
+ return Thought for a few seconds
;
+ }
+ return Thought for {duration} seconds
;
+};
+
+export const ReasoningTrigger = memo(({ className, children, ...props }: ReasoningTriggerProps) => {
+ const { isStreaming, isOpen, duration } = useReasoning();
+
+ return (
+
+ {children ?? (
+ <>
+
+ {getThinkingMessage(isStreaming, duration)}
+
+ >
+ )}
+
+ );
+});
+
+export type ReasoningContentProps = ComponentProps & {
+ children: string;
+};
+
+export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => (
+
+ {children}
+
+));
+
+Reasoning.displayName = "Reasoning";
+ReasoningTrigger.displayName = "ReasoningTrigger";
+ReasoningContent.displayName = "ReasoningContent";
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/response.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/response.tsx
new file mode 100644
index 000000000..8c7a7daf1
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/response.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { type ComponentProps, memo } from "react";
+import { Streamdown } from "streamdown";
+
+type ResponseProps = ComponentProps;
+
+export const Response = memo(
+ ({ className, ...props }: ResponseProps) => (
+ *:first-child]:mt-0 [&>*:last-child]:mb-0", className)}
+ {...props}
+ />
+ ),
+ (prevProps, nextProps) => prevProps.children === nextProps.children,
+);
+
+Response.displayName = "Response";
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/shimmer.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/shimmer.tsx
new file mode 100644
index 000000000..059bf155d
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/shimmer.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { motion } from "motion/react";
+import { type CSSProperties, type ElementType, memo, useMemo } from "react";
+
+export type TextShimmerProps = {
+ children: string;
+ as?: ElementType;
+ className?: string;
+ duration?: number;
+ spread?: number;
+};
+
+// Pre-create motion components to avoid creating them during render
+const MotionP = motion.p;
+const MotionSpan = motion.span;
+const MotionDiv = motion.div;
+const MotionH1 = motion.h1;
+const MotionH2 = motion.h2;
+const MotionH3 = motion.h3;
+const MotionH4 = motion.h4;
+const MotionH5 = motion.h5;
+const MotionH6 = motion.h6;
+
+const motionComponentMap: Record = {
+ p: MotionP,
+ span: MotionSpan,
+ div: MotionDiv,
+ h1: MotionH1,
+ h2: MotionH2,
+ h3: MotionH3,
+ h4: MotionH4,
+ h5: MotionH5,
+ h6: MotionH6,
+};
+
+const ShimmerComponent = ({
+ children,
+ as: Component = "p",
+ className,
+ duration = 2,
+ spread = 2,
+}: TextShimmerProps) => {
+ const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
+
+ const componentKey = typeof Component === "string" ? Component : "p";
+ const MotionComponent = motionComponentMap[componentKey] || MotionP;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const Shimmer = memo(ShimmerComponent);
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/sources.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/sources.tsx
new file mode 100644
index 000000000..ed96a7045
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/sources.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { BookIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type SourcesProps = ComponentProps<"div">;
+
+export const Sources = ({ className, ...props }: SourcesProps) => (
+
+);
+
+export type SourcesTriggerProps = ComponentProps & {
+ count: number;
+};
+
+export const SourcesTrigger = ({ className, count, children, ...props }: SourcesTriggerProps) => (
+
+ {children ?? (
+ <>
+ Used {count} sources
+
+ >
+ )}
+
+);
+
+export type SourcesContentProps = ComponentProps;
+
+export const SourcesContent = ({ className, ...props }: SourcesContentProps) => (
+
+);
+
+export type SourceProps = ComponentProps<"a">;
+
+export const Source = ({ href, title, children, ...props }: SourceProps) => (
+
+ {children ?? (
+ <>
+
+ {title}
+ >
+ )}
+
+);
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/suggestion.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/suggestion.tsx
new file mode 100644
index 000000000..2f9f9cf20
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/suggestion.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import type { ComponentProps } from "react";
+
+export type SuggestionsProps = ComponentProps;
+
+export const Suggestions = ({ className, children, ...props }: SuggestionsProps) => (
+
+ {children}
+
+
+);
+
+export type SuggestionProps = Omit, "onClick"> & {
+ suggestion: string;
+ onClick?: (suggestion: string) => void;
+};
+
+export const Suggestion = ({
+ suggestion,
+ onClick,
+ className,
+ variant = "outline",
+ size = "sm",
+ children,
+ ...props
+}: SuggestionProps) => {
+ const handleClick = () => {
+ onClick?.(suggestion);
+ };
+
+ return (
+
+ {children || suggestion}
+
+ );
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/task.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/task.tsx
new file mode 100644
index 000000000..49a2b994f
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/task.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon, SearchIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type TaskItemFileProps = ComponentProps<"div">;
+
+export const TaskItemFile = ({ children, className, ...props }: TaskItemFileProps) => (
+
+ {children}
+
+);
+
+export type TaskItemProps = ComponentProps<"div">;
+
+export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
+
+ {children}
+
+);
+
+export type TaskProps = ComponentProps;
+
+export const Task = ({ defaultOpen = true, className, ...props }: TaskProps) => (
+
+);
+
+export type TaskTriggerProps = ComponentProps & {
+ title: string;
+};
+
+export const TaskTrigger = ({ children, className, title, ...props }: TaskTriggerProps) => (
+
+ {children ?? (
+
+ )}
+
+);
+
+export type TaskContentProps = ComponentProps;
+
+export const TaskContent = ({ children, className, ...props }: TaskContentProps) => (
+
+ {children}
+
+);
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/tool.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/tool.tsx
new file mode 100644
index 000000000..f96790f69
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/tool.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { safeStringify } from "@voltagent/internal/utils";
+import type { ToolUIPart } from "ai";
+import {
+ CheckCircleIcon,
+ ChevronDownIcon,
+ CircleIcon,
+ ClockIcon,
+ WrenchIcon,
+ XCircleIcon,
+} from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { isValidElement } from "react";
+import { CodeBlock } from "./code-block";
+
+export type ToolProps = ComponentProps;
+
+export const Tool = ({ className, ...props }: ToolProps) => (
+
+);
+
+export type ToolHeaderProps = {
+ title?: string;
+ type: ToolUIPart["type"];
+ state: ToolUIPart["state"];
+ className?: string;
+};
+
+const getStatusBadge = (status: ToolUIPart["state"]) => {
+ const labels: Record = {
+ "input-streaming": "Pending",
+ "input-available": "Running",
+ "approval-requested": "Awaiting Approval",
+ "approval-responded": "Responded",
+ "output-available": "Completed",
+ "output-error": "Error",
+ "output-denied": "Denied",
+ };
+
+ const icons: Record = {
+ "input-streaming": ,
+ "input-available": ,
+ "approval-requested": ,
+ "approval-responded": ,
+ "output-available": ,
+ "output-error": ,
+ "output-denied": ,
+ };
+
+ return (
+
+ {icons[status]}
+ {labels[status]}
+
+ );
+};
+
+export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => (
+
+
+
+ {title ?? type.split("-").slice(1).join("-")}
+ {getStatusBadge(state)}
+
+
+
+);
+
+export type ToolContentProps = ComponentProps;
+
+export const ToolContent = ({ className, ...props }: ToolContentProps) => (
+
+);
+
+export type ToolInputProps = ComponentProps<"div"> & {
+ input: ToolUIPart["input"];
+};
+
+export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
+
+);
+
+export type ToolOutputProps = ComponentProps<"div"> & {
+ output: ToolUIPart["output"];
+ errorText: ToolUIPart["errorText"];
+};
+
+export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {
+ if (!(output || errorText)) {
+ return null;
+ }
+
+ let Output = {output as ReactNode}
;
+
+ if (typeof output === "object" && !isValidElement(output)) {
+ Output = ;
+ } else if (typeof output === "string") {
+ Output = ;
+ }
+
+ return (
+
+
+ {errorText ? "Error" : "Result"}
+
+
+ {errorText &&
{errorText}
}
+ {Output}
+
+
+ );
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/toolbar.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/toolbar.tsx
new file mode 100644
index 000000000..12b8c6c6d
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/toolbar.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+import { NodeToolbar, Position } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+type ToolbarProps = ComponentProps;
+
+export const Toolbar = ({ className, ...props }: ToolbarProps) => (
+
+);
diff --git a/examples/with-nextjs-resumable-stream/components/ai-elements/web-preview.tsx b/examples/with-nextjs-resumable-stream/components/ai-elements/web-preview.tsx
new file mode 100644
index 000000000..c44ccf5d6
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ai-elements/web-preview.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
+
+export type WebPreviewContextValue = {
+ url: string;
+ setUrl: (url: string) => void;
+ consoleOpen: boolean;
+ setConsoleOpen: (open: boolean) => void;
+};
+
+const WebPreviewContext = createContext(null);
+
+const useWebPreview = () => {
+ const context = useContext(WebPreviewContext);
+ if (!context) {
+ throw new Error("WebPreview components must be used within a WebPreview");
+ }
+ return context;
+};
+
+export type WebPreviewProps = ComponentProps<"div"> & {
+ defaultUrl?: string;
+ onUrlChange?: (url: string) => void;
+};
+
+export const WebPreview = ({
+ className,
+ children,
+ defaultUrl = "",
+ onUrlChange,
+ ...props
+}: WebPreviewProps) => {
+ const [url, setUrl] = useState(defaultUrl);
+ const [consoleOpen, setConsoleOpen] = useState(false);
+
+ const handleUrlChange = (newUrl: string) => {
+ setUrl(newUrl);
+ onUrlChange?.(newUrl);
+ };
+
+ const contextValue: WebPreviewContextValue = {
+ url,
+ setUrl: handleUrlChange,
+ consoleOpen,
+ setConsoleOpen,
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type WebPreviewNavigationProps = ComponentProps<"div">;
+
+export const WebPreviewNavigation = ({
+ className,
+ children,
+ ...props
+}: WebPreviewNavigationProps) => (
+
+ {children}
+
+);
+
+export type WebPreviewNavigationButtonProps = ComponentProps & {
+ tooltip?: string;
+};
+
+export const WebPreviewNavigationButton = ({
+ onClick,
+ disabled,
+ tooltip,
+ children,
+ ...props
+}: WebPreviewNavigationButtonProps) => (
+
+
+
+
+ {children}
+
+
+
+ {tooltip}
+
+
+
+);
+
+export type WebPreviewUrlProps = ComponentProps;
+
+export const WebPreviewUrl = ({ value, onChange, onKeyDown, ...props }: WebPreviewUrlProps) => {
+ const { url, setUrl } = useWebPreview();
+ const [inputValue, setInputValue] = useState(url);
+
+ // Sync input value with context URL when it changes externally
+ useEffect(() => {
+ setInputValue(url);
+ }, [url]);
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setInputValue(event.target.value);
+ onChange?.(event);
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ const target = event.target as HTMLInputElement;
+ setUrl(target.value);
+ }
+ onKeyDown?.(event);
+ };
+
+ return (
+
+ );
+};
+
+export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
+ loading?: ReactNode;
+};
+
+export const WebPreviewBody = ({ className, loading, src, ...props }: WebPreviewBodyProps) => {
+ const { url } = useWebPreview();
+
+ return (
+
+
+ {loading}
+
+ );
+};
+
+export type WebPreviewConsoleProps = ComponentProps<"div"> & {
+ logs?: Array<{
+ level: "log" | "warn" | "error";
+ message: string;
+ timestamp: Date;
+ }>;
+};
+
+export const WebPreviewConsole = ({
+ className,
+ logs = [],
+ children,
+ ...props
+}: WebPreviewConsoleProps) => {
+ const { consoleOpen, setConsoleOpen } = useWebPreview();
+
+ return (
+
+
+
+ Console
+
+
+
+
+
+ {logs.length === 0 ? (
+
No console output
+ ) : (
+ logs.map((log, index) => (
+
+ {log.timestamp.toLocaleTimeString()} {" "}
+ {log.message}
+
+ ))
+ )}
+ {children}
+
+
+
+ );
+};
diff --git a/examples/with-nextjs-resumable-stream/components/chat-interface.tsx b/examples/with-nextjs-resumable-stream/components/chat-interface.tsx
new file mode 100644
index 000000000..9653ac639
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/chat-interface.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import {
+ Conversation,
+ ConversationContent,
+ ConversationEmptyState,
+ ConversationScrollButton,
+} from "@/components/ai-elements/conversation";
+import { Loader } from "@/components/ai-elements/loader";
+import { Message, MessageAvatar, MessageContent } from "@/components/ai-elements/message";
+import {
+ PromptInput,
+ PromptInputButton,
+ PromptInputFooter,
+ type PromptInputMessage,
+ PromptInputTextarea,
+} from "@/components/ai-elements/prompt-input";
+import { Response } from "@/components/ai-elements/response";
+import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion";
+import {
+ Tool,
+ ToolContent,
+ ToolHeader,
+ ToolInput,
+ ToolOutput,
+} from "@/components/ai-elements/tool";
+import { useChat } from "@ai-sdk/react";
+import { DefaultChatTransport, type UIMessage, getToolName, isToolUIPart } from "ai";
+import { Bot, Send, Sparkles } from "lucide-react";
+
+type ChatInterfaceProps = {
+ chatId: string;
+ userId: string;
+ initialMessages: UIMessage[];
+ resume?: boolean;
+};
+
+export function ChatInterface({
+ chatId,
+ userId,
+ initialMessages,
+ resume = true,
+}: ChatInterfaceProps) {
+ const { messages, sendMessage, status, error } = useChat({
+ id: chatId,
+ messages: initialMessages,
+ resume,
+ 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)}`,
+ }),
+ }),
+ onError: (err: Error) => {
+ console.error("Chat error:", err);
+ },
+ });
+
+ const examplePrompts = [
+ "What is 2 + 2?",
+ "What's the current date and time?",
+ "Generate a random number between 1 and 100",
+ "What's the weather like?",
+ ];
+
+ const handlePromptClick = (suggestion: string) => {
+ sendMessage({ text: suggestion });
+ };
+
+ const handleSubmit = (message: PromptInputMessage) => {
+ if (message.text) {
+ sendMessage({ text: message.text });
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
VoltAgent
+
AI Assistant
+
+
+
+
+
+ {/* Messages */}
+
+
+ {messages.length === 0 ? (
+
+
+
+ }
+ >
+
+
+ Try these suggestions
+
+
+ {examplePrompts.map((prompt) => (
+
+ ))}
+
+
+
+ ) : (
+ <>
+ {messages.map((message) => {
+ const role = message.role;
+ return (
+
+
+
+ {/* Render message parts */}
+ {message.parts?.map((part, idx) => {
+ // Render text parts
+ if (part.type === "text" && "text" in part) {
+ return {part.text} ;
+ }
+
+ // Render tool invocation parts
+ if (isToolUIPart(part)) {
+ const toolName = getToolName(part);
+
+ return (
+
+
+
+ <>
+ {part.input && }
+
+ >
+
+
+ );
+ }
+
+ return null;
+ })}
+
+
+ );
+ })}
+
+ {status === "streaming" && (
+
+
+
+
+
+ Thinking...
+
+
+
+ )}
+
+ {error && (
+
+
+
Error
+
{error.message}
+
+
+ )}
+ >
+ )}
+
+
+
+
+ {/* Input */}
+
+
+ );
+}
diff --git a/examples/with-nextjs-resumable-stream/components/ui/alert.tsx b/examples/with-nextjs-resumable-stream/components/ui/alert.tsx
new file mode 100644
index 000000000..621044eee
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/alert.tsx
@@ -0,0 +1,60 @@
+import { type VariantProps, cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/avatar.tsx b/examples/with-nextjs-resumable-stream/components/ui/avatar.tsx
new file mode 100644
index 000000000..f2c75ce3d
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/avatar.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/badge.tsx b/examples/with-nextjs-resumable-stream/components/ui/badge.tsx
new file mode 100644
index 000000000..81e9dc3d4
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/badge.tsx
@@ -0,0 +1,39 @@
+import { Slot } from "@radix-ui/react-slot";
+import { type VariantProps, cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/button.tsx b/examples/with-nextjs-resumable-stream/components/ui/button.tsx
new file mode 100644
index 000000000..5d6a63769
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import { Slot } from "@radix-ui/react-slot";
+import { type VariantProps, cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/card.tsx b/examples/with-nextjs-resumable-stream/components/ui/card.tsx
new file mode 100644
index 000000000..c1ea7d4a6
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/card.tsx
@@ -0,0 +1,75 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/carousel.tsx b/examples/with-nextjs-resumable-stream/components/ui/carousel.tsx
new file mode 100644
index 000000000..50e6b4b46
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/carousel.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+
+ Previous slide
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+
+ Next slide
+
+ );
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ui/collapsible.tsx b/examples/with-nextjs-resumable-stream/components/ui/collapsible.tsx
new file mode 100644
index 000000000..e77b9a8c6
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/collapsible.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
+
+function Collapsible({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/command.tsx b/examples/with-nextjs-resumable-stream/components/ui/command.tsx
new file mode 100644
index 000000000..3ae2cd1d8
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/command.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { Command as CommandPrimitive } from "cmdk";
+import { SearchIcon } from "lucide-react";
+import type * as React from "react";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+
+function Command({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ title?: string;
+ description?: string;
+ className?: string;
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ );
+}
+
+function CommandList({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandEmpty({ ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandItem({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ui/dialog.tsx b/examples/with-nextjs-resumable-stream/components/ui/dialog.tsx
new file mode 100644
index 000000000..c4160f304
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/dialog.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Dialog({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ui/dropdown-menu.tsx b/examples/with-nextjs-resumable-stream/components/ui/dropdown-menu.tsx
new file mode 100644
index 000000000..458578392
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/dropdown-menu.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ui/hover-card.tsx b/examples/with-nextjs-resumable-stream/components/ui/hover-card.tsx
new file mode 100644
index 000000000..709d4a84b
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/hover-card.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function HoverCard({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function HoverCardTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function HoverCardContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/input-group.tsx b/examples/with-nextjs-resumable-stream/components/ui/input-group.tsx
new file mode 100644
index 000000000..601571a5b
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/input-group.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { type VariantProps, cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ textarea]:h-auto",
+
+ // Variants based on alignment.
+ "has-[>[data-align=inline-start]]:[&>input]:pl-2",
+ "has-[>[data-align=inline-end]]:[&>input]:pr-2",
+ "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
+ "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
+
+ // Focus state.
+ "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
+
+ // Error state.
+ "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
+
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
+ {
+ variants: {
+ align: {
+ "inline-start": "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
+ "inline-end": "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
+ "block-start":
+ "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
+ "block-end":
+ "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ },
+);
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return;
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
+ }}
+ {...props}
+ />
+ );
+}
+
+const inputGroupButtonVariants = cva("text-sm shadow-none flex gap-2 items-center", {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
+ sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
+ "icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+});
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size"> &
+ VariantProps) {
+ return (
+
+ );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ui/input.tsx b/examples/with-nextjs-resumable-stream/components/ui/input.tsx
new file mode 100644
index 000000000..73ea8679a
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/progress.tsx b/examples/with-nextjs-resumable-stream/components/ui/progress.tsx
new file mode 100644
index 000000000..030797718
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/progress.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import * as ProgressPrimitive from "@radix-ui/react-progress";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { Progress };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/scroll-area.tsx b/examples/with-nextjs-resumable-stream/components/ui/scroll-area.tsx
new file mode 100644
index 000000000..511519d8b
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/scroll-area.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/select.tsx b/examples/with-nextjs-resumable-stream/components/ui/select.tsx
new file mode 100644
index 000000000..feaa41332
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/select.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Select({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SelectGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SelectValue({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default";
+}) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectLabel({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/examples/with-nextjs-resumable-stream/components/ui/textarea.tsx b/examples/with-nextjs-resumable-stream/components/ui/textarea.tsx
new file mode 100644
index 000000000..4f6221bae
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/examples/with-nextjs-resumable-stream/components/ui/tooltip.tsx b/examples/with-nextjs-resumable-stream/components/ui/tooltip.tsx
new file mode 100644
index 000000000..6f5b44031
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/components/ui/tooltip.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({ ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/examples/with-nextjs-resumable-stream/lib/resumable-stream.ts b/examples/with-nextjs-resumable-stream/lib/resumable-stream.ts
new file mode 100644
index 000000000..d66968660
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/lib/resumable-stream.ts
@@ -0,0 +1,19 @@
+import type { ResumableStreamAdapter } from "@voltagent/core";
+import {
+ createResumableStreamAdapter,
+ createResumableStreamRedisStore,
+} from "@voltagent/resumable-streams";
+import { after } from "next/server";
+
+let adapterPromise: Promise | undefined;
+
+export function getResumableStreamAdapter() {
+ if (!adapterPromise) {
+ adapterPromise = (async () => {
+ const streamStore = await createResumableStreamRedisStore({ waitUntil: after });
+ return createResumableStreamAdapter({ streamStore });
+ })();
+ }
+
+ return adapterPromise;
+}
diff --git a/examples/with-nextjs-resumable-stream/lib/utils.ts b/examples/with-nextjs-resumable-stream/lib/utils.ts
new file mode 100644
index 000000000..365058ceb
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/examples/with-nextjs-resumable-stream/next.config.ts b/examples/with-nextjs-resumable-stream/next.config.ts
new file mode 100644
index 000000000..6d26fbbad
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/next.config.ts
@@ -0,0 +1,11 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ serverExternalPackages: [
+ // Externalize only what’s needed at runtime.
+ // LibSQL client is safe to externalize; native platform packages are optional.
+ "@libsql/client",
+ ],
+};
+
+export default nextConfig;
diff --git a/examples/with-nextjs-resumable-stream/package.json b/examples/with-nextjs-resumable-stream/package.json
new file mode 100644
index 000000000..4e5402c57
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "voltagent-example-with-nextjs-resumable-stream",
+ "version": "0.1.0",
+ "dependencies": {
+ "@ai-sdk/openai": "^3.0.0",
+ "@ai-sdk/react": "^3.0.0",
+ "@libsql/client": "^0.15.0",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-hover-card": "^1.1.15",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@radix-ui/react-use-controllable-state": "^1.2.2",
+ "@voltagent/cli": "^0.1.20",
+ "@voltagent/core": "^2.0.5",
+ "@voltagent/internal": "^1.0.2",
+ "@voltagent/libsql": "^2.0.2",
+ "@voltagent/resumable-streams": "^2.0.0",
+ "@voltagent/server-hono": "^2.0.2",
+ "@xyflow/react": "^12.9.2",
+ "ai": "^6.0.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "embla-carousel-react": "^8.6.0",
+ "hast": "^1.0.0",
+ "import-in-the-middle": "^1.14.2",
+ "libsql": "^0.5.17",
+ "lucide-react": "^0.460.0",
+ "motion": "^12.23.24",
+ "nanoid": "^5.1.6",
+ "next": "^16.0.7",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "require-in-the-middle": "^7.5.2",
+ "shiki": "^3.14.0",
+ "sonner": "^2.0.7",
+ "streamdown": "^1.4.0",
+ "tailwind-merge": "^3.3.1",
+ "tokenlens": "^1.3.1",
+ "use-stick-to-bottom": "^1.1.1",
+ "zod": "^3.25.76"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.1.4",
+ "@types/node": "^24.2.1",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4.1.4",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.8.2"
+ },
+ "private": true,
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/VoltAgent/voltagent.git",
+ "directory": "examples/with-nextjs-ai-elements"
+ },
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev",
+ "start": "next start",
+ "volt": "volt"
+ }
+}
diff --git a/examples/with-nextjs-resumable-stream/postcss.config.mjs b/examples/with-nextjs-resumable-stream/postcss.config.mjs
new file mode 100644
index 000000000..7059fe95a
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/postcss.config.mjs
@@ -0,0 +1,6 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+export default config;
diff --git a/examples/with-nextjs-resumable-stream/public/favicon.ico b/examples/with-nextjs-resumable-stream/public/favicon.ico
new file mode 100644
index 000000000..8db921429
Binary files /dev/null and b/examples/with-nextjs-resumable-stream/public/favicon.ico differ
diff --git a/examples/with-nextjs-resumable-stream/tsconfig.json b/examples/with-nextjs-resumable-stream/tsconfig.json
new file mode 100644
index 000000000..3b6e4050d
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/with-nextjs-resumable-stream/voltagent/index.ts b/examples/with-nextjs-resumable-stream/voltagent/index.ts
new file mode 100644
index 000000000..5b681bd7e
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/voltagent/index.ts
@@ -0,0 +1,112 @@
+import { openai } from "@ai-sdk/openai";
+import { Agent, VoltAgent, createTool } from "@voltagent/core";
+import { honoServer } from "@voltagent/server-hono";
+import { z } from "zod";
+import { sharedMemory } from "./memory";
+// Uppercase conversion tool
+const uppercaseTool = createTool({
+ name: "uppercase",
+ description: "Convert text to uppercase",
+ parameters: z.object({
+ text: z.string().describe("Text to convert to uppercase"),
+ }),
+ execute: async (args) => {
+ return { result: args.text.toUpperCase() };
+ },
+});
+
+// Word count tool
+const wordCountTool = createTool({
+ name: "countWords",
+ description: "Count words in text",
+ parameters: z.object({
+ text: z.string().describe("Text to count words in"),
+ }),
+ execute: async (args) => {
+ const words = args.text
+ .trim()
+ .split(/\s+/)
+ .filter((word) => word.length > 0);
+ return { count: words.length, words: words };
+ },
+});
+
+// Story writing tool
+const storyWriterTool = createTool({
+ name: "writeStory",
+ description: "Write a 50-word story about the given text",
+ parameters: z.object({
+ text: z.string().describe("Text to write a story about"),
+ }),
+ execute: async (args) => {
+ // The agent will handle the creative writing
+ return { topic: args.text };
+ },
+});
+
+// Uppercase agent
+const uppercaseAgent = new Agent({
+ name: "UppercaseAgent",
+ instructions:
+ "You are a text transformer. When given text, use the uppercase tool to convert it to uppercase and return the result.",
+ model: openai("gpt-4o-mini"),
+ tools: [uppercaseTool],
+ memory: sharedMemory,
+});
+
+// Word count agent
+const wordCountAgent = new Agent({
+ name: "WordCountAgent",
+ instructions:
+ "You are a text analyzer. When given text, use the countWords tool to count the words and return the count.",
+ model: openai("gpt-4o-mini"),
+ tools: [wordCountTool],
+ memory: sharedMemory,
+});
+
+// Story writer agent
+const storyWriterAgent = new Agent({
+ name: "StoryWriterAgent",
+ instructions:
+ "You are a creative story writer. When given text, use the writeStory tool to acknowledge the topic, then write EXACTLY a 50-word story about or inspired by that text. Be creative and engaging. Make sure your story is exactly 50 words, no more, no less.",
+ model: openai("gpt-4o-mini"),
+ tools: [storyWriterTool],
+ memory: sharedMemory,
+});
+
+// Supervisor agent that delegates to sub-agents
+export const supervisorAgent = new Agent({
+ name: "Supervisor",
+ instructions:
+ "You are a text processing supervisor. When given any text input, you MUST delegate to ALL THREE agents: UppercaseAgent, WordCountAgent, AND StoryWriterAgent. Delegate to all of them to process the text in parallel. Then combine and present all three results to the user: the uppercase version, the word count, and the 50-word story.",
+ model: openai("gpt-4o-mini"),
+ subAgents: [uppercaseAgent, wordCountAgent, storyWriterAgent],
+ memory: sharedMemory,
+});
+
+// Type declaration for global augmentation
+declare global {
+ var voltAgentInstance: VoltAgent | undefined;
+}
+
+// Singleton initialization function
+function getVoltAgentInstance() {
+ if (!globalThis.voltAgentInstance) {
+ globalThis.voltAgentInstance = new VoltAgent({
+ agents: {
+ supervisorAgent,
+ storyWriterAgent,
+ wordCountAgent,
+ uppercaseAgent,
+ },
+ server: honoServer(),
+ });
+ }
+ return globalThis.voltAgentInstance;
+}
+
+// Initialize the singleton
+export const voltAgent = getVoltAgentInstance();
+
+// Export the supervisor as the main agent
+export const agent = supervisorAgent;
diff --git a/examples/with-nextjs-resumable-stream/voltagent/memory.ts b/examples/with-nextjs-resumable-stream/voltagent/memory.ts
new file mode 100644
index 000000000..68e437100
--- /dev/null
+++ b/examples/with-nextjs-resumable-stream/voltagent/memory.ts
@@ -0,0 +1,7 @@
+import { Memory } from "@voltagent/core";
+import { LibSQLMemoryAdapter } from "@voltagent/libsql";
+
+// Shared memory instance - all agents and APIs will use the same instance
+export const sharedMemory = new Memory({
+ storage: new LibSQLMemoryAdapter({}),
+});
diff --git a/examples/with-resumable-streams/.env.example b/examples/with-resumable-streams/.env.example
new file mode 100644
index 000000000..56c90caee
--- /dev/null
+++ b/examples/with-resumable-streams/.env.example
@@ -0,0 +1,3 @@
+OPENAI_API_KEY=
+REDIS_URL=redis://localhost:6379
+PORT=3141
diff --git a/examples/with-resumable-streams/README.md b/examples/with-resumable-streams/README.md
new file mode 100644
index 000000000..c8ac63870
--- /dev/null
+++ b/examples/with-resumable-streams/README.md
@@ -0,0 +1,65 @@
+# VoltAgent Resumable Streams (No Next.js)
+
+This example shows how to use resumable streams with VoltAgent and the Hono server, without Next.js.
+
+## Features
+
+- VoltAgent Hono server with resumable stream adapter
+- Opt-in resumable streaming via `options.resumableStream: true`
+- Resume endpoint using `userId` + `conversationId`
+- Redis-backed stream storage via `resumable-stream/redis`
+
+## Setup
+
+1. Install dependencies:
+
+```bash
+pnpm install
+```
+
+2. Set your environment variables:
+
+```bash
+cp .env.example .env
+```
+
+Update `OPENAI_API_KEY` and `REDIS_URL` in `.env`.
+
+3. Start the server:
+
+```bash
+pnpm dev
+```
+
+The server runs on `http://localhost:3141` by default.
+
+## Usage
+
+Start a resumable stream:
+
+```bash
+curl -N -X POST http://localhost:3141/agents/assistant/chat \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": "Say hello in Turkish and English.",
+ "options": {
+ "conversationId": "conv-1",
+ "userId": "user-1",
+ "resumableStream": true
+ }
+ }'
+```
+
+Resume the stream (after closing the first request):
+
+```bash
+curl -N "http://localhost:3141/agents/assistant/chat/conv-1/stream?userId=user-1"
+```
+
+## Notes
+
+- `options.conversationId` and `options.userId` are required for resumable streams.
+- If you do not have a `userId`, generate a stable one (e.g. `crypto.randomUUID()`) and reuse it for the conversation.
+- To enable resumable streaming by default, set `resumableStream.defaultEnabled: true` in `honoServer` and omit `options.resumableStream`.
+- This example uses Redis. Set `REDIS_URL` or `KV_URL` for the adapter.
+- The active stream store defaults to the same Redis store; override `activeStreamStore` if you need a different backend.
diff --git a/examples/with-resumable-streams/package.json b/examples/with-resumable-streams/package.json
new file mode 100644
index 000000000..ca865dbf6
--- /dev/null
+++ b/examples/with-resumable-streams/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "voltagent-example-with-resumable-streams",
+ "description": "Resumable streams without Next.js using VoltAgent Hono server",
+ "version": "1.0.0",
+ "author": "",
+ "dependencies": {
+ "@ai-sdk/openai": "^3.0.0",
+ "@voltagent/core": "^2.0.6",
+ "@voltagent/logger": "^2.0.2",
+ "@voltagent/resumable-streams": "^2.0.0",
+ "@voltagent/server-hono": "^2.0.2",
+ "ai": "^6.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.2.1",
+ "tsx": "^4.19.3",
+ "typescript": "^5.8.2"
+ },
+ "keywords": [
+ "agent",
+ "ai",
+ "resumable-streams",
+ "voltagent"
+ ],
+ "license": "MIT",
+ "private": true,
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/VoltAgent/voltagent.git",
+ "directory": "examples/with-resumable-streams"
+ },
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsx watch --env-file=.env ./src/index.ts",
+ "start": "tsx --env-file=.env ./src/index.ts"
+ },
+ "type": "module"
+}
diff --git a/examples/with-resumable-streams/src/index.ts b/examples/with-resumable-streams/src/index.ts
new file mode 100644
index 000000000..914d96c70
--- /dev/null
+++ b/examples/with-resumable-streams/src/index.ts
@@ -0,0 +1,43 @@
+import { openai } from "@ai-sdk/openai";
+import { Agent, VoltAgent } from "@voltagent/core";
+import { createPinoLogger } from "@voltagent/logger";
+import {
+ createResumableStreamAdapter,
+ createResumableStreamRedisStore,
+} from "@voltagent/resumable-streams";
+import { honoServer } from "@voltagent/server-hono";
+
+const logger = createPinoLogger({
+ name: "resumable-streams-example",
+ level: "info",
+});
+
+async function start() {
+ const streamStore = await createResumableStreamRedisStore();
+ const resumableStreamAdapter = await createResumableStreamAdapter({ streamStore });
+
+ const agent = new Agent({
+ id: "assistant",
+ name: "Resumable Stream Agent",
+ instructions: "You are a helpful assistant.",
+ model: openai("gpt-4o-mini"),
+ });
+
+ const port = Number(process.env.PORT ?? 3141);
+
+ new VoltAgent({
+ agents: { assistant: agent },
+ logger,
+ server: honoServer({
+ port,
+ resumableStream: { adapter: resumableStreamAdapter },
+ }),
+ });
+
+ logger.info(`Server running at http://localhost:${port}`);
+}
+
+start().catch((error) => {
+ logger.error("Failed to start server", { error });
+ process.exit(1);
+});
diff --git a/examples/with-resumable-streams/tsconfig.json b/examples/with-resumable-streams/tsconfig.json
new file mode 100644
index 000000000..b813d4286
--- /dev/null
+++ b/examples/with-resumable-streams/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/examples/with-voltops-resumable-streams/.env.example b/examples/with-voltops-resumable-streams/.env.example
new file mode 100644
index 000000000..96496dfc8
--- /dev/null
+++ b/examples/with-voltops-resumable-streams/.env.example
@@ -0,0 +1,5 @@
+OPENAI_API_KEY=
+VOLTAGENT_PUBLIC_KEY=
+VOLTAGENT_SECRET_KEY=
+# VOLTAGENT_API_BASE_URL=https://api.voltagent.dev
+PORT=3141
diff --git a/examples/with-voltops-resumable-streams/README.md b/examples/with-voltops-resumable-streams/README.md
new file mode 100644
index 000000000..f727ed2cb
--- /dev/null
+++ b/examples/with-voltops-resumable-streams/README.md
@@ -0,0 +1,66 @@
+# VoltAgent Resumable Streams with VoltOps (No Next.js)
+
+This example shows how to use resumable streams with VoltAgent and the Hono server, backed by the managed VoltOps store (no Redis setup).
+
+## Features
+
+- VoltAgent Hono server with resumable stream adapter
+- Opt-in resumable streaming via `options.resumableStream: true`
+- Resume endpoint using `userId` + `conversationId`
+- VoltOps-managed stream storage (no Redis required)
+
+## Setup
+
+1. Install dependencies:
+
+```bash
+pnpm install
+```
+
+2. Set your environment variables:
+
+```bash
+cp .env.example .env
+```
+
+Update `OPENAI_API_KEY`, `VOLTAGENT_PUBLIC_KEY`, and `VOLTAGENT_SECRET_KEY` in `.env`.
+
+Optional: set `VOLTAGENT_API_BASE_URL` if you are using a non-default VoltOps API URL.
+
+3. Start the server:
+
+```bash
+pnpm dev
+```
+
+The server runs on `http://localhost:3141` by default.
+
+## Usage
+
+Start a resumable stream:
+
+```bash
+curl -N -X POST http://localhost:3141/agents/assistant/chat \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": "Say hello in Turkish and English.",
+ "options": {
+ "conversationId": "conv-1",
+ "userId": "user-1",
+ "resumableStream": true
+ }
+ }'
+```
+
+Resume the stream (after closing the first request):
+
+```bash
+curl -N "http://localhost:3141/agents/assistant/chat/conv-1/stream?userId=user-1"
+```
+
+## Notes
+
+- `options.conversationId` and `options.userId` are required for resumable streams.
+- If you do not have a `userId`, generate a stable one (e.g. `crypto.randomUUID()`) and reuse it for the conversation.
+- To enable resumable streaming by default, set `resumableStream.defaultEnabled: true` in `honoServer` and omit `options.resumableStream`.
+- VoltOps-managed resumable streams are limited per project: Free 1, Core 100, Pro 1000 concurrent streams.
diff --git a/examples/with-voltops-resumable-streams/package.json b/examples/with-voltops-resumable-streams/package.json
new file mode 100644
index 000000000..bc931f66c
--- /dev/null
+++ b/examples/with-voltops-resumable-streams/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "voltagent-example-with-voltops-resumable-streams",
+ "description": "Resumable streams with VoltOps managed store using VoltAgent Hono server",
+ "version": "1.0.0",
+ "author": "",
+ "dependencies": {
+ "@ai-sdk/openai": "^3.0.0",
+ "@voltagent/core": "^2.0.6",
+ "@voltagent/logger": "^2.0.2",
+ "@voltagent/resumable-streams": "^2.0.0",
+ "@voltagent/server-hono": "^2.0.2",
+ "ai": "^6.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.2.1",
+ "tsx": "^4.19.3",
+ "typescript": "^5.8.2"
+ },
+ "keywords": [
+ "agent",
+ "ai",
+ "resumable-streams",
+ "voltagent",
+ "voltops"
+ ],
+ "license": "MIT",
+ "private": true,
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/VoltAgent/voltagent.git",
+ "directory": "examples/with-voltops-resumable-streams"
+ },
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsx watch --env-file=.env ./src/index.ts",
+ "start": "tsx --env-file=.env ./src/index.ts"
+ },
+ "type": "module"
+}
diff --git a/examples/with-voltops-resumable-streams/src/index.ts b/examples/with-voltops-resumable-streams/src/index.ts
new file mode 100644
index 000000000..977f5f20f
--- /dev/null
+++ b/examples/with-voltops-resumable-streams/src/index.ts
@@ -0,0 +1,38 @@
+import { openai } from "@ai-sdk/openai";
+import { Agent, VoltAgent } from "@voltagent/core";
+import { createPinoLogger } from "@voltagent/logger";
+import {
+ createResumableStreamAdapter,
+ createResumableStreamVoltOpsStore,
+} from "@voltagent/resumable-streams";
+import { honoServer } from "@voltagent/server-hono";
+
+const logger = createPinoLogger({
+ name: "voltops-resumable-streams-example",
+ level: "info",
+});
+
+async function start() {
+ const streamStore = await createResumableStreamVoltOpsStore();
+ const resumableStreamAdapter = 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 },
+ logger,
+ server: honoServer({
+ resumableStream: { adapter: resumableStreamAdapter },
+ }),
+ });
+}
+
+start().catch((error) => {
+ logger.error("Failed to start server", { error });
+ process.exit(1);
+});
diff --git a/examples/with-voltops-resumable-streams/tsconfig.json b/examples/with-voltops-resumable-streams/tsconfig.json
new file mode 100644
index 000000000..b813d4286
--- /dev/null
+++ b/examples/with-voltops-resumable-streams/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 6df10dd95..24f859c43 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -378,6 +378,11 @@ export type GenerateTextOptions = Omit<
};
export type StreamTextOptions = BaseGenerationOptions & {
onFinish?: (result: any) => void | Promise;
+ /**
+ * When true, avoids wiring the HTTP abort signal into the stream so clients can resume later.
+ * Use with a resumable stream store to prevent orphaned streams.
+ */
+ resumableStream?: boolean;
};
export type GenerateObjectOptions = BaseGenerationOptions;
export type StreamObjectOptions = BaseGenerationOptions & {
diff --git a/packages/core/src/memory/manager/memory-manager.ts b/packages/core/src/memory/manager/memory-manager.ts
index 6794839b6..1f26a66ce 100644
--- a/packages/core/src/memory/manager/memory-manager.ts
+++ b/packages/core/src/memory/manager/memory-manager.ts
@@ -103,6 +103,8 @@ export class MemoryManager {
): Promise {
if (!this.conversationMemory || !userId) return;
+ const messageWithMetadata = this.applyOperationMetadata(message, context);
+
// Use contextual logger from operation context - PRESERVED
const memoryLogger = context.logger.child({
operation: "write",
@@ -110,9 +112,10 @@ export class MemoryManager {
// Event tracking with OpenTelemetry spans
const trace = context.traceContext;
- const spanInput = { userId, conversationId, message };
+ const spanInput = { userId, conversationId, message: messageWithMetadata };
const writeSpan = trace.createChildSpan("memory.write", "memory", {
- label: message.role === "user" ? "Persist User Message" : "Persist Assistant Message",
+ label:
+ messageWithMetadata.role === "user" ? "Persist User Message" : "Persist Assistant Message",
attributes: {
"memory.operation": "write",
input: safeStringify(spanInput),
@@ -137,7 +140,7 @@ export class MemoryManager {
// Add message to conversation using Memory V2's saveMessageWithContext
await this.conversationMemory?.saveMessageWithContext(
- message,
+ messageWithMetadata,
userId,
conversationId,
{
@@ -158,7 +161,7 @@ export class MemoryManager {
memoryLogger.debug("[Memory] Write successful (1 record)", {
event: LogEvents.MEMORY_OPERATION_COMPLETED,
operation: "write",
- message,
+ message: messageWithMetadata,
});
} catch (error) {
// End span with error
@@ -177,6 +180,30 @@ export class MemoryManager {
}
}
+ private applyOperationMetadata(message: UIMessage, context: OperationContext): UIMessage {
+ const operationId = context.operationId;
+ if (!operationId) {
+ return message;
+ }
+
+ const existingMetadata =
+ typeof message.metadata === "object" && message.metadata !== null
+ ? (message.metadata as Record)
+ : undefined;
+
+ if (existingMetadata?.operationId === operationId) {
+ return message;
+ }
+
+ return {
+ ...message,
+ metadata: {
+ ...(existingMetadata ?? {}),
+ operationId,
+ },
+ };
+ }
+
async saveConversationSteps(
context: OperationContext,
steps: ConversationStepRecord[],
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 5258fe422..8a2088134 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -41,6 +41,24 @@ export interface MCPElicitationAdapter {
sendRequest(request: unknown): Promise;
}
+export interface ResumableStreamContext {
+ conversationId: string;
+ agentId?: string;
+ userId: string;
+}
+
+export interface ResumableStreamAdapter {
+ createStream(
+ params: ResumableStreamContext & {
+ stream: ReadableStream;
+ metadata?: Record;
+ },
+ ): Promise;
+ resumeStream(streamId: string): Promise | null>;
+ getActiveStreamId(params: ResumableStreamContext): Promise;
+ clearActiveStream(params: ResumableStreamContext & { streamId?: string }): Promise;
+}
+
// Re-export VoltOps types for convenience
export type {
PromptReference,
@@ -124,6 +142,8 @@ export interface ServerProviderDeps {
registry: A2AServerRegistry;
};
triggerRegistry: TriggerRegistry;
+ resumableStream?: ResumableStreamAdapter;
+ resumableStreamDefault?: boolean;
ensureEnvironment?: (env?: Record) => void;
}
diff --git a/packages/core/src/voltops/global-client.ts b/packages/core/src/voltops/global-client.ts
new file mode 100644
index 000000000..136d5ce62
--- /dev/null
+++ b/packages/core/src/voltops/global-client.ts
@@ -0,0 +1,5 @@
+import { AgentRegistry } from "../registries/agent-registry";
+import type { VoltOpsClient } from "./client";
+
+export const getGlobalVoltOpsClient = (): VoltOpsClient | undefined =>
+ AgentRegistry.getInstance().getGlobalVoltOpsClient();
diff --git a/packages/core/src/voltops/index.ts b/packages/core/src/voltops/index.ts
index 14f55fa1f..87ad07433 100644
--- a/packages/core/src/voltops/index.ts
+++ b/packages/core/src/voltops/index.ts
@@ -7,6 +7,7 @@
// Export main client class
export { VoltOpsClient } from "./client";
+export { getGlobalVoltOpsClient } from "./global-client";
// Export all types
export type {
diff --git a/packages/resumable-streams/package.json b/packages/resumable-streams/package.json
new file mode 100644
index 000000000..075854c62
--- /dev/null
+++ b/packages/resumable-streams/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@voltagent/resumable-streams",
+ "description": "Resumable stream utilities for VoltAgent",
+ "version": "2.0.0",
+ "author": "VoltAgent Team",
+ "dependencies": {
+ "@voltagent/core": "^2.0.5",
+ "@voltagent/internal": "^1.0.2",
+ "redis": "^4.7.0",
+ "resumable-stream": "^2.2.10"
+ },
+ "devDependencies": {},
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "keywords": [
+ "ai",
+ "resumable",
+ "streams",
+ "voltagent"
+ ],
+ "license": "MIT",
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "peerDependencies": {
+ "@voltagent/core": "^2.0.0",
+ "ai": "^6.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/VoltAgent/voltagent.git",
+ "directory": "packages/resumable-streams"
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "test": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "types": "dist/index.d.ts"
+}
diff --git a/packages/resumable-streams/src/chat-handlers.ts b/packages/resumable-streams/src/chat-handlers.ts
new file mode 100644
index 000000000..81efb997e
--- /dev/null
+++ b/packages/resumable-streams/src/chat-handlers.ts
@@ -0,0 +1,284 @@
+import type { Agent, ResumableStreamAdapter } from "@voltagent/core";
+import { getGlobalLogger, setWaitUntil } from "@voltagent/core";
+import { type Logger, safeStringify } from "@voltagent/internal";
+import { UI_MESSAGE_STREAM_HEADERS } from "ai";
+
+type StreamTextInput = Parameters[0];
+type StreamTextOptions = Parameters[1];
+type RouteParams = Record;
+
+type HandlerContext = {
+ request: Request;
+ body?: unknown;
+ params?: Params;
+};
+
+type RouteContext = {
+ params?: Params | Promise;
+};
+
+export type ResumableChatHandlersOptions =
+ {
+ agent: Agent;
+ adapter: ResumableStreamAdapter;
+ waitUntil?: (promise: Promise) => void;
+ logger?: Logger;
+ agentId?: string;
+ sendReasoning?: boolean;
+ sendSources?: boolean;
+ resolveInput?: (body: unknown) => StreamTextInput | null;
+ resolveConversationId?: (context: HandlerContext) => string | null;
+ resolveUserId?: (context: HandlerContext) => string | undefined;
+ resolveOptions?: (
+ context: HandlerContext & {
+ conversationId: string;
+ userId?: string;
+ input: StreamTextInput;
+ },
+ ) => StreamTextOptions;
+ };
+
+export function createResumableChatHandlers<
+ Params extends RouteParams | undefined = { id: string },
+>(options: ResumableChatHandlersOptions) {
+ const logger = options.logger ?? getGlobalLogger();
+ const adapter = options.adapter;
+ const resolveInput = options.resolveInput ?? defaultResolveInput;
+ const resolveConversationId = options.resolveConversationId ?? defaultResolveConversationId;
+ const resolveUserId = options.resolveUserId ?? defaultResolveUserId;
+ const resolveOptions = options.resolveOptions ?? defaultResolveOptions;
+ const agentId = options.agentId ?? options.agent.id;
+
+ const jsonError = (status: number, message: string) =>
+ new Response(safeStringify({ error: message, message }), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+
+ async function POST(request: Request) {
+ if (!adapter) {
+ return jsonError(404, "Resumable streams are not configured");
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch (error) {
+ logger.warn("Invalid JSON payload for resumable chat", { error });
+ return jsonError(400, "Invalid JSON payload");
+ }
+
+ const context: HandlerContext = { request, body };
+ const conversationId = resolveConversationId(context);
+ if (!conversationId) {
+ return jsonError(400, "conversationId is required");
+ }
+
+ const input = resolveInput(body);
+ if (input == null || isEmptyInput(input)) {
+ return jsonError(400, "Message input is required");
+ }
+
+ const userId = resolveUserId(context);
+ if (!userId) {
+ return jsonError(400, "userId is required");
+ }
+
+ if (options.waitUntil) {
+ setWaitUntil(options.waitUntil);
+ }
+
+ try {
+ await adapter.clearActiveStream({ conversationId, agentId, userId });
+ } catch (error) {
+ logger.warn("Failed to clear active resumable stream", { error });
+ }
+
+ try {
+ const streamOptions = resolveOptions({ ...context, conversationId, userId, input });
+ const result = await options.agent.streamText(input, streamOptions);
+ let activeStreamId: string | null = null;
+
+ return result.toUIMessageStreamResponse({
+ sendReasoning: options.sendReasoning ?? false,
+ sendSources: options.sendSources ?? false,
+ consumeSseStream: async ({ stream }) => {
+ try {
+ activeStreamId = await adapter.createStream({
+ conversationId,
+ agentId,
+ userId,
+ stream,
+ });
+ } catch (error) {
+ logger.error("Failed to persist resumable chat stream", { error });
+ }
+ },
+ onFinish: async () => {
+ try {
+ await adapter.clearActiveStream({
+ conversationId,
+ agentId,
+ userId,
+ streamId: activeStreamId ?? undefined,
+ });
+ } catch (error) {
+ logger.error("Failed to clear resumable chat stream", { error });
+ }
+ },
+ });
+ } catch (error) {
+ logger.error("Failed to handle resumable chat stream", { error });
+ return jsonError(500, "Internal server error");
+ }
+ }
+
+ async function GET(request: Request, context?: RouteContext) {
+ if (!adapter) {
+ return jsonError(404, "Resumable streams are not configured");
+ }
+
+ const params = await resolveRouteParams(context?.params);
+ const handlerContext: HandlerContext = { request, params };
+ const conversationId = resolveConversationId(handlerContext);
+ if (!conversationId) {
+ return jsonError(400, "conversationId is required");
+ }
+
+ const userId = resolveUserId(handlerContext);
+ if (!userId) {
+ return jsonError(400, "userId is required");
+ }
+
+ try {
+ const streamId = await adapter.getActiveStreamId({
+ conversationId,
+ agentId,
+ userId,
+ });
+
+ if (!streamId) {
+ return new Response(null, { status: 204 });
+ }
+
+ const stream = await adapter.resumeStream(streamId);
+ if (!stream) {
+ try {
+ await adapter.clearActiveStream({
+ conversationId,
+ agentId,
+ userId,
+ streamId,
+ });
+ } catch (error) {
+ logger.warn("Failed to clear inactive resumable stream", { error });
+ }
+ return new Response(null, { status: 204 });
+ }
+
+ const encodedStream = stream.pipeThrough(new TextEncoderStream());
+ return new Response(encodedStream, { headers: UI_MESSAGE_STREAM_HEADERS });
+ } catch (error) {
+ logger.error("Failed to resume chat stream", { error });
+ return new Response(null, { status: 204 });
+ }
+ }
+
+ return { POST, GET };
+}
+
+function defaultResolveInput(body: unknown): StreamTextInput | null {
+ if (!body || typeof body !== "object") {
+ return null;
+ }
+
+ const payload = body as Record;
+ if (payload.input !== undefined) {
+ return payload.input as StreamTextInput;
+ }
+
+ if (payload.message !== undefined) {
+ if (typeof payload.message === "string") {
+ return payload.message as StreamTextInput;
+ }
+ return [payload.message] as StreamTextInput;
+ }
+
+ if (Array.isArray(payload.messages)) {
+ return payload.messages as StreamTextInput;
+ }
+
+ return null;
+}
+
+function defaultResolveConversationId({
+ body,
+ params,
+}: HandlerContext): string | null {
+ if (body && typeof body === "object") {
+ const payload = body as Record;
+ const options =
+ payload.options && typeof payload.options === "object"
+ ? (payload.options as Record)
+ : undefined;
+ if (options && typeof options.conversationId === "string") {
+ return options.conversationId;
+ }
+ }
+
+ if (params && typeof (params as Record).id === "string") {
+ return (params as Record).id;
+ }
+
+ return null;
+}
+
+function defaultResolveUserId({
+ body,
+ request,
+}: HandlerContext): string | undefined {
+ if (body && typeof body === "object") {
+ const payload = body as Record;
+ const options =
+ payload.options && typeof payload.options === "object"
+ ? (payload.options as Record)
+ : undefined;
+ if (options && typeof options.userId === "string") {
+ return options.userId;
+ }
+ }
+
+ try {
+ return new URL(request.url).searchParams.get("userId") ?? undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function defaultResolveOptions({
+ conversationId,
+ userId,
+}: HandlerContext & { conversationId: string; userId?: string }): StreamTextOptions {
+ return {
+ conversationId,
+ userId,
+ } satisfies StreamTextOptions;
+}
+
+async function resolveRouteParams(
+ params?: Params | Promise,
+): Promise {
+ if (!params) {
+ return undefined;
+ }
+
+ return await params;
+}
+
+function isEmptyInput(input: StreamTextInput): boolean {
+ if (typeof input === "string") {
+ return input.trim().length === 0;
+ }
+
+ return Array.isArray(input) && input.length === 0;
+}
diff --git a/packages/resumable-streams/src/chat-session.ts b/packages/resumable-streams/src/chat-session.ts
new file mode 100644
index 000000000..1ebbf6877
--- /dev/null
+++ b/packages/resumable-streams/src/chat-session.ts
@@ -0,0 +1,114 @@
+import type { ResumableStreamAdapter, ResumableStreamContext } from "@voltagent/core";
+import type { Logger } from "@voltagent/internal";
+import { UI_MESSAGE_STREAM_HEADERS } from "ai";
+
+type ResumableChatSessionOptions = ResumableStreamContext & {
+ adapter: ResumableStreamAdapter;
+ logger?: Logger;
+ resumeHeaders?: HeadersInit;
+};
+
+export type ResumableChatSession = {
+ consumeSseStream: (args: { stream: ReadableStream }) => Promise;
+ onFinish: () => Promise;
+ resumeResponse: () => Promise;
+ getActiveStreamId: () => Promise;
+ clearActiveStream: (streamId?: string) => Promise;
+ createStream: (stream: ReadableStream) => Promise;
+ resumeStream: (streamId: string) => Promise | null>;
+};
+
+export function createResumableChatSession({
+ adapter,
+ conversationId,
+ agentId,
+ userId,
+ logger,
+ resumeHeaders,
+}: ResumableChatSessionOptions): ResumableChatSession {
+ if (!conversationId) {
+ throw new Error("conversationId is required");
+ }
+
+ if (!userId) {
+ throw new Error("userId is required");
+ }
+
+ const context: ResumableStreamContext = { conversationId, agentId, userId };
+ let activeStreamId: string | null = null;
+
+ const clearActiveStream = async (streamId?: string) => {
+ await adapter.clearActiveStream({ ...context, streamId });
+ if (!streamId || activeStreamId === streamId) {
+ activeStreamId = null;
+ }
+ };
+
+ const getActiveStreamId = async () => {
+ const streamId = await adapter.getActiveStreamId(context);
+ activeStreamId = streamId;
+ return streamId;
+ };
+
+ const resumeStream = (streamId: string) => adapter.resumeStream(streamId);
+
+ const createStream = async (stream: ReadableStream) => {
+ const streamId = await adapter.createStream({ ...context, stream });
+ activeStreamId = streamId;
+ return streamId;
+ };
+
+ const consumeSseStream = async ({ stream }: { stream: ReadableStream }) => {
+ try {
+ await createStream(stream);
+ } catch (error) {
+ logger?.error("Failed to persist resumable chat stream", { error });
+ }
+ };
+
+ const onFinish = async () => {
+ try {
+ const streamId = activeStreamId ?? (await getActiveStreamId());
+ if (!streamId) {
+ return;
+ }
+
+ await clearActiveStream(streamId);
+ } catch (error) {
+ logger?.error("Failed to clear resumable chat stream", { error });
+ }
+ };
+
+ const resumeResponse = async () => {
+ try {
+ const streamId = await getActiveStreamId();
+ if (!streamId) {
+ return new Response(null, { status: 204 });
+ }
+
+ const stream = await resumeStream(streamId);
+ if (!stream) {
+ await clearActiveStream(streamId);
+ return new Response(null, { status: 204 });
+ }
+
+ const encodedStream = stream.pipeThrough(new TextEncoderStream());
+ return new Response(encodedStream, {
+ headers: resumeHeaders ?? UI_MESSAGE_STREAM_HEADERS,
+ });
+ } catch (error) {
+ logger?.error("Failed to resume chat stream", { error });
+ return new Response(null, { status: 204 });
+ }
+ };
+
+ return {
+ consumeSseStream,
+ onFinish,
+ resumeResponse,
+ getActiveStreamId,
+ clearActiveStream,
+ createStream,
+ resumeStream,
+ };
+}
diff --git a/packages/resumable-streams/src/index.ts b/packages/resumable-streams/src/index.ts
new file mode 100644
index 000000000..9e47ff561
--- /dev/null
+++ b/packages/resumable-streams/src/index.ts
@@ -0,0 +1,26 @@
+export type {
+ ResumableStreamActiveStore,
+ ResumableStreamAdapterConfig,
+ ResumableStreamGenericStoreOptions,
+ ResumableStreamPublisher,
+ ResumableStreamRedisStoreOptions,
+ ResumableStreamSubscriber,
+ ResumableStreamStore,
+ ResumableStreamStoreOptions,
+ ResumableStreamVoltOpsStoreOptions,
+} from "./types";
+export {
+ createResumableStreamAdapter,
+ createResumableStreamGenericStore,
+ createResumableStreamMemoryStore,
+ createResumableStreamRedisStore,
+ createResumableStreamVoltOpsStore,
+ createMemoryResumableStreamActiveStore,
+ resolveResumableStreamAdapter,
+ resolveResumableStreamDeps,
+} from "./resumable-streams";
+export {
+ createResumableChatHandlers,
+ type ResumableChatHandlersOptions,
+} from "./chat-handlers";
+export { createResumableChatSession, type ResumableChatSession } from "./chat-session";
diff --git a/packages/resumable-streams/src/resumable-streams.ts b/packages/resumable-streams/src/resumable-streams.ts
new file mode 100644
index 000000000..cad154466
--- /dev/null
+++ b/packages/resumable-streams/src/resumable-streams.ts
@@ -0,0 +1,691 @@
+import { VoltOpsClient, getGlobalVoltOpsClient } from "@voltagent/core";
+import type {
+ ResumableStreamAdapter,
+ ResumableStreamContext,
+ ServerProviderDeps,
+} from "@voltagent/core";
+import type { Logger } from "@voltagent/internal";
+import type {
+ ResumableStreamActiveStore,
+ ResumableStreamAdapterConfig,
+ ResumableStreamGenericStoreOptions,
+ ResumableStreamPublisher,
+ ResumableStreamRedisStoreOptions,
+ ResumableStreamStore,
+ ResumableStreamStoreOptions,
+ ResumableStreamSubscriber,
+ ResumableStreamVoltOpsStoreOptions,
+} from "./types";
+
+const DEFAULT_KEY_PREFIX = "resumable-stream";
+const RESUMABLE_STREAM_DOCS_URL = "https://voltagent.dev/docs/agents/resumable-streaming/";
+const RESUMABLE_STREAM_DISABLED = "__voltagentResumableDisabled" as const;
+const RESUMABLE_STREAM_DISABLED_REASON = "__voltagentResumableDisabledReason" as const;
+const RESUMABLE_STREAM_DISABLED_DOCS_URL = "__voltagentResumableDisabledDocsUrl" as const;
+const RESUMABLE_STREAM_STORE_TYPE = "__voltagentResumableStoreType" as const;
+const RESUMABLE_STREAM_STORE_DISPLAY_NAME = "__voltagentResumableStoreDisplayName" as const;
+const VOLTOPS_MISSING_KEYS_REASON =
+ "Resumable streams are disabled because VOLTAGENT_PUBLIC_KEY and VOLTAGENT_SECRET_KEY are not configured.";
+
+const resolveRedisUrl = () => {
+ const redisUrl = process.env.REDIS_URL || process.env.KV_URL;
+ if (!redisUrl) {
+ throw new Error("REDIS_URL or KV_URL environment variable is not set");
+ }
+ return redisUrl;
+};
+
+const normalizeBaseUrl = (value: string) => value.replace(/\/$/, "");
+
+type ResumableStreamDisabledMetadata = {
+ [RESUMABLE_STREAM_DISABLED]: true;
+ [RESUMABLE_STREAM_DISABLED_REASON]?: string;
+ [RESUMABLE_STREAM_DISABLED_DOCS_URL]?: string;
+};
+
+type ResumableStreamStoreMetadata = {
+ [RESUMABLE_STREAM_STORE_TYPE]?: string;
+ [RESUMABLE_STREAM_STORE_DISPLAY_NAME]?: string;
+};
+
+const markResumableStreamDisabled = (
+ value: T,
+ reason: string,
+ docsUrl: string = RESUMABLE_STREAM_DOCS_URL,
+): T & ResumableStreamDisabledMetadata => {
+ Object.assign(value as Record, {
+ [RESUMABLE_STREAM_DISABLED]: true,
+ [RESUMABLE_STREAM_DISABLED_REASON]: reason,
+ [RESUMABLE_STREAM_DISABLED_DOCS_URL]: docsUrl,
+ });
+
+ return value as T & ResumableStreamDisabledMetadata;
+};
+
+const getResumableStreamDisabledInfo = (value: unknown) => {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+
+ const record = value as Record;
+ if (record[RESUMABLE_STREAM_DISABLED] !== true) {
+ return null;
+ }
+
+ const reason =
+ typeof record[RESUMABLE_STREAM_DISABLED_REASON] === "string"
+ ? (record[RESUMABLE_STREAM_DISABLED_REASON] as string)
+ : "Resumable streams are disabled.";
+ const docsUrl =
+ typeof record[RESUMABLE_STREAM_DISABLED_DOCS_URL] === "string"
+ ? (record[RESUMABLE_STREAM_DISABLED_DOCS_URL] as string)
+ : RESUMABLE_STREAM_DOCS_URL;
+
+ return { reason, docsUrl };
+};
+
+const markResumableStreamStoreType = (
+ value: T,
+ type: string,
+ displayName?: string,
+): T & ResumableStreamStoreMetadata => {
+ Object.assign(value as Record, {
+ [RESUMABLE_STREAM_STORE_TYPE]: type,
+ ...(displayName ? { [RESUMABLE_STREAM_STORE_DISPLAY_NAME]: displayName } : {}),
+ });
+
+ return value as T & ResumableStreamStoreMetadata;
+};
+
+const getResumableStreamStoreInfo = (value: unknown) => {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+
+ const record = value as Record;
+ const type = record[RESUMABLE_STREAM_STORE_TYPE];
+ if (typeof type !== "string") {
+ return null;
+ }
+
+ const displayName =
+ typeof record[RESUMABLE_STREAM_STORE_DISPLAY_NAME] === "string"
+ ? (record[RESUMABLE_STREAM_STORE_DISPLAY_NAME] as string)
+ : null;
+
+ return { type, displayName };
+};
+
+const createDisabledResumableStreamStore = (reason: string) => {
+ const store: ResumableStreamStore & ResumableStreamActiveStore = {
+ async createNewResumableStream() {
+ return null;
+ },
+ async resumeExistingStream() {
+ return undefined;
+ },
+ async getActiveStreamId() {
+ return null;
+ },
+ async setActiveStreamId() {},
+ async clearActiveStream() {},
+ };
+
+ return markResumableStreamDisabled(store, reason);
+};
+
+const createDisabledResumableStreamAdapter = (reason: string) => {
+ const adapter: ResumableStreamAdapter = {
+ async createStream() {
+ return "";
+ },
+ async resumeStream() {
+ return null;
+ },
+ async getActiveStreamId() {
+ return null;
+ },
+ async clearActiveStream() {},
+ };
+
+ return markResumableStreamDisabled(adapter, reason);
+};
+
+const resolveVoltOpsClient = (
+ options: ResumableStreamVoltOpsStoreOptions,
+): VoltOpsClient | null => {
+ if (options.voltOpsClient) {
+ return options.voltOpsClient;
+ }
+
+ const globalClient = getGlobalVoltOpsClient();
+ if (globalClient) {
+ return globalClient;
+ }
+
+ const publicKey = options.publicKey ?? process.env.VOLTAGENT_PUBLIC_KEY;
+ const secretKey = options.secretKey ?? process.env.VOLTAGENT_SECRET_KEY;
+
+ if (!publicKey || !secretKey) {
+ return null;
+ }
+
+ const baseUrl = normalizeBaseUrl(
+ options.baseUrl ?? process.env.VOLTAGENT_API_BASE_URL ?? "https://api.voltagent.dev",
+ );
+
+ return new VoltOpsClient({ baseUrl, publicKey, secretKey });
+};
+
+const createRandomUUID = () => {
+ const cryptoApi = typeof globalThis !== "undefined" ? (globalThis as any).crypto : undefined;
+ if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
+ return cryptoApi.randomUUID();
+ }
+
+ const random = () => Math.floor(Math.random() * 0xffff);
+ return (
+ `${random().toString(16).padStart(4, "0")}${random().toString(16).padStart(4, "0")}-` +
+ `${random().toString(16).padStart(4, "0")}-` +
+ `${((random() & 0x0fff) | 0x4000).toString(16).padStart(4, "0")}-` +
+ `${((random() & 0x3fff) | 0x8000).toString(16).padStart(4, "0")}-` +
+ `${random().toString(16).padStart(4, "0")}${random().toString(16).padStart(4, "0")}${random().toString(16).padStart(4, "0")}`
+ );
+};
+
+const buildStreamKey = ({ conversationId, userId }: ResumableStreamContext) => {
+ if (!userId) {
+ throw new Error("userId is required for resumable streams");
+ }
+
+ return `${userId}-${conversationId}`;
+};
+
+const buildActiveStreamKey = (keyPrefix: string, context: ResumableStreamContext) =>
+ `${keyPrefix}:active:${buildStreamKey(context)}`;
+
+const buildActiveStreamQuery = (context: ResumableStreamContext, streamId?: string): string => {
+ buildStreamKey(context);
+ const params = new URLSearchParams({
+ conversationId: context.conversationId,
+ userId: context.userId,
+ });
+ if (streamId) {
+ params.set("streamId", streamId);
+ }
+ return params.toString();
+};
+
+const createActiveStreamStoreFromPublisher = (
+ publisher: ResumableStreamPublisher,
+ keyPrefix: string,
+): ResumableStreamActiveStore => ({
+ async getActiveStreamId(context) {
+ const value = await publisher.get(buildActiveStreamKey(keyPrefix, context));
+ if (value == null) {
+ return null;
+ }
+ const id = String(value);
+ return id.length === 0 ? null : id;
+ },
+ async setActiveStreamId(context, streamId) {
+ await publisher.set(buildActiveStreamKey(keyPrefix, context), streamId);
+ },
+ async clearActiveStream({ streamId, ...context }) {
+ const key = buildActiveStreamKey(keyPrefix, context);
+ if (streamId) {
+ const current = await publisher.get(key);
+ if (current == null || String(current) !== streamId) {
+ return;
+ }
+ }
+
+ const del = (publisher as { del?: (key: string) => Promise }).del;
+ if (typeof del === "function") {
+ await del.call(publisher, key);
+ return;
+ }
+
+ await publisher.set(key, "", { EX: 1 });
+ },
+});
+
+const mergeStreamAndActiveStore = (
+ streamStore: T,
+ activeStreamStore: ResumableStreamActiveStore,
+): T & ResumableStreamActiveStore => ({
+ ...streamStore,
+ getActiveStreamId: activeStreamStore.getActiveStreamId,
+ setActiveStreamId: activeStreamStore.setActiveStreamId,
+ clearActiveStream: activeStreamStore.clearActiveStream,
+});
+
+export function createMemoryResumableStreamActiveStore(): ResumableStreamActiveStore {
+ const activeStreams = new Map();
+
+ return {
+ async getActiveStreamId(context) {
+ return activeStreams.get(buildStreamKey(context)) ?? null;
+ },
+ async setActiveStreamId(context, streamId) {
+ activeStreams.set(buildStreamKey(context), streamId);
+ },
+ async clearActiveStream({ streamId, ...context }) {
+ const key = buildStreamKey(context);
+ if (streamId && activeStreams.get(key) !== streamId) {
+ return;
+ }
+ activeStreams.delete(key);
+ },
+ };
+}
+
+type InMemoryValue = {
+ value: string;
+ expiresAt?: number;
+ timeoutId?: ReturnType;
+};
+
+const createInMemoryPubSub = () => {
+ const channels = new Map void>>();
+ const values = new Map();
+
+ const getValue = (key: string) => {
+ const entry = values.get(key);
+ if (!entry) {
+ return null;
+ }
+
+ if (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) {
+ if (entry.timeoutId) {
+ clearTimeout(entry.timeoutId);
+ }
+ values.delete(key);
+ return null;
+ }
+
+ return entry;
+ };
+
+ const publisher: ResumableStreamPublisher = {
+ async connect() {},
+ async publish(channel, message) {
+ const listeners = channels.get(channel);
+ if (!listeners || listeners.size === 0) {
+ return 0;
+ }
+
+ for (const listener of listeners) {
+ listener(message);
+ }
+
+ return listeners.size;
+ },
+ async set(key, value, options) {
+ const existing = values.get(key);
+ if (existing?.timeoutId) {
+ clearTimeout(existing.timeoutId);
+ }
+
+ let expiresAt: number | undefined;
+ let timeoutId: ReturnType | undefined;
+ if (options?.EX !== undefined) {
+ const ttlMs = options.EX * 1000;
+ expiresAt = Date.now() + ttlMs;
+ timeoutId = setTimeout(() => {
+ values.delete(key);
+ }, ttlMs);
+ }
+
+ values.set(key, { value, expiresAt, timeoutId });
+ return "OK";
+ },
+ async get(key) {
+ const entry = getValue(key);
+ return entry ? entry.value : null;
+ },
+ async incr(key) {
+ const entry = getValue(key);
+ if (!entry) {
+ values.set(key, { value: "1" });
+ return 1;
+ }
+
+ const current = Number(entry.value);
+ if (!Number.isInteger(current)) {
+ throw new Error("ERR value is not an integer or out of range");
+ }
+
+ const next = current + 1;
+ entry.value = String(next);
+ values.set(key, entry);
+ return next;
+ },
+ };
+
+ const subscriber: ResumableStreamSubscriber = {
+ async connect() {},
+ async subscribe(channel, callback) {
+ let listeners = channels.get(channel);
+ if (!listeners) {
+ listeners = new Set();
+ channels.set(channel, listeners);
+ }
+
+ listeners.add(callback);
+ return listeners.size;
+ },
+ async unsubscribe(channel) {
+ channels.delete(channel);
+ },
+ };
+
+ return { publisher, subscriber };
+};
+
+export async function createResumableStreamMemoryStore(
+ options: ResumableStreamStoreOptions = {},
+): Promise {
+ const { publisher, subscriber } = createInMemoryPubSub();
+ const { createResumableStreamContext } = await import("resumable-stream/generic");
+
+ const streamStore = createResumableStreamContext({
+ keyPrefix: options.keyPrefix,
+ waitUntil: options.waitUntil ?? null,
+ publisher,
+ subscriber,
+ }) as ResumableStreamStore;
+
+ const keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX;
+ const activeStreamStore = createActiveStreamStoreFromPublisher(publisher, keyPrefix);
+ const mergedStore = mergeStreamAndActiveStore(streamStore, activeStreamStore);
+ return markResumableStreamStoreType(mergedStore, "memory", "Memory");
+}
+
+export async function createResumableStreamRedisStore(
+ options: ResumableStreamRedisStoreOptions = {},
+): Promise {
+ const { createResumableStreamContext } = await import("resumable-stream/redis");
+ const keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX;
+
+ let publisher = options.publisher;
+ let subscriber = options.subscriber;
+ const shouldCreatePublisher = !publisher;
+ const shouldCreateSubscriber = !subscriber;
+
+ if (shouldCreatePublisher || shouldCreateSubscriber) {
+ const { createClient } = await import("redis");
+ const redisUrl = resolveRedisUrl();
+
+ if (shouldCreatePublisher) {
+ publisher = createClient({ url: redisUrl });
+ }
+ if (shouldCreateSubscriber) {
+ subscriber = createClient({ url: redisUrl });
+ }
+
+ await Promise.all([
+ shouldCreatePublisher ? publisher?.connect() : Promise.resolve(),
+ shouldCreateSubscriber ? subscriber?.connect() : Promise.resolve(),
+ ]);
+ }
+
+ if (!publisher || !subscriber) {
+ throw new Error("Redis resumable streams require both publisher and subscriber");
+ }
+
+ const streamStore = createResumableStreamContext({
+ keyPrefix,
+ waitUntil: options.waitUntil ?? null,
+ publisher,
+ subscriber,
+ }) as ResumableStreamStore;
+
+ const activeStreamStore = createActiveStreamStoreFromPublisher(publisher, keyPrefix);
+ const mergedStore = mergeStreamAndActiveStore(streamStore, activeStreamStore);
+ return markResumableStreamStoreType(mergedStore, "redis", "Redis");
+}
+
+export async function createResumableStreamGenericStore(
+ options: ResumableStreamGenericStoreOptions,
+): Promise {
+ if (!options.publisher || !options.subscriber) {
+ throw new Error("Generic resumable streams require both publisher and subscriber");
+ }
+
+ const { createResumableStreamContext } = await import("resumable-stream/generic");
+ const keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX;
+
+ const streamStore = createResumableStreamContext({
+ keyPrefix,
+ waitUntil: options.waitUntil ?? null,
+ publisher: options.publisher,
+ subscriber: options.subscriber,
+ }) as ResumableStreamStore;
+
+ const activeStreamStore = createActiveStreamStoreFromPublisher(options.publisher, keyPrefix);
+ const mergedStore = mergeStreamAndActiveStore(streamStore, activeStreamStore);
+ return markResumableStreamStoreType(mergedStore, "custom", "Custom");
+}
+
+export async function createResumableStreamVoltOpsStore(
+ options: ResumableStreamVoltOpsStoreOptions = {},
+): Promise {
+ const voltOpsClient = resolveVoltOpsClient(options);
+ if (!voltOpsClient) {
+ return createDisabledResumableStreamStore(VOLTOPS_MISSING_KEYS_REASON);
+ }
+
+ const streamStore: ResumableStreamStore = {
+ async createNewResumableStream(streamId, makeStream) {
+ const stream = makeStream();
+ const encodedStream = stream.pipeThrough(new TextEncoderStream());
+ const requestInit = {
+ method: "POST",
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ },
+ body: encodedStream,
+ duplex: "half",
+ } as RequestInit;
+
+ const uploadPromise = voltOpsClient
+ .sendRequest(`/resumable-streams/streams/${encodeURIComponent(streamId)}`, requestInit)
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to persist resumable stream (${response.status})`);
+ }
+ });
+
+ if (options.waitUntil) {
+ options.waitUntil(uploadPromise);
+ } else {
+ void uploadPromise.catch(() => {});
+ }
+
+ return stream;
+ },
+ async resumeExistingStream(streamId) {
+ const response = await voltOpsClient.sendRequest(
+ `/resumable-streams/streams/${encodeURIComponent(streamId)}`,
+ );
+
+ if (response.status === 204) {
+ return undefined;
+ }
+
+ if (response.status === 410) {
+ return null;
+ }
+
+ if (!response.ok) {
+ throw new Error(`Failed to resume resumable stream (${response.status})`);
+ }
+
+ if (!response.body) {
+ return null;
+ }
+
+ return response.body.pipeThrough(new TextDecoderStream());
+ },
+ };
+
+ const activeStreamStore: ResumableStreamActiveStore = {
+ async getActiveStreamId(context) {
+ const response = await voltOpsClient.sendRequest(
+ `/resumable-streams/active?${buildActiveStreamQuery(context)}`,
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch active resumable stream (${response.status})`);
+ }
+
+ const payload = (await response.json()) as { streamId?: string | null };
+ return typeof payload.streamId === "string" && payload.streamId.length > 0
+ ? payload.streamId
+ : null;
+ },
+ async setActiveStreamId(context, streamId) {
+ const response = await voltOpsClient.sendRequest(
+ `/resumable-streams/active?${buildActiveStreamQuery(context, streamId)}`,
+ { method: "POST" },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to set active resumable stream (${response.status})`);
+ }
+ },
+ async clearActiveStream({ streamId, ...context }) {
+ const response = await voltOpsClient.sendRequest(
+ `/resumable-streams/active?${buildActiveStreamQuery(context, streamId)}`,
+ { method: "DELETE" },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to clear active resumable stream (${response.status})`);
+ }
+ },
+ };
+
+ const mergedStore = mergeStreamAndActiveStore(streamStore, activeStreamStore);
+ return markResumableStreamStoreType(mergedStore, "voltops", "VoltOps");
+}
+
+export async function createResumableStreamAdapter(
+ config: ResumableStreamAdapterConfig,
+): Promise {
+ if (!config?.streamStore) {
+ throw new Error("Resumable stream store is required");
+ }
+
+ const streamStore = config.streamStore;
+ const activeStreamStore =
+ config.activeStreamStore ?? (isResumableStreamActiveStore(streamStore) ? streamStore : null);
+ if (!activeStreamStore) {
+ throw new Error("Resumable stream activeStreamStore is required");
+ }
+
+ const disabledInfo =
+ getResumableStreamDisabledInfo(streamStore) ??
+ getResumableStreamDisabledInfo(activeStreamStore);
+ if (disabledInfo) {
+ return createDisabledResumableStreamAdapter(disabledInfo.reason);
+ }
+
+ const adapter: ResumableStreamAdapter = {
+ async createStream({ conversationId, agentId, userId, stream }) {
+ const streamId = createRandomUUID();
+ await streamStore.createNewResumableStream(streamId, () => stream);
+ await activeStreamStore.setActiveStreamId({ conversationId, agentId, userId }, streamId);
+ return streamId;
+ },
+ async resumeStream(streamId) {
+ const stream = await streamStore.resumeExistingStream(streamId);
+ return stream ?? null;
+ },
+ async getActiveStreamId(context) {
+ return await activeStreamStore.getActiveStreamId(context);
+ },
+ async clearActiveStream({ streamId, ...context }) {
+ await activeStreamStore.clearActiveStream({ ...context, streamId });
+ },
+ };
+
+ const storeInfo =
+ getResumableStreamStoreInfo(streamStore) ?? getResumableStreamStoreInfo(activeStreamStore);
+ if (storeInfo) {
+ return markResumableStreamStoreType(
+ adapter,
+ storeInfo.type,
+ storeInfo.displayName ?? undefined,
+ );
+ }
+
+ return adapter;
+}
+
+export async function resolveResumableStreamDeps(
+ deps: ServerProviderDeps,
+ adapter: ResumableStreamAdapter | undefined,
+ logger?: Logger,
+): Promise {
+ if (deps.resumableStream) {
+ if (adapter) {
+ logger?.warn("Resumable stream adapter ignored because an adapter is already provided");
+ }
+ return deps;
+ }
+
+ const resolvedAdapter = resolveResumableStreamAdapter(adapter, logger);
+ if (!resolvedAdapter) {
+ return deps;
+ }
+
+ return {
+ ...deps,
+ resumableStream: resolvedAdapter,
+ };
+}
+
+export function resolveResumableStreamAdapter(
+ adapter: ResumableStreamAdapter | undefined,
+ logger?: Logger,
+): ResumableStreamAdapter | undefined {
+ if (!adapter) {
+ return undefined;
+ }
+
+ const disabledInfo = getResumableStreamDisabledInfo(adapter);
+ if (disabledInfo) {
+ logger?.warn(disabledInfo.reason, { docsUrl: disabledInfo.docsUrl });
+ return undefined;
+ }
+
+ if (!isResumableStreamAdapter(adapter)) {
+ logger?.error("Invalid resumable stream adapter provided");
+ return undefined;
+ }
+
+ return adapter;
+}
+
+function isResumableStreamAdapter(value: unknown): value is ResumableStreamAdapter {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as ResumableStreamAdapter).createStream === "function" &&
+ typeof (value as ResumableStreamAdapter).resumeStream === "function" &&
+ typeof (value as ResumableStreamAdapter).getActiveStreamId === "function" &&
+ typeof (value as ResumableStreamAdapter).clearActiveStream === "function"
+ );
+}
+
+function isResumableStreamActiveStore(value: unknown): value is ResumableStreamActiveStore {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as ResumableStreamActiveStore).getActiveStreamId === "function" &&
+ typeof (value as ResumableStreamActiveStore).setActiveStreamId === "function" &&
+ typeof (value as ResumableStreamActiveStore).clearActiveStream === "function"
+ );
+}
diff --git a/packages/resumable-streams/src/types.ts b/packages/resumable-streams/src/types.ts
new file mode 100644
index 000000000..f13d2b169
--- /dev/null
+++ b/packages/resumable-streams/src/types.ts
@@ -0,0 +1,50 @@
+import type { ResumableStreamContext, VoltOpsClient } from "@voltagent/core";
+import type { Publisher, Subscriber } from "resumable-stream";
+
+export type ResumableStreamSubscriber = Subscriber;
+export type ResumableStreamPublisher = Publisher;
+
+export type ResumableStreamStore = {
+ createNewResumableStream: (
+ streamId: string,
+ makeStream: () => ReadableStream,
+ skipCharacters?: number,
+ ) => Promise | null>;
+ resumeExistingStream: (
+ streamId: string,
+ skipCharacters?: number,
+ ) => Promise | null | undefined>;
+};
+
+export type ResumableStreamActiveStore = {
+ getActiveStreamId: (context: ResumableStreamContext) => Promise;
+ setActiveStreamId: (context: ResumableStreamContext, streamId: string) => Promise;
+ clearActiveStream: (context: ResumableStreamContext & { streamId?: string }) => Promise;
+};
+
+export type ResumableStreamStoreOptions = {
+ keyPrefix?: string;
+ waitUntil?: ((promise: Promise) => void) | null;
+};
+
+export type ResumableStreamRedisStoreOptions = ResumableStreamStoreOptions & {
+ publisher?: ResumableStreamPublisher;
+ subscriber?: ResumableStreamSubscriber;
+};
+
+export type ResumableStreamGenericStoreOptions = ResumableStreamStoreOptions & {
+ publisher: ResumableStreamPublisher;
+ subscriber: ResumableStreamSubscriber;
+};
+
+export type ResumableStreamVoltOpsStoreOptions = ResumableStreamStoreOptions & {
+ voltOpsClient?: VoltOpsClient;
+ publicKey?: string;
+ secretKey?: string;
+ baseUrl?: string;
+};
+
+export type ResumableStreamAdapterConfig = {
+ streamStore: ResumableStreamStore;
+ activeStreamStore?: ResumableStreamActiveStore;
+};
diff --git a/packages/resumable-streams/tsconfig.json b/packages/resumable-streams/tsconfig.json
new file mode 100644
index 000000000..d4612442c
--- /dev/null
+++ b/packages/resumable-streams/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "target": "es2018",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "module": "esnext",
+ "moduleResolution": "node",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist",
+ "rootDir": "./",
+ "strict": true,
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "strictFunctionTypes": true,
+ "strictBindCallApply": true,
+ "strictPropertyInitialization": true,
+ "noImplicitThis": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/resumable-streams/tsup.config.ts b/packages/resumable-streams/tsup.config.ts
new file mode 100644
index 000000000..e019584d6
--- /dev/null
+++ b/packages/resumable-streams/tsup.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "tsup";
+import { markAsExternalPlugin } from "../shared/tsup-plugins/mark-as-external";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ format: ["cjs", "esm"],
+ splitting: false,
+ sourcemap: true,
+ clean: false,
+ target: "es2022",
+ outDir: "dist",
+ dts: true,
+ esbuildPlugins: [markAsExternalPlugin],
+ esbuildOptions(options) {
+ options.keepNames = true;
+ return options;
+ },
+});
diff --git a/packages/server-core/src/auth/defaults.spec.ts b/packages/server-core/src/auth/defaults.spec.ts
index 5b54c0468..d56ea2c68 100644
--- a/packages/server-core/src/auth/defaults.spec.ts
+++ b/packages/server-core/src/auth/defaults.spec.ts
@@ -95,6 +95,7 @@ describe("Auth Defaults", () => {
it("should require auth for agent execution endpoints", () => {
expect(requiresAuth("POST", "/agents/my-agent/text")).toBe(true);
expect(requiresAuth("POST", "/agents/123/stream")).toBe(true);
+ expect(requiresAuth("GET", "/agents/123/chat/conv-1/stream")).toBe(true);
expect(requiresAuth("POST", "/agents/abc/object")).toBe(true);
expect(requiresAuth("POST", "/agents/test/stream-object")).toBe(true);
});
diff --git a/packages/server-core/src/auth/defaults.ts b/packages/server-core/src/auth/defaults.ts
index 0cd782cb5..54541474d 100644
--- a/packages/server-core/src/auth/defaults.ts
+++ b/packages/server-core/src/auth/defaults.ts
@@ -87,6 +87,7 @@ export const PROTECTED_ROUTES = [
"POST /agents/:id/text", // generateText
"POST /agents/:id/stream", // streamText
"POST /agents/:id/chat", // chatStream
+ "GET /agents/:id/chat/:conversationId/stream", // resumeChatStream
"POST /agents/:id/object", // generateObject
"POST /agents/:id/stream-object", // streamObject
"WS /ws/agents/:id", // WebSocket connection
diff --git a/packages/server-core/src/edge.ts b/packages/server-core/src/edge.ts
index ffcc1fd3b..67d923244 100644
--- a/packages/server-core/src/edge.ts
+++ b/packages/server-core/src/edge.ts
@@ -14,6 +14,7 @@ export {
handleGenerateText,
handleStreamText,
handleChatStream,
+ handleResumeChatStream,
handleGenerateObject,
handleStreamObject,
} from "./handlers/agent.handlers";
diff --git a/packages/server-core/src/handlers/agent.handlers.ts b/packages/server-core/src/handlers/agent.handlers.ts
index e8e414562..add7e75ec 100644
--- a/packages/server-core/src/handlers/agent.handlers.ts
+++ b/packages/server-core/src/handlers/agent.handlers.ts
@@ -1,6 +1,7 @@
import { ClientHTTPError, type ServerProviderDeps } from "@voltagent/core";
import { convertUsage } from "@voltagent/core";
import { type Logger, safeStringify } from "@voltagent/internal";
+import { type UIMessage, UI_MESSAGE_STREAM_HEADERS, generateId } from "ai";
import { z } from "zod";
import { convertJsonSchemaToZod } from "zod-from-json-schema";
import { convertJsonSchemaToZod as convertJsonSchemaToZodV3 } from "zod-from-json-schema-v3";
@@ -233,14 +234,126 @@ export async function handleChatStream(
}
const { input } = body;
+ const originalMessages =
+ Array.isArray(input) &&
+ input.length > 0 &&
+ input.every((message) => Array.isArray((message as { parts?: unknown }).parts))
+ ? (input as UIMessage[])
+ : undefined;
+ let resumableStreamRequested =
+ typeof body?.options?.resumableStream === "boolean"
+ ? body.options.resumableStream
+ : (deps.resumableStreamDefault ?? false);
const options = processAgentOptions(body, signal);
+ const conversationId =
+ typeof options.conversationId === "string" ? options.conversationId : undefined;
+ const userId =
+ typeof options.userId === "string" && options.userId.trim().length > 0
+ ? options.userId
+ : undefined;
+ const resumableEnabled = Boolean(deps.resumableStream);
+ const resumableStreamEnabled =
+ resumableEnabled &&
+ resumableStreamRequested === true &&
+ Boolean(conversationId) &&
+ Boolean(userId);
+
+ if (resumableStreamRequested === true && !resumableEnabled) {
+ logger.warn(
+ "Resumable streams requested but not configured. Falling back to non-resumable streams.",
+ {
+ docsUrl: "https://voltagent.dev/docs/agents/resumable-streaming/",
+ },
+ );
+ resumableStreamRequested = false;
+ }
+
+ if (resumableStreamRequested === true && !conversationId) {
+ return new Response(
+ safeStringify({
+ error: "conversationId is required for resumable streams",
+ message: "conversationId is required for resumable streams",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ },
+ );
+ }
+
+ if (resumableStreamRequested === true && !userId) {
+ return new Response(
+ safeStringify({
+ error: "userId is required for resumable streams",
+ message: "userId is required for resumable streams",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ },
+ );
+ }
+
+ if (resumableStreamEnabled) {
+ options.abortSignal = undefined;
+ }
+
+ options.resumableStream = resumableStreamEnabled;
+
+ const resumableStreamAdapter = deps.resumableStream;
+ if (resumableStreamEnabled && resumableStreamAdapter && conversationId && userId) {
+ try {
+ await resumableStreamAdapter.clearActiveStream({ conversationId, agentId, userId });
+ } catch (error) {
+ logger.warn("Failed to clear active resumable stream", { error });
+ }
+ }
const result = await agent.streamText(input, options);
+ let activeStreamId: string | null = null;
// Use the built-in toUIMessageStreamResponse - it handles errors properly
return result.toUIMessageStreamResponse({
+ originalMessages,
+ generateMessageId: generateId,
sendReasoning: true,
sendSources: true,
+ consumeSseStream: async ({ stream }) => {
+ if (!resumableStreamEnabled || !resumableStreamAdapter || !conversationId || !userId) {
+ return;
+ }
+
+ try {
+ activeStreamId = await resumableStreamAdapter.createStream({
+ conversationId,
+ agentId,
+ userId,
+ stream,
+ });
+ } catch (error) {
+ logger.error("Failed to persist resumable chat stream", { error });
+ }
+ },
+ onFinish: async () => {
+ if (!resumableStreamEnabled || !resumableStreamAdapter || !conversationId || !userId) {
+ return;
+ }
+
+ try {
+ await resumableStreamAdapter.clearActiveStream({
+ conversationId,
+ agentId,
+ userId,
+ streamId: activeStreamId ?? undefined,
+ });
+ } catch (error) {
+ logger.error("Failed to clear resumable chat stream", { error });
+ }
+ },
});
} catch (error) {
logger.error("Failed to handle chat stream request", { error });
@@ -262,6 +375,86 @@ export async function handleChatStream(
}
}
+/**
+ * Handler for resuming chat streams
+ * Returns SSE stream if active, or 204 if no stream is active
+ */
+export async function handleResumeChatStream(
+ agentId: string,
+ conversationId: string,
+ deps: ServerProviderDeps,
+ logger: Logger,
+ userId?: string,
+): Promise {
+ try {
+ if (!deps.resumableStream) {
+ return new Response(null, { status: 204 });
+ }
+
+ if (!userId) {
+ return new Response(
+ safeStringify({
+ error: "userId is required for resumable streams",
+ message: "userId is required for resumable streams",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ },
+ );
+ }
+
+ const streamId = await deps.resumableStream.getActiveStreamId({
+ conversationId,
+ agentId,
+ userId,
+ });
+
+ if (!streamId) {
+ return new Response(null, { status: 204 });
+ }
+
+ const stream = await deps.resumableStream.resumeStream(streamId);
+ if (!stream) {
+ try {
+ await deps.resumableStream.clearActiveStream({
+ conversationId,
+ agentId,
+ userId,
+ streamId,
+ });
+ } catch (error) {
+ logger.warn("Failed to clear inactive resumable stream", { error });
+ }
+ return new Response(null, { status: 204 });
+ }
+
+ const encodedStream = stream.pipeThrough(new TextEncoderStream());
+
+ return new Response(encodedStream, {
+ status: 200,
+ headers: UI_MESSAGE_STREAM_HEADERS,
+ });
+ } catch (error) {
+ logger.error("Failed to resume chat stream", { error });
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ return new Response(
+ safeStringify({
+ error: errorMessage,
+ message: errorMessage,
+ }),
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ },
+ );
+ }
+}
+
/**
* Handler for generating objects
* Returns generated object data
diff --git a/packages/server-core/src/handlers/observability.handlers.ts b/packages/server-core/src/handlers/observability.handlers.ts
index 2008266d8..96733aa52 100644
--- a/packages/server-core/src/handlers/observability.handlers.ts
+++ b/packages/server-core/src/handlers/observability.handlers.ts
@@ -166,6 +166,22 @@ export async function getObservabilityStatusHandler(deps: ServerProviderDeps): P
// Get VoltAgentObservability instance from deps
const observability = deps.observability;
const enabled = !!observability;
+ const resumableStreamAdapter = deps.resumableStream;
+ const resumableStreamEnabled = !!resumableStreamAdapter;
+ const resumableStreamStoreType =
+ typeof (resumableStreamAdapter as { __voltagentResumableStoreType?: unknown })
+ ?.__voltagentResumableStoreType === "string"
+ ? ((resumableStreamAdapter as { __voltagentResumableStoreType?: string })
+ .__voltagentResumableStoreType as string)
+ : resumableStreamEnabled
+ ? "custom"
+ : null;
+ const resumableStreamStoreDisplayName =
+ typeof (resumableStreamAdapter as { __voltagentResumableStoreDisplayName?: unknown })
+ ?.__voltagentResumableStoreDisplayName === "string"
+ ? ((resumableStreamAdapter as { __voltagentResumableStoreDisplayName?: string })
+ .__voltagentResumableStoreDisplayName as string)
+ : null;
let storageType = "none";
let storageAdapterName: string | null = null;
@@ -218,6 +234,11 @@ export async function getObservabilityStatusHandler(deps: ServerProviderDeps): P
traceCount,
spanCount,
logCount,
+ resumableStream: {
+ enabled: resumableStreamEnabled,
+ store: resumableStreamStoreType,
+ storeDisplayName: resumableStreamStoreDisplayName,
+ },
message: enabled
? `Observability is enabled with ${storageType} storage`
: "Observability is not configured",
diff --git a/packages/server-core/src/routes/definitions.ts b/packages/server-core/src/routes/definitions.ts
index 0c0d03528..1d270c6ec 100644
--- a/packages/server-core/src/routes/definitions.ts
+++ b/packages/server-core/src/routes/definitions.ts
@@ -155,6 +155,37 @@ export const AGENT_ROUTES = {
},
},
},
+ resumeChatStream: {
+ method: "get" as const,
+ path: "/agents/:id/chat/:conversationId/stream",
+ summary: "Resume chat stream",
+ description:
+ "Resume an in-progress UI message stream for a chat conversation. Requires userId query parameter. Returns 204 if no active stream is found.",
+ tags: ["Agent Generation"],
+ operationId: "resumeChatStream",
+ responses: {
+ 200: {
+ description: "Successfully resumed SSE stream for chat generation",
+ contentType: "text/event-stream",
+ },
+ 400: {
+ description: "Missing or invalid userId",
+ contentType: "application/json",
+ },
+ 204: {
+ description: "No active stream found for the conversation",
+ contentType: "text/plain",
+ },
+ 404: {
+ description: "Resumable streams not configured",
+ contentType: "application/json",
+ },
+ 500: {
+ description: "Failed to resume chat stream due to server error",
+ contentType: "application/json",
+ },
+ },
+ },
generateObject: {
method: "post" as const,
path: "/agents/:id/object",
diff --git a/packages/server-core/src/schemas/agent.schemas.ts b/packages/server-core/src/schemas/agent.schemas.ts
index d74035814..5de934d99 100644
--- a/packages/server-core/src/schemas/agent.schemas.ts
+++ b/packages/server-core/src/schemas/agent.schemas.ts
@@ -75,7 +75,10 @@ export const BasicJsonSchema = z
// Generation options schema
export const GenerateOptionsSchema = z
.object({
- userId: z.string().optional().describe("Optional user ID for context tracking"),
+ userId: z
+ .string()
+ .optional()
+ .describe("Optional user ID for context tracking (required for resumable streams)"),
conversationId: z.string().optional().describe("Optional conversation ID for context tracking"),
context: z
.record(z.string(), z.unknown())
@@ -135,6 +138,12 @@ export const GenerateOptionsSchema = z
.record(z.string(), z.unknown())
.nullish()
.describe("Provider-specific options for AI SDK providers (e.g., OpenAI's reasoningEffort)"),
+ resumableStream: z
+ .boolean()
+ .optional()
+ .describe(
+ "When true, avoids wiring the HTTP abort signal into streams so they can be resumed (requires resumable streams and options.conversationId + options.userId). If omitted, server defaults may apply.",
+ ),
output: z
.object({
type: z
diff --git a/packages/server-core/src/utils/options.ts b/packages/server-core/src/utils/options.ts
index a1a8afd54..f340d0dd9 100644
--- a/packages/server-core/src/utils/options.ts
+++ b/packages/server-core/src/utils/options.ts
@@ -24,6 +24,7 @@ export interface ProcessedAgentOptions {
abortSignal?: AbortSignal;
onFinish?: (result: unknown) => Promise;
output?: any;
+ resumableStream?: boolean;
[key: string]: any;
}
diff --git a/packages/server-hono/package.json b/packages/server-hono/package.json
index 6460c2f0a..7c9bed0e1 100644
--- a/packages/server-hono/package.json
+++ b/packages/server-hono/package.json
@@ -9,6 +9,7 @@
"@voltagent/core": "^2.0.2",
"@voltagent/internal": "^1.0.2",
"@voltagent/mcp-server": "^2.0.2",
+ "@voltagent/resumable-streams": "^2.0.0",
"@voltagent/server-core": "^2.0.2",
"fetch-to-node": "^2.1.0",
"hono": "^4.7.7",
diff --git a/packages/server-hono/src/app-factory.ts b/packages/server-hono/src/app-factory.ts
index 2e4f777ac..51ef5d800 100644
--- a/packages/server-hono/src/app-factory.ts
+++ b/packages/server-hono/src/app-factory.ts
@@ -1,5 +1,6 @@
import { swaggerUI } from "@hono/swagger-ui";
import type { ServerProviderDeps } from "@voltagent/core";
+import { resolveResumableStreamDeps } from "@voltagent/resumable-streams";
import {
getLandingPageHTML,
getOpenApiDoc,
@@ -35,17 +36,27 @@ export async function createApp(
// Get logger from dependencies or use global
const logger = getOrCreateLogger(deps, "api-server");
+ const resumableStreamConfig = config.resumableStream;
+ const baseDeps = await resolveResumableStreamDeps(deps, resumableStreamConfig?.adapter, logger);
+ const resumableStreamDefault =
+ typeof resumableStreamConfig?.defaultEnabled === "boolean"
+ ? resumableStreamConfig.defaultEnabled
+ : baseDeps.resumableStreamDefault;
+ const resolvedDeps: ServerProviderDeps = {
+ ...baseDeps,
+ ...(resumableStreamDefault !== undefined ? { resumableStreamDefault } : {}),
+ };
// Register all routes with dependencies
const routes = {
- agents: () => registerAgentRoutes(app as any, deps, logger),
- workflows: () => registerWorkflowRoutes(app as any, deps, logger),
- logs: () => registerLogRoutes(app as any, deps, logger),
- updates: () => registerUpdateRoutes(app as any, deps, logger),
- observability: () => registerObservabilityRoutes(app as any, deps, logger),
- tools: () => registerToolRoutes(app as any, deps as any, logger),
- triggers: () => registerTriggerRoutes(app as any, deps, logger),
- mcp: () => registerMcpRoutes(app as any, deps as any, logger),
- a2a: () => registerA2ARoutes(app as any, deps as any, logger),
+ agents: () => registerAgentRoutes(app as any, resolvedDeps, logger),
+ workflows: () => registerWorkflowRoutes(app as any, resolvedDeps, logger),
+ logs: () => registerLogRoutes(app as any, resolvedDeps, logger),
+ updates: () => registerUpdateRoutes(app as any, resolvedDeps, logger),
+ observability: () => registerObservabilityRoutes(app as any, resolvedDeps, logger),
+ tools: () => registerToolRoutes(app as any, resolvedDeps as any, logger),
+ triggers: () => registerTriggerRoutes(app as any, resolvedDeps, logger),
+ mcp: () => registerMcpRoutes(app as any, resolvedDeps as any, logger),
+ a2a: () => registerA2ARoutes(app as any, resolvedDeps as any, logger),
doc: () => {
app.get("/doc", (c) => {
const baseDoc = getOpenApiDoc(port || config.port || 3141);
diff --git a/packages/server-hono/src/routes/agent.routes.ts b/packages/server-hono/src/routes/agent.routes.ts
index 6440177e5..f7050eb32 100644
--- a/packages/server-hono/src/routes/agent.routes.ts
+++ b/packages/server-hono/src/routes/agent.routes.ts
@@ -24,6 +24,8 @@ import { createRoute, z } from "../zod-openapi-compat";
import { createPathParam } from "./path-params";
const agentIdParam = () => createPathParam("id", "The ID of the agent", "my-agent-123");
+const conversationIdParam = () =>
+ createPathParam("conversationId", "The ID of the conversation", "chat_123");
const workflowIdParam = () => createPathParam("id", "The ID of the workflow", "my-workflow-123");
const executionIdParam = () =>
createPathParam("executionId", "The ID of the execution to operate on", "exec_1234567890_abc123");
@@ -242,6 +244,83 @@ export const chatRoute = createRoute({
description: AGENT_ROUTES.chatStream.description,
});
+// Resume chat stream response (UI message stream)
+export const resumeChatStreamRoute = createRoute({
+ method: AGENT_ROUTES.resumeChatStream.method,
+ path: AGENT_ROUTES.resumeChatStream.path
+ .replace(":id", "{id}")
+ .replace(":conversationId", "{conversationId}"),
+ request: {
+ params: z.object({
+ id: agentIdParam(),
+ conversationId: conversationIdParam(),
+ }),
+ query: z.object({
+ userId: z
+ .string()
+ .min(1)
+ .openapi("QueryParam.userId", {
+ description: "User ID for resumable streams",
+ param: { name: "userId", in: "query", required: true },
+ example: "user-123",
+ }),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "text/event-stream": {
+ schema: StreamTextEventSchema,
+ },
+ },
+ description:
+ AGENT_ROUTES.resumeChatStream.responses?.[200]?.description ||
+ "Successfully resumed SSE stream for chat generation",
+ },
+ 204: {
+ content: {
+ "text/plain": {
+ schema: z.string(),
+ },
+ },
+ description:
+ AGENT_ROUTES.resumeChatStream.responses?.[204]?.description ||
+ "No active stream found for the conversation",
+ },
+ 400: {
+ content: {
+ "application/json": {
+ schema: ErrorSchema,
+ },
+ },
+ description: "Missing or invalid userId",
+ },
+ 404: {
+ content: {
+ "application/json": {
+ schema: ErrorSchema,
+ },
+ },
+ description:
+ AGENT_ROUTES.resumeChatStream.responses?.[404]?.description ||
+ "Resumable streams not configured",
+ },
+ 500: {
+ content: {
+ "application/json": {
+ schema: ErrorSchema,
+ },
+ },
+ description:
+ AGENT_ROUTES.resumeChatStream.responses?.[500]?.description ||
+ "Failed to resume chat stream",
+ },
+ },
+ tags: [...AGENT_ROUTES.resumeChatStream.tags],
+ summary: AGENT_ROUTES.resumeChatStream.summary,
+ description: AGENT_ROUTES.resumeChatStream.description,
+});
+
// Generate object response
export const objectRoute = createRoute({
method: AGENT_ROUTES.generateObject.method,
diff --git a/packages/server-hono/src/routes/index.ts b/packages/server-hono/src/routes/index.ts
index a5af82146..221590716 100644
--- a/packages/server-hono/src/routes/index.ts
+++ b/packages/server-hono/src/routes/index.ts
@@ -17,6 +17,7 @@ import {
handleGetWorkflows,
handleInstallUpdates,
handleListWorkflowRuns,
+ handleResumeChatStream,
handleResumeWorkflow,
handleStreamObject,
handleStreamText,
@@ -33,6 +34,7 @@ import {
getAgentsRoute,
getWorkflowsRoute,
objectRoute,
+ resumeChatStreamRoute,
resumeWorkflowRoute,
streamObjectRoute,
streamRoute,
@@ -122,6 +124,18 @@ export function registerAgentRoutes(
return response;
});
+ // GET /agents/:id/chat/:conversationId/stream - Resume chat stream (UI message stream SSE)
+ app.openapi(resumeChatStreamRoute, async (c) => {
+ const agentId = c.req.param("id");
+ const conversationId = c.req.param("conversationId");
+ const userId = c.req.query("userId");
+ if (!agentId || !conversationId) {
+ throw new Error("Missing agent or conversation id parameter");
+ }
+
+ return handleResumeChatStream(agentId, conversationId, deps, logger, userId);
+ });
+
// POST /agents/:id/object - Generate object
app.openapi(objectRoute, async (c) => {
const agentId = c.req.param("id");
diff --git a/packages/server-hono/src/types.ts b/packages/server-hono/src/types.ts
index 5c88a8204..72e6c8404 100644
--- a/packages/server-hono/src/types.ts
+++ b/packages/server-hono/src/types.ts
@@ -1,3 +1,4 @@
+import type { ResumableStreamAdapter } from "@voltagent/core";
import type { AuthNextConfig, AuthProvider } from "@voltagent/server-core";
import type { Context } from "hono";
import type { OpenAPIHonoType } from "./zod-openapi-compat";
@@ -14,6 +15,14 @@ type CORSOptions = {
export interface HonoServerConfig {
port?: number;
+ /**
+ * Resumable stream configuration.
+ */
+ resumableStream?: {
+ adapter: ResumableStreamAdapter;
+ defaultEnabled?: boolean;
+ };
+
enableSwaggerUI?: boolean;
/**
diff --git a/packages/serverless-hono/package.json b/packages/serverless-hono/package.json
index 895e3999f..f3b586329 100644
--- a/packages/serverless-hono/package.json
+++ b/packages/serverless-hono/package.json
@@ -4,6 +4,7 @@
"version": "2.0.4",
"dependencies": {
"@voltagent/internal": "^1.0.2",
+ "@voltagent/resumable-streams": "^2.0.0",
"@voltagent/server-core": "^2.1.1",
"hono": "^4.7.7"
},
diff --git a/packages/serverless-hono/src/app-factory.ts b/packages/serverless-hono/src/app-factory.ts
index 61673574d..270852cbc 100644
--- a/packages/serverless-hono/src/app-factory.ts
+++ b/packages/serverless-hono/src/app-factory.ts
@@ -1,5 +1,6 @@
import type { ServerProviderDeps } from "@voltagent/core";
import type { Logger } from "@voltagent/internal";
+import { resolveResumableStreamDeps } from "@voltagent/resumable-streams";
import { getOrCreateLogger } from "@voltagent/server-core";
import { Hono } from "hono";
import { cors } from "hono/cors";
@@ -42,6 +43,16 @@ function resolveCorsConfig(config?: ServerlessConfig) {
export async function createServerlessApp(deps: ServerProviderDeps, config?: ServerlessConfig) {
const app = new Hono();
const logger: Logger = getOrCreateLogger(deps, "serverless");
+ const resumableStreamConfig = config?.resumableStream;
+ const baseDeps = await resolveResumableStreamDeps(deps, resumableStreamConfig?.adapter, logger);
+ const resumableStreamDefault =
+ typeof resumableStreamConfig?.defaultEnabled === "boolean"
+ ? resumableStreamConfig.defaultEnabled
+ : baseDeps.resumableStreamDefault;
+ const resolvedDeps: ServerProviderDeps = {
+ ...baseDeps,
+ ...(resumableStreamDefault !== undefined ? { resumableStreamDefault } : {}),
+ };
const corsConfig = resolveCorsConfig(config);
app.use("*", cors(corsConfig));
@@ -65,17 +76,17 @@ export async function createServerlessApp(deps: ServerProviderDeps, config?: Ser
),
);
- registerAgentRoutes(app, deps, logger);
- registerWorkflowRoutes(app, deps, logger);
- registerToolRoutes(app, deps, logger);
- registerLogRoutes(app, deps, logger);
- registerUpdateRoutes(app, deps, logger);
- registerObservabilityRoutes(app, deps, logger);
- registerTriggerRoutes(app, deps, logger);
- registerA2ARoutes(app, deps, logger);
+ registerAgentRoutes(app, resolvedDeps, logger);
+ registerWorkflowRoutes(app, resolvedDeps, logger);
+ registerToolRoutes(app, resolvedDeps, logger);
+ registerLogRoutes(app, resolvedDeps, logger);
+ registerUpdateRoutes(app, resolvedDeps, logger);
+ registerObservabilityRoutes(app, resolvedDeps, logger);
+ registerTriggerRoutes(app, resolvedDeps, logger);
+ registerA2ARoutes(app, resolvedDeps, logger);
if (config?.configureApp) {
- await config.configureApp(app, deps);
+ await config.configureApp(app, resolvedDeps);
}
return app;
diff --git a/packages/serverless-hono/src/routes.ts b/packages/serverless-hono/src/routes.ts
index 5aa6d11a9..07105b9b5 100644
--- a/packages/serverless-hono/src/routes.ts
+++ b/packages/serverless-hono/src/routes.ts
@@ -52,6 +52,7 @@ import {
handleInstallUpdates,
handleListTools,
handleListWorkflowRuns,
+ handleResumeChatStream,
handleResumeWorkflow,
handleStreamObject,
handleStreamText,
@@ -280,6 +281,19 @@ export function registerAgentRoutes(app: Hono, deps: ServerProviderDeps, logger:
);
});
+ app.get(AGENT_ROUTES.resumeChatStream.path, async (c) => {
+ const agentId = c.req.param("id");
+ const conversationId = c.req.param("conversationId");
+ const userId = c.req.query("userId");
+ if (!agentId || !conversationId) {
+ return c.json({ error: "Missing agent or conversation id parameter" }, 400);
+ }
+ if (!userId) {
+ return c.json({ error: "Missing userId parameter" }, 400);
+ }
+ return handleResumeChatStream(agentId, conversationId, deps, logger, userId);
+ });
+
app.post(AGENT_ROUTES.generateObject.path, async (c) => {
const agentId = c.req.param("id");
const body = await readJsonBody(c, logger);
diff --git a/packages/serverless-hono/src/types.ts b/packages/serverless-hono/src/types.ts
index e8e095c7f..2941f565b 100644
--- a/packages/serverless-hono/src/types.ts
+++ b/packages/serverless-hono/src/types.ts
@@ -1,4 +1,4 @@
-import type { ServerProviderDeps } from "@voltagent/core";
+import type { ResumableStreamAdapter, ServerProviderDeps } from "@voltagent/core";
import type { Hono } from "hono";
export type ServerlessRuntime = "cloudflare" | "vercel" | "deno" | "unknown";
@@ -8,5 +8,9 @@ export interface ServerlessConfig {
corsAllowMethods?: string[];
corsAllowHeaders?: string[];
maxRequestSize?: number;
+ resumableStream?: {
+ adapter: ResumableStreamAdapter;
+ defaultEnabled?: boolean;
+ };
configureApp?: (app: Hono, deps: ServerProviderDeps) => void | Promise;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f3b744a25..879ff8c3e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1882,6 +1882,160 @@ importers:
specifier: ^5.8.2
version: 5.9.2
+ examples/with-nextjs-resumable-stream:
+ dependencies:
+ '@ai-sdk/openai':
+ specifier: ^3.0.0
+ version: 3.0.1(zod@3.25.76)
+ '@ai-sdk/react':
+ specifier: ^3.0.0
+ version: 3.0.3(react@19.2.3)(zod@3.25.76)
+ '@libsql/client':
+ specifier: ^0.15.0
+ version: 0.15.10
+ '@radix-ui/react-avatar':
+ specifier: ^1.1.10
+ version: 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-collapsible':
+ specifier: ^1.1.12
+ version: 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-dropdown-menu':
+ specifier: ^2.1.16
+ version: 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-hover-card':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-progress':
+ specifier: ^1.1.7
+ version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.2.10
+ version: 1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-select':
+ specifier: ^2.2.6
+ version: 2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-slot':
+ specifier: ^1.2.3
+ version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-tooltip':
+ specifier: ^1.2.8
+ version: 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state':
+ specifier: ^1.2.2
+ version: 1.2.2(@types/react@19.2.7)(react@19.2.3)
+ '@voltagent/cli':
+ specifier: ^0.1.20
+ version: link:../../packages/cli
+ '@voltagent/core':
+ specifier: ^2.0.5
+ version: link:../../packages/core
+ '@voltagent/internal':
+ specifier: ^1.0.2
+ version: link:../../packages/internal
+ '@voltagent/libsql':
+ specifier: ^2.0.2
+ version: link:../../packages/libsql
+ '@voltagent/resumable-streams':
+ specifier: ^2.0.0
+ version: link:../../packages/resumable-streams
+ '@voltagent/server-hono':
+ specifier: ^2.0.2
+ version: link:../../packages/server-hono
+ '@xyflow/react':
+ specifier: ^12.9.2
+ version: 12.9.2(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ ai:
+ specifier: ^6.0.0
+ version: 6.0.3(zod@3.25.76)
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ cmdk:
+ specifier: ^1.1.1
+ version: 1.1.1(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ embla-carousel-react:
+ specifier: ^8.6.0
+ version: 8.6.0(react@19.2.3)
+ hast:
+ specifier: ^1.0.0
+ version: 1.0.0
+ import-in-the-middle:
+ specifier: ^1.14.2
+ version: 1.14.2
+ libsql:
+ specifier: ^0.5.17
+ version: 0.5.17
+ lucide-react:
+ specifier: ^0.460.0
+ version: 0.460.0(react@19.2.3)
+ motion:
+ specifier: ^12.23.24
+ version: 12.23.26(react-dom@19.2.3)(react@19.2.3)
+ nanoid:
+ specifier: ^5.1.6
+ version: 5.1.6
+ next:
+ specifier: ^16.0.7
+ version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.3)(react@19.2.3)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ require-in-the-middle:
+ specifier: ^7.5.2
+ version: 7.5.2
+ shiki:
+ specifier: ^3.14.0
+ version: 3.15.0
+ sonner:
+ specifier: ^2.0.7
+ version: 2.0.7(react-dom@19.2.3)(react@19.2.3)
+ streamdown:
+ specifier: ^1.4.0
+ version: 1.4.0(@types/react@19.2.7)(react@19.2.3)
+ tailwind-merge:
+ specifier: ^3.3.1
+ version: 3.4.0
+ tokenlens:
+ specifier: ^1.3.1
+ version: 1.3.1
+ use-stick-to-bottom:
+ specifier: ^1.1.1
+ version: 1.1.1(react@19.2.3)
+ zod:
+ specifier: ^3.25.76
+ version: 3.25.76
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4.1.4
+ version: 4.1.14
+ '@types/node':
+ specifier: ^24.2.1
+ version: 24.6.2
+ '@types/react':
+ specifier: ^19
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: ^19
+ version: 19.2.3(@types/react@19.2.7)
+ tailwindcss:
+ specifier: ^4.1.4
+ version: 4.1.14
+ tw-animate-css:
+ specifier: ^1.4.0
+ version: 1.4.0
+ typescript:
+ specifier: ^5.8.2
+ version: 5.9.2
+
examples/with-nuxt:
dependencies:
'@ai-sdk/openai':
@@ -2359,6 +2513,37 @@ importers:
specifier: ^5.8.2
version: 5.9.2
+ examples/with-resumable-streams:
+ dependencies:
+ '@ai-sdk/openai':
+ specifier: ^3.0.0
+ version: 3.0.1(zod@4.2.1)
+ '@voltagent/core':
+ specifier: ^2.0.6
+ version: link:../../packages/core
+ '@voltagent/logger':
+ specifier: ^2.0.2
+ version: link:../../packages/logger
+ '@voltagent/resumable-streams':
+ specifier: ^2.0.0
+ version: link:../../packages/resumable-streams
+ '@voltagent/server-hono':
+ specifier: ^2.0.2
+ version: link:../../packages/server-hono
+ ai:
+ specifier: ^6.0.0
+ version: 6.0.3(zod@4.2.1)
+ devDependencies:
+ '@types/node':
+ specifier: ^24.2.1
+ version: 24.6.2
+ tsx:
+ specifier: ^4.19.3
+ version: 4.20.4
+ typescript:
+ specifier: ^5.8.2
+ version: 5.9.2
+
examples/with-retrieval:
dependencies:
'@ai-sdk/openai':
@@ -3027,6 +3212,37 @@ importers:
specifier: ^5.8.2
version: 5.9.2
+ examples/with-voltops-resumable-streams:
+ dependencies:
+ '@ai-sdk/openai':
+ specifier: ^3.0.0
+ version: 3.0.1(zod@4.2.1)
+ '@voltagent/core':
+ specifier: ^2.0.6
+ version: link:../../packages/core
+ '@voltagent/logger':
+ specifier: ^2.0.2
+ version: link:../../packages/logger
+ '@voltagent/resumable-streams':
+ specifier: ^2.0.0
+ version: link:../../packages/resumable-streams
+ '@voltagent/server-hono':
+ specifier: ^2.0.2
+ version: link:../../packages/server-hono
+ ai:
+ specifier: ^6.0.0
+ version: 6.0.3(zod@4.2.1)
+ devDependencies:
+ '@types/node':
+ specifier: ^24.2.1
+ version: 24.6.2
+ tsx:
+ specifier: ^4.19.3
+ version: 4.20.4
+ typescript:
+ specifier: ^5.8.2
+ version: 5.9.2
+
examples/with-voltops-retrieval:
dependencies:
'@ai-sdk/openai':
@@ -3736,6 +3952,24 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.2.1)(@vitest/ui@1.6.1)(jsdom@22.1.0)(msw@2.11.6)
+ packages/resumable-streams:
+ dependencies:
+ '@voltagent/core':
+ specifier: ^2.0.5
+ version: link:../core
+ '@voltagent/internal':
+ specifier: ^1.0.2
+ version: link:../internal
+ ai:
+ specifier: ^6.0.0
+ version: 6.0.3(zod@4.2.1)
+ redis:
+ specifier: ^4.7.0
+ version: 4.7.1
+ resumable-stream:
+ specifier: ^2.2.10
+ version: 2.2.10
+
packages/scorers:
dependencies:
'@voltagent/core':
@@ -3865,6 +4099,9 @@ importers:
'@voltagent/mcp-server':
specifier: ^2.0.2
version: link:../mcp-server
+ '@voltagent/resumable-streams':
+ specifier: ^2.0.0
+ version: link:../resumable-streams
'@voltagent/server-core':
specifier: ^2.0.2
version: link:../server-core
@@ -3893,6 +4130,9 @@ importers:
'@voltagent/internal':
specifier: ^1.0.2
version: link:../internal
+ '@voltagent/resumable-streams':
+ specifier: ^2.0.0
+ version: link:../resumable-streams
'@voltagent/server-core':
specifier: ^2.1.1
version: link:../server-core
@@ -4668,7 +4908,7 @@ packages:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
zod: 3.25.76
- zod-to-json-schema: 3.25.0(zod@3.25.76)
+ zod-to-json-schema: 3.25.1(zod@3.25.76)
dev: false
/@ai-sdk/vue@2.0.63(vue@3.5.22)(zod@3.25.76):
@@ -13870,8 +14110,8 @@ packages:
dev: false
optional: true
- /@oxc-project/types@0.106.0:
- resolution: {integrity: sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg==}
+ /@oxc-project/types@0.107.0:
+ resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==}
dev: true
/@oxc-project/types@0.94.0:
@@ -14892,6 +15132,34 @@ packages:
react-dom: 19.2.3(react@19.2.3)
dev: false
+ /@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
+ resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ dev: false
+
/@radix-ui/react-id@1.1.1(@types/react@19.1.10)(react@19.2.3):
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@@ -15266,6 +15534,27 @@ packages:
react-dom: 19.2.3(react@19.2.3)
dev: false
+ /@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
+ resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ dev: false
+
/@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.7)(@types/react@19.1.10)(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
@@ -15349,6 +15638,34 @@ packages:
react-dom: 19.2.3(react@19.2.3)
dev: false
+ /@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ '@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ dev: false
+
/@radix-ui/react-select@2.2.6(@types/react-dom@19.1.7)(@types/react@19.1.10)(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -15389,7 +15706,7 @@ packages:
react-remove-scroll: 2.7.1(@types/react@19.1.10)(react@19.2.3)
dev: false
- /@radix-ui/react-select@2.2.6(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
+ /@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
'@types/react': '*'
@@ -15422,11 +15739,11 @@ packages:
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@types/react': 19.2.7
+ '@types/react-dom': 19.2.3(@types/react@19.2.7)
aria-hidden: 1.2.6
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.2.3)
- dev: true
/@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
@@ -15775,7 +16092,6 @@ packages:
dependencies:
'@types/react': 19.2.7
react: 19.2.3
- dev: true
/@radix-ui/react-use-rect@1.1.1(@types/react@19.1.10)(react@19.2.3):
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
@@ -15952,11 +16268,60 @@ packages:
react: 19.2.3
dev: false
+ /@redis/bloom@1.2.0(@redis/client@1.6.1):
+ resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+ dependencies:
+ '@redis/client': 1.6.1
+ dev: false
+
+ /@redis/client@1.6.1:
+ resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==}
+ engines: {node: '>=14'}
+ dependencies:
+ cluster-key-slot: 1.1.2
+ generic-pool: 3.9.0
+ yallist: 4.0.0
+ dev: false
+
+ /@redis/graph@1.1.1(@redis/client@1.6.1):
+ resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+ dependencies:
+ '@redis/client': 1.6.1
+ dev: false
+
+ /@redis/json@1.0.7(@redis/client@1.6.1):
+ resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+ dependencies:
+ '@redis/client': 1.6.1
+ dev: false
+
+ /@redis/search@1.2.0(@redis/client@1.6.1):
+ resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+ dependencies:
+ '@redis/client': 1.6.1
+ dev: false
+
+ /@redis/time-series@1.1.0(@redis/client@1.6.1):
+ resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+ dependencies:
+ '@redis/client': 1.6.1
+ dev: false
+
/@repeaterjs/repeater@3.0.6:
resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==}
- /@rolldown/binding-android-arm64@1.0.0-beta.58:
- resolution: {integrity: sha512-mWj5eE4Qc8TbPdGGaaLvBb9XfDPvE1EmZkJQgiGKwchkWH4oAJcRAKMTw7ZHnb1L+t7Ah41sBkAecaIsuUgsug==}
+ /@rolldown/binding-android-arm64@1.0.0-beta.59:
+ resolution: {integrity: sha512-6yLLgyswYwiCfls9+hoNFY9F8TQdwo15hpXDHzlAR0X/GojeKF+AuNcXjYNbOJ4zjl/5D6lliE8CbpB5t1OWIQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
@@ -15964,8 +16329,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-darwin-arm64@1.0.0-beta.58:
- resolution: {integrity: sha512-wFxUymI/5R8bH8qZFYDfAxAN9CyISEIYke+95oZPiv6EWo88aa5rskjVcCpKA532R+klFmdqjbbaD56GNmTF4Q==}
+ /@rolldown/binding-darwin-arm64@1.0.0-beta.59:
+ resolution: {integrity: sha512-hqGXRc162qCCIOAcHN2Cw4eXiVTwYsMFLOhAy1IG2CxY+dwc/l4Ga+dLPkLor3Ikqy5WDn+7kxHbbh6EmshEpQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
@@ -15973,8 +16338,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-darwin-x64@1.0.0-beta.58:
- resolution: {integrity: sha512-ybp3MkPj23VDV9PhtRwdU5qrGhlViWRV5BjKwO6epaSlUD5lW0WyY+roN3ZAzbma/9RrMTgZ/a/gtQq8YXOcqw==}
+ /@rolldown/binding-darwin-x64@1.0.0-beta.59:
+ resolution: {integrity: sha512-ezvvGuhteE15JmMhJW0wS7BaXmhwLy1YHeEwievYaPC1PgGD86wgBKfOpHr9tSKllAXbCe0BeeMvasscWLhKdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
@@ -15982,8 +16347,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-freebsd-x64@1.0.0-beta.58:
- resolution: {integrity: sha512-Evxj3yh7FWvyklUYZa0qTVT9N2zX9TPDqGF056hl8hlCZ9/ndQ2xMv6uw9PD1VlLpukbsqL+/C6M0qwipL0QMg==}
+ /@rolldown/binding-freebsd-x64@1.0.0-beta.59:
+ resolution: {integrity: sha512-4fhKVJiEYVd5n6no/mrL3LZ9kByfCGwmONOrdtvx8DJGDQhehH/q3RfhG3V/4jGKhpXgbDjpIjkkFdybCTcgew==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
@@ -15991,8 +16356,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.58:
- resolution: {integrity: sha512-tYeXprDOrEgVHUbPXH6MPso4cM/c6RTkmJNICMQlYdki4hGMh92aj3yU6CKs+4X5gfG0yj5kVUw/L4M685SYag==}
+ /@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.59:
+ resolution: {integrity: sha512-T3Y52sW6JAhvIqArBw+wtjNU1Ieaz4g0NBxyjSJoW971nZJBZygNlSYx78G4cwkCmo1dYTciTPDOnQygLV23pA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
@@ -16000,8 +16365,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-linux-arm64-gnu@1.0.0-beta.58:
- resolution: {integrity: sha512-N78vmZzP6zG967Ohr+MasCjmKtis0geZ1SOVmxrA0/bklTQSzH5kHEjW5Qn+i1taFno6GEre1E40v0wuWsNOQw==}
+ /@rolldown/binding-linux-arm64-gnu@1.0.0-beta.59:
+ resolution: {integrity: sha512-NIW40jQDSQap2KDdmm9z3B/4OzWJ6trf8dwx3FD74kcQb3v34ThsBFTtzE5KjDuxnxgUlV+DkAu+XgSMKrgufw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
@@ -16009,8 +16374,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-linux-arm64-musl@1.0.0-beta.58:
- resolution: {integrity: sha512-l+p4QVtG72C7wI2SIkNQw/KQtSjuYwS3rV6AKcWrRBF62ClsFUcif5vLaZIEbPrCXu5OFRXigXFJnxYsVVZqdQ==}
+ /@rolldown/binding-linux-arm64-musl@1.0.0-beta.59:
+ resolution: {integrity: sha512-CCKEk+H+8c0WGe/8n1E20n85Tq4Pv+HNAbjP1KfUXW+01aCWSMjU56ChNrM2tvHnXicfm7QRNoZyfY8cWh7jLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
@@ -16018,8 +16383,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-linux-x64-gnu@1.0.0-beta.58:
- resolution: {integrity: sha512-urzJX0HrXxIh0FfxwWRjfPCMeInU9qsImLQxHBgLp5ivji1EEUnOfux8KxPPnRQthJyneBrN2LeqUix9DYrNaQ==}
+ /@rolldown/binding-linux-x64-gnu@1.0.0-beta.59:
+ resolution: {integrity: sha512-VlfwJ/HCskPmQi8R0JuAFndySKVFX7yPhE658o27cjSDWWbXVtGkSbwaxstii7Q+3Rz87ZXN+HLnb1kd4R9Img==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
@@ -16027,8 +16392,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-linux-x64-musl@1.0.0-beta.58:
- resolution: {integrity: sha512-7ijfVK3GISnXIwq/1FZo+KyAUJjL3kWPJ7rViAL6MWeEBhEgRzJ0yEd9I8N9aut8Y8ab+EKFJyRNMWZuUBwQ0A==}
+ /@rolldown/binding-linux-x64-musl@1.0.0-beta.59:
+ resolution: {integrity: sha512-kuO92hTRyGy0Ts3Nsqll0rfO8eFsEJe9dGQGktkQnZ2hrJrDVN0y419dMgKy/gB2S2o7F2dpWhpfQOBehZPwVA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
@@ -16036,8 +16401,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-openharmony-arm64@1.0.0-beta.58:
- resolution: {integrity: sha512-/m7sKZCS+cUULbzyJTIlv8JbjNohxbpAOA6cM+lgWgqVzPee3U6jpwydrib328JFN/gF9A99IZEnuGYqEDJdww==}
+ /@rolldown/binding-openharmony-arm64@1.0.0-beta.59:
+ resolution: {integrity: sha512-PXAebvNL4sYfCqi8LdY4qyFRacrRoiPZLo3NoUmiTxm7MPtYYR8CNtBGNokqDmMuZIQIecRaD/jbmFAIDz7DxQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
@@ -16045,8 +16410,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-wasm32-wasi@1.0.0-beta.58:
- resolution: {integrity: sha512-6SZk7zMgv+y3wFFQ9qE5P9NnRHcRsptL1ypmudD26PDY+PvFCvfHRkJNfclWnvacVGxjowr7JOL3a9fd1wWhUw==}
+ /@rolldown/binding-wasm32-wasi@1.0.0-beta.59:
+ resolution: {integrity: sha512-yJoklQg7XIZq8nAg0bbkEXcDK6sfpjxQGxpg2Nd6ERNtvg+eOaEBRgPww0BVTrYFQzje1pB5qPwC2VnJHT3koQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
requiresBuild: true
@@ -16055,8 +16420,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-win32-arm64-msvc@1.0.0-beta.58:
- resolution: {integrity: sha512-sFqfYPnBZ6xBhMkadB7UD0yjEDRvs7ipR3nCggblN+N4ODCXY6qhg/bKL39+W+dgQybL7ErD4EGERVbW9DAWvg==}
+ /@rolldown/binding-win32-arm64-msvc@1.0.0-beta.59:
+ resolution: {integrity: sha512-ljZ4+McmCbIuZwEBaoGtiG8Rq2nJjaXEnLEIx+usWetXn1ECjXY0LAhkELxOV6ytv4ensEmoJJ8nXg47hRMjlw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
@@ -16064,8 +16429,8 @@ packages:
dev: true
optional: true
- /@rolldown/binding-win32-x64-msvc@1.0.0-beta.58:
- resolution: {integrity: sha512-AnFWJdAqB8+IDPcGrATYs67Kik/6tnndNJV2jGRmwlbeNiQQ8GhRJU8ETRlINfII0pqi9k4WWLnb00p1QCxw/Q==}
+ /@rolldown/binding-win32-x64-msvc@1.0.0-beta.59:
+ resolution: {integrity: sha512-bMY4tTIwbdZljW+xe/ln1hvs0SRitahQSXfWtvgAtIzgSX9Ar7KqJzU7lRm33YTRFIHLULRi53yNjw9nJGd6uQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -16083,6 +16448,11 @@ packages:
/@rolldown/pluginutils@1.0.0-beta.58:
resolution: {integrity: sha512-qWhDs6yFGR5xDfdrwiSa3CWGIHxD597uGE/A9xGqytBjANvh4rLCTTkq7szhMV4+Ygh+PMS90KVJ8xWG/TkX4w==}
+ dev: false
+
+ /@rolldown/pluginutils@1.0.0-beta.59:
+ resolution: {integrity: sha512-aoh6LAJRyhtazs98ydgpNOYstxUlsOV1KJXcpf/0c0vFcUA8uyd/hwKRhqE/AAPNqAho9RliGsvitCoOzREoVA==}
+ dev: true
/@rollup/plugin-alias@5.1.1(rollup@4.50.2):
resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==}
@@ -19696,7 +20066,7 @@ packages:
'@iconify-json/mdi': 1.2.3
'@iconify/react': 6.0.2(react@19.2.3)
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
- '@radix-ui/react-select': 2.2.6(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-tabs': 1.1.13(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
'@tanstack/react-router': 1.131.44(react-dom@19.2.3)(react@19.2.3)
@@ -20398,6 +20768,22 @@ packages:
- immer
dev: false
+ /@xyflow/react@12.9.2(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
+ resolution: {integrity: sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+ dependencies:
+ '@xyflow/system': 0.0.72
+ classcat: 5.0.5
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ zustand: 4.5.7(@types/react@19.2.7)(react@19.2.3)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+ dev: false
+
/@xyflow/system@0.0.72:
resolution: {integrity: sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==}
dependencies:
@@ -22315,7 +22701,24 @@ packages:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.10)(react@19.2.3)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.7)(@types/react@19.1.10)(react-dom@19.2.3)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.10)(react@19.2.3)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7)(@types/react@19.1.10)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.1.7)(@types/react@19.1.10)(react-dom@19.2.3)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+ dev: false
+
+ /cmdk@1.1.1(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
+ resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^18 || ^19 || ^19.0.0-rc
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
@@ -25852,6 +26255,11 @@ packages:
- supports-color
dev: false
+ /generic-pool@3.9.0:
+ resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
+ engines: {node: '>= 4'}
+ dev: false
+
/gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -26696,6 +27104,11 @@ packages:
dependencies:
'@types/hast': 3.0.4
+ /hast@1.0.0:
+ resolution: {integrity: sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA==}
+ deprecated: Renamed to rehype
+ dev: false
+
/hastscript@6.0.0:
resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
dependencies:
@@ -34507,6 +34920,17 @@ packages:
dependencies:
redis-errors: 1.2.0
+ /redis@4.7.1:
+ resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
+ dependencies:
+ '@redis/bloom': 1.2.0(@redis/client@1.6.1)
+ '@redis/client': 1.6.1
+ '@redis/graph': 1.1.1(@redis/client@1.6.1)
+ '@redis/json': 1.0.7(@redis/client@1.6.1)
+ '@redis/search': 1.2.0(@redis/client@1.6.1)
+ '@redis/time-series': 1.1.0(@redis/client@1.6.1)
+ dev: false
+
/refa@0.12.1:
resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@@ -34872,6 +35296,10 @@ packages:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
dev: false
+ /resumable-stream@2.2.10:
+ resolution: {integrity: sha512-pSJtiDVkPgirq4x+e+gu67IEkUVYGu1cPgW5AnTHCfYGRfIUjS3d4pj7VGveXmYWb9hy6yIMFTM4YzK7rqj14A==}
+ dev: false
+
/ret@0.4.3:
resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==}
engines: {node: '>=10'}
@@ -34945,7 +35373,7 @@ packages:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
dev: false
- /rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.58)(typescript@5.9.2):
+ /rolldown-plugin-dts@0.16.11(rolldown@1.0.0-beta.59)(typescript@5.9.2):
resolution: {integrity: sha512-9IQDaPvPqTx3RjG2eQCK5GYZITo203BxKunGI80AGYicu1ySFTUyugicAaTZWRzFWh9DSnzkgNeMNbDWBbSs0w==}
engines: {node: '>=20.18.0'}
peerDependencies:
@@ -34973,34 +35401,34 @@ packages:
dts-resolver: 2.1.2
get-tsconfig: 4.10.1
magic-string: 0.30.19
- rolldown: 1.0.0-beta.58
+ rolldown: 1.0.0-beta.59
typescript: 5.9.2
transitivePeerDependencies:
- oxc-resolver
- supports-color
dev: true
- /rolldown@1.0.0-beta.58:
- resolution: {integrity: sha512-v1FCjMZCan7f+xGAHBi+mqiE4MlH7I+SXEHSQSJoMOGNNB2UYtvMiejsq9YuUOiZjNeUeV/a21nSFbrUR+4ZCQ==}
+ /rolldown@1.0.0-beta.59:
+ resolution: {integrity: sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
dependencies:
- '@oxc-project/types': 0.106.0
- '@rolldown/pluginutils': 1.0.0-beta.58
+ '@oxc-project/types': 0.107.0
+ '@rolldown/pluginutils': 1.0.0-beta.59
optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0-beta.58
- '@rolldown/binding-darwin-arm64': 1.0.0-beta.58
- '@rolldown/binding-darwin-x64': 1.0.0-beta.58
- '@rolldown/binding-freebsd-x64': 1.0.0-beta.58
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.58
- '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.58
- '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.58
- '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.58
- '@rolldown/binding-linux-x64-musl': 1.0.0-beta.58
- '@rolldown/binding-openharmony-arm64': 1.0.0-beta.58
- '@rolldown/binding-wasm32-wasi': 1.0.0-beta.58
- '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.58
- '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.58
+ '@rolldown/binding-android-arm64': 1.0.0-beta.59
+ '@rolldown/binding-darwin-arm64': 1.0.0-beta.59
+ '@rolldown/binding-darwin-x64': 1.0.0-beta.59
+ '@rolldown/binding-freebsd-x64': 1.0.0-beta.59
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.59
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.59
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.59
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.59
+ '@rolldown/binding-linux-x64-musl': 1.0.0-beta.59
+ '@rolldown/binding-openharmony-arm64': 1.0.0-beta.59
+ '@rolldown/binding-wasm32-wasi': 1.0.0-beta.59
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.59
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.59
dev: true
/rollup-plugin-inject@3.0.2:
@@ -37118,8 +37546,8 @@ packages:
empathic: 2.0.0
hookable: 5.5.3
publint: 0.3.12
- rolldown: 1.0.0-beta.58
- rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.58)(typescript@5.9.2)
+ rolldown: 1.0.0-beta.59
+ rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-beta.59)(typescript@5.9.2)
semver: 7.7.2
tinyexec: 1.0.1
tinyglobby: 0.2.15
@@ -39642,6 +40070,26 @@ packages:
use-sync-external-store: 1.5.0(react@19.2.3)
dev: false
+ /zustand@4.5.7(@types/react@19.2.7)(react@19.2.3):
+ resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ dependencies:
+ '@types/react': 19.2.7
+ react: 19.2.3
+ use-sync-external-store: 1.5.0(react@19.2.3)
+ dev: false
+
/zustand@5.0.9(@types/react@19.2.7)(react@19.2.3):
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
engines: {node: '>=12.20.0'}
diff --git a/website/docs/agents/resumable-streaming.md b/website/docs/agents/resumable-streaming.md
new file mode 100644
index 000000000..146438430
--- /dev/null
+++ b/website/docs/agents/resumable-streaming.md
@@ -0,0 +1,705 @@
+---
+title: Resumable Streaming
+slug: /agents/resumable-streaming
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+# Resumable Streaming
+
+Resumable streaming lets a client reconnect to an in-flight stream (for example after a refresh) and continue receiving the same response. VoltAgent provides this via `@voltagent/resumable-streams`.
+
+## Install
+
+
+
+ ```bash
+ npm install @voltagent/resumable-streams
+ ```
+
+
+ ```bash
+ yarn add @voltagent/resumable-streams
+ ```
+
+
+ ```bash
+ pnpm add @voltagent/resumable-streams
+ ```
+
+
+
+## How it works
+
+Resumable streaming is split into two storages:
+
+1. **Stream store**: stores stream chunks and pub/sub messages.
+2. **Active stream store**: maps `userId + "-" + conversationId` -> `streamId`.
+
+When a stream starts, a new `streamId` is created, the stream store persists the output, and the active stream store is updated. When the client reconnects, the active stream store is used to find the `streamId`, and the stream store resumes from the last position. When the stream finishes, the active stream store entry is cleared.
+
+## Basic usage (Hono server)
+
+Create a Redis-backed store, build the adapter, and pass it to the Hono server.
+
+```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 },
+ }),
+});
+```
+
+By default, the adapter uses `streamStore` as the `activeStreamStore`.
+For production, use a shared stream store (Redis) so multiple instances can resume.
+
+### Enabling resumable streams
+
+Resumable streams are **opt-in**. You must set:
+
+- `options.resumableStream: true`
+- `options.conversationId`
+- `options.userId`
+
+```ts
+await fetch(`${baseUrl}/agents/${agentId}/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: `{"input":"Hello!","options":{"conversationId":"conv-1","userId":"user-1","resumableStream":true}}`,
+});
+```
+
+### Server-level default (optional)
+
+If you want resumable streaming enabled by default, set it at the server level and omit the per-request flag. You can still opt out by sending `options.resumableStream: false`.
+
+```ts
+new VoltAgent({
+ agents: { assistant: agent },
+ server: honoServer({
+ resumableStream: {
+ adapter: resumableStream,
+ defaultEnabled: true,
+ },
+ }),
+});
+```
+
+### Resume endpoint
+
+The Hono server exposes:
+
+- `GET /agents/:id/chat/:conversationId/stream?userId=...`
+
+If there is no active stream, it returns `204`. `userId` is required.
+
+## Store options
+
+### VoltOps managed store
+
+`createResumableStreamVoltOpsStore` stores streams in VoltOps. It uses:
+
+- the global `VoltOpsClient` if one is already configured by `VoltAgent`, or
+- your explicit `voltOpsClient`, or
+- `VOLTAGENT_PUBLIC_KEY` + `VOLTAGENT_SECRET_KEY` from env.
+
+```ts
+import {
+ createResumableStreamAdapter,
+ createResumableStreamVoltOpsStore,
+} from "@voltagent/resumable-streams";
+
+const streamStore = await createResumableStreamVoltOpsStore();
+const adapter = await createResumableStreamAdapter({ streamStore });
+```
+
+If you need a custom base URL, pass `baseUrl` or set `VOLTAGENT_API_BASE_URL`.
+
+Custom VoltOps client:
+
+```ts
+import { VoltOpsClient } from "@voltagent/core";
+import {
+ createResumableStreamAdapter,
+ createResumableStreamVoltOpsStore,
+} from "@voltagent/resumable-streams";
+
+const voltOpsClient = new VoltOpsClient({
+ publicKey: process.env.VOLTAGENT_PUBLIC_KEY,
+ secretKey: process.env.VOLTAGENT_SECRET_KEY,
+});
+
+const streamStore = await createResumableStreamVoltOpsStore({ voltOpsClient });
+const adapter = await createResumableStreamAdapter({ streamStore });
+```
+
+### Redis store (recommended)
+
+`createResumableStreamRedisStore` reads `REDIS_URL` or `KV_URL` by default.
+If you need a custom connection string, pass your own Redis clients as `publisher` and `subscriber`.
+
+```ts
+import { createClient } from "redis";
+import {
+ createResumableStreamAdapter,
+ createResumableStreamRedisStore,
+} from "@voltagent/resumable-streams";
+
+// Option A: use REDIS_URL or KV_URL from env
+const streamStore = await createResumableStreamRedisStore();
+const adapter = await createResumableStreamAdapter({ streamStore });
+
+// Option B: pass your own clients (custom connection string)
+const publisher = createClient({ url: "redis://localhost:6379" });
+const subscriber = createClient({ url: "redis://localhost:6379" });
+const customStreamStore = await createResumableStreamRedisStore({ publisher, subscriber });
+const adapter = await createResumableStreamAdapter({ streamStore: customStreamStore });
+```
+
+If you pass custom clients, you must connect them yourself.
+
+### Memory store (dev/test)
+
+`createResumableStreamMemoryStore` keeps everything in-process.
+Data is lost on restart and not shared across instances.
+
+```ts
+import {
+ createResumableStreamAdapter,
+ createResumableStreamMemoryStore,
+} from "@voltagent/resumable-streams";
+
+const streamStore = await createResumableStreamMemoryStore();
+const adapter = await createResumableStreamAdapter({ streamStore });
+```
+
+### Generic store (custom backend)
+
+`createResumableStreamGenericStore` accepts your own `publisher`/`subscriber`.
+Use this for non-Redis backends that still provide pub/sub + KV semantics.
+
+```ts
+import type {
+ ResumableStreamPublisher,
+ ResumableStreamSubscriber,
+} from "@voltagent/resumable-streams";
+import {
+ createResumableStreamAdapter,
+ createResumableStreamGenericStore,
+} from "@voltagent/resumable-streams";
+
+const publisher: ResumableStreamPublisher = {
+ async connect() {},
+ async publish(channel, message) {
+ return 1;
+ },
+ async set(key, value, options) {
+ return "OK";
+ },
+ async get(key) {
+ return null;
+ },
+ async incr(key) {
+ return 1;
+ },
+};
+
+const subscriber: ResumableStreamSubscriber = {
+ async connect() {},
+ async subscribe(channel, callback) {
+ return 1;
+ },
+ async unsubscribe(channel) {},
+};
+
+const streamStore = await createResumableStreamGenericStore({
+ publisher,
+ subscriber,
+ keyPrefix: "voltagent",
+ waitUntil: null,
+});
+
+const adapter = await createResumableStreamAdapter({ streamStore });
+```
+
+## Next.js + AI SDK (useChat)
+
+This section mirrors the `with-nextjs-resumable-stream` example. Full project:
+https://github.com/VoltAgent/voltagent/tree/main/examples/with-nextjs-resumable-stream
+
+### 1) Adapter helper
+
+Create the adapter once and reuse it across routes.
+
+```ts
+// lib/resumable-stream.ts
+import type { ResumableStreamAdapter } from "@voltagent/core";
+import {
+ createResumableStreamAdapter,
+ createResumableStreamRedisStore,
+} from "@voltagent/resumable-streams";
+import { after } from "next/server";
+
+let adapterPromise: Promise | undefined;
+
+export function getResumableStreamAdapter() {
+ if (!adapterPromise) {
+ adapterPromise = (async () => {
+ const streamStore = await createResumableStreamRedisStore({ waitUntil: after });
+ return createResumableStreamAdapter({ streamStore });
+ })();
+ }
+
+ return adapterPromise;
+}
+```
+
+### 2) POST route (create stream)
+
+```ts
+// app/api/chat/route.ts
+import { getResumableStreamAdapter } from "@/lib/resumable-stream";
+import { supervisorAgent } from "@/voltagent";
+import { createResumableChatSession } from "@voltagent/resumable-streams";
+import { setWaitUntil } from "@voltagent/core";
+import { after } from "next/server";
+
+export async function POST(req: Request) {
+ const body = await req.json();
+ const { message, messages, options } = body;
+ const conversationId = options.conversationId;
+ const userId = options.userId;
+ const input = message ?? messages;
+
+ setWaitUntil(after);
+
+ const adapter = await getResumableStreamAdapter();
+ const session = createResumableChatSession({
+ adapter,
+ conversationId,
+ userId,
+ agentId: supervisorAgent.id,
+ });
+
+ await session.clearActiveStream();
+
+ const result = await supervisorAgent.streamText(input, {
+ userId,
+ conversationId,
+ });
+
+ return result.toUIMessageStreamResponse({
+ consumeSseStream: session.consumeSseStream,
+ onFinish: session.onFinish,
+ });
+}
+```
+
+### 3) GET route (resume stream)
+
+```ts
+// app/api/chat/[id]/stream/route.ts
+import { getResumableStreamAdapter } from "@/lib/resumable-stream";
+import { supervisorAgent } from "@/voltagent";
+import { createResumableChatSession } from "@voltagent/resumable-streams";
+
+export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const userId = new URL(request.url).searchParams.get("userId") as string;
+
+ const adapter = await getResumableStreamAdapter();
+ const session = createResumableChatSession({
+ adapter,
+ conversationId: id,
+ userId,
+ agentId: supervisorAgent.id,
+ });
+
+ return session.resumeResponse();
+}
+```
+
+### 4) Client-side resume
+
+```tsx
+// components/chat-interface.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)}`,
+ }),
+ }),
+});
+```
+
+## Custom usage (no AI SDK)
+
+Use `createResumableChatSession` when you control the SSE pipeline.
+
+```ts
+import {
+ createResumableChatSession,
+ createResumableStreamAdapter,
+ createResumableStreamRedisStore,
+} from "@voltagent/resumable-streams";
+
+const streamStore = await createResumableStreamRedisStore();
+const adapter = await createResumableStreamAdapter({ streamStore });
+
+const sseHeaders = {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+};
+
+export async function POST(req: Request) {
+ const { options, agentId, text } = await req.json();
+ const conversationId = options.conversationId;
+ const userId = options.userId;
+
+ const session = createResumableChatSession({
+ adapter,
+ conversationId,
+ userId,
+ agentId,
+ resumeHeaders: sseHeaders,
+ });
+
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(`data: ${text}\n\n`);
+ controller.close();
+ },
+ });
+
+ const [persistStream, responseStream] = stream.tee();
+ await session.createStream(persistStream);
+
+ return new Response(responseStream.pipeThrough(new TextEncoderStream()), {
+ headers: sseHeaders,
+ });
+}
+```
+
+If you want JSON payloads in SSE, serialize them with your preferred helper.
+
+## Route helpers (optional)
+
+`createResumableChatHandlers` can generate `POST`/`GET` handlers.
+Defaults:
+
+- `conversationId`: `body.options.conversationId` (GET uses `params.id`)
+- `userId`: `body.options.userId` (GET uses `?userId=...`)
+
+## Custom stores and adapters
+
+You have two extension points:
+
+- Implement `ResumableStreamStore` and pass it to the adapter.
+- Implement `ResumableStreamAdapter` directly for full control.
+
+`ResumableStreamStore` handles stream chunk storage.
+If it also implements `ResumableStreamActiveStore`, it can be used as `activeStreamStore`.
+Otherwise, pass a separate `activeStreamStore`.
+
+## Important notes
+
+- Keying rule: `userId + "-" + conversationId`. `userId` is required.
+ If you do not have a user identity, generate a stable id (for example `crypto.randomUUID()`).
+- When resumable streaming is enabled, the abort signal is removed.
+ If you need abortable streams, do not use `resumableStream`.
+- Resumable streaming does not persist messages. For message persistence, enable memory.
+ See [Memory](../agents/memory.md).
+
+## VoltOps plan limits (managed store)
+
+When you use the VoltOps managed store, concurrent resumable streams are limited per project:
+
+- Free: 1 concurrent stream
+- Core: 100 concurrent streams
+- Pro: 1000 concurrent streams
+
+## API reference (@voltagent/resumable-streams)
+
+### createResumableStreamAdapter
+
+Builds the adapter used by servers or custom routes.
+
+Props:
+
+- `streamStore` (required): a `ResumableStreamStore` for stream chunks.
+- `activeStreamStore` (optional): overrides the active stream index.
+ If omitted, the adapter uses `streamStore` when it implements `ResumableStreamActiveStore`.
+
+```ts
+import {
+ createResumableStreamAdapter,
+ createResumableStreamRedisStore,
+} from "@voltagent/resumable-streams";
+
+const streamStore = await createResumableStreamRedisStore({ keyPrefix: "voltagent" });
+const adapter = await createResumableStreamAdapter({ streamStore });
+```
+
+### createResumableStreamRedisStore
+
+Creates a Redis-backed stream store.
+
+Props:
+
+- `keyPrefix` (optional): prefix for Redis keys.
+- `waitUntil` (optional): keep-alive hook for serverless environments.
+- `publisher` (optional): Redis publisher client.
+- `subscriber` (optional): Redis subscriber client.
+
+```ts
+import { createClient } from "redis";
+import { createResumableStreamRedisStore } from "@voltagent/resumable-streams";
+
+// Option A: use REDIS_URL or KV_URL from env
+const streamStore = await createResumableStreamRedisStore();
+
+// Option B: custom clients (custom connection string)
+const publisher = createClient({ url: "redis://localhost:6379" });
+const subscriber = createClient({ url: "redis://localhost:6379" });
+const customStreamStore = await createResumableStreamRedisStore({ publisher, subscriber });
+```
+
+If you pass custom clients, you must connect them yourself.
+
+### createResumableStreamVoltOpsStore
+
+Creates a managed store backed by VoltOps.
+
+Props:
+
+- `voltOpsClient` (optional)
+- `publicKey` / `secretKey` (optional, used if `voltOpsClient` is not provided)
+- `baseUrl` (optional)
+- `waitUntil` (optional)
+
+```ts
+import { createResumableStreamVoltOpsStore } from "@voltagent/resumable-streams";
+
+const streamStore = await createResumableStreamVoltOpsStore({
+ baseUrl: "https://api.voltagent.dev",
+});
+```
+
+### createResumableStreamMemoryStore
+
+Creates an in-process stream store (dev/test).
+
+Props:
+
+- `keyPrefix` (optional)
+- `waitUntil` (optional)
+
+```ts
+import { createResumableStreamMemoryStore } from "@voltagent/resumable-streams";
+
+const streamStore = await createResumableStreamMemoryStore({ keyPrefix: "local" });
+```
+
+### createResumableStreamGenericStore
+
+Creates a store from a custom pub/sub + KV backend.
+
+Props:
+
+- `publisher` (required)
+- `subscriber` (required)
+- `keyPrefix` (optional)
+- `waitUntil` (optional)
+
+```ts
+import type {
+ ResumableStreamPublisher,
+ ResumableStreamSubscriber,
+} from "@voltagent/resumable-streams";
+import { createResumableStreamGenericStore } from "@voltagent/resumable-streams";
+
+const publisher: ResumableStreamPublisher = {
+ async connect() {},
+ async publish(channel, message) {
+ return 1;
+ },
+ async set(key, value, options) {
+ return "OK";
+ },
+ async get(key) {
+ return null;
+ },
+ async incr(key) {
+ return 1;
+ },
+};
+
+const subscriber: ResumableStreamSubscriber = {
+ async connect() {},
+ async subscribe(channel, callback) {
+ return 1;
+ },
+ async unsubscribe(channel) {},
+};
+
+const streamStore = await createResumableStreamGenericStore({
+ publisher,
+ subscriber,
+ keyPrefix: "voltagent",
+ waitUntil: null,
+});
+```
+
+### createMemoryResumableStreamActiveStore
+
+Creates an in-memory active stream index. Use this to override `activeStreamStore`.
+
+```ts
+import {
+ createMemoryResumableStreamActiveStore,
+ createResumableStreamAdapter,
+ createResumableStreamRedisStore,
+} from "@voltagent/resumable-streams";
+
+const streamStore = await createResumableStreamRedisStore();
+const activeStreamStore = createMemoryResumableStreamActiveStore();
+const adapter = await createResumableStreamAdapter({ streamStore, activeStreamStore });
+```
+
+### createResumableChatSession
+
+Builds a session helper for custom route handlers.
+
+Props:
+
+- `adapter` (required)
+- `conversationId` (required)
+- `userId` (required)
+- `agentId` (optional)
+- `logger` (optional)
+- `resumeHeaders` (optional): headers for SSE resume responses
+
+```ts
+import { createResumableChatSession } from "@voltagent/resumable-streams";
+
+const session = createResumableChatSession({
+ adapter,
+ conversationId,
+ userId,
+ agentId,
+});
+
+const result = await agent.streamText(input, { conversationId, userId });
+return result.toUIMessageStreamResponse({
+ consumeSseStream: session.consumeSseStream,
+ onFinish: session.onFinish,
+});
+```
+
+```ts
+return session.resumeResponse();
+```
+
+### createResumableChatHandlers
+
+Generates `POST` and `GET` handlers.
+
+Props:
+
+- `agent` (required)
+- `adapter` (required)
+- `waitUntil` (optional)
+- `logger` (optional)
+- `agentId` (optional)
+- `sendReasoning` (optional)
+- `sendSources` (optional)
+- `resolveInput` (optional)
+- `resolveConversationId` (optional)
+- `resolveUserId` (optional)
+- `resolveOptions` (optional)
+
+```ts
+import { createResumableChatHandlers } from "@voltagent/resumable-streams";
+
+const { POST, GET } = createResumableChatHandlers({
+ agent,
+ adapter,
+ resolveConversationId: ({ body }) => body.options.conversationId,
+ resolveUserId: ({ body }) => body.options.userId,
+});
+
+export { POST, GET };
+```
+
+## Type contracts
+
+### ResumableStreamStore
+
+- `createNewResumableStream(streamId, makeStream, skipCharacters?)`
+- `resumeExistingStream(streamId, skipCharacters?)`
+
+### ResumableStreamActiveStore
+
+- `getActiveStreamId(context)`
+- `setActiveStreamId(context, streamId)`
+- `clearActiveStream(context)`
+
+### ResumableStreamAdapter
+
+- `createStream({ conversationId, userId, stream, agentId? })`
+- `resumeStream(streamId)`
+- `getActiveStreamId(context)`
+- `clearActiveStream(context)`
+
+### ResumableStreamPublisher / ResumableStreamSubscriber
+
+Publisher methods:
+
+- `connect()`
+- `publish(channel, message)`
+- `set(key, value, { EX })` (TTL in seconds)
+- `get(key)`
+- `incr(key)`
+
+Subscriber methods:
+
+- `connect()`
+- `subscribe(channel, callback)`
+- `unsubscribe(channel)`
diff --git a/website/sidebars.ts b/website/sidebars.ts
index 2a7b4fd59..d0c70a95e 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -88,6 +88,7 @@ const sidebars: SidebarsConfig = {
"agents/context",
"agents/dynamic-agents",
"agents/cancellation",
+ "agents/resumable-streaming",
],
},
{