From 55ae717753b604712927eca69d73923f1b5b81f7 Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Mon, 18 May 2026 12:02:57 +0100 Subject: [PATCH 1/2] Examples: Add AI comments to various (#3468) --- .../nextjs-ai-dashboard-reports/.env.example | 6 + .../next.config.mjs | 1 + .../package-lock.json | 174 ++++++- .../nextjs-ai-dashboard-reports/package.json | 10 +- .../src/app/(dashboard)/layout.tsx | 54 +- .../reports/_components/Header.tsx | 2 +- .../src/app/api/database.ts | 14 +- .../src/app/api/invoices/route.ts | 78 +-- .../src/app/api/liveblocks-auth/route.ts | 35 +- .../src/app/api/liveblocks-webhook/route.ts | 59 +++ .../src/app/api/plan/route.ts | 4 +- .../src/app/api/team/route.ts | 18 +- .../src/app/api/transactions/route.ts | 87 +--- .../src/app/api/users/search/route.ts | 11 +- .../src/app/globals.css | 31 ++ .../src/app/liveblocks.config.ts | 33 ++ .../src/app/providers.tsx | 101 +++- .../src/app/settings/layout.tsx | 96 ++-- .../comments/CommentsFloatingToggle.tsx | 72 +++ .../comments/CommentsRoomProvider.tsx | 22 + .../components/comments/CommentsRoomShell.tsx | 118 +++++ .../comments/CommentsSidebarContext.tsx | 41 ++ .../src/components/comments/ThreadsPanel.tsx | 240 +++++++++ .../ui/navigation/DropdownUserProfile.tsx | 8 +- .../src/components/ui/navigation/Sidebar.tsx | 2 + .../components/ui/navigation/UserProfile.tsx | 68 ++- .../navigation/useLiveblocksDashboardUser.ts | 16 + .../src/data/users.ts | 10 + .../build-dashboard-comment-system-prompt.ts | 87 ++++ .../comment-ai/dashboard-comment-ai-tools.ts | 110 ++++ .../comment-ai/run-dashboard-comment-ai.ts | 325 ++++++++++++ .../src/lib/comments/constants.ts | 9 + .../src/lib/dashboard-ai-knowledge.ts | 25 + .../src/lib/server/filterInvoices.ts | 72 +++ .../src/lib/server/filterTransactions.ts | 79 +++ examples/nextjs-comments-ai/README.md | 2 +- .../nextjs-comments-ai/liveblocks.config.ts | 1 + .../route.ts | 0 .../src/components/Threads.tsx | 40 +- .../src/workflows/ai-comment-reply.ts | 4 +- .../src/components/Comments.tsx | 12 +- .../src/lib/ai-comment-bridge.ts | 3 +- .../src/liveblocks.config.ts | 1 + examples/nextjs-react-flow-ai/.env.example | 3 + examples/nextjs-react-flow-ai/README.md | 16 +- .../nextjs-react-flow-ai/app/api/database.ts | 17 +- .../app/api/liveblocks-webhook/route.ts | 45 ++ .../app/api/users/route.ts | 14 +- .../app/api/users/search/route.ts | 13 + .../app/flowchart/{ => agent}/agent.ts | 57 +-- .../app/flowchart/agent/comment-agent.ts | 274 ++++++++++ .../app/flowchart/agent/input-agent.ts | 31 ++ .../app/flowchart/ai-comments.tsx | 241 +++++++++ .../app/flowchart/comments.tsx | 475 ++++++++++++++++++ .../app/flowchart/editor.tsx | 269 +++++++--- examples/nextjs-react-flow-ai/app/globals.css | 127 ++++- examples/nextjs-react-flow-ai/app/page.tsx | 11 + .../nextjs-react-flow-ai/liveblocks.config.ts | 35 ++ examples/nextjs-react-flow-ai/next.config.js | 1 + .../nextjs-react-flow-ai/package-lock.json | 109 ++-- examples/nextjs-react-flow-ai/package.json | 11 +- 61 files changed, 3472 insertions(+), 458 deletions(-) create mode 100644 examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-webhook/route.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsFloatingToggle.tsx create mode 100644 examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomProvider.tsx create mode 100644 examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomShell.tsx create mode 100644 examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsSidebarContext.tsx create mode 100644 examples/nextjs-ai-dashboard-reports/src/components/comments/ThreadsPanel.tsx create mode 100644 examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/useLiveblocksDashboardUser.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/build-dashboard-comment-system-prompt.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/dashboard-comment-ai-tools.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/run-dashboard-comment-ai.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/lib/comments/constants.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/lib/dashboard-ai-knowledge.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/lib/server/filterInvoices.ts create mode 100644 examples/nextjs-ai-dashboard-reports/src/lib/server/filterTransactions.ts rename examples/nextjs-comments-ai/src/app/api/{ai-comment-reply => liveblocks-webhook}/route.ts (100%) create mode 100644 examples/nextjs-react-flow-ai/app/api/liveblocks-webhook/route.ts create mode 100644 examples/nextjs-react-flow-ai/app/api/users/search/route.ts rename examples/nextjs-react-flow-ai/app/flowchart/{ => agent}/agent.ts (95%) create mode 100644 examples/nextjs-react-flow-ai/app/flowchart/agent/comment-agent.ts create mode 100644 examples/nextjs-react-flow-ai/app/flowchart/agent/input-agent.ts create mode 100644 examples/nextjs-react-flow-ai/app/flowchart/ai-comments.tsx create mode 100644 examples/nextjs-react-flow-ai/app/flowchart/comments.tsx diff --git a/examples/nextjs-ai-dashboard-reports/.env.example b/examples/nextjs-ai-dashboard-reports/.env.example index 3a7ff90fad7..b5ea1584466 100644 --- a/examples/nextjs-ai-dashboard-reports/.env.example +++ b/examples/nextjs-ai-dashboard-reports/.env.example @@ -1,5 +1,11 @@ # https://liveblocks.io/dashboard/apikeys LIVEBLOCKS_SECRET_KEY= +# https://liveblocks.io/dashboard/webhooks +LIVEBLOCKS_WEBHOOK_SECRET_KEY= + +# https://platform.claude.com/settings/keys +ANTHROPIC_API_KEY= + # https://liveblocks.io/dashboard/copilots NEXT_PUBLIC_LIVEBLOCKS_COPILOT_ID= diff --git a/examples/nextjs-ai-dashboard-reports/next.config.mjs b/examples/nextjs-ai-dashboard-reports/next.config.mjs index 3ad9fadabd3..75a764afe85 100644 --- a/examples/nextjs-ai-dashboard-reports/next.config.mjs +++ b/examples/nextjs-ai-dashboard-reports/next.config.mjs @@ -2,6 +2,7 @@ const nextConfig = { turbopack: { root: import.meta.dirname }, + allowedDevOrigins: ["*.ngrok-free.app", "*.ngrok.io", "*.loca.lt"], redirects: async () => { return [ { diff --git a/examples/nextjs-ai-dashboard-reports/package-lock.json b/examples/nextjs-ai-dashboard-reports/package-lock.json index 7d4401626fe..06f9d9f0a09 100644 --- a/examples/nextjs-ai-dashboard-reports/package-lock.json +++ b/examples/nextjs-ai-dashboard-reports/package-lock.json @@ -8,11 +8,12 @@ "name": "nextjs-ai-dashboard-reports", "version": "0.1.0", "dependencies": { + "@ai-sdk/anthropic": "^3.0.64", "@internationalized/date": "^3.7.0", - "@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.1", + "@liveblocks/node": "^3.19.1", + "@liveblocks/react": "^3.19.1", + "@liveblocks/react-ui": "^3.19.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-dialog": "^1.1.15", @@ -33,6 +34,7 @@ "@tailwindcss/forms": "^0.5.11", "@tanstack/react-table": "^8.21.3", "@typescript-eslint/parser": "^8.28.0", + "ai": "^6.0.141", "clsx": "^2.1.1", "date-fns": "^3.6.0", "eslint-plugin-next": "^0.0.0", @@ -67,6 +69,74 @@ "typescript": "^5.8.3" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.77", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.77.tgz", + "integrity": "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.114", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.114.tgz", + "integrity": "sha512-MqkZ5sd+qiq6RgIxELkoFQXg2/JwK+WCMaot7U+rtrZpWJl3fSyYvc28SC03b256o4F7OXjQtdjTqs81B2w+dA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils/node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1703,43 +1773,44 @@ "license": "Apache-2.0" }, "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.1", + "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.19.1.tgz", + "integrity": "sha512-+h645g0o7jCYhkg6b3P8rR8iOacNfIre+GqBkP8G/7WuSMquiEkwW4Ub+N5F74wqC+ssuQWLo6QdJ0ICkWlI5w==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.18.4" + "@liveblocks/core": "3.19.1" } }, "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.1", + "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.19.1.tgz", + "integrity": "sha512-OPRPJLq/TkFatL+5ECZQaAKijwCF9j9pQDC9MZGBzD9suvcJ9/rvnkYDVko6JI9HKpLmvkPQJNxC13IpsPUAtw==", "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.1", + "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.19.1.tgz", + "integrity": "sha512-7N5u6inEos5OgBa5DOnrQvIn6p9PimsKOFpJx1NQEkmsLT/5hh2XyWU53ENE3Txpi6d9HySKHQ9+4k8PsscDPQ==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.18.4", + "@liveblocks/core": "3.19.1", "@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.1", + "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.19.1.tgz", + "integrity": "sha512-torDKWpXqyu7F/YpsCNTREzZtQVaZq7NzXW9czMFRJtLTjgyGU5H+5WbtRLvnZpEnYtIAerl19qBWUs7I+IE4w==", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.18.4", - "@liveblocks/core": "3.18.4" + "@liveblocks/client": "3.19.1", + "@liveblocks/core": "3.19.1" }, "peerDependencies": { "@types/react": "^18 || ^19", @@ -1756,15 +1827,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.1", + "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.19.1.tgz", + "integrity": "sha512-65AfVSh7VbayPv6iosTl5UVC5mCYJH9kjTOMlzyYpHtQ2XW3VCn7s1/MmsN206qtq+0qZYoVy9CNocUx6KXoMA==", "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.1", + "@liveblocks/core": "3.19.1", + "@liveblocks/react": "3.19.1", "frimousse": "^0.2.0", "marked": "^15.0.11", "radix-ui": "^1.4.0", @@ -1993,6 +2064,15 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5034,6 +5114,15 @@ "win32" ] }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5055,6 +5144,24 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ai": { + "version": "6.0.182", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.182.tgz", + "integrity": "sha512-ooJdziFjYrYRcsCx107roqA8gDTI3P82nUfroNWIhVvwrkYzEN3W1l50YK+XNqkUew8AiimaW0/SLBewRXMuHQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.114", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -6739,6 +6846,15 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7827,6 +7943,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/examples/nextjs-ai-dashboard-reports/package.json b/examples/nextjs-ai-dashboard-reports/package.json index 72fc3830158..ad628b4b3eb 100644 --- a/examples/nextjs-ai-dashboard-reports/package.json +++ b/examples/nextjs-ai-dashboard-reports/package.json @@ -11,11 +11,12 @@ "generate:all": "tsx --tsconfig ./tsconfig.scripts.json ./src/data/generateData.ts" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.64", "@internationalized/date": "^3.7.0", - "@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.1", + "@liveblocks/node": "^3.19.1", + "@liveblocks/react": "^3.19.1", + "@liveblocks/react-ui": "^3.19.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-dialog": "^1.1.15", @@ -36,6 +37,7 @@ "@tailwindcss/forms": "^0.5.11", "@tanstack/react-table": "^8.21.3", "@typescript-eslint/parser": "^8.28.0", + "ai": "^6.0.141", "clsx": "^2.1.1", "date-fns": "^3.6.0", "eslint-plugin-next": "^0.0.0", diff --git a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx index fb8bfaf5a56..b96d0f676e2 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx +++ b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx @@ -1,32 +1,46 @@ -"use client" -import React from "react" +"use client"; -import { cx } from "@/lib/utils" +import React, { Suspense } from "react"; -import { Sidebar } from "@/components/ui/navigation/Sidebar" +import { CommentsFloatingToggle } from "@/components/comments/CommentsFloatingToggle"; +import { CommentsRoomProvider } from "@/components/comments/CommentsRoomProvider"; +import { CommentsRoomShell } from "@/components/comments/CommentsRoomShell"; +import { cx } from "@/lib/utils"; + +import { Sidebar } from "@/components/ui/navigation/Sidebar"; export default function Layout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { - const [isCollapsed, setIsCollapsed] = React.useState(false) + const [isCollapsed, setIsCollapsed] = React.useState(false); const toggleSidebar = () => { - setIsCollapsed(!isCollapsed) - } + setIsCollapsed(!isCollapsed); + }; return (
- -
-
- {children} -
-
+ + + +
+ +
+ {children} +
+
+
+
- ) + ); } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx index 43bad56048d..bc1c5669b82 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx +++ b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx @@ -59,7 +59,7 @@ export default function Header() {
u.id === id) || undefined; } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts index 672be1e7399..fdfb03c5334 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts +++ b/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts @@ -1,70 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; -import { invoices } from "@/data/invoices"; -import type { Invoice } from "@/data/schema"; +import { filterInvoices } from "@/lib/server/filterInvoices"; export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl; - // Parse filters from query params - const dateFrom = searchParams.get("dateFrom"); - const dateTo = searchParams.get("dateTo"); - const currency = searchParams.get("currency"); - const continent = searchParams.get("continent"); - const country = searchParams.get("country"); + const dateFrom = searchParams.get("dateFrom") ?? undefined; + const dateTo = searchParams.get("dateTo") ?? undefined; + const currency = searchParams.get("currency") ?? undefined; + const continent = searchParams.get("continent") ?? undefined; + const country = searchParams.get("country") ?? undefined; const minAmount = searchParams.get("minAmount"); const maxAmount = searchParams.get("maxAmount"); - const invoiceStatus = searchParams.get("invoiceStatus"); + const invoiceStatus = searchParams.get("invoiceStatus") ?? undefined; const limit = parseInt(searchParams.get("limit") || "20", 10); - const client = searchParams.get("client"); + const client = searchParams.get("client") ?? undefined; - let filtered = invoices; - - if (dateFrom) { - filtered = filtered.filter( - (i: Invoice) => new Date(i.invoice_date) >= new Date(dateFrom) - ); - } - if (dateTo) { - filtered = filtered.filter( - (i: Invoice) => new Date(i.invoice_date) <= new Date(dateTo) - ); - } - if (currency) { - filtered = filtered.filter((i: Invoice) => i.currency === currency); - } - if (continent) { - filtered = filtered.filter((i: Invoice) => i.continent === continent); - } - if (country) { - filtered = filtered.filter((i: Invoice) => i.country === country); - } - if (minAmount) { - filtered = filtered.filter( - (i: Invoice) => i.amount >= parseFloat(minAmount) - ); - } - if (maxAmount) { - filtered = filtered.filter( - (i: Invoice) => i.amount <= parseFloat(maxAmount) - ); - } - if (invoiceStatus) { - filtered = filtered.filter( - (i: Invoice) => i.invoice_status === invoiceStatus - ); - } - if (client) { - filtered = filtered.filter((i: Invoice) => i.client === client); - } - - // Sort by date descending (most recent first) - filtered = filtered.sort( - (a: Invoice, b: Invoice) => - new Date(b.invoice_date).getTime() - new Date(a.invoice_date).getTime() - ); - - // Limit the number of results - const result = filtered.slice(0, limit); + const result = filterInvoices({ + dateFrom, + dateTo, + currency, + continent, + country, + minAmount: minAmount ? parseFloat(minAmount) : undefined, + maxAmount: maxAmount ? parseFloat(maxAmount) : undefined, + invoiceStatus, + limit, + client, + }); return NextResponse.json({ invoices: result }); } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts index 97b44a68547..b7ec60145de 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts +++ b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts @@ -1,6 +1,6 @@ import { Liveblocks } from "@liveblocks/node"; import { NextRequest, NextResponse } from "next/server"; -import { users } from "@/data/users"; +import { getRandomUser, getUser } from "@/data/users"; /** * Authenticating your Liveblocks application @@ -13,14 +13,17 @@ const liveblocks = new Liveblocks({ export async function POST(request: NextRequest) { if (!process.env.LIVEBLOCKS_SECRET_KEY) { - return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + return NextResponse.json( + { error: "Missing LIVEBLOCKS_SECRET_KEY" }, + { status: 403 }, + ); } // Get the current user's unique id and info from your database const user = await getSession(request); if (!user) { - return new NextResponse("User not found", { status: 404 }); + return NextResponse.json({ error: "User not found" }, { status: 404 }); } // Create a session for the current user (access token auth) @@ -43,13 +46,25 @@ export async function POST(request: NextRequest) { // Imagine this is your auth setup async function getSession(request: NextRequest) { - // Used to deploy to https://liveblocks.io/examples - const { userId } = await request.json(); - const user = users.find((user) => user.email === "charlie.layne@example.com"); - - if (!user) { - return null; + let parsed: unknown = null; + try { + parsed = await request.json(); + } catch { + parsed = null; } - return { ...user, email: (userId as string) || user.email }; + const userId = + parsed !== null && + typeof parsed === "object" && + "userId" in parsed && + typeof (parsed as { userId: unknown }).userId === "string" + ? (parsed as { userId: string }).userId + : undefined; + + const fromEmail = + typeof userId === "string" && userId.includes("@") + ? getUser(userId) + : undefined; + + return fromEmail ?? getRandomUser(); } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-webhook/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-webhook/route.ts new file mode 100644 index 00000000000..434035f4b62 --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-webhook/route.ts @@ -0,0 +1,59 @@ +import { isDashboardCommentsRoomId } from "@/lib/comments/constants"; +import { runDashboardCommentAiReply } from "@/lib/comment-ai/run-dashboard-comment-ai"; +import { WebhookHandler } from "@liveblocks/node"; +import { NextResponse } from "next/server"; + +export const maxDuration = 800; + +const WEBHOOK_SECRET = process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY; + +const webhookHandler = WEBHOOK_SECRET + ? new WebhookHandler(WEBHOOK_SECRET) + : null; + +export async function POST(request: Request) { + if (!WEBHOOK_SECRET || !webhookHandler) { + return new NextResponse("Missing LIVEBLOCKS_WEBHOOK_SECRET_KEY", { + status: 500, + }); + } + + const rawBody = await request.text(); + const headers = request.headers; + + let event; + try { + event = webhookHandler.verifyRequest({ + headers, + rawBody, + }); + } catch (err) { + console.error(err); + return new NextResponse("Could not verify webhook call", { status: 400 }); + } + + if (event.type !== "commentCreated") { + return NextResponse.json({ message: "Event type not used" }); + } + + const { roomId, threadId, commentId } = event.data; + + if (!isDashboardCommentsRoomId(roomId)) { + return NextResponse.json({ message: "Room not handled by this endpoint" }); + } + + const result = await runDashboardCommentAiReply({ + roomId, + threadId, + commentId, + }); + + if (result.error) { + return NextResponse.json( + { message: result.error }, + { status: result.status } + ); + } + + return NextResponse.json({ message: result.body }); +} diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts index 61ed8c2ca6a..552bcb8e335 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts +++ b/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { currentPlan } from "@/data/data"; +import { getDashboardPlanKnowledge } from "@/lib/dashboard-ai-knowledge"; export async function GET() { - return NextResponse.json(currentPlan); + return NextResponse.json(getDashboardPlanKnowledge()); } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts index 556d2a141ea..258fc741550 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts +++ b/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts @@ -1,22 +1,8 @@ import type { AiChatProps } from "@liveblocks/react-ui"; -import { users } from "@/data/users"; -import { departments, roles } from "@/data/data"; +import { getDashboardTeamKnowledge } from "@/lib/dashboard-ai-knowledge"; export async function GET() { - const aiKnowledge: AiChatProps["knowledge"] = [ - { - description: "A list of all users added to the team", - value: JSON.stringify(users), - }, - { - description: "Every department in this team", - value: JSON.stringify(departments), - }, - { - description: "Every role in this team", - value: JSON.stringify(roles), - }, - ]; + const aiKnowledge: AiChatProps["knowledge"] = getDashboardTeamKnowledge(); return Response.json(aiKnowledge, { status: 200 }); } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts index 232ff5369d6..08bca79c18a 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts +++ b/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts @@ -1,77 +1,34 @@ import { NextRequest, NextResponse } from "next/server"; -import { transactions } from "../../../../src/data/transactions"; -import type { Transaction } from "../../../../src/data/schema"; +import { filterTransactions } from "@/lib/server/filterTransactions"; export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl; - // Parse filters from query params - const dateFrom = searchParams.get("dateFrom"); - const dateTo = searchParams.get("dateTo"); - const currency = searchParams.get("currency"); - const continent = searchParams.get("continent"); - const country = searchParams.get("country"); + const dateFrom = searchParams.get("dateFrom") ?? undefined; + const dateTo = searchParams.get("dateTo") ?? undefined; + const currency = searchParams.get("currency") ?? undefined; + const continent = searchParams.get("continent") ?? undefined; + const country = searchParams.get("country") ?? undefined; const minAmount = searchParams.get("minAmount"); const maxAmount = searchParams.get("maxAmount"); - const expenseStatus = searchParams.get("expenseStatus"); - const paymentStatus = searchParams.get("paymentStatus"); + const expenseStatus = searchParams.get("expenseStatus") ?? undefined; + const paymentStatus = searchParams.get("paymentStatus") ?? undefined; const limit = parseInt(searchParams.get("limit") || "20", 10); - const merchant = searchParams.get("merchant"); + const merchant = searchParams.get("merchant") ?? undefined; - let filtered = transactions; - - if (dateFrom) { - filtered = filtered.filter( - (t: Transaction) => new Date(t.transaction_date) >= new Date(dateFrom) - ); - } - if (dateTo) { - filtered = filtered.filter( - (t: Transaction) => new Date(t.transaction_date) <= new Date(dateTo) - ); - } - if (currency) { - filtered = filtered.filter((t: Transaction) => t.currency === currency); - } - if (continent) { - filtered = filtered.filter((t: Transaction) => t.continent === continent); - } - if (country) { - filtered = filtered.filter((t: Transaction) => t.country === country); - } - if (minAmount) { - filtered = filtered.filter( - (t: Transaction) => t.amount >= parseFloat(minAmount) - ); - } - if (maxAmount) { - filtered = filtered.filter( - (t: Transaction) => t.amount <= parseFloat(maxAmount) - ); - } - if (expenseStatus) { - filtered = filtered.filter( - (t: Transaction) => t.expense_status === expenseStatus - ); - } - if (paymentStatus) { - filtered = filtered.filter( - (t: Transaction) => t.payment_status === paymentStatus - ); - } - if (merchant) { - filtered = filtered.filter((t: Transaction) => t.merchant === merchant); - } - - // Sort by date descending (most recent first) - filtered = filtered.sort( - (a: Transaction, b: Transaction) => - new Date(b.transaction_date).getTime() - - new Date(a.transaction_date).getTime() - ); - - // Limit the number of results - const result = filtered.slice(0, limit); + const result = filterTransactions({ + dateFrom, + dateTo, + currency, + continent, + country, + minAmount: minAmount ? parseFloat(minAmount) : undefined, + maxAmount: maxAmount ? parseFloat(maxAmount) : undefined, + expenseStatus, + paymentStatus, + limit, + merchant, + }); return NextResponse.json({ transactions: result }); } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts index e40bb84ed83..e1f73beb378 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts +++ b/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts @@ -8,11 +8,18 @@ import { getAllUsers } from "../../database"; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const text = searchParams.get("text") as string; + const text = searchParams.get("text") ?? ""; + + const q = text.trim().toLowerCase(); + if (!q) { + return NextResponse.json([]); + } const filteredUserIds = getAllUsers() .filter((user) => { - return user.info.name.toLowerCase().includes(text.toLowerCase()); + const name = user.info.name.toLowerCase(); + const id = user.id.toLowerCase(); + return name.includes(q) || id.includes(q); }) .map((user) => user.id); diff --git a/examples/nextjs-ai-dashboard-reports/src/app/globals.css b/examples/nextjs-ai-dashboard-reports/src/app/globals.css index 8fd1e9a86c5..db12b56a67b 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/globals.css +++ b/examples/nextjs-ai-dashboard-reports/src/app/globals.css @@ -168,3 +168,34 @@ button, --lb-dynamic-background: var(--color-neutral-900); --lb-foreground: var(--color-neutral-50); } + +.comments-sidebar .thread { + width: 100%; + max-width: none; + position: relative; +} + +.comments-sidebar .composer { + width: 100%; + max-width: none; + position: relative; +} + +.comments-sidebar .lb-thread { + width: 100%; + max-width: none; +} + +.comments-sidebar .lb-comment { + background: transparent; +} + +.comments-sidebar .lb-composer { + width: 100%; + max-width: none; +} + +.dark .comments-sidebar .thread::after, +.dark .comments-sidebar .composer::after { + display: none; +} diff --git a/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts b/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts index d97009acb30..e2973af07b7 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts +++ b/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts @@ -8,6 +8,39 @@ declare global { color: string; }; }; + + CommentMetadata: { + feedId?: string; + pathname?: string; + }; + + ThreadMetadata: { + pathname?: string; + }; + + 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; + }; } } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx b/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx index 9ca5aedc630..56720a5a5c1 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx +++ b/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx @@ -3,41 +3,70 @@ import { LiveblocksProvider } from "@liveblocks/react"; import { ThemeProvider } from "next-themes"; import { NuqsAdapter } from "nuqs/adapters/next/app"; -import { ReactNode } from "react"; +import { ReactNode, Suspense } from "react"; import { Toaster } from "sonner"; import { SWRConfig } from "swr"; +import { CommentsSidebarProvider } from "@/components/comments/CommentsSidebarContext"; import { InvitedUsersProvider } from "@/lib/useInvitedUsers"; export function Providers({ children }: { children: ReactNode }) { return ( - - - fetch(resource, init).then((res) => res.json()), + + { + 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 response.json(); }} + resolveMentionSuggestions={async ({ text }) => { + const response = await fetch( + `/api/users/search?text=${encodeURIComponent(text)}`, + ); + + if (!response.ok) { + throw new Error("Problem resolving mention suggestions"); + } + + return response.json(); + }} + badgeLocation="bottom-left" > - + fetch(resource, init).then((res) => res.json()), + }} > - - {children} - - - + + + + {children} + + + + + ); } // Not needed, just used to deploy to https://liveblocks.io/examples function authWithExampleId(endpoint: string) { - return async (room?: string) => { + return async (room?: string): Promise<{ token: string }> => { let userId = localStorage.getItem("liveblocks-example-id"); if (!userId) { userId = Math.random().toString(36).substring(2); @@ -50,6 +79,38 @@ function authWithExampleId(endpoint: string) { }, body: JSON.stringify({ room, userId }), }); - return await response.json(); + + const text = await response.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : null; + } catch { + throw new Error( + text.startsWith("Authentication failed") + ? text + : `Authentication failed: ${text.slice(0, 120)}`, + ); + } + + if (!response.ok) { + const message = + typeof data === "object" && + data !== null && + "error" in data && + typeof (data as { error: unknown }).error === "string" + ? (data as { error: string }).error + : `HTTP ${response.status}`; + throw new Error(message); + } + + if (typeof data !== "object" || data === null || !("token" in data)) { + throw new Error("Invalid authentication response"); + } + const token = (data as { token: unknown }).token; + if (typeof token !== "string") { + throw new Error("Invalid authentication response"); + } + + return { token }; }; } diff --git a/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx b/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx index 9e80e191c76..5e7cffd3de5 100644 --- a/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx +++ b/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx @@ -1,56 +1,72 @@ -"use client" -import React from "react" +"use client"; -import { TabNavigation, TabNavigationLink } from "@/components/TabNavigation" -import { Sidebar } from "@/components/ui/navigation/Sidebar" -import { cx } from "@/lib/utils" -import Link from "next/link" -import { usePathname } from "next/navigation" -import { siteConfig } from "../siteConfig" +import React, { Suspense } from "react"; + +import { CommentsFloatingToggle } from "@/components/comments/CommentsFloatingToggle"; +import { CommentsRoomProvider } from "@/components/comments/CommentsRoomProvider"; +import { CommentsRoomShell } from "@/components/comments/CommentsRoomShell"; +import { TabNavigation, TabNavigationLink } from "@/components/TabNavigation"; +import { Sidebar } from "@/components/ui/navigation/Sidebar"; +import { cx } from "@/lib/utils"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { siteConfig } from "../siteConfig"; const navigationSettings = [ // { name: "Audit", href: siteConfig.baseLinks.settings.audit }, { name: "Billing & usage", href: siteConfig.baseLinks.settings.billing }, { name: "Users", href: siteConfig.baseLinks.settings.users }, -] +]; export default function Layout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { - const [isCollapsed, setIsCollapsed] = React.useState(false) + const [isCollapsed, setIsCollapsed] = React.useState(false); const toggleSidebar = () => { - setIsCollapsed(!isCollapsed) - } - const pathname = usePathname() + setIsCollapsed(!isCollapsed); + }; + const pathname = usePathname(); return (
- -
-
-

