From a76a6e4e7fce682735039897c285ecd4315dc13c Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Mon, 6 Jul 2026 11:32:30 +0100 Subject: [PATCH] Example: AI HTML slide generator (#3562) Co-authored-by: Cursor Agent Co-authored-by: Chris Nicholas --- examples/nextjs-ai-slideshow/.env.example | 5 + examples/nextjs-ai-slideshow/.gitignore | 12 + examples/nextjs-ai-slideshow/README.md | 97 + .../app/api/ai-reply/route.ts | 394 + .../app/api/apply-slide/route.ts | 134 + .../app/api/liveblocks-auth/route.ts | 38 + .../app/api/users/route.ts | 16 + examples/nextjs-ai-slideshow/app/chat.tsx | 743 ++ .../app/collaborative-editor.tsx | 103 + examples/nextjs-ai-slideshow/app/database.ts | 69 + examples/nextjs-ai-slideshow/app/globals.css | 156 + examples/nextjs-ai-slideshow/app/layout.tsx | 44 + examples/nextjs-ai-slideshow/app/page.tsx | 275 + .../app/proposal-actions.ts | 26 + .../nextjs-ai-slideshow/app/providers.tsx | 43 + .../nextjs-ai-slideshow/app/slide-html.ts | 73 + .../nextjs-ai-slideshow/app/slide-preview.tsx | 423 + .../nextjs-ai-slideshow/app/use-slide-html.ts | 31 + examples/nextjs-ai-slideshow/components.json | 21 + .../ai-elements/chain-of-thought.tsx | 231 + .../components/ai-elements/code-block.tsx | 178 + .../components/ai-elements/context.tsx | 408 + .../components/ai-elements/conversation.tsx | 100 + .../components/ai-elements/loader.tsx | 96 + .../components/ai-elements/message.tsx | 445 + .../components/ai-elements/prompt-input.tsx | 1413 +++ .../components/ai-elements/reasoning.tsx | 187 + .../components/ai-elements/shimmer.tsx | 64 + .../components/ai-elements/sources.tsx | 77 + .../components/ai-elements/suggestion.tsx | 53 + .../components/ai-elements/tool.tsx | 163 + .../components/help-button.tsx | 323 + .../components/ui/badge.tsx | 48 + .../components/ui/button-group.tsx | 83 + .../components/ui/button.tsx | 64 + .../components/ui/collapsible.tsx | 33 + .../components/ui/command.tsx | 184 + .../components/ui/dialog.tsx | 158 + .../components/ui/dropdown-menu.tsx | 257 + .../components/ui/hover-card.tsx | 44 + .../components/ui/input-group.tsx | 170 + .../components/ui/input.tsx | 21 + .../components/ui/progress.tsx | 31 + .../components/ui/scroll-area.tsx | 58 + .../components/ui/select.tsx | 190 + .../components/ui/separator.tsx | 28 + .../components/ui/tabs.tsx | 91 + .../components/ui/textarea.tsx | 18 + .../components/ui/tooltip.tsx | 57 + .../hooks/use-example-room-id.ts | 16 + examples/nextjs-ai-slideshow/lib/utils.ts | 6 + .../nextjs-ai-slideshow/liveblocks.config.ts | 71 + examples/nextjs-ai-slideshow/next.config.ts | 14 + .../nextjs-ai-slideshow/package-lock.json | 8035 +++++++++++++++++ examples/nextjs-ai-slideshow/package.json | 51 + .../nextjs-ai-slideshow/postcss.config.mjs | 7 + examples/nextjs-ai-slideshow/tsconfig.json | 34 + examples/nextjs-ai-slideshow/vercel.json | 4 + 58 files changed, 16214 insertions(+) create mode 100644 examples/nextjs-ai-slideshow/.env.example create mode 100644 examples/nextjs-ai-slideshow/.gitignore create mode 100644 examples/nextjs-ai-slideshow/README.md create mode 100644 examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts create mode 100644 examples/nextjs-ai-slideshow/app/api/apply-slide/route.ts create mode 100644 examples/nextjs-ai-slideshow/app/api/liveblocks-auth/route.ts create mode 100644 examples/nextjs-ai-slideshow/app/api/users/route.ts create mode 100644 examples/nextjs-ai-slideshow/app/chat.tsx create mode 100644 examples/nextjs-ai-slideshow/app/collaborative-editor.tsx create mode 100644 examples/nextjs-ai-slideshow/app/database.ts create mode 100644 examples/nextjs-ai-slideshow/app/globals.css create mode 100644 examples/nextjs-ai-slideshow/app/layout.tsx create mode 100644 examples/nextjs-ai-slideshow/app/page.tsx create mode 100644 examples/nextjs-ai-slideshow/app/proposal-actions.ts create mode 100644 examples/nextjs-ai-slideshow/app/providers.tsx create mode 100644 examples/nextjs-ai-slideshow/app/slide-html.ts create mode 100644 examples/nextjs-ai-slideshow/app/slide-preview.tsx create mode 100644 examples/nextjs-ai-slideshow/app/use-slide-html.ts create mode 100644 examples/nextjs-ai-slideshow/components.json create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/chain-of-thought.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/code-block.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/context.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/conversation.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/loader.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/message.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/prompt-input.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/reasoning.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/shimmer.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/sources.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/suggestion.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ai-elements/tool.tsx create mode 100644 examples/nextjs-ai-slideshow/components/help-button.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/badge.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/button-group.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/button.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/collapsible.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/command.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/dialog.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/dropdown-menu.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/hover-card.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/input-group.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/input.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/progress.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/scroll-area.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/select.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/separator.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/tabs.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/textarea.tsx create mode 100644 examples/nextjs-ai-slideshow/components/ui/tooltip.tsx create mode 100644 examples/nextjs-ai-slideshow/hooks/use-example-room-id.ts create mode 100644 examples/nextjs-ai-slideshow/lib/utils.ts create mode 100644 examples/nextjs-ai-slideshow/liveblocks.config.ts create mode 100644 examples/nextjs-ai-slideshow/next.config.ts create mode 100644 examples/nextjs-ai-slideshow/package-lock.json create mode 100644 examples/nextjs-ai-slideshow/package.json create mode 100644 examples/nextjs-ai-slideshow/postcss.config.mjs create mode 100644 examples/nextjs-ai-slideshow/tsconfig.json create mode 100644 examples/nextjs-ai-slideshow/vercel.json diff --git a/examples/nextjs-ai-slideshow/.env.example b/examples/nextjs-ai-slideshow/.env.example new file mode 100644 index 00000000000..dde87e67753 --- /dev/null +++ b/examples/nextjs-ai-slideshow/.env.example @@ -0,0 +1,5 @@ +# https://liveblocks.io/dashboard/apikeys +LIVEBLOCKS_SECRET_KEY= + +# https://vercel.com/docs/ai-gateway +AI_GATEWAY_API_KEY= diff --git a/examples/nextjs-ai-slideshow/.gitignore b/examples/nextjs-ai-slideshow/.gitignore new file mode 100644 index 00000000000..3a68e0cfc9d --- /dev/null +++ b/examples/nextjs-ai-slideshow/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules +.env +.env.* +!.env.example +*.tsbuildinfo +.vercel +.next +out +next-env.d.ts +# Turborepo +.turbo diff --git a/examples/nextjs-ai-slideshow/README.md b/examples/nextjs-ai-slideshow/README.md new file mode 100644 index 00000000000..b50d248f89f --- /dev/null +++ b/examples/nextjs-ai-slideshow/README.md @@ -0,0 +1,97 @@ +

+ + Liveblocks + + + Liveblocks + +

+ +# AI Slideshow Generator + +

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

+ +This example shows how to build a multiplayer AI slideshow builder with +[Liveblocks Feeds](https://liveblocks.io/docs/collaboration-features/ai-collaboration), +[Yjs](https://yjs.dev/), [CodeMirror](https://codemirror.net/), +[Liveblocks Comments](https://liveblocks.io/docs/products/comments), and +[Next.js](https://nextjs.org/). Chat with AI to generate slides with HTML, and +it’ll create previews of new slides that you can apply. Alternatively,edit the +HTML directly in the collaborative editor. You can also leave comments on +slides. + +## Getting started + +Run the following command to try this example locally: + +```bash +npx create-liveblocks-app@latest --example nextjs-ai-slideshow --api-key +``` + +This will download the example and ask permission to open your browser, enabling +you to automatically get your API key from your +[liveblocks.io](https://liveblocks.io) account. + +### Manual setup + +
Read more + +

+ +Alternatively, you can set up your project manually: + +- Install all dependencies with `npm install` +- Create an account on [liveblocks.io](https://liveblocks.io/dashboard) +- Copy your **secret** key from the + [dashboard](https://liveblocks.io/dashboard/apikeys) +- Create an `.env.local` file and add your **secret** key as the + `LIVEBLOCKS_SECRET_KEY` environment variable +- Add an `AI_GATEWAY_API_KEY` from the + [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). +- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) + +To see realtime sync, open the page in two browser tabs. Edits to the HTML, +comment pins, chat messages, and applied proposals sync across both tabs. + +
+ +### Deploy on Vercel + +
Read more + +

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

+ +After forking +[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/nextjs-ai-slideshow) +on CodeSandbox, create the `LIVEBLOCKS_SECRET_KEY` environment variable as a +[secret](https://codesandbox.io/docs/secrets). Add `AI_GATEWAY_API_KEY` if you +want real model responses instead of the mock slide designer. + +
diff --git a/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts b/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts new file mode 100644 index 00000000000..a289a8d1d2c --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts @@ -0,0 +1,394 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import { AI_USER_AVATAR, AI_USER_ID, AI_USER_NAME } from "@/app/database"; +import { STARTER_SLIDE_HTML } from "@/app/slide-html"; + +/** + * Generates an assistant reply and streams it into the room's feed using + * `@liveblocks/node`. Chat content excludes fenced HTML while streaming; the + * completed HTML document is attached as a proposal for clients to apply. + */ + +type ChatMessage = { role: "user" | "assistant"; content: string }; +type Source = { title: string; url: string }; +type ChainStep = { + label: string; + description?: string; + status?: "complete" | "active" | "pending"; + search?: string[]; +}; +type ToolCall = { + name: string; + input: Record; + output?: string; +}; + +type AssistantUpdate = { + content: string; + reasoning?: string; + sources?: Source[]; + suggestions?: string[]; + chainOfThought?: ChainStep[]; + tool?: ToolCall; + proposedHtml?: string; + proposalStatus?: "pending" | "applied" | "rejected"; + usedTokens?: number; + maxTokens?: number; + streaming: boolean; +}; + +const ROOM_ID_PREFIX = "liveblocks:examples:nextjs-ai-slideshow"; +const MAX_TOKENS = 128_000; + +const AUTHOR = { + userId: AI_USER_ID, + name: AI_USER_NAME, + avatar: AI_USER_AVATAR, +} as const; + +const SYSTEM_PROMPT = [ + "You are an expert slide designer inside a multiplayer slideshow builder.", + "Reply with a SHORT conversational message plus the COMPLETE slide HTML document in a single fenced ```html code block.", + "The HTML must be a full self-contained document with inline + + +
+
Live collaboration
+
+

Turn one prompt into a shared presentation asset.

+

AI proposes the structure, Yjs keeps the HTML editable, and comments keep decisions visible.

+
+
+
3xfaster iteration loops
+
100%multiplayer review
+
1source of truth
+
+
+ +`, + ` + + + + + + +
+
+
+
AI workflow
+

From chat to slide, without losing control.

+

Every proposal is reviewable HTML. Applying it performs a Yjs diff so collaborators keep context.

+
+
+
01Prompt the slide designer
+
02Apply the proposal into code
+
03Comment and export to PPTX
+
+
+
+ +`, + ` + + + + + + +
+
+
+

Creative reviews that happen in context.

+

Pin feedback directly on the generated slide, then keep iterating with the AI or in the shared code editor.

+
+
+
AI
+
Collaborative by default
+
+
+
+ +`, +]; diff --git a/examples/nextjs-ai-slideshow/app/api/apply-slide/route.ts b/examples/nextjs-ai-slideshow/app/api/apply-slide/route.ts new file mode 100644 index 00000000000..8e52ae3ced3 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/api/apply-slide/route.ts @@ -0,0 +1,134 @@ +import diff from "fast-diff"; +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import * as Y from "yjs"; + +type FeedMessageData = { + role: "user" | "assistant"; + content: string; + userId?: string; + name?: string; + avatar?: string; + model?: string; + reasoning?: string; + sources?: { title: string; url: string }[]; + suggestions?: string[]; + proposedHtml?: string; + proposalStatus?: "pending" | "applied" | "rejected"; + chainOfThought?: { + label: string; + description?: string; + status?: "complete" | "active" | "pending"; + search?: string[]; + }[]; + tool?: { + name: string; + input: Record; + output?: string; + }; + usedTokens?: number; + maxTokens?: number; + streaming?: boolean; +}; + +const ROOM_ID_PREFIX = "liveblocks:examples:nextjs-ai-slideshow"; + +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const body: unknown = await request.json(); + if (!isRecord(body)) { + return new NextResponse("Invalid request", { status: 400 }); + } + + const roomId = typeof body.roomId === "string" ? body.roomId : ""; + const feedId = typeof body.feedId === "string" ? body.feedId : ""; + const messageId = typeof body.messageId === "string" ? body.messageId : ""; + const action = + body.action === "apply" || body.action === "reject" + ? body.action + : undefined; + const html = typeof body.html === "string" ? body.html : undefined; + + if (!roomId.startsWith(ROOM_ID_PREFIX) || !feedId || !messageId || !action) { + return new NextResponse("Invalid room, feed, message, or action", { + status: 400, + }); + } + + if (action === "apply" && !html) { + return new NextResponse("Missing slide HTML", { status: 400 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + }); + + const messages = await liveblocks.getFeedMessages({ + roomId, + feedId, + }); + const message = messages.data.find((item) => item.id === messageId); + if (!message) { + return new NextResponse("Feed message not found", { status: 404 }); + } + + if (action === "apply" && html) { + await applyHtmlToYjsDocument(liveblocks, roomId, html); + } + + await liveblocks.updateFeedMessage({ + roomId, + feedId, + messageId, + data: { + ...message.data, + proposalStatus: action === "apply" ? "applied" : "rejected", + }, + }); + + return NextResponse.json({ ok: true }); +} + +async function applyHtmlToYjsDocument( + liveblocks: Liveblocks, + roomId: string, + nextHtml: string +) { + const binaryUpdate = await liveblocks.getYjsDocumentAsBinaryUpdate(roomId); + const ydoc = new Y.Doc(); + Y.applyUpdate(ydoc, new Uint8Array(binaryUpdate)); + + const ytext = ydoc.getText("codemirror"); + const currentHtml = ytext.toString(); + if (currentHtml === nextHtml) { + return; + } + + const stateVectorBefore = Y.encodeStateVector(ydoc); + const changes = diff(currentHtml, nextHtml); + + ydoc.transact(() => { + let index = 0; + + for (const [operation, text] of changes) { + if (operation === 0) { + index += text.length; + } else if (operation === -1) { + ytext.delete(index, text.length); + } else if (operation === 1) { + ytext.insert(index, text); + index += text.length; + } + } + }); + + const incrementalUpdate = Y.encodeStateAsUpdate(ydoc, stateVectorBefore); + await liveblocks.sendYjsBinaryUpdate(roomId, incrementalUpdate); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/examples/nextjs-ai-slideshow/app/api/liveblocks-auth/route.ts b/examples/nextjs-ai-slideshow/app/api/liveblocks-auth/route.ts new file mode 100644 index 00000000000..7ec0fe84d1a --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/api/liveblocks-auth/route.ts @@ -0,0 +1,38 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import { getRandomUser, getUser } from "@/app/database"; + +/** + * Authenticating your Liveblocks application + * https://liveblocks.io/docs/authentication + */ + +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + }); + + const { userId } = (await request.json().catch(() => ({}))) as { + userId?: string; + }; + + const user = userId ? getUser(userId) : getRandomUser(); + + if (!user) { + return new NextResponse("User not found", { status: 403 }); + } + + const session = liveblocks.prepareSession(`${user.id}`, { + userInfo: user.info, + }); + + // Use a naming pattern to allow access to rooms with a wildcard + session.allow(`liveblocks:examples:*`, ["*:write"]); + + const { status, body } = await session.authorize(); + return new NextResponse(body, { status }); +} diff --git a/examples/nextjs-ai-slideshow/app/api/users/route.ts b/examples/nextjs-ai-slideshow/app/api/users/route.ts new file mode 100644 index 00000000000..15bd38e741b --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/api/users/route.ts @@ -0,0 +1,16 @@ +import { getUser } from "@/app/database"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const userIds = searchParams.getAll("userIds"); + + if (!userIds || !Array.isArray(userIds)) { + return new NextResponse("Missing or invalid userIds", { status: 400 }); + } + + return NextResponse.json( + userIds.map((userId) => getUser(userId)?.info || null), + { status: 200 } + ); +} diff --git a/examples/nextjs-ai-slideshow/app/chat.tsx b/examples/nextjs-ai-slideshow/app/chat.tsx new file mode 100644 index 00000000000..8401391d9e5 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/chat.tsx @@ -0,0 +1,743 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { nanoid } from "nanoid"; +import { + ClientSideSuspense, + useCreateFeed, + useCreateFeedMessage, + useDeleteFeedMessage, + useFeedMessages, + useFeeds, + useOthers, + useSelf, + useUpdateMyPresence, +} from "@liveblocks/react/suspense"; +import { Avatar } from "@liveblocks/react-ui"; +import { + CopyIcon, + EyeIcon, + HistoryIcon, + PlusIcon, + RefreshCcwIcon, + SparklesIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { + Message, + MessageAction, + MessageActions, + MessageContent, + MessageResponse, +} from "@/components/ai-elements/message"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtHeader, + ChainOfThoughtSearchResult, + ChainOfThoughtSearchResults, + ChainOfThoughtStep, +} from "@/components/ai-elements/chain-of-thought"; +import { + Tool, + ToolContent, + ToolHeader, + ToolInput, + ToolOutput, +} from "@/components/ai-elements/tool"; +import { Context, ContextTrigger } from "@/components/ai-elements/context"; +import { + Source, + Sources, + SourcesContent, + SourcesTrigger, +} from "@/components/ai-elements/sources"; +import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; +import { Loader } from "@/components/ai-elements/loader"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSelect, + PromptInputSelectContent, + PromptInputSelectItem, + PromptInputSelectTrigger, + PromptInputSelectValue, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { HelpButton } from "@/components/help-button"; +import { resolveProposal, type SlideProposal } from "./proposal-actions"; +import { useSlideHtml } from "./use-slide-html"; + +// Each chat is a feed in the room. Everyone connected reads and writes to the +// selected feed, so messages (and the AI's replies) appear live for all users. + +// Cheap, reasoning-capable models, resolved through the Vercel AI Gateway in +// the server route. +const MODELS = [ + { id: "google/gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "openai/gpt-5.4-mini", name: "GPT-5.4 mini" }, + { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { id: "deepseek/deepseek-r1", name: "DeepSeek R1" }, +]; + +const STARTER_PROMPTS = [ + "Create a launch slide for a realtime design tool", + "Make this slide feel more premium", + "Turn the slide into a metrics update", + "Add a clear product story in three beats", +]; + +export function Chat({ + roomId, + previewedProposal, + onPreviewProposal, +}: { + roomId: string; + previewedProposal: SlideProposal | null; + onPreviewProposal: (proposal: SlideProposal | null) => void; +}) { + const { feeds } = useFeeds(); + + // Chat history: every feed in the room, newest first. + const chats = useMemo( + () => [...feeds].sort((a, b) => b.createdAt - a.createdAt), + [feeds] + ); + + // The currently selected chat (feed). Defaults to the most recent one, or a + // stable default id for the first chat. (A render-time `nanoid()` here would + // change on every Suspense retry and never settle.) + const [feedId, setFeedId] = useState(() => chats[0]?.feedId ?? "main"); + const [model, setModel] = useState(MODELS[0].id); + + const newChat = useCallback(() => setFeedId(nanoid()), []); + + return ( + +
+
+
+ + + + + + + + Chat history + + {chats.length === 0 ? ( + No chats yet + ) : ( + chats.map((chat) => ( + setFeedId(chat.feedId)} + className={ + chat.feedId === feedId ? "bg-accent" : undefined + } + > + + {chat.metadata?.title || "Untitled chat"} + + + )) + )} + + +
+ +
+ + {/* The chat is suspense-wrapped on its own so switching chats only + shows a loader in the conversation area, not the whole screen. */} + + +
+ } + > + + + +
+ ); +} + +function ChatWindow({ + roomId, + feedId, + model, + setModel, + previewedProposal, + onPreviewProposal, +}: { + roomId: string; + feedId: string; + model: string; + setModel: (model: string) => void; + previewedProposal: SlideProposal | null; + onPreviewProposal: (proposal: SlideProposal | null) => void; +}) { + const { messages } = useFeedMessages(feedId); + const createFeed = useCreateFeed(); + const createFeedMessage = useCreateFeedMessage(); + const deleteFeedMessage = useDeleteFeedMessage(); + const self = useSelf(); + const updateMyPresence = useUpdateMyPresence(); + const currentSlideHtml = useSlideHtml(); + + // The AI "thinking" status is shared via presence (scoped to this chat), so + // everyone viewing this chat sees it — not just whoever triggered the reply. + const selfPrompting = self.presence.promptingFeedId === feedId; + const othersPrompting = useOthers((others) => + others.some((other) => other.presence.promptingFeedId === feedId) + ); + const aiThinking = selfPrompting || othersPrompting; + + // Ensure a feed exists before its first message is added. + const ensuredFeeds = useRef(new Set(messages.length > 0 ? [feedId] : [])); + const ensureFeed = useCallback( + async (id: string, title: string) => { + if (ensuredFeeds.current.has(id)) { + return; + } + ensuredFeeds.current.add(id); + try { + await createFeed(id, { metadata: { title } }); + } catch { + // Feed already exists (likely created by another user), ignore. + } + }, + [createFeed] + ); + + // Synchronous guard against double-sends (e.g. fast clicks on a suggestion). + const inFlight = useRef(false); + + const sorted = [...messages].sort((a, b) => a.createdAt - b.createdAt); + + // Automatically open new pending proposals in the Slide tab, once per + // message. Guarded by a ref so users who dismissed the preview aren't + // pulled back into it on unrelated re-renders. + const autoPreviewedIds = useRef(new Set()); + useEffect(() => { + const latestProposal = [...sorted] + .reverse() + .find( + (message) => + message.data.role === "assistant" && + message.data.proposedHtml && + !message.data.streaming + ); + if ( + latestProposal?.data.proposedHtml && + latestProposal.data.proposalStatus === "pending" && + !autoPreviewedIds.current.has(latestProposal.id) + ) { + autoPreviewedIds.current.add(latestProposal.id); + onPreviewProposal({ + feedId, + messageId: latestProposal.id, + html: latestProposal.data.proposedHtml, + }); + } + }, [sorted, feedId, onPreviewProposal]); + + // Close the preview when the previewed proposal gets resolved (possibly by + // someone else in the room) or its message is deleted. + useEffect(() => { + if (!previewedProposal || previewedProposal.feedId !== feedId) { + return; + } + const message = sorted.find( + (item) => item.id === previewedProposal.messageId + ); + if (!message || message.data.proposalStatus !== "pending") { + onPreviewProposal(null); + } + }, [sorted, feedId, previewedProposal, onPreviewProposal]); + + const postReply = useCallback( + async (history: { role: "user" | "assistant"; content: string }[]) => { + await fetch("/api/ai-reply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId, + feedId, + model, + messages: history, + currentSlideHtml, + }), + }); + }, + [roomId, feedId, model, currentSlideHtml] + ); + + const send = useCallback( + async (text: string) => { + const content = text.trim(); + if (!content || inFlight.current) { + return; + } + + inFlight.current = true; + updateMyPresence({ promptingFeedId: feedId }); + try { + await ensureFeed(feedId, content.slice(0, 60)); + await createFeedMessage(feedId, { + role: "user", + content, + userId: self.id, + name: self.info.name, + avatar: self.info.avatar, + }); + + const history = [ + ...sorted.map((message) => ({ + role: message.data.role, + content: message.data.content, + })), + { role: "user" as const, content }, + ]; + await postReply(history); + } catch { + // Best-effort in this demo; errors are non-fatal to the UI. + } finally { + updateMyPresence({ promptingFeedId: null }); + inFlight.current = false; + } + }, + [ + feedId, + ensureFeed, + createFeedMessage, + self, + sorted, + postReply, + updateMyPresence, + ] + ); + + const regenerate = useCallback( + async (messageId: string) => { + const index = sorted.findIndex((message) => message.id === messageId); + if (index === -1 || inFlight.current) { + return; + } + + inFlight.current = true; + updateMyPresence({ promptingFeedId: feedId }); + try { + const history = sorted.slice(0, index).map((message) => ({ + role: message.data.role, + content: message.data.content, + })); + await deleteFeedMessage(feedId, messageId); + await postReply(history); + } catch { + // Best-effort in this demo; errors are non-fatal to the UI. + } finally { + updateMyPresence({ promptingFeedId: null }); + inFlight.current = false; + } + }, + [feedId, sorted, deleteFeedMessage, postReply, updateMyPresence] + ); + + // Total context-window usage across the conversation, shown in the composer. + const usedTokens = sorted.reduce( + (sum, message) => sum + (message.data.usedTokens ?? 0), + 0 + ); + + const lastMessage = sorted.at(-1); + const streamingInProgress = + lastMessage?.data.role === "assistant" && !!lastMessage.data.streaming; + const followUps = + !aiThinking && + lastMessage?.data.role === "assistant" && + !lastMessage.data.streaming + ? lastMessage.data.suggestions + : undefined; + + return ( + <> + + + {sorted.length === 0 ? ( + + +
+

Start the conversation

+

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

+
+
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+
+ ) : ( + sorted.map((message) => { + const { + role, + content, + reasoning, + sources, + name, + avatar, + streaming, + chainOfThought, + tool, + proposedHtml, + proposalStatus, + } = message.data; + const isAssistant = role === "assistant"; + + return ( + + +
+ + + {name ?? (isAssistant ? "Liveblocks AI" : "Someone")} + +
+ + {isAssistant && + chainOfThought && + chainOfThought.length > 0 ? ( + + + Chain of thought + + + {chainOfThought.map((step, index) => ( + + {step.search && step.search.length > 0 ? ( + + {step.search.map((term) => ( + + {term} + + ))} + + ) : null} + + ))} + + + ) : null} + + {isAssistant && reasoning ? ( + + + {reasoning} + + ) : null} + + {isAssistant && tool ? ( + + + + + + + + ) : null} + + {isAssistant && sources && sources.length > 0 ? ( + + + + {sources.map((source) => ( + + ))} + + + ) : null} + +
+ {content ? ( + {content} + ) : null} + + {isAssistant && streaming && !content && !reasoning ? ( + Thinking… + ) : null} +
+ + {isAssistant && proposedHtml ? ( + + ) : null} + + {isAssistant ? ( + + { + void navigator.clipboard + ?.writeText(content) + ?.catch(() => {}); + }} + disabled={!content} + > + + + regenerate(message.id)} + disabled={streaming} + > + + + + ) : null} +
+
+ ); + }) + )} +
+ +
+ +
+ {followUps && followUps.length > 0 ? ( + + {followUps.map((prompt) => ( + + ))} + + ) : null} + + send(message.text)} + > + + + + + + + + + + + {MODELS.map((m) => ( + + {m.name} + + ))} + + + +
+ {usedTokens > 0 ? ( + + + + ) : null} + +
+
+
+
+ + ); +} + +function ProposalCard({ + roomId, + feedId, + messageId, + html, + status = "pending", + generating, + previewing, + onPreview, +}: { + roomId: string; + feedId: string; + messageId: string; + html: string; + status?: "pending" | "applied" | "rejected"; + generating: boolean; + previewing: boolean; + onPreview: (proposal: SlideProposal) => void; +}) { + const [submitting, setSubmitting] = useState<"apply" | "reject" | null>(null); + + // While the HTML streams in, keep the code preview scrolled to the bottom + // so the newest output stays visible. + const preRef = useRef(null); + useEffect(() => { + if (generating && preRef.current) { + preRef.current.scrollTop = preRef.current.scrollHeight; + } + }, [generating, html]); + + const updateProposal = useCallback( + async (action: "apply" | "reject") => { + if (submitting) { + return; + } + + setSubmitting(action); + try { + await resolveProposal(roomId, { feedId, messageId, html }, action); + } finally { + setSubmitting(null); + } + }, + [feedId, html, messageId, roomId, submitting] + ); + + return ( +
+
+ Changes + {generating ? ( + Generating… + ) : status !== "pending" ? ( + + {status === "applied" ? "Applied" : "Rejected"} + + ) : null} +
+
+        {html}
+      
+ {!generating && status === "pending" ? ( +
+ + + +
+ ) : null} +
+ ); +} diff --git a/examples/nextjs-ai-slideshow/app/collaborative-editor.tsx b/examples/nextjs-ai-slideshow/app/collaborative-editor.tsx new file mode 100644 index 00000000000..09d7e3da00a --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/collaborative-editor.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { html } from "@codemirror/lang-html"; +import { EditorState } from "@codemirror/state"; +import { basicSetup, EditorView } from "codemirror"; +import { useRoom, useSelf } from "@liveblocks/react/suspense"; +import { getYjsProviderForRoom } from "@liveblocks/yjs"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { yCollab } from "y-codemirror.next"; +import * as Y from "yjs"; +import { STARTER_SLIDE_HTML } from "./slide-html"; + +const editorTheme = EditorView.theme({ + "&": { + height: "100%", + backgroundColor: "#ffffff", + color: "#111827", + fontSize: "13px", + }, + ".cm-scroller": { + fontFamily: + "var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + }, + ".cm-content": { + padding: "16px 0", + }, + ".cm-line": { + padding: "0 16px", + }, + ".cm-gutters": { + backgroundColor: "#fafafa", + borderRight: "1px solid rgba(15, 23, 42, 0.06)", + color: "#94a3b8", + }, +}); + +export function CollaborativeEditor() { + const room = useRoom(); + const userInfo = useSelf((me) => me.info); + const [element, setElement] = useState(null); + const seeded = useRef(false); + + const ref = useCallback((node: HTMLDivElement | null) => { + setElement(node); + }, []); + + useEffect(() => { + if (!element) { + return; + } + + const provider = getYjsProviderForRoom(room); + const ydoc = provider.getYDoc(); + const ytext = ydoc.getText("codemirror"); + const undoManager = new Y.UndoManager(ytext); + + provider.awareness.setLocalStateField("user", { + name: userInfo.name, + color: userInfo.color, + colorLight: `${userInfo.color}80`, + }); + + const seedAfterSync = (isSynced: boolean) => { + if (!isSynced || seeded.current) { + return; + } + + seeded.current = true; + if (ytext.length === 0) { + ytext.insert(0, STARTER_SLIDE_HTML); + } + }; + + provider.on("sync", seedAfterSync); + // The provider is cached per room, so it may already be synced by the + // time this effect runs (e.g. after a remount) and "sync" won't re-fire. + seedAfterSync(provider.synced); + + const state = EditorState.create({ + doc: ytext.toString(), + extensions: [ + basicSetup, + html(), + EditorView.lineWrapping, + editorTheme, + yCollab(ytext, provider.awareness, { undoManager }), + ], + }); + + const view = new EditorView({ + state, + parent: element, + }); + + return () => { + provider.off("sync", seedAfterSync); + undoManager.destroy(); + view.destroy(); + }; + }, [element, room, userInfo]); + + return
; +} diff --git a/examples/nextjs-ai-slideshow/app/database.ts b/examples/nextjs-ai-slideshow/app/database.ts new file mode 100644 index 00000000000..56870d7bd0d --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/database.ts @@ -0,0 +1,69 @@ +export const AI_USER_ID = "ai-assistant"; +export const AI_USER_NAME = "Liveblocks AI"; +export const AI_USER_AVATAR = + "https://liveblocks.io/api/avatar?u=ai-assistant&agent=true"; + +// A mock database with example users +const USER_INFO: Liveblocks["UserMeta"][] = [ + { + id: "charlie.layne@example.com", + info: { + name: "Charlie Layne", + color: "#D583F0", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "mislav.abha@example.com", + info: { + name: "Mislav Abha", + color: "#F08385", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, + { + id: "tatum.paolo@example.com", + info: { + name: "Tatum Paolo", + color: "#F0D885", + avatar: "https://liveblocks.io/avatars/avatar-3.png", + }, + }, + { + id: "anjali.wanda@example.com", + info: { + name: "Anjali Wanda", + color: "#85EED6", + avatar: "https://liveblocks.io/avatars/avatar-4.png", + }, + }, + { + id: "quinn.elton@example.com", + info: { + name: "Quinn Elton", + color: "#87EE85", + avatar: "https://liveblocks.io/avatars/avatar-8.png", + }, + }, + { + id: AI_USER_ID, + info: { + name: AI_USER_NAME, + color: "#000000", + avatar: AI_USER_AVATAR, + }, + }, +]; + +export function getRandomUser() { + const humans = USER_INFO.filter((user) => user.id !== AI_USER_ID); + return humans[Math.floor(Math.random() * humans.length)]; +} + +export function getUser(id: string) { + return USER_INFO.find((u) => u.id === id) || undefined; +} + +export function getUsers() { + return USER_INFO; +} diff --git a/examples/nextjs-ai-slideshow/app/globals.css b/examples/nextjs-ai-slideshow/app/globals.css new file mode 100644 index 00000000000..b964294726c --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/globals.css @@ -0,0 +1,156 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "@liveblocks/react-ui/styles.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@theme inline { + --font-sans: var(--font-inter); + --font-mono: var(--font-jetbrains-mono); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +.cm-editor { + height: 100%; +} + +.cm-editor .cm-ySelectionInfo { + position: absolute; + top: -1.6em; + left: -1px; + padding: 2px 6px; + opacity: 1; + color: #fff; + border: 0; + border-radius: 6px; + border-bottom-left-radius: 0; + line-height: normal; + white-space: nowrap; + font-size: 12px; + font-family: var(--font-sans); + font-style: normal; + font-weight: 600; + pointer-events: none; + user-select: none; + z-index: 1000; +} + +.cm-editor .cm-ySelectionCaretDot { + display: none; +} + +.ͼ1 .cm-yLineSelection { + margin-left: 0; +} diff --git a/examples/nextjs-ai-slideshow/app/layout.tsx b/examples/nextjs-ai-slideshow/app/layout.tsx new file mode 100644 index 00000000000..4b849228c02 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/layout.tsx @@ -0,0 +1,44 @@ +import "./globals.css"; +import type { Metadata } from "next"; +import { Inter, JetBrains_Mono } from "next/font/google"; +import { ReactNode, Suspense } from "react"; +import { Providers } from "./providers"; + +export const metadata: Metadata = { + title: "AI Slideshow - Liveblocks", +}; + +const sans = Inter({ subsets: ["latin"], variable: "--font-inter" }); +const mono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains-mono", + weight: ["400", "500", "700"], +}); + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + + + + + + {children} + + + + ); +} diff --git a/examples/nextjs-ai-slideshow/app/page.tsx b/examples/nextjs-ai-slideshow/app/page.tsx new file mode 100644 index 00000000000..92c9d63d3e4 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/page.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { ClientSideSuspense, RoomProvider } from "@liveblocks/react/suspense"; +import { AvatarStack } from "@liveblocks/react-ui"; +import { + DownloadIcon, + EyeIcon, + Loader2Icon, + MessageSquarePlusIcon, +} from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { Loader } from "@/components/ai-elements/loader"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useExampleRoomId } from "@/hooks/use-example-room-id"; +import { Chat } from "./chat"; +import { CollaborativeEditor } from "./collaborative-editor"; +import { resolveProposal, type SlideProposal } from "./proposal-actions"; +import { SLIDE_HEIGHT, SLIDE_WIDTH } from "./slide-html"; +import { SlidePreview } from "./slide-preview"; + +type Panel = "slide" | "code"; + +function waitForPaint() { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +export default function Page() { + const roomId = useExampleRoomId(); + + return ( + + + +
+ } + > + + + + ); +} + +function SlideshowApp({ roomId }: { roomId: string }) { + const [panel, setPanel] = useState("slide"); + const [placingComment, setPlacingComment] = useState(false); + const [exporting, setExporting] = useState(false); + const iframeRef = useRef(null); + + // The proposal currently shown in the Slide tab instead of the shared + // document. Local to this user; accept/reject resolves it for everyone. + const [previewedProposal, setPreviewedProposal] = + useState(null); + const [resolvingProposal, setResolvingProposal] = useState< + "apply" | "reject" | null + >(null); + + const previewProposal = useCallback((proposal: SlideProposal | null) => { + setPreviewedProposal(proposal); + if (proposal) { + setPlacingComment(false); + setPanel("slide"); + } + }, []); + + const resolvePreviewedProposal = useCallback( + async (action: "apply" | "reject") => { + if (!previewedProposal || resolvingProposal) { + return; + } + setResolvingProposal(action); + try { + await resolveProposal(roomId, previewedProposal, action); + setPreviewedProposal(null); + } finally { + setResolvingProposal(null); + } + }, + [previewedProposal, resolvingProposal, roomId] + ); + + const exportPptx = useCallback(async () => { + if (exporting) { + return; + } + + setExporting(true); + try { + if (panel !== "slide") { + setPanel("slide"); + await waitForPaint(); + } + + const document = iframeRef.current?.contentDocument; + const element = document?.body ?? document?.documentElement; + if (!element) { + throw new Error("Slide preview is not ready yet."); + } + + const [{ toPng }, { default: PptxGenJS }] = await Promise.all([ + import("html-to-image"), + import("pptxgenjs"), + ]); + + const dataUrl = await toPng(element, { + width: SLIDE_WIDTH, + height: SLIDE_HEIGHT, + pixelRatio: 10, + cacheBust: true, + style: { + width: `${SLIDE_WIDTH}px`, + height: `${SLIDE_HEIGHT}px`, + margin: "0", + }, + }); + + const pptx = new PptxGenJS(); + pptx.defineLayout({ name: "LIVEBLOCKS_16_9", width: 10, height: 5.625 }); + pptx.layout = "LIVEBLOCKS_16_9"; + const slide = pptx.addSlide(); + slide.addImage({ data: dataUrl, x: 0, y: 0, w: 10, h: 5.625 }); + await pptx.writeFile({ fileName: "slide.pptx" }); + } finally { + setExporting(false); + } + }, [exporting, panel]); + + return ( +
+
+
+ { + if (value === "code") { + setPlacingComment(false); + setPanel("code"); + } else { + setPanel("slide"); + } + }} + > + + Preview + Code + + + +
+ + {panel === "slide" ? ( + + ) : null} + +
+
+ +
+
+ setPlacingComment(false)} + proposal={previewedProposal} + resolvingProposal={resolvingProposal} + onResolveProposal={resolvePreviewedProposal} + /> +
+
+ {previewedProposal ? ( + + ) : ( + + )} +
+
+
+ + +
+ ); +} + +function ProposalCodePreview({ + proposal, + resolvingProposal, + onResolveProposal, +}: { + proposal: SlideProposal; + resolvingProposal: "apply" | "reject" | null; + onResolveProposal: (action: "apply" | "reject") => void; +}) { + return ( +
+
+ + + Previewing proposed code + +
+ + +
+
+ +
+        {proposal.html}
+      
+
+ ); +} diff --git a/examples/nextjs-ai-slideshow/app/proposal-actions.ts b/examples/nextjs-ai-slideshow/app/proposal-actions.ts new file mode 100644 index 00000000000..9181a641ca2 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/proposal-actions.ts @@ -0,0 +1,26 @@ +// A slide proposal made by the AI, attached to a feed message. Selecting one +// for preview is local to each user; accepting/rejecting is shared with the +// whole room through the feed message's `proposalStatus`. +export type SlideProposal = { + feedId: string; + messageId: string; + html: string; +}; + +export async function resolveProposal( + roomId: string, + proposal: SlideProposal, + action: "apply" | "reject" +) { + await fetch("/api/apply-slide", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + roomId, + feedId: proposal.feedId, + messageId: proposal.messageId, + html: action === "apply" ? proposal.html : undefined, + }), + }); +} diff --git a/examples/nextjs-ai-slideshow/app/providers.tsx b/examples/nextjs-ai-slideshow/app/providers.tsx new file mode 100644 index 00000000000..e24ae496347 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/providers.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react/suspense"; +import { PropsWithChildren } from "react"; +import { getRandomUser } from "./database"; + +const userId = getRandomUser().id; + +function authWithRandomUser(endpoint: string) { + return async (room?: string) => { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ room, userId }), + }); + + return await response.json(); + }; +} + +export function Providers({ children }: PropsWithChildren) { + return ( + { + const search = new URLSearchParams( + userIds.map((userId) => ["userIds", userId]) + ); + const response = await fetch(`/api/users?${search}`); + if (!response.ok) { + throw new Error("Problem resolving users"); + } + return await response.json(); + }} + > + {children} + + ); +} diff --git a/examples/nextjs-ai-slideshow/app/slide-html.ts b/examples/nextjs-ai-slideshow/app/slide-html.ts new file mode 100644 index 00000000000..4f028ea40fe --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/slide-html.ts @@ -0,0 +1,73 @@ +export const SLIDE_WIDTH = 1280; +export const SLIDE_HEIGHT = 720; + +export const STARTER_SLIDE_HTML = ` + + + + + + + +
+
+

Liveblocks AI Slideshow

+

Design together, present faster.

+

Prompt the AI, apply its slide HTML into a shared Yjs document, and leave multiplayer comments right on the preview.

+
+
+ +`; diff --git a/examples/nextjs-ai-slideshow/app/slide-preview.tsx b/examples/nextjs-ai-slideshow/app/slide-preview.tsx new file mode 100644 index 00000000000..d32b66f7aea --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/slide-preview.tsx @@ -0,0 +1,423 @@ +"use client"; + +import type { ThreadData } from "@liveblocks/client"; +import { + DndContext, + type DragEndEvent, + PointerSensor, + TouchSensor, + useDraggable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + useEditThreadMetadata, + useSelf, + useThreads, +} from "@liveblocks/react/suspense"; +import { + CommentPin, + FloatingComposer, + FloatingThread, +} from "@liveblocks/react-ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { MouseEvent, ReactNode, RefObject } from "react"; +import { EyeIcon, Loader2Icon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { SlideProposal } from "./proposal-actions"; +import { SLIDE_HEIGHT, SLIDE_WIDTH } from "./slide-html"; +import { useSlideHtml } from "./use-slide-html"; + +type Coords = { x: number; y: number }; + +type Size = { width: number; height: number }; + +const PREVIEW_INSET = 16; + +function clampPercentage(value: number) { + return Math.min(100, Math.max(0, value)); +} + +function useElementSize() { + const [node, setNode] = useState(null); + const [size, setSize] = useState({ + width: SLIDE_WIDTH, + height: SLIDE_HEIGHT, + }); + + useEffect(() => { + if (!node) { + return; + } + + const update = () => { + const rect = node.getBoundingClientRect(); + setSize({ width: rect.width, height: rect.height }); + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }, [node]); + + return { ref: setNode, size }; +} + +function useMaxZIndex(threads: readonly ThreadData[]) { + return useMemo(() => { + let max = 0; + for (const thread of threads) { + if (thread.metadata.zIndex > max) { + max = thread.metadata.zIndex; + } + } + return max; + }, [threads]); +} + +export function SlidePreview({ + iframeRef, + placingComment, + onPlacingDone, + proposal, + resolvingProposal, + onResolveProposal, +}: { + iframeRef: RefObject; + placingComment: boolean; + onPlacingDone: () => void; + proposal: SlideProposal | null; + resolvingProposal: "apply" | "reject" | null; + onResolveProposal: (action: "apply" | "reject") => void; +}) { + const documentHtml = useSlideHtml(); + // While previewing a proposal, the slide shows the proposed HTML instead of + // the shared document, and comment pins are hidden (they belong to the + // shared slide, not to an unapplied proposal). + const html = proposal ? proposal.html : documentHtml; + const { threads } = useThreads(); + const editThreadMetadata = useEditThreadMetadata(); + const maxZIndex = useMaxZIndex(threads); + const { ref: wrapperRef, size: wrapperSize } = useElementSize(); + const [placedCoords, setPlacedCoords] = useState(null); + + // Leave room around the slide so the shadow and proposal ring are visible + // even when the slide would otherwise fit exactly edge-to-edge. + const availableWidth = Math.max(1, wrapperSize.width - PREVIEW_INSET * 2); + const availableHeight = Math.max(1, wrapperSize.height - PREVIEW_INSET * 2); + const scale = Math.min( + availableWidth / SLIDE_WIDTH, + availableHeight / SLIDE_HEIGHT + ); + const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 3 }, + }), + useSensor(TouchSensor, { + activationConstraint: { distance: 3 }, + }) + ); + + const resetPlacement = useCallback(() => { + setPlacedCoords(null); + onPlacingDone(); + }, [onPlacingDone]); + + const handleDragEnd = useCallback( + ({ active, delta }: DragEndEvent) => { + const thread = threads.find((item) => item.id === String(active.id)); + if (!thread) { + return; + } + + const nextX = clampPercentage( + thread.metadata.x + (delta.x / (SLIDE_WIDTH * safeScale)) * 100 + ); + const nextY = clampPercentage( + thread.metadata.y + (delta.y / (SLIDE_HEIGHT * safeScale)) * 100 + ); + + editThreadMetadata({ + threadId: thread.id, + metadata: { + x: nextX, + y: nextY, + zIndex: maxZIndex + 1, + }, + }); + }, + [editThreadMetadata, maxZIndex, safeScale, threads] + ); + + return ( +
+ {proposal ? ( +
+ + + Previewing proposed slide + +
+ + +
+
+ ) : null} +
+