From ed1f1d0778147c3d16c2139fffd6380ad8c3b719 Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Tue, 12 May 2026 14:40:46 +0100 Subject: [PATCH 1/3] feat: Initial --- examples/nextjs-ai-cms/.env.example | 2 + examples/nextjs-ai-cms/.gitignore | 4 + examples/nextjs-ai-cms/README.md | 21 + examples/nextjs-ai-cms/app/Providers.tsx | 31 + .../nextjs-ai-cms/app/[postId]/CmsEditor.tsx | 236 + examples/nextjs-ai-cms/app/[postId]/Room.tsx | 50 + examples/nextjs-ai-cms/app/[postId]/page.tsx | 17 + .../nextjs-ai-cms/app/actions/liveblocks.ts | 33 + .../nextjs-ai-cms/app/api/ai-edit/route.ts | 220 + .../app/api/liveblocks-auth/route.ts | 25 + examples/nextjs-ai-cms/app/api/users/route.ts | 32 + .../app/components/DefaultLayout.tsx | 44 + .../app/components/PostLinks.tsx | 63 + examples/nextjs-ai-cms/app/config.ts | 27 + examples/nextjs-ai-cms/app/database.ts | 42 + examples/nextjs-ai-cms/app/example.ts | 32 + examples/nextjs-ai-cms/app/globals.css | 13 + .../nextjs-ai-cms/app/hooks/usePostLinks.ts | 57 + examples/nextjs-ai-cms/app/layout.tsx | 36 + examples/nextjs-ai-cms/app/page.tsx | 18 + .../nextjs-ai-cms/app/utils/liveblocks.ts | 105 + examples/nextjs-ai-cms/liveblocks.config.ts | 49 + examples/nextjs-ai-cms/next-env.d.ts | 6 + examples/nextjs-ai-cms/next.config.js | 4 + examples/nextjs-ai-cms/package-lock.json | 4393 +++++++++++++++++ examples/nextjs-ai-cms/package.json | 35 + examples/nextjs-ai-cms/postcss.config.js | 6 + examples/nextjs-ai-cms/tailwind.config.js | 8 + examples/nextjs-ai-cms/tsconfig.json | 28 + 29 files changed, 5637 insertions(+) create mode 100644 examples/nextjs-ai-cms/.env.example create mode 100644 examples/nextjs-ai-cms/.gitignore create mode 100644 examples/nextjs-ai-cms/README.md create mode 100644 examples/nextjs-ai-cms/app/Providers.tsx create mode 100644 examples/nextjs-ai-cms/app/[postId]/CmsEditor.tsx create mode 100644 examples/nextjs-ai-cms/app/[postId]/Room.tsx create mode 100644 examples/nextjs-ai-cms/app/[postId]/page.tsx create mode 100644 examples/nextjs-ai-cms/app/actions/liveblocks.ts create mode 100644 examples/nextjs-ai-cms/app/api/ai-edit/route.ts create mode 100644 examples/nextjs-ai-cms/app/api/liveblocks-auth/route.ts create mode 100644 examples/nextjs-ai-cms/app/api/users/route.ts create mode 100644 examples/nextjs-ai-cms/app/components/DefaultLayout.tsx create mode 100644 examples/nextjs-ai-cms/app/components/PostLinks.tsx create mode 100644 examples/nextjs-ai-cms/app/config.ts create mode 100644 examples/nextjs-ai-cms/app/database.ts create mode 100644 examples/nextjs-ai-cms/app/example.ts create mode 100644 examples/nextjs-ai-cms/app/globals.css create mode 100644 examples/nextjs-ai-cms/app/hooks/usePostLinks.ts create mode 100644 examples/nextjs-ai-cms/app/layout.tsx create mode 100644 examples/nextjs-ai-cms/app/page.tsx create mode 100644 examples/nextjs-ai-cms/app/utils/liveblocks.ts create mode 100644 examples/nextjs-ai-cms/liveblocks.config.ts create mode 100644 examples/nextjs-ai-cms/next-env.d.ts create mode 100644 examples/nextjs-ai-cms/next.config.js create mode 100644 examples/nextjs-ai-cms/package-lock.json create mode 100644 examples/nextjs-ai-cms/package.json create mode 100644 examples/nextjs-ai-cms/postcss.config.js create mode 100644 examples/nextjs-ai-cms/tailwind.config.js create mode 100644 examples/nextjs-ai-cms/tsconfig.json diff --git a/examples/nextjs-ai-cms/.env.example b/examples/nextjs-ai-cms/.env.example new file mode 100644 index 0000000000..ab5a1e8730 --- /dev/null +++ b/examples/nextjs-ai-cms/.env.example @@ -0,0 +1,2 @@ +LIVEBLOCKS_SECRET_KEY=sk_xxx +OPENAI_API_KEY= diff --git a/examples/nextjs-ai-cms/.gitignore b/examples/nextjs-ai-cms/.gitignore new file mode 100644 index 0000000000..c3a91b99cf --- /dev/null +++ b/examples/nextjs-ai-cms/.gitignore @@ -0,0 +1,4 @@ +.next +node_modules +.env +.env.local diff --git a/examples/nextjs-ai-cms/README.md b/examples/nextjs-ai-cms/README.md new file mode 100644 index 0000000000..32a8084cbb --- /dev/null +++ b/examples/nextjs-ai-cms/README.md @@ -0,0 +1,21 @@ +# Next.js AI CMS + +Collaborative CMS fields (title, slug, excerpt, body, published date) stored in **Liveblocks Storage**, with: + +- A **sidebar** of rooms (posts) and **New post** (same pattern as the Notion-like example: rooms are created via `@liveblocks/node`, listed with `getRooms` + a `roomId` prefix query). +- A **local-only** prompt at the top; submitting it calls `POST /api/ai-edit`. +- The API uses the [Vercel AI SDK](https://sdk.vercel.ai/) `streamText` with `Output.object()` and a Zod schema so the model streams **structured partial objects**; on each partial, the server calls [`mutateStorage`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomId-storage-mutate) and [`createFeedMessage`](https://liveblocks.io/docs/collaboration-features/ai-collaboration) so clients see live storage + feed updates. +- **[`setPresence`](https://liveblocks.io/docs/collaboration-features/ai-collaboration#Presence)** marks which field the AI is touching (`editingField`), surfaced in the form UI and in **`AvatarStack`**. + +## Docs + +- [AI collaboration](https://liveblocks.io/docs/collaboration-features/ai-collaboration) +- [Feeds and agent workflows](https://liveblocks.io/blog/introducing-feeds-and-apis-for-agent-workflows) + +## Setup + +1. Copy `.env.example` to `.env.local`. +2. Add your **Liveblocks secret** (`sk_...`) and **OpenAI** API key (`OPENAI_API_KEY`). +3. `npm install` then `npm run dev`. + +`LIVEBLOCKS_SECRET_KEY` must be a valid `sk_` key so the app can build and run server routes. diff --git a/examples/nextjs-ai-cms/app/Providers.tsx b/examples/nextjs-ai-cms/app/Providers.tsx new file mode 100644 index 0000000000..81e473959d --- /dev/null +++ b/examples/nextjs-ai-cms/app/Providers.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react/suspense"; +import { ReactNode, Suspense } from "react"; +import { authWithRandomUser } from "./example"; +import { getRoomsInfoForProvider } from "./actions/liveblocks"; + +export function Providers({ children }: { children: ReactNode }) { + return ( + { + const searchParams = new URLSearchParams( + userIds.map((userId) => ["userIds", userId]) + ); + const response = await fetch(`/api/users?${searchParams}`); + + if (!response.ok) { + throw new Error("Problem resolving users"); + } + + return await response.json(); + }} + resolveRoomsInfo={async ({ roomIds }) => { + return await getRoomsInfoForProvider(roomIds); + }} + > + {children} + + ); +} diff --git a/examples/nextjs-ai-cms/app/[postId]/CmsEditor.tsx b/examples/nextjs-ai-cms/app/[postId]/CmsEditor.tsx new file mode 100644 index 0000000000..e3b8c478e9 --- /dev/null +++ b/examples/nextjs-ai-cms/app/[postId]/CmsEditor.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { + useFeedMessages, + useMutation, + useOthers, + useRoom, + useStorage, + shallow, + ClientSideSuspense, +} from "@liveblocks/react/suspense"; +import { AvatarStack } from "@liveblocks/react-ui"; +import { useCallback, useMemo, useState } from "react"; +import type { CmsPost } from "../../liveblocks.config"; +import { CMS_AI_FEED_ID, AI_CMS_USER_ID } from "../config"; + +const FIELD_LABEL: Record = { + title: "Title", + slug: "Slug", + excerpt: "Excerpt", + body: "Body", + publishedAt: "Published", +}; + +export function CmsEditor({ postId }: { postId: string }) { + return ( + Loading room…} + > + + + ); +} + +export function CmsEditorInner({ postId }: { postId: string }) { + void postId; + const room = useRoom(); + const roomId = room.id; + + const post = useStorage((root) => root.post); + const { messages } = useFeedMessages(CMS_AI_FEED_ID); + + const [prompt, setPrompt] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const updateField = useMutation( + ({ storage }, key: keyof CmsPost, value: string) => { + storage.get("post").set(key, value); + }, + [] + ); + + const setEditingField = useMutation( + ({ setMyPresence }, field: keyof CmsPost | null) => { + setMyPresence({ editingField: field }); + }, + [] + ); + + const runAi = useCallback(async () => { + const trimmed = prompt.trim(); + if (!trimmed || busy) return; + + setBusy(true); + setError(null); + try { + const res = await fetch("/api/ai-edit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId, prompt: trimmed }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error ?? res.statusText); + } + setPrompt(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Request failed"); + } finally { + setBusy(false); + } + }, [busy, prompt, roomId]); + + const lastFeedLine = useMemo(() => { + const last = messages[messages.length - 1]; + if (!last) return null; + const d = last.data; + if (d.kind === "start") return `Started: ${d.message ?? ""}`; + if (d.kind === "partial") return "Streaming field updates…"; + if (d.kind === "complete") return "AI edit complete."; + if (d.kind === "error") return d.message ?? "Error"; + return null; + }, [messages]); + + if (!post) { + return null; + } + + return ( +
+
+
+

+ {post.title || "Untitled post"} +

+

Room: {roomId}

+
+ +
+ +
+
+
+ Ask AI to edit all fields +
+

+ This prompt stays on your machine until you send it. The model + streams structured updates into Liveblocks Storage and the feed + below. +

+