diff --git a/docs/pages/get-started/nextjs-comments-ai.mdx b/docs/pages/get-started/nextjs-comments-ai.mdx new file mode 100644 index 00000000000..9de0e66eb69 --- /dev/null +++ b/docs/pages/get-started/nextjs-comments-ai.mdx @@ -0,0 +1,844 @@ +--- +meta: + title: "Get started with AI replies in Comments using Liveblocks and Next.js" + parentTitle: "Quickstart" + description: + "Learn how to add an AI agent that replies in Liveblocks Comments threads + using Next.js" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to add an AI agent that +replies in [Comments](/docs/ready-made-features/comments) when mentioned in a +thread, using [`@liveblocks/node`](/docs/api-reference/liveblocks-node), the +[Vercel AI SDK](https://ai-sdk.dev), and [Anthropic](https://anthropic.com), in +your Next.js `/app` directory application. + +## Quickstart + + + + + + Have a Comments app ready + + + To add AI replies to comment thread, you first need to have a Liveblocks + Comments app set up with secret key authentication and resolved users. + Open up your app, or set up comments if you haven’t already. + + + + + + + + Install dependencies + + + Install [`@liveblocks/node`](/docs/api-reference/liveblocks-node) to verify + webhooks and write comments, along with the [Vercel AI SDK](https://ai-sdk.dev) + and the [Anthropic provider](https://ai-sdk.dev/providers/ai-sdk-providers/anthropic) + to generate AI responses. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/node ai @ai-sdk/anthropic + ``` + + + + + + Add your environment variables + + + Create a new `.env.local` file and add your Liveblocks secret key from + the [dashboard](/dashboard/apikeys), your Anthropic API key from the + [Anthropic dashboard](https://platform.claude.com/settings/keys), and a + placeholder for your Liveblocks webhook secret. You’ll create the + webhook secret in the final step. + + ```env file=".env.local" + LIVEBLOCKS_SECRET_KEY="{{SECRET_KEY}}" + LIVEBLOCKS_WEBHOOK_SECRET_KEY="whsec_..." + ANTHROPIC_API_KEY="sk-ant-..." + ``` + + + + + + Add an AI user to your database + + + The AI agent posts replies like a regular user, so it needs a user ID + and info. Add a dedicated user for your agent next to your real users. + Where you resolve user info in + [`resolveUsers`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) and + [`resolveMentionSuggestions`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveMentionSuggestions), + you should return this AI user so it can be `@`-mentioned and rendered + in threads. + + ```tsx title="Find where you fetch data in your resolver functions…" + { + // +++ + const users = await fetchUsers(userIds); + // +++ + // ... + }} + resolveMentionSuggestions={async ({ text }) => { + // +++ + const users = await fetchAllUsers(); + // +++ + // ... + }} + > + {children} + + ``` + + ```ts title="…and modify the return values to include an AI user" + // +++ + export const AI_USER_INFO = { + id: "__AI_AGENT", + info: { + name: "AI Assistant", + avatar: "https://liveblocks.io/api/avatar?u=__AI_AGENT&agent=true", + }, + }; + // +++ + + async function fetchUsers(userIds: string[]) { + const users = []; + + for (const userId of userIds) { + // +++ + if (userId === AI_USER_INFO.id) { + users.push(AI_USER_INFO); + continue; + } + // +++ + + const user = await __getUserFromDb__(userId); + users.push(user ? { id: userId, info: user.info } : undefined); + } + + return users; + } + + async function fetchAllUsers() { + const dbUsers = await __getAllUsersFromDb__(); + // +++ + return [AI_USER_INFO, ...dbUsers]; + // +++ + } + ``` + + + + + Create the webhook endpoint + + + Create an API route to receive Liveblocks webhooks. We’ll verify the + request, only respond to + [`commentCreated`](/docs/platform/webhooks#CommentCreatedEvent) events, + and ignore comments that weren’t written by a human, otherwise the AI + would reply to its own messages. + + ```ts file="app/api/liveblocks-webhook/route.ts" + import { WebhookHandler } from "@liveblocks/node"; + import { NextResponse } from "next/server"; + import { AI_USER_INFO } from "@/app/database"; + import { handleAiCommentReply } from "@/app/ai-comment-reply"; + + const webhookHandler = new WebhookHandler( + process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY! + ); + + export async function POST(request: Request) { + const body = await request.json(); + + let event; + try { + event = webhookHandler.verifyRequest({ + headers: request.headers, + rawBody: JSON.stringify(body), + }); + } catch (err) { + return new Response("Could not verify webhook call", { status: 400 }); + } + + if (event.type === "commentCreated") { + // Ignore comments posted by the AI itself + if (event.data.createdBy === AI_USER_INFO.id) { + return NextResponse.json({ message: "Ignored AI comment" }); + } + + // Run the AI reply in the background so the webhook responds quickly + handleAiCommentReply(event.data).catch(console.error); + } + + return NextResponse.json({ message: "Received" }); + } + ``` + + + + + + Create an AI reply + + + Next, create the AI response. There are a few steps to follow for a full user experience: + + 1. Get the thread with [`getThread`](/docs/api-reference/liveblocks-node#get-rooms-roomId-threads-threadId) + 2. Check if the AI user was `@`-mentioned with [`getMentionsFromCommentBody`](/docs/api-reference/liveblocks-node#get-mentions-from-comment-body). + 3. Add a “👀” reaction to the comment that mentioned the user with [`addCommentReaction`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments-commentId-add-reaction). + 4. Show AI presence in avatar stacks and [`useOthers`](/docs/api-reference/liveblocks-react#useOthers) with [`setPresence`](/docs/api-reference/liveblocks-node#post-rooms-roomId-presence). + 5. Create a placeholder comment, “Thinking…”, with [`createComment`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments). + 6. Convert the thread into chat messages and generate a response from Claude. + 7. Update the placeholder comment with the AI response using [`editComment`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments-commentId). + +```ts file="app/ai-comment-reply.ts" +import { Liveblocks, getMentionsFromCommentBody } from "@liveblocks/node"; +import { stringifyCommentBody } from "@liveblocks/client"; +import { generateText, type ModelMessage } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { markdownToCommentBody } from "@liveblocks/node"; +import { AI_USER_INFO } from "@/app/database"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +export async function handleAiCommentReply(data: { + roomId: string; + threadId: string; + commentId: string; +}) { + const { roomId, threadId, commentId } = data; + + // Get the thread and the comment that triggered the webhook + const thread = await liveblocks.getThread({ roomId, threadId }); + const comment = thread.comments.find((c) => c.id === commentId); + if (!comment?.body) return; + + // Only reply if the AI user was @-mentioned + const mentions = getMentionsFromCommentBody(comment.body); + if (!mentions.some((m) => m.id === AI_USER_INFO.id)) return; + + // Add a “👀” reaction to the comment that mentioned the user + liveblocks.addCommentReaction({ + roomId, + threadId, + commentId, + data: { + emoji: "👀", + userId: AI_USER_INFO.id, + }, + }); + + // If you have an avatar stack, show AI presence for 30 secs + liveblocks.setPresence(roomId, { + userId: AI_USER_INFO.id, + data: {}, + userInfo: AI_USER_INFO, + ttl: 30, + }); + + // Create a placeholder comment as the AI thinks + const aiComment = await liveblocks.createComment({ + roomId, + threadId, + data: { + userId: AI_USER_INFO.id, + body: markdownToCommentBody("Thinking…"), + metadata: {}, + }, + }); + + // Convert the thread into chat messages + const messages: ModelMessage[] = await Promise.all( + thread.comments.map(async (c) => ({ + role: c.userId === AI_USER_INFO.id ? "assistant" : "user", + content: c.body ? await stringifyCommentBody(c.body) : "Deleted comment", + })) + ); + + // Generate a response from Claude + const { text } = await generateText({ + model: anthropic("claude-sonnet-4-5"), + system: `You are a helpful assistant replying inside a Liveblocks comment thread. + + - Reply concisely and to the point. + - Reply in plain text. Do not use markdown. + - Your user ID is ${AI_USER_INFO.id}.`, + messages, + }); + + // Update the comment with the AI response + await liveblocks.editComment({ + roomId, + threadId, + commentId: aiComment.id, + data: { + userId: AI_USER_INFO.id, + body: markdownToCommentBody(text), + metadata: {}, + }, + }); +} +``` + + + + + + + + Set up Liveblocks webhooks + + + + The final step is to configure Liveblocks webhooks so the AI is + notified when new comments are created. + + 1. Follow the guide on + [testing webhooks locally](/docs/guides/how-to-test-webhooks-on-localhost). + 2. In the [dashboard](/dashboard), create a webhook endpoint that + points to `/api/liveblocks-webhook` and enables the + [`commentCreated`](/docs/platform/webhooks#CommentCreatedEvent) + event. + 3. Copy your **webhook secret** (`whsec_...`) and add it to + `.env.local` as `LIVEBLOCKS_WEBHOOK_SECRET_KEY`. + + Now whenever a user `@`-mentions your AI in a thread, your endpoint + will generate a response and post it back as a reply. + + + + + + + + + Complete! + + You now have an AI agent capable of replying to mentions in comment threads. + When it’s mentioned in a acomment, it’ll leave a placeholder comment, and + edit it after generating a response. + + + + + (Optional) Add streaming to AI replies + + + To stream the AI’s response into the comment in realtime, like in the + [AI Comments example](/examples/ai-comments/nextjs-comments-ai), we can + take advantage of [Feeds](/docs/get-started/nextjs-feeds), streaming each + chunk of reasoning and writing into the comment as it arrives. + + To get + started, [type your data](/docs/api-reference/liveblocks-react#Typing-your-data) + in `liveblocks.config.ts` so the + [`CommentMetadata`](/docs/api-reference/liveblocks-react#CommentMetadata), + feed, and feed message types are available across your app. + + ```ts file="liveblocks.config.ts" isCollapsed isCollapsable + declare global { + interface Liveblocks { + UserMeta: { + id: string; + info: { + name: string; + avatar: string; + color: string; + }; + }; + + // +++ + CommentMetadata: { + feedId?: string; + feedComplete?: boolean; + }; + + FeedMetadata: { + type: "ai-comment-reply"; + threadId: string; + commentId: string; + }; + + FeedMessageData: + | { + stage: "thinking"; + response: string; + responsePart: string; + } + | { + stage: "writing"; + response: string; + responsePart: string; + } + | { + stage: "complete"; + response: string; + reasoning: string; + thinkingTime: number; + }; + // +++ + } + } + + export {}; + ``` + + Next, extend the endpoint to write streaming updates into the feed. + + ```ts file="app/ai-comment-reply.ts" isCollapsed isCollapsable + import { + Liveblocks, + getMentionsFromCommentBody, + markdownToCommentBody, + } from "@liveblocks/node"; + import { stringifyCommentBody } from "@liveblocks/client"; + // +++ + import { streamText, type ModelMessage } from "ai"; + import { + anthropic, + type AnthropicLanguageModelOptions, + } from "@ai-sdk/anthropic"; + // +++ + import { AI_USER_INFO } from "@/app/database"; + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, + }); + + export async function handleAiCommentReply(data: { + roomId: string; + threadId: string; + commentId: string; + }) { + const { roomId, threadId, commentId } = data; + + const thread = await liveblocks.getThread({ roomId, threadId }); + const comment = thread.comments.find((c) => c.id === commentId); + if (!comment?.body) return; + + const mentions = getMentionsFromCommentBody(comment.body); + if (!mentions.some((m) => m.id === AI_USER_INFO.id)) return; + + liveblocks.addCommentReaction({ + roomId, + threadId, + commentId, + data: { emoji: "👀", userId: AI_USER_INFO.id }, + }); + + liveblocks.setPresence(roomId, { + userId: AI_USER_INFO.id, + data: {}, + userInfo: AI_USER_INFO, + ttl: 30, + }); + + // +++ + // Create a feed to hold streaming AI updates + const feedId = `comment-reply-${roomId}-${threadId}-${commentId}`; + await liveblocks.createFeed({ + roomId, + feedId, + metadata: { type: "ai-comment-reply", threadId, commentId }, + }); + // +++ + + // Create a placeholder comment for the AI response + const aiComment = await liveblocks.createComment({ + roomId, + threadId, + data: { + userId: AI_USER_INFO.id, + body: markdownToCommentBody("Thinking…"), + // +++ + metadata: { feedId, feedComplete: false }, + // +++ + }, + }); + + // Convert the thread into chat messages + const messages: ModelMessage[] = await Promise.all( + thread.comments.map(async (c) => ({ + role: c.userId === AI_USER_INFO.id ? "assistant" : "user", + content: c.body + ? await stringifyCommentBody(c.body) + : "Deleted comment", + })) + ); + + // +++ + // Stream the response from Claude with reasoning enabled + const startedAt = performance.now(); + const result = streamText({ + model: anthropic("claude-sonnet-4-5"), + system: `You are a helpful assistant replying inside a Liveblocks comment thread. + +- Reply concisely and to the point. +- You can use inline markdown. +- Your user ID is ${AI_USER_INFO.id}.`, messages, providerOptions: { anthropic: + { sendReasoning: true, thinking: { type: "enabled", budgetTokens: 10000 }, } + satisfies AnthropicLanguageModelOptions, }, }); + + // Push each reasoning + text delta into the feed + let reasoning = ""; + let response = ""; + + for await (const part of result.fullStream) { + if (part.type === "reasoning-delta") { + reasoning += part.text; + await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { + stage: "thinking", + responsePart: part.text, + response: reasoning, + }, + }); + } else if (part.type === "text-delta") { + response += part.text; + await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { + stage: "writing", + responsePart: part.text, + response, + }, + }); + } + } + + // Send a final “complete” message so the UI can swap to the finished render + await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { + stage: "complete", + response, + reasoning, + thinkingTime: (performance.now() - startedAt) / 1000, + }, + }); + // +++ + + // Update the placeholder comment with the final response + await liveblocks.editComment({ + roomId, + threadId, + commentId: aiComment.id, + data: { + userId: AI_USER_INFO.id, + // +++ + metadata: { feedId, feedComplete: true }, + // +++ + body: markdownToCommentBody(response), + }, + }); + } + ``` + + In React, render the streaming feed by creating an `AiComment` + component that reads from + [`useFeedMessages`](/docs/api-reference/liveblocks-react#useFeedMessages). + It renders live status updates from the feed, including reasoning and the + final response, and then switches back to the default + [`Comment`](/docs/api-reference/liveblocks-react-ui#Comment) once the + placeholder has been edited. + + ```tsx file="app/components/AiComment.tsx" isCollapsed isCollapsable + "use client"; + + import { useState } from "react"; + import { useFeedMessages, ClientSideSuspense } from "@liveblocks/react"; + import { useUser } from "@liveblocks/react/suspense"; + import { + Comment, + CommentProps, + } from "@liveblocks/react-ui"; + import { Comment as CommentPrimitive } from "@liveblocks/react-ui/primitives"; + import { Markdown } from "@liveblocks/react-ui/_private"; + import Link from "next/link"; + + export function AiComment({ + feedId, + commentProps, + }: { + feedId: string; + commentProps: CommentProps; + }) { + const { messages } = useFeedMessages(feedId); + const lastMessage = messages?.[messages.length - 1]; + + if (!messages || !lastMessage) { + return ( + + ); + } + + // Thinking stage: reasoning is being written + if (lastMessage.data.stage === "thinking") { + return ( + + ); + } + + // Writing stage: the actual response is being streamed + if (lastMessage.data.stage === "writing") { + return ( + + ); + } + + // Complete stage: show the final response + reasoning + return ( + + ); + } + + // Inline comment shown while the AI is thinking or writing + function StreamingComment({ + commentProps, + title, + responsePart, + response, + }: { + commentProps: CommentProps; + title: string; + responsePart: string; + response: string; + }) { + const [open, setOpen] = useState(false); + const trimmedResponsePart = responsePart.trim(); + + return ( + setOpen(!open)}> + + {title} + + {trimmedResponsePart.length ? `…${trimmedResponsePart}` : ""} + + +
+ {response} +
+ + } + /> + ); + } + + // Final comment shown once streaming has finished + function StreamedComment({ + commentProps, + reasoning, + response, + thinkingTime, + }: { + commentProps: CommentProps; + reasoning: string; + response: string; + thinkingTime: number; + }) { + const [open, setOpen] = useState(false); + + return ( + +
setOpen(!open)}> + + Thought for {Number(thinkingTime).toFixed(0)} seconds + +
+ {reasoning} +
+
+ + {commentProps.comment.metadata.feedComplete ? ( + ( + + @ + + + + + ), + Link: ({ href, children }) => ( + {children} + ), + }} + /> + ) : ( +
+ +
+ )} + + } + /> + ); + } + + function User({ userId }: { userId: string }) { + const { user } = useUser(userId); + return <>{user?.name ?? userId}; + } + ``` + + Finally, import `AiComment` into your threads UI and pass it to + [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) via the + `components.Comment` override. Placeholder comments created by the + workflow carry a `feedId` in their metadata, which is how you know when + to render the streaming view instead of the default one. + + ```tsx file="app/components/Threads.tsx" isCollapsed isCollapsable + "use client"; + + import { useThreads } from "@liveblocks/react/suspense"; + import { Composer, Thread, Comment } from "@liveblocks/react-ui"; + // +++ + import { AiComment } from "./AiComment"; + // +++ + + export function Threads() { + const { threads } = useThreads(); + + return ( +
+ {threads.map((thread) => ( + { + const feedId = commentProps.comment.metadata.feedId; + + if (feedId) { + return ( + + ); + } + + return ; + }, + // +++ + }} + /> + ))} + +
+ ); + } + ``` + +
+ +
+ +
+ +## What to read next + +Congratulations! You’ve set up an AI agent that replies to mentions in +Liveblocks Comments threads. + +- [@liveblocks/node API reference](/docs/api-reference/liveblocks-node) +- [Comments overview](/docs/ready-made-features/comments) +- [Webhooks documentation](/docs/platform/webhooks) +- [How to test webhooks on localhost](/docs/guides/how-to-test-webhooks-on-localhost) + +--- + +## Examples using AI in Comments + + + + + + + diff --git a/docs/pages/get-started/nextjs-comments.mdx b/docs/pages/get-started/nextjs-comments.mdx index 16ae4ce055d..67c13fc2886 100644 --- a/docs/pages/get-started/nextjs-comments.mdx +++ b/docs/pages/get-started/nextjs-comments.mdx @@ -179,6 +179,7 @@ experience for your Next.js application. - [API Reference](/docs/api-reference/liveblocks-react-ui) - [Overview](/docs/ready-made-features/comments) +- [How to add AI replies to comments](/docs/get-started/nextjs-comments-ai) - [How to send email notifications when comments are created](/docs/guides/how-to-send-email-notifications-when-comments-are-created) --- diff --git a/docs/routes.json b/docs/routes.json index 1e1f081f191..dc2debbe4bd 100644 --- a/docs/routes.json +++ b/docs/routes.json @@ -51,6 +51,11 @@ "path": "/get-started/nextjs-comments", "hidden": true }, + { + "title": "Comments / AI", + "path": "/get-started/nextjs-comments-ai", + "hidden": true + }, { "title": "Chat SDK Bot", "path": "/get-started/nextjs-chat-sdk-bot", diff --git a/examples/nextjs-comments-ai/package-lock.json b/examples/nextjs-comments-ai/package-lock.json index c622de3a9ff..7751ebaaf1e 100644 --- a/examples/nextjs-comments-ai/package-lock.json +++ b/examples/nextjs-comments-ai/package-lock.json @@ -8,10 +8,10 @@ "license": "Apache-2.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.64", - "@liveblocks/client": "^3.18.4", - "@liveblocks/node": "^3.18.4", - "@liveblocks/react": "^3.18.4", - "@liveblocks/react-ui": "^3.18.4", + "@liveblocks/client": "^3.19.2", + "@liveblocks/node": "^3.19.2", + "@liveblocks/react": "^3.19.2", + "@liveblocks/react-ui": "^3.19.2", "@tailwindcss/postcss": "^4.2.2", "ai": "^6.0.141", "next": "^16.1.6", @@ -1602,43 +1602,44 @@ "peer": true }, "node_modules/@liveblocks/client": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.18.4.tgz", - "integrity": "sha512-ARz21wluGQg4PNhTQYWVcsOWhOn0eStKIXGXEv9hBODSWDOQB20U7FjaS9EDRsYSstWnN9Q7FqBIn4w9BNfWpQ==", + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.19.2.tgz", + "integrity": "sha512-zWO2jInYRHuDY2rYTT/me+8XtWW1KdeqAS8BJhF3TyOlu+E8yMXIQ8GOLTgQOAuL5quc8EzHWrBQIO2m2ckVmw==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.18.4" + "@liveblocks/core": "3.19.2" } }, "node_modules/@liveblocks/core": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.18.4.tgz", - "integrity": "sha512-Pw8vHlUAH0GQmErBG/swq5Jkd6XCKKM9M3uiVZOBEBajaPt7mKSAUWnIT9cFhzdwWjo9TbDhd5/mfJ4JTTNOYQ==", + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.19.2.tgz", + "integrity": "sha512-/MlE3AElANSclJhXefjmVRGzN8lXn187HS8ha5pLSyZII0mjG3bGKB9xPG4PPSErrNCiEPMRirCRVdy3HysHfQ==", "license": "Apache-2.0", "peerDependencies": { "@types/json-schema": "^7" } }, "node_modules/@liveblocks/node": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.18.4.tgz", - "integrity": "sha512-Pf+zunHIUG36e6XndDQpAqQowl2BBb/vtcjlRuE785naDCFl4+QIj0NAdswT/ZB8q+mWGZ5CxamlXtPthtIxPQ==", + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.19.2.tgz", + "integrity": "sha512-0gp7rA4SJ7G+J1FJjR++W117wfz5oWI2jo0Qchjlt87um2j7oQFPK+hjlJXpkLtlRmosA0237vU20JryJJqPoA==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.18.4", + "@liveblocks/core": "3.19.2", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", + "marked": "^15.0.11", "node-fetch": "^2.6.1" } }, "node_modules/@liveblocks/react": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.18.4.tgz", - "integrity": "sha512-KRenW5ilS5QsWrCVjXriDqE6aWhqBh5CxUs1rfkGRRy+ImcDHVGEPKLT1YCB55/ImmxkxnVNIrt3tstkZ1JafQ==", + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.19.2.tgz", + "integrity": "sha512-7YJvmiR1+Or3xedAYvW+Jf9Sqpl6tjKXzjfoj1fWRrbeVesibrC/yknSF+8hZSSWmIqnYVy4/33NzT4Y7ueuFw==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.18.4", - "@liveblocks/core": "3.18.4" + "@liveblocks/client": "3.19.2", + "@liveblocks/core": "3.19.2" }, "peerDependencies": { "@types/react": "^18 || ^19", @@ -1655,15 +1656,15 @@ } }, "node_modules/@liveblocks/react-ui": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.18.4.tgz", - "integrity": "sha512-Ip02XZxLiKuc2BMJewwf1Wuk216bh5FCck9WYPYs6BxUnwr2ln8kavhm2ncbphnDFOT6ycugW21gcV2HuIzjbg==", + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.19.2.tgz", + "integrity": "sha512-xtvgiPTGjY5Wa3QW6a1RCFT/1P+sp93v4B9etAQB82uCy+5G+4252zCNH8w5N04YbRQctmdX9eIyJ0WSTXkOqQ==", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.18.4", - "@liveblocks/core": "3.18.4", - "@liveblocks/react": "3.18.4", + "@liveblocks/client": "3.19.2", + "@liveblocks/core": "3.19.2", + "@liveblocks/react": "3.19.2", "frimousse": "^0.2.0", "marked": "^15.0.11", "radix-ui": "^1.4.0", diff --git a/examples/nextjs-comments-ai/package.json b/examples/nextjs-comments-ai/package.json index bd92dc32f50..7d606d0f584 100644 --- a/examples/nextjs-comments-ai/package.json +++ b/examples/nextjs-comments-ai/package.json @@ -10,10 +10,10 @@ }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", - "@liveblocks/client": "^3.18.4", - "@liveblocks/node": "^3.18.4", - "@liveblocks/react": "^3.18.4", - "@liveblocks/react-ui": "^3.18.4", + "@liveblocks/client": "^3.19.2", + "@liveblocks/node": "^3.19.2", + "@liveblocks/react": "^3.19.2", + "@liveblocks/react-ui": "^3.19.2", "@tailwindcss/postcss": "^4.2.2", "ai": "^6.0.141", "next": "^16.1.6", diff --git a/examples/nextjs-comments-ai/src/workflows/ai-comment-reply.ts b/examples/nextjs-comments-ai/src/workflows/ai-comment-reply.ts index e8bb8f62deb..a4fd0c23b87 100644 --- a/examples/nextjs-comments-ai/src/workflows/ai-comment-reply.ts +++ b/examples/nextjs-comments-ai/src/workflows/ai-comment-reply.ts @@ -1,12 +1,12 @@ import { AI_USER_INFO } from "@/database"; -import { getMentionsFromCommentBody, Liveblocks } from "@liveblocks/node"; +import { + getMentionsFromCommentBody, + Liveblocks, + markdownToCommentBody, +} from "@liveblocks/node"; import { streamText } from "ai"; import { anthropic, AnthropicLanguageModelOptions } from "@ai-sdk/anthropic"; -import type { - ThreadData, - CommentData, - CommentBodyParagraph, -} from "@liveblocks/node"; +import type { ThreadData, CommentData } from "@liveblocks/node"; import { stringifyCommentBody } from "@liveblocks/client"; import { ModelMessage } from "ai"; @@ -162,10 +162,9 @@ async function streamResponse({ ## Rules -- You MUST respond in plain text, for example: "Hi, how can I help you today?". -- You can use new lines to format your response, for example: "Hi, how can I help you today?\nI'm here to help you with any questions you have.". - You MUST reply concisely and to the point. -- You MUST NOT use markdown. +- You MAY use Markdown for **bold**, _italics_, ~~strikethrough~~, \`inline code\`, and [links](https://example.com). +- You MUST NOT use Markdown headings, lists, tables, or blockquotes — they will be rendered as plain text. - You MUST NOT start your messages with "${AI_USER_INFO.id} at ...". ## Example @@ -389,24 +388,13 @@ async function updatePlaceholderComment({ }) { "use step"; - // Convert new lines into new paragraphs - const content: CommentBodyParagraph[] = response - .split("\n\n") - .map((line) => ({ - type: "paragraph", - children: [{ text: line }], - })); - return await liveblocks.editComment({ roomId, threadId, commentId, data: { metadata: { feedId, feedComplete: true }, - body: { - version: 1, - content, - }, + body: markdownToCommentBody(response), }, }); }