- Settings -

- - {navigationSettings.map((item) => ( - - {item.name} - - ))} - -
{children}
-
-
+ + + +
+ +
+ +

+ Settings +

+ + {navigationSettings.map((item) => ( + + {item.name} + + ))} + +
{children}
+
+
+
+
+
- ) + ); } diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsFloatingToggle.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsFloatingToggle.tsx new file mode 100644 index 00000000000..012f1bbd547 --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsFloatingToggle.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { MessageSquareIcon } from "lucide-react"; + +import { useCommentsSidebar } from "@/components/comments/CommentsSidebarContext"; +import { cx } from "@/lib/utils"; + +const mobileHeaderButtonStyles = + "*:text-neutral-600 dark:*:text-neutral-400 size-[38px] relative justify-center border text-center whitespace-nowrap transition-all duration-100 ease-in-out sm:text-sm disabled:pointer-events-none disabled:shadow-none outline-solid outline-offset-2 outline-0 focus-visible:outline-2 outline-blue-500 dark:outline-blue-500 shadow-none border-transparent text-neutral-900 dark:text-neutral-50 bg-transparent disabled:text-neutral-400 dark:disabled:text-neutral-600 group flex items-center rounded-md p-1.5 text-sm font-medium hover:bg-neutral-50 data-[state=open]:bg-neutral-400/10 dark:hover:bg-neutral-400/10"; + +const desktopFloatingButtonStyles = + "mt-px inline-flex size-8 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"; + +type CommentsOpenButtonProps = { + className?: string; + variant?: "mobile-header" | "desktop-floating"; + hideWhenOpen?: boolean; +}; + +export function CommentsOpenButton({ + className, + variant = "mobile-header", + hideWhenOpen = false, +}: CommentsOpenButtonProps) { + const { open, setOpen } = useCommentsSidebar(); + + if (hideWhenOpen && open) { + return null; + } + + const isMobile = variant === "mobile-header"; + + return ( + + ); +} + +export function CommentsFloatingToggle() { + return ( +
+ +
+ ); +} diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomProvider.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomProvider.tsx new file mode 100644 index 00000000000..8701c86dc8b --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomProvider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { COMMENTS_ROOM_ID_BASE } from "@/lib/comments/constants"; +import { RoomProvider } from "@liveblocks/react/suspense"; +import { useSearchParams } from "next/navigation"; +import React from "react"; + +export function CommentsRoomProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const roomId = useExampleRoomId(COMMENTS_ROOM_ID_BASE); + return {children}; +} + +function useExampleRoomId(roomId: string) { + const params = useSearchParams(); + const exampleId = params?.get("exampleId"); + + return React.useMemo(() => { + return exampleId ? `${roomId}-${exampleId}` : roomId; + }, [roomId, exampleId]); +} diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomShell.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomShell.tsx new file mode 100644 index 00000000000..9848125bcd7 --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomShell.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/Drawer"; +import { useCommentsSidebar } from "@/components/comments/CommentsSidebarContext"; +import { ThreadsPanel } from "@/components/comments/ThreadsPanel"; +import { cx } from "@/lib/utils"; +import { RiLoader2Fill } from "@remixicon/react"; +import { ClientSideSuspense } from "@liveblocks/react/suspense"; +import { XIcon } from "lucide-react"; +import React from "react"; + +export function CommentsRoomShell({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return {children}; +} + +function CommentsSidebarLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { open, setOpen } = useCommentsSidebar(); + const isLg = useIsMinWidthLg(); + + const threadsSuspense = ( + }> + + + ); + + return ( +
+
+ {children} +
+ + {!isLg ? ( + + + + Comments + + +
+ {threadsSuspense} +
+
+
+
+ ) : ( + + )} +
+ ); +} + +function CommentsSuspenseFallback() { + return ( +
+
+ ); +} + +function subscribeMinWidthLg(onStoreChange: () => void) { + const mq = window.matchMedia("(min-width: 1024px)"); + mq.addEventListener("change", onStoreChange); + return () => mq.removeEventListener("change", onStoreChange); +} + +function getMinWidthLgSnapshot() { + return window.matchMedia("(min-width: 1024px)").matches; +} + +function useIsMinWidthLg() { + return React.useSyncExternalStore( + subscribeMinWidthLg, + getMinWidthLgSnapshot, + () => true + ); +} + diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsSidebarContext.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsSidebarContext.tsx new file mode 100644 index 00000000000..459ab74de0d --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsSidebarContext.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; + +type CommentsSidebarContextValue = { + open: boolean; + setOpen: Dispatch>; +}; + +const CommentsSidebarContext = + createContext(null); + +export function CommentsSidebarProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + + const value = useMemo(() => ({ open, setOpen }), [open]); + + return ( + + {children} + + ); +} + +export function useCommentsSidebar(): CommentsSidebarContextValue { + const ctx = useContext(CommentsSidebarContext); + if (!ctx) { + throw new Error( + "useCommentsSidebar must be used within CommentsSidebarProvider", + ); + } + return ctx; +} diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/ThreadsPanel.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/ThreadsPanel.tsx new file mode 100644 index 00000000000..7510ce1fb7a --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/ThreadsPanel.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useFeedMessages, useUser } from "@liveblocks/react"; +import { useThreads } from "@liveblocks/react/suspense"; +import { markdownToCommentBody } from "@liveblocks/node"; +import { Composer, Thread, Comment, CommentProps } from "@liveblocks/react-ui"; +import { + Comment as CommentPrimitive, + type CommentBodyLinkProps, + type CommentBodyMentionProps, +} from "@liveblocks/react-ui/primitives"; +import { BrainIcon, ChevronDownIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; + +export function ThreadsPanel() { + const { threads } = useThreads(); + const pathname = usePathname(); + + const commentsCardClass = + "shrink-0 overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950"; + + return ( +
+ + {threads.map((thread) => ( + { + const feedId = commentProps.comment.metadata?.feedId; + + if (feedId) { + return ( + + ); + } + + return ; + }, + }} + /> + ))} +
+ ); +} + +function AiComment({ + feedId, + commentProps, +}: { + feedId: string; + commentProps: CommentProps; +}) { + const { messages } = useFeedMessages(feedId); + const lastMessage = messages?.[messages.length - 1]; + + if (!messages || !lastMessage) { + return ( + + ); + } + + if (lastMessage.data.stage === "thinking") { + return ( + + ); + } + + if (lastMessage.data.stage === "writing") { + return ( + + ); + } + + return ( + + ); +} + +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} +
+ + } + /> + ); +} + +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} +
+
+
+ + + } + /> + ); +} + +function MarkdownCommentBody({ markdown }: { markdown: string }) { + return ( +
+ +
+ ); +} + +function CommentMarkdownLink({ href, children }: CommentBodyLinkProps) { + return ( + + {children} + + ); +} + +function CommentMarkdownMention({ mention }: CommentBodyMentionProps) { + return ( + + @ + + + ); +} + +function ResolvedMarkdownMentionName({ userId }: { userId: string }) { + const { user, isLoading } = useUser(userId); + + if (isLoading) { + return <>…; + } + + return <>{user?.name ?? userId}; +} diff --git a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx index 37bb9660344..65d25be72a3 100644 --- a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx +++ b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx @@ -17,7 +17,8 @@ import { import { ArrowUpRight, Monitor, Moon, Sun } from "lucide-react" import { useTheme } from "next-themes" import * as React from "react" -import { users } from "@/data/users" + +import { useLiveblocksDashboardUser } from "./useLiveblocksDashboardUser" export type DropdownUserProfileProps = { children: React.ReactNode @@ -28,6 +29,7 @@ export function DropdownUserProfile({ children, align = "start", }: DropdownUserProfileProps) { + const me = useLiveblocksDashboardUser() const [mounted, setMounted] = React.useState(false) const { theme, setTheme } = useTheme() React.useEffect(() => { @@ -45,7 +47,9 @@ export function DropdownUserProfile({ align={align} className="min-w-[calc(var(--radix-dropdown-menu-trigger-width))]!" > - {users[0].email} + + {me?.id ?? "Connecting…"} + Theme diff --git a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx index 454abe0ec4d..1431e60d2f2 100644 --- a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx +++ b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx @@ -1,5 +1,6 @@ "use client" import { siteConfig } from "@/app/siteConfig" +import { CommentsOpenButton } from "@/components/comments/CommentsFloatingToggle" import { Tooltip } from "@/components/Tooltip" import { cx, focusRing } from "@/lib/utils" import { @@ -225,6 +226,7 @@ export function Sidebar({ isCollapsed, toggleSidebar }: SidebarProps) {
+
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx index 295ddd50752..25344641d45 100644 --- a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx +++ b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx @@ -2,11 +2,11 @@ import { Button } from "@/components/Button" import { cx, focusRing } from "@/lib/utils" -import { ChevronsUpDown, User } from "lucide-react" +import { ChevronsUpDown, Loader2, User } from "lucide-react" import Image from "next/image" -import { users } from "@/data/users" import { DropdownUserProfile } from "./DropdownUserProfile" +import { useLiveblocksDashboardUser } from "./useLiveblocksDashboardUser" interface UserProfileDesktopProps { isCollapsed?: boolean @@ -15,6 +15,8 @@ interface UserProfileDesktopProps { export const UserProfileDesktop = ({ isCollapsed, }: UserProfileDesktopProps) => { + const me = useLiveblocksDashboardUser() + return ( ) diff --git a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/useLiveblocksDashboardUser.ts b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/useLiveblocksDashboardUser.ts new file mode 100644 index 00000000000..5eae285fc9c --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/useLiveblocksDashboardUser.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useSelf } from "@liveblocks/react"; + +export function useLiveblocksDashboardUser() { + return useSelf((me) => + me + ? { + id: me.id, + name: me.info.name, + avatar: me.info.avatar, + color: me.info.color, + } + : null + ); +} diff --git a/examples/nextjs-ai-dashboard-reports/src/data/users.ts b/examples/nextjs-ai-dashboard-reports/src/data/users.ts index bafaa489666..00d0bda6f5d 100644 --- a/examples/nextjs-ai-dashboard-reports/src/data/users.ts +++ b/examples/nextjs-ai-dashboard-reports/src/data/users.ts @@ -88,3 +88,13 @@ export const users = [ avatar: "https://liveblocks.io/avatars/avatar-8.png", }, ]; + +export type DashboardUser = (typeof users)[number]; + +export function getUser(email: string): DashboardUser | undefined { + return users.find((u) => u.email === email); +} + +export function getRandomUser(): DashboardUser { + return users[Math.floor(Math.random() * users.length)]!; +} diff --git a/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/build-dashboard-comment-system-prompt.ts b/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/build-dashboard-comment-system-prompt.ts new file mode 100644 index 00000000000..c5fd77596f5 --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/build-dashboard-comment-system-prompt.ts @@ -0,0 +1,87 @@ +import { AI_USER_INFO } from "@/app/api/database"; +import { siteConfig } from "@/app/siteConfig"; +import { + getDashboardPlanKnowledge, + getDashboardTeamKnowledge, +} from "@/lib/dashboard-ai-knowledge"; +import { + categories, + currencies, + expense_statuses, + invoice_statuses, + locations, + merchants, + payment_statuses, +} from "@/data/schema"; + +/** + * Mirrors the `RegisterAiKnowledge` blocks in `AiPopup` Chat component, + * plus tool enums used by the dashboard copilot tools. + */ +export function buildDashboardCommentSystemPrompt( + stringifiedComment: string, + pathnameFromApp?: string | null +) { + const pathnameSection = + pathnameFromApp && pathnameFromApp.length > 0 + ? pathnameFromApp + : "(Unknown for this thread — older threads may lack path metadata. Ask which page they mean if needed.)"; + + const team = getDashboardTeamKnowledge(); + const plan = getDashboardPlanKnowledge(); + + return `You are an assistant that helpfully responds to comments in a thread inside this app's Comments sidebar. + +## Info + +- Threads contain messages sent from multiple users. +- Your user ID is: ${AI_USER_INFO.id} +- Your messages list prefixes the user and the time of the message. +- Respond appropriately and keep track of who is speaking. + +## Dashboard copilot knowledge (same RegisterAiKnowledge items as the floating AI chat) + +### The current date and time for the user's timezone +${new Date().toString()} + +### The page the user is currently on +${pathnameSection} + +### Pages you can navigate to. Use markdown to add hyperlinks to your answers, and always link when appropriate. For example: \`[Billing page](/settings/billing)\`. +${JSON.stringify(siteConfig.baseLinks, null, 2)} +Note for Comments: markdown may not render as clickable links in comment bodies — still include clear paths (e.g. /settings/billing) so users can paste them into the address bar. + +### How to use tools +Don't tell the user the names of any tools. Just say you're doing the action. + +### The user's plan information. There's more information in the billing page, add a link to it with markdown. +${JSON.stringify(plan, null, 2)} + +### The team's information. There's more information in the users page, add a link to it with markdown. +${JSON.stringify(team, null, 2)} + +When querying transactions/invoices via tools, these enums match the dashboard demo schema: + +expenseStatus: ${JSON.stringify(expense_statuses)} +paymentStatus: ${JSON.stringify(payment_statuses)} +locations: ${JSON.stringify(locations)} +currencies: ${JSON.stringify(currencies)} +categories: ${JSON.stringify(categories)} +merchants: ${JSON.stringify(merchants)} +invoiceStatus: ${JSON.stringify(invoice_statuses)} + +## Rules + +- You MUST respond in plain text (no markdown headings or code fences). Paths like /reports are fine as plain text. +- You can use new lines to separate paragraphs. +- You MUST reply concisely and to the point. +- You MUST NOT start your messages with "${AI_USER_INFO.id} at ...". +- When you mention transactions you looked up with tools, NEVER paste internal transaction IDs (values beginning with "tx-" or any transaction_id field from tool results). Refer to each transaction by merchant, date, amount, category, and country instead. + +## Respond + +Respond to the following comment inside the thread: + +${stringifiedComment} +`; +} diff --git a/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/dashboard-comment-ai-tools.ts b/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/dashboard-comment-ai-tools.ts new file mode 100644 index 00000000000..4f1861e5b6e --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/dashboard-comment-ai-tools.ts @@ -0,0 +1,110 @@ +import { getUser } from "@/app/api/database"; +import { filterInvoices } from "@/lib/server/filterInvoices"; +import { filterTransactions } from "@/lib/server/filterTransactions"; +import { transactions } from "@/data/transactions"; +import { tool } from "ai"; +import { z } from "zod"; + +const nullableString = z.string().nullable(); +const nullableNumber = z.number().nullable(); + +export function createDashboardCommentAiTools() { + return { + "query-transaction": tool({ + description: `Query transaction rows from this app's demo dataset (same intent as the dashboard copilot). +Filters combine with AND. Use correct ISO dates where helpful.`, + inputSchema: z.object({ + dateFrom: nullableString, + dateTo: nullableString, + currency: nullableString, + continent: nullableString, + country: nullableString, + minAmount: nullableNumber, + maxAmount: nullableNumber, + expenseStatus: nullableString, + paymentStatus: nullableString, + limit: nullableNumber, + merchant: nullableString, + }), + execute: async (args) => { + const txs = filterTransactions({ + dateFrom: args.dateFrom ?? undefined, + dateTo: args.dateTo ?? undefined, + currency: args.currency ?? undefined, + continent: args.continent ?? undefined, + country: args.country ?? undefined, + minAmount: args.minAmount ?? undefined, + maxAmount: args.maxAmount ?? undefined, + expenseStatus: args.expenseStatus ?? undefined, + paymentStatus: args.paymentStatus ?? undefined, + limit: args.limit ?? 20, + merchant: args.merchant ?? undefined, + }); + return { transactions: txs }; + }, + }), + + "query-invoice": tool({ + description: + "Query invoice rows from this app's demo dataset (same intent as the dashboard copilot).", + inputSchema: z.object({ + dateFrom: nullableString, + dateTo: nullableString, + currency: nullableString, + continent: nullableString, + country: nullableString, + minAmount: nullableNumber, + maxAmount: nullableNumber, + invoiceStatus: nullableString, + limit: nullableNumber, + client: nullableString, + }), + execute: async (args) => { + const inv = filterInvoices({ + dateFrom: args.dateFrom ?? undefined, + dateTo: args.dateTo ?? undefined, + currency: args.currency ?? undefined, + continent: args.continent ?? undefined, + country: args.country ?? undefined, + minAmount: args.minAmount ?? undefined, + maxAmount: args.maxAmount ?? undefined, + invoiceStatus: args.invoiceStatus ?? undefined, + limit: args.limit ?? 20, + client: args.client ?? undefined, + }); + return { invoices: inv }; + }, + }), + + transaction: tool({ + description: + "Fetch one transaction by transaction_id from the demo dataset (structured JSON; no UI card).", + inputSchema: z.object({ + transactionId: z.string(), + }), + execute: async ({ transactionId }) => { + const row = transactions.find((t) => t.transaction_id === transactionId); + return row ? { transaction: row } : { error: "Transaction not found" }; + }, + }), + + member: tool({ + description: + "Look up a team member by email from the demo user directory (same intent as the member card tool).", + inputSchema: z.object({ + email: z.string(), + }), + execute: async ({ email }) => { + const user = getUser(email); + return user + ? { + id: user.id, + name: user.info.name, + avatar: user.info.avatar, + color: user.info.color, + } + : { error: "User not found" }; + }, + }), + }; +} diff --git a/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/run-dashboard-comment-ai.ts b/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/run-dashboard-comment-ai.ts new file mode 100644 index 00000000000..1ab86d13c7b --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/lib/comment-ai/run-dashboard-comment-ai.ts @@ -0,0 +1,325 @@ +import { AI_USER_INFO } from "@/app/api/database"; +import { buildDashboardCommentSystemPrompt } from "@/lib/comment-ai/build-dashboard-comment-system-prompt"; +import { createDashboardCommentAiTools } from "@/lib/comment-ai/dashboard-comment-ai-tools"; +import { stringifyCommentBody } from "@liveblocks/client"; +import { + getMentionsFromCommentBody, + Liveblocks, + type CommentBodyParagraph, + type CommentData, + type ThreadData, +} from "@liveblocks/node"; +import { anthropic, AnthropicLanguageModelOptions } from "@ai-sdk/anthropic"; +import { ModelMessage, stepCountIs, streamText } from "ai"; + +export type CommentLocation = { + roomId: string; + threadId: string; + commentId: string; +}; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +export async function runDashboardCommentAiReply( + commentLocation: CommentLocation +): Promise<{ status: number; body?: string; error?: string }> { + const { roomId, threadId, commentId } = commentLocation; + const feedId = `comment-reply-${roomId}-${threadId}-${commentId}`; + + try { + const { thread, comment } = await getThreadAndComment(commentLocation); + + if (!thread || !comment) { + throw new Error("Thread or comment not found"); + } + + if (!comment.body) { + throw new Error("Comment deleted"); + } + + if (!(await isAiMentionedInComment(comment))) { + return { status: 200, body: "AI is not mentioned in the comment" }; + } + + const placeholderComment = await createPlaceholderComment({ + ...commentLocation, + feedId, + }); + const placeholderCommentLocation = { + ...commentLocation, + commentId: placeholderComment.id, + }; + + await Promise.all([ + liveblocks.createFeed({ + roomId, + feedId, + metadata: { + type: "ai-comment-reply", + threadId, + commentId: placeholderComment.id, + }, + }), + showPresence(commentLocation), + leaveReactionOnComment(commentLocation), + ]); + + const { response } = await streamResponse({ + roomId, + feedId, + thread, + comment, + }); + + if (!response) { + await hidePresence(commentLocation); + return { status: 500, error: "Failed to generate response" }; + } + + await updatePlaceholderComment({ + ...placeholderCommentLocation, + feedId, + response, + }); + + await hidePresence(commentLocation); + + return { status: 200, body: "AI replied to comment" }; + } catch (err) { + await hidePresence(commentLocation).catch(() => undefined); + + return { status: 400, error: `${err}` }; + } +} + +function resolvePathnameForPrompt( + thread: ThreadData, + comment: CommentData +): string | undefined { + const threadMeta = thread.metadata as { pathname?: string } | undefined; + if (threadMeta?.pathname) { + return threadMeta.pathname; + } + + const commentMeta = comment.metadata as { pathname?: string } | undefined; + if (commentMeta?.pathname) { + return commentMeta.pathname; + } + + return undefined; +} + +async function streamResponse({ + roomId, + feedId, + thread, + comment, +}: { + roomId: string; + feedId: string; + thread: ThreadData; + comment: CommentData; +}) { + const stringifiedComment = comment.body + ? await stringifyCommentBody(comment.body) + : "Deleted comment"; + + const system = buildDashboardCommentSystemPrompt( + stringifiedComment, + resolvePathnameForPrompt(thread, comment) + ); + + const messages: ModelMessage[] = []; + + for (const c of thread.comments) { + const buildMessageContent = (content: string) => + `${c.userId} at ${c.createdAt}: + +${content} +`; + messages.push({ + role: c.userId === AI_USER_INFO.id ? "assistant" : "user", + content: c.body + ? buildMessageContent(await stringifyCommentBody(c.body)) + : buildMessageContent("Deleted comment"), + }); + } + + const result = streamText({ + model: anthropic("claude-sonnet-4-5"), + system, + messages, + tools: createDashboardCommentAiTools(), + stopWhen: stepCountIs(16), + providerOptions: { + anthropic: { + sendReasoning: true, + thinking: { type: "enabled", budgetTokens: 10000 }, + } satisfies AnthropicLanguageModelOptions, + }, + }); + + let totalReasoning = ""; + let totalText = ""; + const thinkingStartedAt = performance.now(); + const feedWrites: Promise[] = []; + + for await (const part of result.fullStream) { + if (part.type === "reasoning-delta") { + totalReasoning += part.text; + + feedWrites.push( + liveblocks.createFeedMessage({ + roomId, + feedId, + data: { + stage: "thinking", + responsePart: part.text, + response: totalReasoning, + }, + }) + ); + } else if (part.type === "text-delta") { + totalText += part.text; + + feedWrites.push( + liveblocks.createFeedMessage({ + roomId, + feedId, + data: { + stage: "writing", + responsePart: part.text, + response: totalText, + }, + }) + ); + } + } + + const thinkingEndedAt = performance.now(); + + await Promise.all(feedWrites); + + await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { + stage: "complete", + response: totalText, + reasoning: totalReasoning, + thinkingTime: (thinkingEndedAt - thinkingStartedAt) / 1000, + }, + }); + + return { response: totalText, reasoning: totalReasoning }; +} + +async function showPresence({ roomId }: CommentLocation) { + return liveblocks.setPresence(roomId, { + userId: AI_USER_INFO.id, + userInfo: AI_USER_INFO, + data: {}, + }); +} + +async function hidePresence({ roomId }: CommentLocation) { + return liveblocks.setPresence(roomId, { + ttl: 2, + userId: AI_USER_INFO.id, + userInfo: AI_USER_INFO, + data: {}, + }); +} + +async function getThreadAndComment({ + roomId, + threadId, + commentId, +}: CommentLocation) { + const thread = await liveblocks.getThread({ roomId, threadId }); + const c = thread?.comments.find((x) => x.id === commentId); + return { thread, comment: c }; +} + +async function leaveReactionOnComment({ + roomId, + threadId, + commentId, +}: CommentLocation) { + return liveblocks.addCommentReaction({ + roomId, + threadId, + commentId, + data: { + emoji: "👀", + userId: AI_USER_INFO.id, + createdAt: new Date(), + }, + }); +} + +async function isAiMentionedInComment(comment: CommentData) { + if (!comment.body) { + return false; + } + + const mentions = getMentionsFromCommentBody(comment.body); + return mentions.map((m) => m.id).includes(AI_USER_INFO.id); +} + +async function createPlaceholderComment({ + roomId, + threadId, + feedId, +}: CommentLocation & { + feedId: string; +}) { + return await liveblocks.createComment({ + roomId, + threadId, + data: { + userId: AI_USER_INFO.id, + metadata: { feedId }, + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "Thinking…" }], + }, + ], + }, + }, + }); +} + +async function updatePlaceholderComment({ + roomId, + threadId, + commentId, + feedId, + response, +}: CommentLocation & { + feedId: string; + response: string; +}) { + const content: CommentBodyParagraph[] = response.split("\n\n").map((line) => ({ + type: "paragraph", + children: [{ text: line }], + })); + + return await liveblocks.editComment({ + roomId, + threadId, + commentId, + data: { + metadata: { feedId }, + body: { + version: 1, + content, + }, + }, + }); +} diff --git a/examples/nextjs-ai-dashboard-reports/src/lib/comments/constants.ts b/examples/nextjs-ai-dashboard-reports/src/lib/comments/constants.ts new file mode 100644 index 00000000000..8502f94a30b --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/lib/comments/constants.ts @@ -0,0 +1,9 @@ +export const COMMENTS_ROOM_ID_BASE = + "liveblocks:examples:nextjs-ai-dashboard-reports-comments"; + +export function isDashboardCommentsRoomId(roomId: string) { + return ( + roomId === COMMENTS_ROOM_ID_BASE || + roomId.startsWith(`${COMMENTS_ROOM_ID_BASE}-`) + ); +} diff --git a/examples/nextjs-ai-dashboard-reports/src/lib/dashboard-ai-knowledge.ts b/examples/nextjs-ai-dashboard-reports/src/lib/dashboard-ai-knowledge.ts new file mode 100644 index 00000000000..7ead413976c --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/lib/dashboard-ai-knowledge.ts @@ -0,0 +1,25 @@ +import type { AiChatProps } from "@liveblocks/react-ui"; +import { currentPlan, departments, roles } from "@/data/data"; +import { users } from "@/data/users"; + +/** Same payload shape as `GET /api/team` — keeps Comments AI aligned with the dashboard copilot. */ +export function getDashboardTeamKnowledge(): AiChatProps["knowledge"] { + return [ + { + description: "A list of all users added to the team", + value: JSON.stringify(users), + }, + { + description: "Every department in this team", + value: JSON.stringify(departments), + }, + { + description: "Every role in this team", + value: JSON.stringify(roles), + }, + ]; +} + +export function getDashboardPlanKnowledge() { + return currentPlan; +} diff --git a/examples/nextjs-ai-dashboard-reports/src/lib/server/filterInvoices.ts b/examples/nextjs-ai-dashboard-reports/src/lib/server/filterInvoices.ts new file mode 100644 index 00000000000..2d529e5e38b --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/lib/server/filterInvoices.ts @@ -0,0 +1,72 @@ +import type { Invoice } from "@/data/schema"; +import { invoices } from "@/data/invoices"; + +export type InvoiceFilterArgs = { + dateFrom?: string; + dateTo?: string; + currency?: string; + continent?: string; + country?: string; + minAmount?: number; + maxAmount?: number; + invoiceStatus?: string; + limit?: number; + client?: string; +}; + +export function filterInvoices({ + dateFrom, + dateTo, + currency, + continent, + country, + minAmount, + maxAmount, + invoiceStatus, + limit = 20, + client, +}: InvoiceFilterArgs): Invoice[] { + let filtered = invoices; + + if (dateFrom) { + filtered = filtered.filter( + (i: Invoice) => new Date(i.invoice_date) >= new Date(dateFrom) + ); + } + if (dateTo) { + filtered = filtered.filter( + (i: Invoice) => new Date(i.invoice_date) <= new Date(dateTo) + ); + } + if (currency) { + filtered = filtered.filter((i: Invoice) => i.currency === currency); + } + if (continent) { + filtered = filtered.filter((i: Invoice) => i.continent === continent); + } + if (country) { + filtered = filtered.filter((i: Invoice) => i.country === country); + } + if (minAmount !== undefined) { + filtered = filtered.filter((i: Invoice) => i.amount >= minAmount); + } + if (maxAmount !== undefined) { + filtered = filtered.filter((i: Invoice) => i.amount <= maxAmount); + } + if (invoiceStatus) { + filtered = filtered.filter( + (i: Invoice) => i.invoice_status === invoiceStatus + ); + } + if (client) { + filtered = filtered.filter((i: Invoice) => i.client === client); + } + + filtered = filtered.sort( + (a: Invoice, b: Invoice) => + new Date(b.invoice_date).getTime() - + new Date(a.invoice_date).getTime() + ); + + return filtered.slice(0, limit); +} diff --git a/examples/nextjs-ai-dashboard-reports/src/lib/server/filterTransactions.ts b/examples/nextjs-ai-dashboard-reports/src/lib/server/filterTransactions.ts new file mode 100644 index 00000000000..bde4058d28f --- /dev/null +++ b/examples/nextjs-ai-dashboard-reports/src/lib/server/filterTransactions.ts @@ -0,0 +1,79 @@ +import type { Transaction } from "@/data/schema"; +import { transactions } from "@/data/transactions"; + +export type TransactionFilterArgs = { + dateFrom?: string; + dateTo?: string; + currency?: string; + continent?: string; + country?: string; + minAmount?: number; + maxAmount?: number; + expenseStatus?: string; + paymentStatus?: string; + limit?: number; + merchant?: string; +}; + +export function filterTransactions({ + dateFrom, + dateTo, + currency, + continent, + country, + minAmount, + maxAmount, + expenseStatus, + paymentStatus, + limit = 20, + merchant, +}: TransactionFilterArgs): Transaction[] { + let filtered = transactions; + + if (dateFrom) { + filtered = filtered.filter( + (t: Transaction) => new Date(t.transaction_date) >= new Date(dateFrom) + ); + } + if (dateTo) { + filtered = filtered.filter( + (t: Transaction) => new Date(t.transaction_date) <= new Date(dateTo) + ); + } + if (currency) { + filtered = filtered.filter((t: Transaction) => t.currency === currency); + } + if (continent) { + filtered = filtered.filter((t: Transaction) => t.continent === continent); + } + if (country) { + filtered = filtered.filter((t: Transaction) => t.country === country); + } + if (minAmount !== undefined) { + filtered = filtered.filter((t: Transaction) => t.amount >= minAmount); + } + if (maxAmount !== undefined) { + filtered = filtered.filter((t: Transaction) => t.amount <= maxAmount); + } + if (expenseStatus) { + filtered = filtered.filter( + (t: Transaction) => t.expense_status === expenseStatus + ); + } + if (paymentStatus) { + filtered = filtered.filter( + (t: Transaction) => t.payment_status === paymentStatus + ); + } + if (merchant) { + filtered = filtered.filter((t: Transaction) => t.merchant === merchant); + } + + filtered = filtered.sort( + (a: Transaction, b: Transaction) => + new Date(b.transaction_date).getTime() - + new Date(a.transaction_date).getTime() + ); + + return filtered.slice(0, limit); +} diff --git a/examples/nextjs-comments-ai/README.md b/examples/nextjs-comments-ai/README.md index 6de46a4bd26..69703b581c0 100644 --- a/examples/nextjs-comments-ai/README.md +++ b/examples/nextjs-comments-ai/README.md @@ -47,7 +47,7 @@ You need to set up webhooks to make this example run. making sure to [check the “**commentCreated**” event](https://liveblocks.io/docs/platform/webhooks#edit-endpoint-events) when creating the webhook -- In the webhooks dashboard, point to the `/api/ai-comment-reply` path +- In the webhooks dashboard, point to the `/api/liveblocks-webhook` path - Copy your **webhook secret key** from the webhooks dashboard - Add your webhook secret key to `.env.local` as the `LIVEBLOCKS_WEBHOOK_SECRET_KEY` environment variable diff --git a/examples/nextjs-comments-ai/liveblocks.config.ts b/examples/nextjs-comments-ai/liveblocks.config.ts index 9caec9a7204..d474d56d139 100644 --- a/examples/nextjs-comments-ai/liveblocks.config.ts +++ b/examples/nextjs-comments-ai/liveblocks.config.ts @@ -13,6 +13,7 @@ declare global { CommentMetadata: { feedId?: string; + feedComplete?: boolean; }; FeedMetadata: { diff --git a/examples/nextjs-comments-ai/src/app/api/ai-comment-reply/route.ts b/examples/nextjs-comments-ai/src/app/api/liveblocks-webhook/route.ts similarity index 100% rename from examples/nextjs-comments-ai/src/app/api/ai-comment-reply/route.ts rename to examples/nextjs-comments-ai/src/app/api/liveblocks-webhook/route.ts diff --git a/examples/nextjs-comments-ai/src/components/Threads.tsx b/examples/nextjs-comments-ai/src/components/Threads.tsx index 6b69ee1e5a0..0c2752cd418 100644 --- a/examples/nextjs-comments-ai/src/components/Threads.tsx +++ b/examples/nextjs-comments-ai/src/components/Threads.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useThreads } from "@liveblocks/react/suspense"; -import { useFeedMessages } from "@liveblocks/react"; +import { useFeedMessages, ClientSideSuspense } from "@liveblocks/react"; import { AvatarStack, Composer, @@ -10,7 +10,12 @@ import { Comment, CommentProps, } from "@liveblocks/react-ui"; +import { Comment as CommentPrimitive } from "@liveblocks/react-ui/primitives"; import { BrainIcon, ChevronIcon } from "./icons"; +import { Markdown } from "@liveblocks/react-ui/_private"; +import Link from "next/link"; +import { useUser } from "@liveblocks/react/suspense"; +import { ComponentProps } from "react"; /** * Displays a list of threads, along with a composer for creating @@ -190,9 +195,40 @@ function StreamedComment({ -
{response}
+ {commentProps.comment.metadata.feedComplete ? ( + ( + + @ + + + + + ), + Link: ({ href, children }) => ( + {children} + ), + }} + /> + ) : ( +
+ +
+ )} } /> ); } + +interface UserProps extends ComponentProps<"span"> { + userId: string; +} + +export function User({ userId, className, ...props }: UserProps) { + const { user } = useUser(userId); + + return {user?.name ?? userId}; +} 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 0896239aa9f..e8bb8f62deb 100644 --- a/examples/nextjs-comments-ai/src/workflows/ai-comment-reply.ts +++ b/examples/nextjs-comments-ai/src/workflows/ai-comment-reply.ts @@ -362,7 +362,7 @@ async function createPlaceholderComment({ threadId, data: { userId: AI_USER_INFO.id, - metadata: { feedId }, + metadata: { feedId, feedComplete: false }, body: { version: 1, content: [ @@ -402,7 +402,7 @@ async function updatePlaceholderComment({ threadId, commentId, data: { - metadata: { feedId }, + metadata: { feedId, feedComplete: true }, body: { version: 1, content, diff --git a/examples/nextjs-linear-like-issue-tracker/src/components/Comments.tsx b/examples/nextjs-linear-like-issue-tracker/src/components/Comments.tsx index db37748aade..99fc6e402fa 100644 --- a/examples/nextjs-linear-like-issue-tracker/src/components/Comments.tsx +++ b/examples/nextjs-linear-like-issue-tracker/src/components/Comments.tsx @@ -28,7 +28,7 @@ import { ProgressDoneIcon } from "@/icons/ProgressDoneIcon"; import { ProgressInProgressIcon } from "@/icons/ProgressInProgressIcon"; import { ProgressInReviewIcon } from "@/icons/ProgressInReviewIcon"; import { ProgressTodoIcon } from "@/icons/ProgressTodoIcon"; -import { markdownToCommentBody } from "@liveblocks/node"; +import { Markdown } from "@liveblocks/react-ui/_private"; function parseReferencedIssueIdsFromCommentMetadata( metadata: CommentProps["comment"]["metadata"] @@ -357,12 +357,16 @@ function StreamedComment({ -
+ {commentProps.comment.metadata.feedComplete ? ( -
+ ) : ( +
+ +
+ )} {showReferenced ? (
{referencedIssueIds.map((issueId) => ( diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-comment-bridge.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-comment-bridge.ts index 616a43dc3b7..584c27b5216 100644 --- a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-comment-bridge.ts +++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-comment-bridge.ts @@ -23,7 +23,7 @@ export async function createAiPlaceholderComment({ threadId, data: { userId: AI_USER_INFO.id, - metadata: { feedId }, + metadata: { feedId, feedComplete: false }, body: markdownToCommentBody("Thinking…"), }, }); @@ -60,6 +60,7 @@ export async function updateAiPlaceholderComment({ data: { metadata: { feedId, + feedComplete: true, ...(referencedIssueIdsCsv !== undefined && referencedIssueIdsCsv.length > 0 ? { referencedIssueIds: referencedIssueIdsCsv } diff --git a/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts b/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts index 78764bb27b6..7efd1f57959 100644 --- a/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts +++ b/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts @@ -16,6 +16,7 @@ declare global { CommentMetadata: { // Feed ID attached to Ai comments feedId?: string; + feedComplete?: boolean; // Comma-separated issue IDs that we display as links below comments referencedIssueIds?: string; diff --git a/examples/nextjs-react-flow-ai/.env.example b/examples/nextjs-react-flow-ai/.env.example index 4887892c1f4..70200e84e1e 100644 --- a/examples/nextjs-react-flow-ai/.env.example +++ b/examples/nextjs-react-flow-ai/.env.example @@ -3,3 +3,6 @@ LIVEBLOCKS_SECRET_KEY= # https://platform.openai.com/settings/organization/api-keys OPENAI_API_KEY= + +# https://liveblocks.io/dashboard/webhooks (enable commentCreated) +LIVEBLOCKS_WEBHOOK_SECRET_KEY= diff --git a/examples/nextjs-react-flow-ai/README.md b/examples/nextjs-react-flow-ai/README.md index 74ab53ad29b..5324e6f9803 100644 --- a/examples/nextjs-react-flow-ai/README.md +++ b/examples/nextjs-react-flow-ai/README.md @@ -26,8 +26,9 @@ powered by [Liveblocks](https://liveblocks.io), [React Flow](https://reactflow.dev/), [Next.js](https://nextjs.org/), the [Vercel AI SDK](https://sdk.vercel.ai/), and [OpenAI](https://openai.com). -You can place blocks, connect them, edit labels, undo and redo, and ask the AI -to edit the diagram in real time for everyone in the room. +You can place blocks, connect them, edit labels, undo and redo, add pinned +comments on the canvas, and ask the AI to edit the diagram in real-time for +everyone in the room. You can also talk to the AI Assistant in comment threads. Collaborative React Flow with AI @@ -43,6 +44,17 @@ 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. +### Setting up webhooks + +To enable AI replies when you @mention **AI Assistant** in a comment: + +- Follow our guide on + [testing webhooks locally](https://liveblocks.io/docs/guides/how-to-test-webhooks-on-localhost), + making sure to check the **commentCreated** event when creating the webhook +- Point the webhook to `/api/liveblocks-webhook` +- Copy your webhook secret key and add it to `.env.local` as + `LIVEBLOCKS_WEBHOOK_SECRET_KEY` + ### Setting up OpenAI You need your own OpenAI API key to run the AI agent. diff --git a/examples/nextjs-react-flow-ai/app/api/database.ts b/examples/nextjs-react-flow-ai/app/api/database.ts index fb5d3c514d7..9aebd99111f 100644 --- a/examples/nextjs-react-flow-ai/app/api/database.ts +++ b/examples/nextjs-react-flow-ai/app/api/database.ts @@ -1,5 +1,16 @@ import { nanoid } from "nanoid"; +export const COMMENT_AI_USER_ID = "__AI_AGENT"; + +export const COMMENT_AI_USER_INFO: Liveblocks["UserMeta"] = { + id: COMMENT_AI_USER_ID, + info: { + name: "AI Assistant", + color: "#6366f1", + avatar: `https://liveblocks.io/api/avatar?u=${encodeURIComponent(COMMENT_AI_USER_ID)}&agent=true`, + }, +}; + const AI_AGENT_ID_PREFIX = "#agent"; export function isAgentUserId(userId: string): boolean { @@ -95,6 +106,10 @@ export function getRandomUser() { } export function getUser(id: string) { + if (id === COMMENT_AI_USER_ID) { + return COMMENT_AI_USER_INFO; + } + return USER_INFO.find((u) => u.id === id) || undefined; } @@ -103,5 +118,5 @@ export async function getUsers(ids: string[]) { } export function getAllUsers() { - return USER_INFO; + return [COMMENT_AI_USER_INFO, ...USER_INFO]; } diff --git a/examples/nextjs-react-flow-ai/app/api/liveblocks-webhook/route.ts b/examples/nextjs-react-flow-ai/app/api/liveblocks-webhook/route.ts new file mode 100644 index 00000000000..8dfd083c7ef --- /dev/null +++ b/examples/nextjs-react-flow-ai/app/api/liveblocks-webhook/route.ts @@ -0,0 +1,45 @@ +import { runAiCommentReply } from "../../flowchart/agent/comment-agent"; +import { WebhookHandler } from "@liveblocks/node"; +import { NextResponse } from "next/server"; + +const WEBHOOK_SECRET = process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY; + +export async function POST(request: Request) { + if (!WEBHOOK_SECRET) { + return NextResponse.json( + { + error: + "Webhook is not configured (set LIVEBLOCKS_WEBHOOK_SECRET_KEY in .env.local)", + }, + { status: 501 } + ); + } + + const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); + const body = await request.json(); + + let event; + try { + event = webhookHandler.verifyRequest({ + headers: request.headers, + rawBody: JSON.stringify(body), + }); + } catch (err) { + console.error(err); + return new Response("Could not verify webhook call", { status: 400 }); + } + + if (event.type === "commentCreated") { + const { roomId, threadId, commentId } = event.data; + + const result = await runAiCommentReply({ roomId, threadId, commentId }); + + if (result.error) { + console.error("[liveblocks-webhook commentCreated]", result.error); + } + + return NextResponse.json({ ok: true, handled: "commentCreated" }); + } + + return NextResponse.json({ ok: true, handled: "ignored" }); +} diff --git a/examples/nextjs-react-flow-ai/app/api/users/route.ts b/examples/nextjs-react-flow-ai/app/api/users/route.ts index 55265b810a0..a32fefe6ac9 100644 --- a/examples/nextjs-react-flow-ai/app/api/users/route.ts +++ b/examples/nextjs-react-flow-ai/app/api/users/route.ts @@ -1,8 +1,4 @@ -import { - getAgentUserInfo, - getUser, - isAgentUserId, -} from "../database"; +import { getAgentUserInfo, getUser, isAgentUserId } from "../database"; import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { @@ -15,11 +11,17 @@ export async function GET(request: NextRequest) { return NextResponse.json( userIds.map((userId) => { + const user = getUser(userId); + + if (user) { + return user.info; + } + if (isAgentUserId(userId)) { return getAgentUserInfo(userId); } - return getUser(userId)?.info ?? null; + return null; }), { status: 200 } ); diff --git a/examples/nextjs-react-flow-ai/app/api/users/search/route.ts b/examples/nextjs-react-flow-ai/app/api/users/search/route.ts new file mode 100644 index 00000000000..4a28ec7db25 --- /dev/null +++ b/examples/nextjs-react-flow-ai/app/api/users/search/route.ts @@ -0,0 +1,13 @@ +import { getAllUsers } from "../../database"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const text = searchParams.get("text") ?? ""; + + const filteredUserIds = getAllUsers() + .filter((user) => user.info.name.toLowerCase().includes(text.toLowerCase())) + .map((user) => user.id); + + return NextResponse.json(filteredUserIds); +} diff --git a/examples/nextjs-react-flow-ai/app/flowchart/agent.ts b/examples/nextjs-react-flow-ai/app/flowchart/agent/agent.ts similarity index 95% rename from examples/nextjs-react-flow-ai/app/flowchart/agent.ts rename to examples/nextjs-react-flow-ai/app/flowchart/agent/agent.ts index a731f00048b..9e6be898a76 100644 --- a/examples/nextjs-react-flow-ai/app/flowchart/agent.ts +++ b/examples/nextjs-react-flow-ai/app/flowchart/agent/agent.ts @@ -1,5 +1,3 @@ -"use server"; - import { openai } from "@ai-sdk/openai"; import { Liveblocks } from "@liveblocks/node"; import { mutateFlow } from "@liveblocks/react-flow/node"; @@ -7,7 +5,7 @@ import { generateText, stepCountIs, tool } from "ai"; import dedent from "dedent"; import { nanoid } from "nanoid"; import { z } from "zod"; -import { createAgentUser } from "../api/database"; +import { createAgentUser } from "../../api/database"; import { BLOCK_COLORS, BLOCK_SHAPES, @@ -29,7 +27,7 @@ import { type BlockColor, type Bounds, type Point, -} from "./shared"; +} from "../shared"; const PRESENCE_PROGRESS_TTL_SECONDS = 20; const PRESENCE_DONE_TTL_SECONDS = 2; @@ -63,7 +61,15 @@ const edgeDataSchema = z.object({ label: z.string().optional(), }); -async function runFlowchartAgent(roomId: string, prompt: string) { +export type RunFlowchartAgentOptions = { + onProgress?: (message: string) => void | Promise; +}; + +export async function runFlowchartAgent( + roomId: string, + prompt: string, + options?: RunFlowchartAgentOptions +): Promise<{ text: string }> { const agentUser = createAgentUser(); let lastCursor: Point | null = null; let lastThinking: boolean = true; @@ -107,6 +113,8 @@ async function runFlowchartAgent(roomId: string, prompt: string) { return run; }; + let agentText = ""; + await mutateFlow( { client: liveblocks, @@ -134,7 +142,9 @@ async function runFlowchartAgent(roomId: string, prompt: string) { } try { - await generateText({ + void options?.onProgress?.("Editing flowchart…"); + + const result = await generateText({ model: openai("gpt-5.4-nano"), system: dedent` You edit a live collaborative React Flow flowchart. @@ -566,8 +576,13 @@ async function runFlowchartAgent(roomId: string, prompt: string) { }), }, stopWhen: stepCountIs(30), - experimental_onToolCallStart: stopThinkingInterval, + experimental_onToolCallStart: () => { + stopThinkingInterval(); + void options?.onProgress?.("Editing flowchart…"); + }, }); + + agentText = result.text; } finally { stopThinkingInterval(); } @@ -575,32 +590,6 @@ async function runFlowchartAgent(roomId: string, prompt: string) { ); await setPresence({ ttl: PRESENCE_DONE_TTL_SECONDS }); -} - -type FlowchartAgentActionState = { ok: true } | null; -export async function submitFlowchartAgentAction( - _: FlowchartAgentActionState, - formData: FormData -): Promise { - if (!process.env.LIVEBLOCKS_SECRET_KEY || !process.env.OPENAI_API_KEY) { - return null; - } - - const roomId = String(formData.get("roomId") ?? "").trim(); - const prompt = String(formData.get("prompt") ?? "").trim(); - - if (roomId === "" || prompt === "") { - return null; - } - - try { - await runFlowchartAgent(roomId, prompt); - - return { ok: true }; - } catch (error) { - console.error(error); - - return null; - } + return { text: agentText }; } diff --git a/examples/nextjs-react-flow-ai/app/flowchart/agent/comment-agent.ts b/examples/nextjs-react-flow-ai/app/flowchart/agent/comment-agent.ts new file mode 100644 index 00000000000..46aaf761c9b --- /dev/null +++ b/examples/nextjs-react-flow-ai/app/flowchart/agent/comment-agent.ts @@ -0,0 +1,274 @@ +import { stringifyCommentBody } from "@liveblocks/client"; +import { + getMentionsFromCommentBody, + Liveblocks, + type CommentBodyParagraph, + type CommentData, + type ThreadData, +} from "@liveblocks/node"; +import dedent from "dedent"; +import { COMMENT_AI_USER_INFO } from "../../api/database"; +import { runFlowchartAgent } from "./agent"; + +type CommentLocation = { + roomId: string; + threadId: string; + commentId: string; +}; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +async function createAiPlaceholderComment({ + roomId, + threadId, + feedId, +}: { + roomId: string; + threadId: string; + feedId: string; +}) { + return await liveblocks.createComment({ + roomId, + threadId, + data: { + userId: COMMENT_AI_USER_INFO.id, + metadata: { feedId, feedComplete: false }, + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "Thinking…" }], + }, + ], + }, + }, + }); +} + +async function updateAiPlaceholderComment({ + roomId, + threadId, + commentId, + feedId, + response, +}: CommentLocation & { + feedId: string; + response: string; +}) { + 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, + }, + }, + }); +} + +async function leaveAiReactionOnComment({ + roomId, + threadId, + commentId, +}: CommentLocation) { + return liveblocks.addCommentReaction({ + roomId, + threadId, + commentId, + data: { + emoji: "👀", + userId: COMMENT_AI_USER_INFO.id, + createdAt: new Date(), + }, + }); +} + +async function writeFeedWriting( + roomId: string, + feedId: string, + responsePart: string, + response: string +) { + await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { stage: "writing", responsePart, response }, + }); +} + +async function writeFeedComplete( + roomId: string, + feedId: string, + payload: { response: string; reasoning: string; thinkingTime: number } +) { + await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { + stage: "complete", + response: payload.response, + reasoning: payload.reasoning, + thinkingTime: payload.thinkingTime, + }, + }); +} + +async function getThreadAndComment({ + roomId, + threadId, + commentId, +}: CommentLocation) { + const thread = await liveblocks.getThread({ roomId, threadId }); + const comment = thread?.comments.find((c) => c.id === commentId); + return { thread, comment }; +} + +async function isAiMentionedInComment(comment: CommentData) { + if (!comment.body) { + return false; + } + + const mentions = getMentionsFromCommentBody(comment.body); + return mentions.map((m) => m.id).includes(COMMENT_AI_USER_INFO.id); +} + +async function buildCommentThreadPrompt(thread: ThreadData): Promise { + const lines: string[] = []; + + for (const threadComment of thread.comments) { + const content = threadComment.body + ? await stringifyCommentBody(threadComment.body) + : "Deleted comment"; + + lines.push( + `${threadComment.userId} at ${threadComment.createdAt}:\n${content}` + ); + } + + return dedent` + You were @mentioned in a comment thread on this flowchart. + + Use your flowchart tools when the user wants diagram changes. + Always reply with a short plain-text summary of what you did (no markdown). + If you only edited the diagram, still explain the changes briefly. + + + ${lines.join("\n\n")} + + + + ${thread.metadata.x} + ${thread.metadata.y} + ${thread.metadata.attachedToNodeId ?? "none"} + + `; +} + +async function runCommentThreadAgent({ + roomId, + feedId, + thread, +}: { + roomId: string; + feedId: string; + thread: ThreadData; +}) { + const prompt = await buildCommentThreadPrompt(thread); + const thinkingStartedAt = performance.now(); + let lastFeedText = "Editing flowchart…"; + + await writeFeedWriting(roomId, feedId, lastFeedText, lastFeedText); + + const { text } = await runFlowchartAgent(roomId, prompt, { + onProgress: async (message) => { + lastFeedText = message; + await writeFeedWriting(roomId, feedId, message, message); + }, + }); + + const response = text.trim() || "Done."; + const thinkingEndedAt = performance.now(); + + await writeFeedComplete(roomId, feedId, { + response, + reasoning: "", + thinkingTime: (thinkingEndedAt - thinkingStartedAt) / 1000, + }); + + return { response }; +} + +export async function runAiCommentReply( + commentLocation: CommentLocation +): Promise<{ status: number; body?: string; error?: string }> { + const { roomId, threadId, commentId } = commentLocation; + const feedId = `comment-reply-${roomId}-${threadId}-${commentId}`; + + try { + const { thread, comment } = await getThreadAndComment(commentLocation); + + if (!thread || !comment) { + throw new Error("Thread or comment not found"); + } + + if (!comment.body) { + throw new Error("Comment deleted"); + } + + if (!(await isAiMentionedInComment(comment))) { + return { status: 200, body: "AI is not mentioned in the comment" }; + } + + const placeholderComment = await createAiPlaceholderComment({ + roomId, + threadId, + feedId, + }); + const placeholderCommentLocation: CommentLocation = { + ...commentLocation, + commentId: placeholderComment.id, + }; + + await Promise.all([ + liveblocks.createFeed({ + roomId, + feedId, + metadata: { + type: "ai-comment-reply", + threadId, + commentId: placeholderComment.id, + }, + }), + leaveAiReactionOnComment(commentLocation), + ]); + + const { response } = await runCommentThreadAgent({ + roomId, + feedId, + thread, + }); + + await updateAiPlaceholderComment({ + ...placeholderCommentLocation, + feedId, + response, + }); + + return { status: 200, body: "AI replied to comment" }; + } catch (err) { + return { status: 400, error: `${err}` }; + } +} diff --git a/examples/nextjs-react-flow-ai/app/flowchart/agent/input-agent.ts b/examples/nextjs-react-flow-ai/app/flowchart/agent/input-agent.ts new file mode 100644 index 00000000000..b8e0d2156c1 --- /dev/null +++ b/examples/nextjs-react-flow-ai/app/flowchart/agent/input-agent.ts @@ -0,0 +1,31 @@ +"use server"; + +import { runFlowchartAgent } from "./agent"; + +type FlowchartAgentActionState = { ok: true } | null; + +export async function submitFlowchartAgentAction( + _: FlowchartAgentActionState, + formData: FormData +): Promise { + if (!process.env.LIVEBLOCKS_SECRET_KEY || !process.env.OPENAI_API_KEY) { + return null; + } + + const roomId = String(formData.get("roomId") ?? "").trim(); + const prompt = String(formData.get("prompt") ?? "").trim(); + + if (roomId === "" || prompt === "") { + return null; + } + + try { + await runFlowchartAgent(roomId, prompt); + + return { ok: true }; + } catch (error) { + console.error(error); + + return null; + } +} diff --git a/examples/nextjs-react-flow-ai/app/flowchart/ai-comments.tsx b/examples/nextjs-react-flow-ai/app/flowchart/ai-comments.tsx new file mode 100644 index 00000000000..7169b531f96 --- /dev/null +++ b/examples/nextjs-react-flow-ai/app/flowchart/ai-comments.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { ClientSideSuspense, useFeedMessages } from "@liveblocks/react"; +import { useUser } from "@liveblocks/react/suspense"; +import { Comment, type 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"; +import { type ComponentProps, useState } from "react"; + +export function FlowchartThreadComment(props: CommentProps) { + const rawFeedId = props.comment.metadata?.feedId; + const feedId = typeof rawFeedId === "string" ? rawFeedId : undefined; + + if (feedId) { + return ; + } + + return ; +} + +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}
+ + } + /> + ); +} + +function StreamedComment({ + commentProps, + reasoning, + response, + thinkingTime, +}: { + commentProps: CommentProps; + reasoning: string; + response: string; + thinkingTime: number; +}) { + const [open, setOpen] = useState(false); + const hasReasoning = reasoning.trim().length > 0; + + return ( + + {hasReasoning ? ( +
setOpen(!open)} + > + + + Thought for {Number(thinkingTime).toFixed(0)} seconds + + + + + +
+
{reasoning}
+
+
+ ) : null} + {commentProps.comment.metadata.feedComplete ? ( + ( + + @ + + + + + ), + Link: ({ href, children }) => ( + {children} + ), + }} + /> + ) : ( +
+ +
+ )} + + } + /> + ); +} + +function AiComment({ + feedId, + commentProps, +}: { + feedId: string; + commentProps: CommentProps; +}) { + const { messages } = useFeedMessages(feedId); + const lastMessage = messages?.[messages.length - 1]; + + if (!messages || !lastMessage) { + return ( + + ); + } + + if (lastMessage.data.stage === "thinking") { + return ( + + ); + } + + if (lastMessage.data.stage === "writing") { + return ( + + ); + } + + return ( + + ); +} + +function ChevronIcon({ + rotate = false, + size = 17, +}: { + rotate?: boolean; + size?: number; +}) { + return ( + + + + ); +} + +function BrainIcon() { + return ( + + + + + ); +} + +interface UserProps extends ComponentProps<"span"> { + userId: string; +} + +function User({ userId, ...props }: UserProps) { + const { user } = useUser(userId); + + return {user?.name ?? userId}; +} diff --git a/examples/nextjs-react-flow-ai/app/flowchart/comments.tsx b/examples/nextjs-react-flow-ai/app/flowchart/comments.tsx new file mode 100644 index 00000000000..df554092f90 --- /dev/null +++ b/examples/nextjs-react-flow-ai/app/flowchart/comments.tsx @@ -0,0 +1,475 @@ +"use client"; + +import type { ThreadData } from "@liveblocks/client"; +import { useSelf } from "@liveblocks/react"; +import { + useCreateThread, + useEditThreadMetadata, + useThreads, +} from "@liveblocks/react/suspense"; +import { + CommentPin, + FloatingComposer, + FloatingThread, + Icon, + type CommentProps, +} from "@liveblocks/react-ui"; +import { FlowchartThreadComment } from "./ai-comments"; +import { + DndContext, + type DragEndEvent, + PointerSensor, + TouchSensor, + useDraggable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { useNodes, useStore, useStoreApi } from "@xyflow/react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, + type WheelEvent as ReactWheelEvent, +} from "react"; +import { + flowPointToNormalized, + getNodeAtFlowPoint, + normalizedToFlowPoint, + type FlowchartEdge, + type FlowchartNode, + type Point, +} from "./shared"; + +export type ThreadPinPlacement = + | { kind: "canvas"; flow: Point } + | { + kind: "block"; + nodeId: string; + normalized: Point; + }; + +export type CommentPlacementMode = + | { kind: "idle" } + | { kind: "placing-comment"; pointer: Point } + | { kind: "composing-comment"; placement: ThreadPinPlacement }; + +function isThreadAttachedToMissingNode( + thread: ThreadData, + nodes: FlowchartNode[] +): boolean { + const attachedToNodeId = thread.metadata.attachedToNodeId; + + if (attachedToNodeId == null) { + return false; + } + + return !nodes.some((node) => node.id === attachedToNodeId); +} + +export function getThreadPinFlowPosition( + thread: ThreadData, + nodes: FlowchartNode[] +): Point { + const { x, y, attachedToNodeId } = thread.metadata; + + if (attachedToNodeId != null) { + const node = nodes.find((node) => node.id === attachedToNodeId); + + if (node) { + return normalizedToFlowPoint(node, { x, y }); + } + } + + return { x, y }; +} + +export function getPlacementAtFlowPoint( + nodes: FlowchartNode[], + flowPosition: Point +): ThreadPinPlacement { + const hit = getNodeAtFlowPoint(nodes, flowPosition); + + if (!hit) { + return { kind: "canvas", flow: flowPosition }; + } + + const normalized = flowPointToNormalized( + hit, + flowPosition.x, + flowPosition.y + ); + + return { + kind: "block", + nodeId: hit.id, + normalized, + }; +} + +function getThreadMetadataForPlacement( + placement: ThreadPinPlacement +): ThreadData["metadata"] { + if (placement.kind === "canvas") { + return { + x: placement.flow.x, + y: placement.flow.y, + }; + } + + return { + attachedToNodeId: placement.nodeId, + x: placement.normalized.x, + y: placement.normalized.y, + }; +} + +function usePointerPosition(initial: Point): Point { + const [position, setPosition] = useState(initial); + + useEffect(() => { + const updatePosition = (event: { clientX: number; clientY: number }) => { + setPosition({ x: event.clientX, y: event.clientY }); + }; + + document.addEventListener("pointermove", updatePosition); + document.addEventListener("pointerenter", updatePosition); + document.addEventListener("pointerdown", updatePosition, true); + + return () => { + document.removeEventListener("pointermove", updatePosition); + document.removeEventListener("pointerenter", updatePosition); + document.removeEventListener("pointerdown", updatePosition, true); + }; + }, []); + + return position; +} + +const DraggableFlowThread = memo(function DraggableFlowThread({ + thread, + defaultOpen, +}: { + thread: ThreadData; + defaultOpen: boolean; +}) { + const nodes = useNodes(); + const transform = useStore((state) => state.transform); + const [panX, panY, zoom] = transform; + const [isOpen, setIsOpen] = useState(defaultOpen); + const { + isDragging, + attributes, + listeners, + setNodeRef, + transform: dragDelta, + } = useDraggable({ + id: thread.id, + data: { thread }, + }); + + const { x: flowX, y: flowY } = useMemo( + () => getThreadPinFlowPosition(thread, nodes), + [thread, nodes] + ); + + const x = flowX * zoom + panX + (dragDelta?.x ?? 0); + const y = flowY * zoom + panY + (dragDelta?.y ?? 0); + + const handleWheel = useCallback((event: ReactWheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + if (isThreadAttachedToMissingNode(thread, nodes)) { + return null; + } + + return ( + ( + + ), + }} + > +
+ +
+
+ ); +}); + +function NewThreadCursor({ pointer }: { pointer: Point }) { + const position = usePointerPosition(pointer); + + return ( + + ); +} + +function ThreadComposer({ + placement, + onSubmit, + onThreadCreated, +}: { + placement: ThreadPinPlacement; + onSubmit: () => void; + onThreadCreated: (threadId: string) => void; +}) { + const createThread = useCreateThread(); + const creatorId = useSelf((me) => me.id); + const nodes = useNodes(); + const transform = useStore((state) => state.transform); + const [panX, panY, zoom] = transform; + + const composerMetadata = useMemo( + () => getThreadMetadataForPlacement(placement), + [placement] + ); + + const { x, y } = useMemo(() => { + if (placement.kind === "canvas") { + return { + x: placement.flow.x * zoom + panX, + y: placement.flow.y * zoom + panY, + }; + } + + const node = nodes.find((item) => item.id === placement.nodeId); + + if (node) { + const point = normalizedToFlowPoint(node, placement.normalized); + + return { + x: point.x * zoom + panX, + y: point.y * zoom + panY, + }; + } + + return { x: 0, y: 0 }; + }, [placement, nodes, zoom, panX, panY]); + + return ( +
+ { + event.preventDefault(); + + const thread = createThread({ + body: comment.body, + metadata: composerMetadata, + attachments: comment.attachments, + }); + + onThreadCreated(thread.id); + onSubmit(); + }} + onOpenChange={(open) => { + if (!open) { + onSubmit(); + } + }} + side="right" + > +
+ +
+
+
+ ); +} + +function PlaceThreadControl({ + mode, + onCancel, + onThreadCreated, +}: { + mode: CommentPlacementMode; + onCancel: () => void; + onThreadCreated: (threadId: string) => void; +}) { + if (mode.kind === "placing-comment") { + return ; + } + + if (mode.kind === "composing-comment") { + return ( + + ); + } + + return null; +} + +export function FlowchartCommentToolbarButton({ + onAddComment, +}: { + onAddComment: (pointer: Point) => void; +}) { + return ( + + onAddComment({ x: event.clientX, y: event.clientY }) + } + > + + + ); +} + +export function FlowchartCanvasComments({ + children, + commentMode, + onCancelPlacement, +}: { + children: ReactNode; + commentMode: CommentPlacementMode; + onCancelPlacement: () => void; +}) { + const { threads } = useThreads(); + const editThreadMetadata = useEditThreadMetadata(); + const storeApi = useStoreApi(); + const [threadIdsOpenByDefault, setThreadIdsOpenByDefault] = useState( + () => new Set() + ); + + const registerThreadOpenByDefault = useCallback((threadId: string) => { + setThreadIdsOpenByDefault((prev) => new Set(prev).add(threadId)); + }, []); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + useSensor(TouchSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, delta } = event; + const data = active.data.current; + const thread = data?.thread as ThreadData | undefined; + + if (thread) { + const [, , zoom] = storeApi.getState().transform; + const nodes = storeApi.getState().nodes; + const dx = delta.x / zoom; + const dy = delta.y / zoom; + + const start = getThreadPinFlowPosition(thread, nodes); + const finalFlowPosition = { x: start.x + dx, y: start.y + dy }; + + const hit = getNodeAtFlowPoint(nodes, finalFlowPosition); + + if (hit) { + const normalized = flowPointToNormalized( + hit, + finalFlowPosition.x, + finalFlowPosition.y + ); + + editThreadMetadata({ + threadId: thread.id, + metadata: { + attachedToNodeId: hit.id, + x: normalized.x, + y: normalized.y, + }, + }); + } else { + editThreadMetadata({ + threadId: thread.id, + metadata: { + attachedToNodeId: undefined, + x: finalFlowPosition.x, + y: finalFlowPosition.y, + }, + }); + } + } + }, + [editThreadMetadata, storeApi] + ); + + return ( + + {children} +
+ {threads.map((thread) => ( + + ))} + +
+
+ ); +} diff --git a/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx b/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx index 5382b0faa7e..7e0e0fd897b 100644 --- a/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx +++ b/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx @@ -43,9 +43,14 @@ import { type MiniMapNodeProps, type NodeChange, type NodeProps, + type NodeRemoveChange, type OnResize, } from "@xyflow/react"; import { AvatarStack, Cursor, Icon } from "@liveblocks/react-ui"; +import { + useEditThreadMetadata, + useThreads, +} from "@liveblocks/react/suspense"; import { ComponentProps, CSSProperties, @@ -61,7 +66,13 @@ import { type PointerEvent as ReactPointerEvent, } from "react"; import { isAgentUserId } from "../api/database"; -import { submitFlowchartAgentAction } from "./agent"; +import { submitFlowchartAgentAction } from "./agent/input-agent"; +import { + FlowchartCanvasComments, + FlowchartCommentToolbarButton, + getPlacementAtFlowPoint, + type CommentPlacementMode, +} from "./comments"; import { BLOCK_COLORS, BLOCK_SHAPES, @@ -76,6 +87,7 @@ import { createFlowchartNode, getBlockColor, getBlockShape, + normalizedToFlowPoint, type BlockColor, type BlockHandleSide, type BlockShape, @@ -729,12 +741,16 @@ function ToolbarShapeItem({ function FlowToolbar({ mode, + commentMode, onSelectShapeForPlacement, + onAddComment, }: { mode: PlacementMode; + commentMode: CommentPlacementMode; onSelectShapeForPlacement: (shape: BlockShape, pointer: Point) => void; + onAddComment: (pointer: Point) => void; }) { - if (mode.kind !== "idle") { + if (mode.kind !== "idle" || commentMode.kind !== "idle") { return null; } @@ -747,6 +763,7 @@ function FlowToolbar({ onSelectForPlacement={onSelectShapeForPlacement} /> ))} +
); } @@ -809,8 +826,16 @@ function Flow({ className, ...props }: ComponentProps<"div">) { const [placementMode, setPlacementMode] = useState({ kind: "idle", }); - const isPlacing = placementMode.kind !== "idle"; - const isPickingPlacement = placementMode.kind === "placing-shape"; + const [commentMode, setCommentMode] = useState({ + kind: "idle", + }); + const { threads } = useThreads(); + const editThreadMetadata = useEditThreadMetadata(); + const isPlacing = + placementMode.kind !== "idle" || commentMode.kind !== "idle"; + const isPickingPlacement = + placementMode.kind === "placing-shape" || + commentMode.kind === "placing-comment"; const roomId = useRoom().id; const [agentPrompt, setAgentPrompt] = useState(""); const trimmedAgentPrompt = agentPrompt.trim(); @@ -832,6 +857,7 @@ function Flow({ className, ...props }: ComponentProps<"div">) { const resetPlacementMode = useCallback(() => { setPlacementMode({ kind: "idle" }); + setCommentMode({ kind: "idle" }); }, []); useEffect(() => { @@ -854,7 +880,7 @@ function Flow({ className, ...props }: ComponentProps<"div">) { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - if (placementMode.kind !== "idle") { + if (isPlacing) { resetPlacementMode(); } return; @@ -885,7 +911,7 @@ function Flow({ className, ...props }: ComponentProps<"div">) { return () => { window.removeEventListener("keydown", onKeyDown); }; - }, [placementMode.kind, resetPlacementMode, undo, redo, canUndo, canRedo]); + }, [isPlacing, resetPlacementMode, undo, redo, canUndo, canRedo]); const addBlockAtPosition = useCallback( (shape: BlockShape, position: Point) => { @@ -915,9 +941,32 @@ function Flow({ className, ...props }: ComponentProps<"div">) { [reactFlow, onNodesChange] ); + const commitThreadPlacementAtScreenPoint = useCallback( + (clientX: number, clientY: number) => { + if (commentMode.kind !== "placing-comment") { + return; + } + + const flowPosition = reactFlow.screenToFlowPosition({ + x: clientX, + y: clientY, + }); + + setCommentMode({ + kind: "composing-comment", + placement: getPlacementAtFlowPoint(reactFlow.getNodes(), flowPosition), + }); + }, + [commentMode.kind, reactFlow] + ); + const handleCanvasClickForPlacement = useCallback( (event: ReactMouseEvent) => { - if (placementMode.kind !== "placing-shape") { + if (!isPlacing) { + return; + } + + if (commentMode.kind === "composing-comment") { return; } @@ -929,15 +978,78 @@ function Flow({ className, ...props }: ComponentProps<"div">) { y: event.clientY, }); - const half = DEFAULT_BLOCK_SIZE / 2; + if (placementMode.kind === "placing-shape") { + const half = DEFAULT_BLOCK_SIZE / 2; - addBlockAtPosition(placementMode.shape, { - x: flowPosition.x - half, - y: flowPosition.y - half, - }); - resetPlacementMode(); + addBlockAtPosition(placementMode.shape, { + x: flowPosition.x - half, + y: flowPosition.y - half, + }); + resetPlacementMode(); + return; + } + + if (commentMode.kind === "placing-comment") { + commitThreadPlacementAtScreenPoint(event.clientX, event.clientY); + } + }, + [ + addBlockAtPosition, + commitThreadPlacementAtScreenPoint, + commentMode.kind, + isPlacing, + placementMode, + reactFlow, + resetPlacementMode, + ] + ); + + const onNodesChangeWithThreadDetach = useCallback( + (changes: NodeChange[]) => { + const removedIds = changes + .filter( + (change): change is NodeRemoveChange => change.type === "remove" + ) + .map((change) => change.id); + + if (removedIds.length > 0) { + const currentNodes = reactFlow.getNodes(); + + for (const thread of threads) { + const { attachedToNodeId } = thread.metadata; + + if ( + attachedToNodeId == null || + !removedIds.includes(attachedToNodeId) + ) { + continue; + } + + const node = currentNodes.find( + (node) => node.id === attachedToNodeId + ); + + if (!node) { + continue; + } + + const { x, y } = thread.metadata; + const point = normalizedToFlowPoint(node, { x, y }); + + editThreadMetadata({ + threadId: thread.id, + metadata: { + attachedToNodeId: undefined, + x: point.x, + y: point.y, + }, + }); + } + } + + onNodesChange(changes); }, - [addBlockAtPosition, placementMode, reactFlow, resetPlacementMode] + [editThreadMetadata, onNodesChange, reactFlow, threads] ); const onReconnect = useCallback( @@ -984,7 +1096,7 @@ function Flow({ className, ...props }: ComponentProps<"div">) { ) { onReconnect={onReconnect} onReconnectEnd={onReconnectEnd} > - - - - - - - - - - getBlockColor(node.data.color)} - nodeStrokeWidth={0} - /> - - { - setPlacementMode({ kind: "placing-shape", shape, pointer }); - }} + + + + + + + + + + + getBlockColor(node.data.color)} + nodeStrokeWidth={0} /> - - -
- -
-
- -
- -