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/README.md b/examples/nextjs-ai-slideshow/README.md index b50d248f89..0215ea85b6 100644 --- a/examples/nextjs-ai-slideshow/README.md +++ b/examples/nextjs-ai-slideshow/README.md @@ -24,10 +24,10 @@ 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. +[Next.js](https://nextjs.org/). Chat with AI to generate and edit slides with +HTML. You can drag-and-drop elements, edit the code, and leave comments for +other users. As other users edit slides, you can see their presence in the +preview and code editor. ## Getting started diff --git a/examples/nextjs-ai-slideshow/app/api/ai-reply/html-proposals.ts b/examples/nextjs-ai-slideshow/app/api/ai-reply/html-proposals.ts new file mode 100644 index 0000000000..69a7ef0c74 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/api/ai-reply/html-proposals.ts @@ -0,0 +1,94 @@ +export type HtmlProposal = { slideId: string; html: string }; + +const HTML_FENCE_OPEN_PATTERN = + /```html(?:[ \t]+(?:(?:id=([^\s`]+))|(new)))?[ \t]*(?:\r?\n|$)/gi; + +export function extractHtmlProposal(text: string, currentSlideId: string) { + return { + content: stripHtmlFencesForChat(text), + proposals: collectHtmlProposals(text, currentSlideId, false), + }; +} + +export function extractStreamingHtml( + text: string, + currentSlideId: string +): HtmlProposal[] | undefined { + const proposals = collectHtmlProposals(text, currentSlideId, true); + return proposals.length > 0 ? proposals : undefined; +} + +export function stripHtmlFencesForChat(text: string) { + const pattern = new RegExp(HTML_FENCE_OPEN_PATTERN); + let result = ""; + let lastIndex = 0; + let match = pattern.exec(text); + + while (match) { + result += text.slice(lastIndex, match.index); + + const closeIndex = text.indexOf("```", pattern.lastIndex); + if (closeIndex === -1) { + return result.trim(); + } + + lastIndex = closeIndex + "```".length; + pattern.lastIndex = lastIndex; + match = pattern.exec(text); + } + + return (result + text.slice(lastIndex)).trim(); +} + +function collectHtmlProposals( + text: string, + currentSlideId: string, + includeOpenFence: boolean +) { + const proposals: HtmlProposal[] = []; + const pattern = new RegExp(HTML_FENCE_OPEN_PATTERN); + let searchIndex = 0; + + while (searchIndex < text.length) { + pattern.lastIndex = searchIndex; + const match = pattern.exec(text); + if (!match) { + break; + } + + const bodyStart = pattern.lastIndex; + const closeIndex = text.indexOf("```", bodyStart); + const slideId = match[2] === "new" ? "new" : (match[1] ?? currentSlideId); + + if (closeIndex === -1) { + if (includeOpenFence) { + addProposal(proposals, { + slideId, + html: text.slice(bodyStart).trim(), + }); + } + break; + } + + addProposal(proposals, { + slideId, + html: text.slice(bodyStart, closeIndex).trim(), + }); + searchIndex = closeIndex + "```".length; + } + + return proposals.filter((proposal) => proposal.html.length > 0); +} + +function addProposal(proposals: HtmlProposal[], proposal: HtmlProposal) { + if (proposal.slideId !== "new") { + const existingIndex = proposals.findIndex( + (item) => item.slideId === proposal.slideId + ); + if (existingIndex !== -1) { + proposals.splice(existingIndex, 1); + } + } + + proposals.push(proposal); +} diff --git a/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts b/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts index a289a8d1d2..24f7efd0c3 100644 --- a/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts +++ b/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts @@ -1,7 +1,14 @@ 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 { INITIAL_SLIDE_ID } from "@/app/slide-doc"; import { STARTER_SLIDE_HTML } from "@/app/slide-html"; +import { + extractHtmlProposal, + extractStreamingHtml, + stripHtmlFencesForChat, + type HtmlProposal, +} from "./html-proposals"; /** * Generates an assistant reply and streams it into the room's feed using @@ -10,6 +17,7 @@ import { STARTER_SLIDE_HTML } from "@/app/slide-html"; */ type ChatMessage = { role: "user" | "assistant"; content: string }; +type SlideContext = { id: string; html: string }; type Source = { title: string; url: string }; type ChainStep = { label: string; @@ -30,7 +38,7 @@ type AssistantUpdate = { suggestions?: string[]; chainOfThought?: ChainStep[]; tool?: ToolCall; - proposedHtml?: string; + proposals?: HtmlProposal[]; proposalStatus?: "pending" | "applied" | "rejected"; usedTokens?: number; maxTokens?: number; @@ -48,12 +56,17 @@ const AUTHOR = { 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.", + "Reply with a SHORT conversational message.", "The HTML must be a full self-contained document with inline
Liveblocks AI Slideshow
-Prompt the AI, apply its slide HTML into a shared Yjs document, and leave multiplayer comments right on the preview.
-