From b722717960edad0894e2192d7a65d6614a6db14f Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Fri, 19 Jun 2026 13:19:31 +0100 Subject: [PATCH 1/2] Example: AI spreadsheet (#3530) Co-authored-by: Cursor Agent Co-authored-by: Chris Nicholas --- examples/nextjs-ai-spreadsheet/.env.example | 8 + examples/nextjs-ai-spreadsheet/.envrc | 2 + examples/nextjs-ai-spreadsheet/.gitignore | 12 + examples/nextjs-ai-spreadsheet/.prettierrc | 11 + examples/nextjs-ai-spreadsheet/README.md | 104 + .../app/CellThreadContext.tsx | 90 + examples/nextjs-ai-spreadsheet/app/Chat.tsx | 533 ++ .../app/CommentOverlay.tsx | 153 + .../nextjs-ai-spreadsheet/app/Loading.tsx | 11 + .../app/NotificationsPopover.tsx | 104 + .../app/OrderContext.tsx | 30 + .../nextjs-ai-spreadsheet/app/Providers.tsx | 36 + examples/nextjs-ai-spreadsheet/app/Room.tsx | 77 + .../app/SelectionContext.tsx | 49 + .../nextjs-ai-spreadsheet/app/Spreadsheet.tsx | 75 + examples/nextjs-ai-spreadsheet/app/Table.tsx | 557 ++ .../nextjs-ai-spreadsheet/app/Toolbar.tsx | 515 ++ .../app/api/ai-chat/route.ts | 287 + .../app/api/liveblocks-auth/route.ts | 32 + .../app/api/liveblocks-webhook/route.ts | 43 + .../app/api/users/route.ts | 16 + .../app/api/users/search/route.ts | 18 + .../nextjs-ai-spreadsheet/app/globals.css | 158 + examples/nextjs-ai-spreadsheet/app/layout.tsx | 35 + examples/nextjs-ai-spreadsheet/app/page.tsx | 10 + .../app/useSpreadsheetActions.ts | 225 + .../nextjs-ai-spreadsheet/components.json | 21 + .../components/HelpButton.tsx | 304 + .../components/ai-elements/artifact.tsx | 147 + .../components/ai-elements/canvas.tsx | 22 + .../ai-elements/chain-of-thought.tsx | 231 + .../components/ai-elements/checkpoint.tsx | 68 + .../components/ai-elements/code-block.tsx | 178 + .../components/ai-elements/confirmation.tsx | 176 + .../components/ai-elements/connection.tsx | 28 + .../components/ai-elements/context.tsx | 408 + .../components/ai-elements/controls.tsx | 18 + .../components/ai-elements/conversation.tsx | 100 + .../components/ai-elements/edge.tsx | 140 + .../components/ai-elements/image.tsx | 24 + .../ai-elements/inline-citation.tsx | 287 + .../components/ai-elements/loader.tsx | 96 + .../components/ai-elements/message.tsx | 445 + .../components/ai-elements/model-selector.tsx | 205 + .../components/ai-elements/node.tsx | 71 + .../components/ai-elements/open-in-chat.tsx | 365 + .../components/ai-elements/panel.tsx | 15 + .../components/ai-elements/plan.tsx | 142 + .../components/ai-elements/prompt-input.tsx | 1413 +++ .../components/ai-elements/queue.tsx | 274 + .../components/ai-elements/reasoning.tsx | 187 + .../components/ai-elements/shimmer.tsx | 64 + .../components/ai-elements/sources.tsx | 77 + .../components/ai-elements/suggestion.tsx | 53 + .../components/ai-elements/task.tsx | 87 + .../components/ai-elements/tool.tsx | 163 + .../components/ai-elements/toolbar.tsx | 16 + .../components/ai-elements/web-preview.tsx | 263 + .../components/ui/alert.tsx | 66 + .../components/ui/badge.tsx | 48 + .../components/ui/button-group.tsx | 83 + .../components/ui/button.tsx | 64 + .../components/ui/card.tsx | 92 + .../components/ui/carousel.tsx | 241 + .../components/ui/collapsible.tsx | 33 + .../components/ui/command.tsx | 184 + .../components/ui/dialog.tsx | 158 + .../components/ui/dropdown-menu.tsx | 257 + .../components/ui/hover-card.tsx | 44 + .../components/ui/input-group.tsx | 170 + .../components/ui/input.tsx | 21 + .../components/ui/progress.tsx | 31 + .../components/ui/scroll-area.tsx | 58 + .../components/ui/select.tsx | 190 + .../components/ui/separator.tsx | 28 + .../components/ui/textarea.tsx | 18 + .../components/ui/tooltip.tsx | 57 + examples/nextjs-ai-spreadsheet/database.ts | 80 + examples/nextjs-ai-spreadsheet/example.ts | 32 + .../hooks/use-example-room-id.ts | 16 + examples/nextjs-ai-spreadsheet/lib/a1.ts | 73 + examples/nextjs-ai-spreadsheet/lib/format.ts | 96 + .../lib/spreadsheet-server.ts | 739 ++ examples/nextjs-ai-spreadsheet/lib/utils.ts | 6 + .../liveblocks.config.ts | 131 + examples/nextjs-ai-spreadsheet/next.config.ts | 16 + .../nextjs-ai-spreadsheet/package-lock.json | 7693 +++++++++++++++++ examples/nextjs-ai-spreadsheet/package.json | 49 + .../nextjs-ai-spreadsheet/postcss.config.mjs | 7 + examples/nextjs-ai-spreadsheet/tsconfig.json | 34 + examples/nextjs-ai-spreadsheet/vercel.json | 4 + 91 files changed, 20098 insertions(+) create mode 100644 examples/nextjs-ai-spreadsheet/.env.example create mode 100644 examples/nextjs-ai-spreadsheet/.envrc create mode 100644 examples/nextjs-ai-spreadsheet/.gitignore create mode 100644 examples/nextjs-ai-spreadsheet/.prettierrc create mode 100644 examples/nextjs-ai-spreadsheet/README.md create mode 100644 examples/nextjs-ai-spreadsheet/app/CellThreadContext.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/Chat.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/CommentOverlay.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/Loading.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/NotificationsPopover.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/OrderContext.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/Providers.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/Room.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/SelectionContext.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/Spreadsheet.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/Table.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/Toolbar.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/api/ai-chat/route.ts create mode 100644 examples/nextjs-ai-spreadsheet/app/api/liveblocks-auth/route.ts create mode 100644 examples/nextjs-ai-spreadsheet/app/api/liveblocks-webhook/route.ts create mode 100644 examples/nextjs-ai-spreadsheet/app/api/users/route.ts create mode 100644 examples/nextjs-ai-spreadsheet/app/api/users/search/route.ts create mode 100644 examples/nextjs-ai-spreadsheet/app/globals.css create mode 100644 examples/nextjs-ai-spreadsheet/app/layout.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/page.tsx create mode 100644 examples/nextjs-ai-spreadsheet/app/useSpreadsheetActions.ts create mode 100644 examples/nextjs-ai-spreadsheet/components.json create mode 100644 examples/nextjs-ai-spreadsheet/components/HelpButton.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/artifact.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/canvas.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/chain-of-thought.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/checkpoint.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/code-block.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/confirmation.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/connection.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/context.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/controls.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/conversation.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/edge.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/image.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/inline-citation.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/loader.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/message.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/model-selector.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/node.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/open-in-chat.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/panel.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/plan.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/prompt-input.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/queue.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/reasoning.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/shimmer.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/sources.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/suggestion.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/task.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/tool.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/toolbar.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ai-elements/web-preview.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/alert.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/badge.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/button-group.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/button.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/card.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/carousel.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/collapsible.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/command.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/dialog.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/dropdown-menu.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/hover-card.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/input-group.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/input.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/progress.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/scroll-area.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/select.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/separator.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/textarea.tsx create mode 100644 examples/nextjs-ai-spreadsheet/components/ui/tooltip.tsx create mode 100644 examples/nextjs-ai-spreadsheet/database.ts create mode 100644 examples/nextjs-ai-spreadsheet/example.ts create mode 100644 examples/nextjs-ai-spreadsheet/hooks/use-example-room-id.ts create mode 100644 examples/nextjs-ai-spreadsheet/lib/a1.ts create mode 100644 examples/nextjs-ai-spreadsheet/lib/format.ts create mode 100644 examples/nextjs-ai-spreadsheet/lib/spreadsheet-server.ts create mode 100644 examples/nextjs-ai-spreadsheet/lib/utils.ts create mode 100644 examples/nextjs-ai-spreadsheet/liveblocks.config.ts create mode 100644 examples/nextjs-ai-spreadsheet/next.config.ts create mode 100644 examples/nextjs-ai-spreadsheet/package-lock.json create mode 100644 examples/nextjs-ai-spreadsheet/package.json create mode 100644 examples/nextjs-ai-spreadsheet/postcss.config.mjs create mode 100644 examples/nextjs-ai-spreadsheet/tsconfig.json create mode 100644 examples/nextjs-ai-spreadsheet/vercel.json diff --git a/examples/nextjs-ai-spreadsheet/.env.example b/examples/nextjs-ai-spreadsheet/.env.example new file mode 100644 index 00000000000..2ca35541a6f --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/.env.example @@ -0,0 +1,8 @@ +# https://liveblocks.io/dashboard/apikeys +LIVEBLOCKS_SECRET_KEY=sk_xxx + +# https://liveblocks.io/dashboard/webhooks - `commentCreated` webhook event at /api/liveblocks-webhook +LIVEBLOCKS_WEBHOOK_SECRET_KEY= + +# https://vercel.com/docs/ai-gateway +AI_GATEWAY_API_KEY= diff --git a/examples/nextjs-ai-spreadsheet/.envrc b/examples/nextjs-ai-spreadsheet/.envrc new file mode 100644 index 00000000000..4ae38971262 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/.envrc @@ -0,0 +1,2 @@ +source_up +layout node diff --git a/examples/nextjs-ai-spreadsheet/.gitignore b/examples/nextjs-ai-spreadsheet/.gitignore new file mode 100644 index 00000000000..3a68e0cfc9d --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules +.env +.env.* +!.env.example +*.tsbuildinfo +.vercel +.next +out +next-env.d.ts +# Turborepo +.turbo diff --git a/examples/nextjs-ai-spreadsheet/.prettierrc b/examples/nextjs-ai-spreadsheet/.prettierrc new file mode 100644 index 00000000000..06998724304 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "tabWidth": 2, + "useTabs": false, + "singleQuote": false, + "jsxSingleQuote": false, + "arrowParens": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "trailingComma": "es5" +} diff --git a/examples/nextjs-ai-spreadsheet/README.md b/examples/nextjs-ai-spreadsheet/README.md new file mode 100644 index 00000000000..3a5fd776a52 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/README.md @@ -0,0 +1,104 @@ +

+ + Liveblocks + + + Liveblocks + +

+ +# Realtime AI spreadsheet + +

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

+ +This example shows how to build a realtime, multiplayer spreadsheet with an AI +that edits the grid, using +[Liveblocks](https://liveblocks.io), +[Handsontable](https://handsontable.com/), +[Next.js](https://nextjs.org/), and the [Vercel AI SDK](https://ai-sdk.dev/). + +The grid is backed by Liveblocks Storage, so cells, formatting, column/row sizes, +and order all sync instantly to everyone — along with live selection presence and +per-cell comment threads. Everything is addressed by stable ids, so moving, +sorting, and inserting or deleting rows and columns never breaks comments, +formatting, or presence, and every user sees the same order. The AI lives in a +[Feeds](https://liveblocks.io/docs/collaboration-features/ai-collaboration)-based +chat: it edits the spreadsheet from the server with `@liveblocks/node` +(`mutateStorage`) and shows its live selection with `setPresence`, streaming both +its reply and the grid edits as it works. + +## Getting started + +Run the following command to try this example locally: + +```bash +npx create-liveblocks-app@latest --example nextjs-ai-spreadsheet --api-key +``` + +This will download the example and ask permission to open your browser, enabling +you to automatically get your API key from your +[liveblocks.io](https://liveblocks.io) account. + +### Manual setup + +
Read more + +

+ +Alternatively, you can set up your project manually: + +- Install all dependencies with `npm install` +- Create an account on [liveblocks.io](https://liveblocks.io/dashboard) +- Copy your **secret** key from the + [dashboard](https://liveblocks.io/dashboard/apikeys) +- Create an `.env.local` file and add your **secret** key as the + `LIVEBLOCKS_SECRET_KEY` environment variable +- Add an `AI_GATEWAY_API_KEY` from the + [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). This is required for + the AI chat — it needs a real, tool-calling model to edit the spreadsheet. +- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) + +To see the realtime sync, open the page in two browser tabs and edit a cell, drag +a row, or ask the AI to fill in some data — it appears instantly in both. + +
+ +### Deploy on Vercel + +
Read more + +

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

+ +After forking +[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/nextjs-ai-spreadsheet) +on CodeSandbox, create the `LIVEBLOCKS_SECRET_KEY` environment variable as a +[secret](https://codesandbox.io/docs/secrets). + +
diff --git a/examples/nextjs-ai-spreadsheet/app/CellThreadContext.tsx b/examples/nextjs-ai-spreadsheet/app/CellThreadContext.tsx new file mode 100644 index 00000000000..78e85ddd92e --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/CellThreadContext.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useThreads } from "@liveblocks/react/suspense"; +import type { ThreadData } from "@liveblocks/client"; +import { cellKey } from "@/liveblocks.config"; +import { useSelectionValue } from "./SelectionContext"; + +export type OpenCell = { rowId: string; colId: string } | null; + +type CellThreadContextValue = { + getThread: (rowId: string, colId: string) => ThreadData | undefined; + // The cell whose thread/composer should be open (e.g. after submitting a new + // comment, or when the toolbar "+ Comment" button is pressed). + openCell: OpenCell; + setOpenCell: (openCell: OpenCell) => void; +}; + +const CellThreadContext = createContext(null); + +export function CellThreadProvider({ children }: { children: ReactNode }) { + const { threads } = useThreads(); + const [openCell, setOpenCell] = useState(null); + const selection = useSelectionValue(); + + // Index the most recent thread per cell for O(1) lookups in each renderer. + // Resolved threads are hidden, so they drop out of the marker, the open logic, + // and the overlay. + const byCell = useMemo(() => { + const map = new Map(); + for (const thread of threads) { + if (thread.resolved) { + continue; + } + const { rowId, colId } = thread.metadata; + if (rowId && colId) { + map.set(cellKey(rowId, colId), thread); + } + } + return map; + }, [threads]); + + // Single-click to open: when the selected (anchor) cell already has a thread, + // open it. Keyed on the live selection value only, so it fires once per + // selection change — it won't reopen after the user closes the thread, and + // doesn't fight the grid's selection dedupe. `byCell` is read via a ref to + // avoid re-running on unrelated thread updates. + const byCellRef = useRef(byCell); + byCellRef.current = byCell; + useEffect(() => { + if (!selection) { + return; + } + const { anchor } = selection; + if (byCellRef.current.has(cellKey(anchor.rowId, anchor.colId))) { + setOpenCell(anchor); + } + }, [selection]); + + const value = useMemo( + () => ({ + getThread: (rowId, colId) => byCell.get(cellKey(rowId, colId)), + openCell, + setOpenCell, + }), + [byCell, openCell] + ); + + return ( + + {children} + + ); +} + +export function useCellThread(): CellThreadContextValue { + const context = useContext(CellThreadContext); + if (!context) { + throw new Error("useCellThread must be used within a CellThreadProvider"); + } + return context; +} diff --git a/examples/nextjs-ai-spreadsheet/app/Chat.tsx b/examples/nextjs-ai-spreadsheet/app/Chat.tsx new file mode 100644 index 00000000000..081d063a1f9 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/Chat.tsx @@ -0,0 +1,533 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { nanoid } from "nanoid"; +import { useStickToBottomContext } from "use-stick-to-bottom"; +import { + ClientSideSuspense, + useCreateFeed, + useCreateFeedMessage, + useDeleteFeedMessage, + useFeedMessages, + useFeeds, + useOthers, + useSelf, + useUpdateMyPresence, +} from "@liveblocks/react/suspense"; +import { Avatar } from "@liveblocks/react-ui"; +import { + CheckIcon, + CopyIcon, + HistoryIcon, + PlusIcon, + RefreshCcwIcon, + SparklesIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { + Message, + MessageAction, + MessageActions, + MessageContent, + MessageResponse, +} from "@/components/ai-elements/message"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtHeader, + ChainOfThoughtStep, +} from "@/components/ai-elements/chain-of-thought"; +import { Context, ContextTrigger } from "@/components/ai-elements/context"; +import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; +import { Loader } from "@/components/ai-elements/loader"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSelect, + PromptInputSelectContent, + PromptInputSelectItem, + PromptInputSelectTrigger, + PromptInputSelectValue, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + type PromptInputMessage, +} from "@/components/ai-elements/prompt-input"; +import { Shimmer } from "@/components/ai-elements/shimmer"; + +const MODELS = [ + { id: "openai/gpt-5.4-mini", name: "GPT-5.4 mini" }, + { id: "google/gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5" }, +]; + +const STARTER_PROMPTS = [ + "Fill in a 5-row sample sales table", + "Add a Q2 column with projected values", + "Make the header row bold and blue", + "Total the Planned and Actual columns", +]; + +const MAX_TOKENS = 128_000; + +export function Chat({ roomId }: { roomId: string }) { + const { feeds } = useFeeds(); + + const chats = useMemo( + () => [...feeds].sort((a, b) => b.createdAt - a.createdAt), + [feeds] + ); + + const [feedId, setFeedId] = useState(() => chats[0]?.feedId ?? "main"); + const [model, setModel] = useState(MODELS[0].id); + + const newChat = useCallback(() => setFeedId(nanoid()), []); + + return ( +
+
+
+ + AI assistant +
+
+ + + + + + + Chat history + + {chats.length === 0 ? ( + No chats yet + ) : ( + chats.map((chat) => ( + setFeedId(chat.feedId)} + className={chat.feedId === feedId ? "bg-accent" : undefined} + > + + {chat.metadata?.title || "Untitled chat"} + + + )) + )} + + +
+
+ + + +
+ } + > + + + + ); +} + +// Exposes the StickToBottom context's `scrollToBottom` to `ChatWindow` (which +// renders `` and so sits outside the context itself). +function ScrollToBottomBridge({ + register, +}: { + register: (scrollToBottom: () => void) => void; +}) { + const { scrollToBottom } = useStickToBottomContext(); + useEffect(() => { + register(scrollToBottom); + }, [register, scrollToBottom]); + return null; +} + +function ChatWindow({ + roomId, + feedId, + model, + setModel, +}: { + roomId: string; + feedId: string; + model: string; + setModel: (model: string) => void; +}) { + const { messages } = useFeedMessages(feedId); + const createFeed = useCreateFeed(); + const createFeedMessage = useCreateFeedMessage(); + const deleteFeedMessage = useDeleteFeedMessage(); + const self = useSelf(); + const updateMyPresence = useUpdateMyPresence(); + + // On open, the panel mounts fresh and its content settles over a few frames. + // Keep scrolling instant during that window so it lands at the bottom without + // animating, then switch to smooth so streaming replies scroll nicely. + const [scrollBehavior, setScrollBehavior] = useState<"instant" | "smooth">( + "instant" + ); + useEffect(() => { + const timeout = setTimeout(() => setScrollBehavior("smooth"), 150); + return () => clearTimeout(timeout); + }, []); + + const selfPrompting = self.presence.promptingFeedId === feedId; + const othersPrompting = useOthers((others) => + others.some((other) => other.presence.promptingFeedId === feedId) + ); + const aiThinking = selfPrompting || othersPrompting; + + const ensuredFeeds = useRef(new Set(messages.length > 0 ? [feedId] : [])); + const ensureFeed = useCallback( + async (id: string, title: string) => { + if (ensuredFeeds.current.has(id)) { + return; + } + ensuredFeeds.current.add(id); + try { + await createFeed(id, { metadata: { title } }); + } catch { + // Feed already exists (likely created by another user), ignore. + } + }, + [createFeed] + ); + + const inFlight = useRef(false); + const sorted = [...messages].sort((a, b) => a.createdAt - b.createdAt); + + // `use-stick-to-bottom` only auto-follows when the view is already pinned to + // the bottom. When the user sends (or regenerates), force a scroll to the + // bottom so the new message + streaming reply are visible even if they had + // scrolled up. The `scrollToBottom` fn lives in the StickToBottom context, so + // a small bridge component inside `` registers it here. + const scrollToBottomRef = useRef<(() => void) | null>(null); + const registerScrollToBottom = useCallback((fn: () => void) => { + scrollToBottomRef.current = fn; + }, []); + + const postReply = useCallback( + async (history: { role: "user" | "assistant"; content: string }[]) => { + await fetch("/api/ai-chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId, feedId, model, messages: history }), + }); + }, + [roomId, feedId, model] + ); + + const send = useCallback( + async (text: string) => { + const content = text.trim(); + if (!content || inFlight.current) { + return; + } + + inFlight.current = true; + updateMyPresence({ promptingFeedId: feedId }); + try { + await ensureFeed(feedId, content.slice(0, 60)); + await createFeedMessage(feedId, { + role: "user", + content, + userId: self.id, + name: self.info.name, + avatar: self.info.avatar, + }); + // Re-pin to the bottom now that a new message is in; the streaming + // reply then follows automatically. + scrollToBottomRef.current?.(); + + const history = [ + ...sorted.map((message) => ({ + role: message.data.role, + content: message.data.content, + })), + { role: "user" as const, content }, + ]; + await postReply(history); + } catch { + // Best-effort in this demo; errors are non-fatal to the UI. + } finally { + updateMyPresence({ promptingFeedId: null }); + inFlight.current = false; + } + }, + [ + feedId, + ensureFeed, + createFeedMessage, + self, + sorted, + postReply, + updateMyPresence, + ] + ); + + const regenerate = useCallback( + async (messageId: string) => { + const index = sorted.findIndex((message) => message.id === messageId); + if (index === -1 || inFlight.current) { + return; + } + + inFlight.current = true; + updateMyPresence({ promptingFeedId: feedId }); + try { + const history = sorted.slice(0, index).map((message) => ({ + role: message.data.role, + content: message.data.content, + })); + await deleteFeedMessage(feedId, messageId); + scrollToBottomRef.current?.(); + await postReply(history); + } catch { + // Best-effort in this demo; errors are non-fatal to the UI. + } finally { + updateMyPresence({ promptingFeedId: null }); + inFlight.current = false; + } + }, + [feedId, sorted, deleteFeedMessage, postReply, updateMyPresence] + ); + + const usedTokens = sorted.reduce( + (sum, message) => sum + (message.data.usedTokens ?? 0), + 0 + ); + + const lastMessage = sorted.at(-1); + const followUps = + !aiThinking && + lastMessage?.data.role === "assistant" && + !lastMessage.data.streaming + ? lastMessage.data.suggestions + : undefined; + + return ( + <> + {/* `scrollBehavior` is "instant" while the panel opens (so it jumps + straight to the latest message) then becomes "smooth" for streaming. */} + + + + {sorted.length === 0 ? ( + + +
+

Edit the sheet with AI

+

+ Ask the assistant to fill, format, or restructure the grid. It + writes to the shared spreadsheet live as it replies. +

+
+ + {STARTER_PROMPTS.map((prompt) => ( + + ))} + +
+ ) : ( + sorted.map((message) => { + const { + role, + content, + reasoning, + name, + avatar, + streaming, + tools, + } = message.data; + const isAssistant = role === "assistant"; + + return ( + + +
+ + + {name ?? (isAssistant ? "Liveblocks AI" : "Someone")} + +
+ + {isAssistant && reasoning ? ( + + + {reasoning} + + ) : null} + + {isAssistant && tools && tools.length > 0 ? ( + + + {tools.length === 1 + ? "1 action taken" + : `${tools.length} actions taken`} + + + {tools.map((tool, index) => ( + + ))} + + + ) : null} + +
+ {content ? ( + {content} + ) : null} + + {isAssistant && streaming && !content && !reasoning ? ( + Working… + ) : null} +
+ + {isAssistant ? ( + + + regenerate(message.id)} + disabled={streaming} + > + + + + ) : null} +
+
+ ); + }) + )} +
+ +
+ +
+ {followUps && followUps.length > 0 ? ( + + {followUps.map((prompt) => ( + + ))} + + ) : null} + + send(message.text)} + > + + + + + + + + + + + {MODELS.map((m) => ( + + {m.name} + + ))} + + + +
+ {usedTokens > 0 ? ( + + + + ) : null} + +
+
+
+
+ + ); +} + +// Copy the message's text to the clipboard, briefly showing a check icon. +function CopyMessageAction({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const copy = useCallback(() => { + if (!text) { + return; + } + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, [text]); + + return ( + + {copied ? ( + + ) : ( + + )} + + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/CommentOverlay.tsx b/examples/nextjs-ai-spreadsheet/app/CommentOverlay.tsx new file mode 100644 index 00000000000..dfc9a6b7e51 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/CommentOverlay.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from "react"; +import type { HotTableRef } from "@handsontable/react-wrapper"; +import { FloatingComposer, FloatingThread } from "@liveblocks/react-ui"; +import { useOrder } from "./OrderContext"; +import { useCellThread } from "./CellThreadContext"; + +type Rect = { top: number; left: number; width: number; height: number }; + +// Render a comment in a corner of a cell, without a popover +export function CommentOverlay({ + hotRef, +}: { + hotRef: RefObject; +}) { + const { rowIds, colIds } = useOrder(); + const { getThread, openCell, setOpenCell } = useCellThread(); + const [rect, setRect] = useState(null); + + // Stop popover closing when submitting a comment + const lastSubmitRef = useRef(0); + const closeAfterInteractOutside = useCallback(() => { + if (Date.now() - lastSubmitRef.current < 1000) { + return; + } + setOpenCell(null); + }, [setOpenCell]); + const onComposerSubmit = useCallback(() => { + lastSubmitRef.current = Date.now(); + }, []); + + const visualRow = openCell ? rowIds.indexOf(openCell.rowId) : -1; + const visualCol = openCell ? colIds.indexOf(openCell.colId) : -1; + + const measure = useCallback(() => { + const instance = hotRef.current?.hotInstance; + if (!instance || visualRow < 0 || visualCol < 0) { + setRect(null); + return; + } + const td = instance.getCell(visualRow, visualCol) as HTMLElement | null; + if (!td) { + setRect(null); + return; + } + const r = td.getBoundingClientRect(); + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); + }, [hotRef, visualRow, visualCol]); + + useLayoutEffect(() => { + measure(); + }, [measure]); + + // Re-measure the anchor whenever the grid scrolls/renders or the window + // resizes, so the fixed-positioned anchor tracks the cell. + useEffect(() => { + const instance = hotRef.current?.hotInstance; + if (!instance) { + return; + } + const onChange = () => measure(); + instance.addHook("afterScrollVertically", onChange); + instance.addHook("afterScrollHorizontally", onChange); + instance.addHook("afterRender", onChange); + window.addEventListener("resize", onChange); + return () => { + // On unmount / Fast Refresh the Handsontable instance may already be + // destroyed; calling removeHook on it then throws. Guard + try/catch. + if (!instance.isDestroyed) { + try { + instance.removeHook("afterScrollVertically", onChange); + instance.removeHook("afterScrollHorizontally", onChange); + instance.removeHook("afterRender", onChange); + } catch { + // Instance was destroyed between the check and the calls; ignore. + } + } + window.removeEventListener("resize", onChange); + }; + }, [hotRef, measure]); + + if (!openCell || !rect) { + return null; + } + + const thread = getThread(openCell.rowId, openCell.colId); + const metadata = { rowId: openCell.rowId, colId: openCell.colId }; + + return ( +
+ {thread ? ( + { + if (!open) { + closeAfterInteractOutside(); + } + }} + onComposerSubmit={onComposerSubmit} + onResolvedChange={(resolved) => { + // Resolving hides the thread, so close the overlay immediately. + if (resolved) { + setOpenCell(null); + } + }} + style={{ zIndex: 50 }} + autoFocus + > +
+ + ) : ( + { + if (open) { + setOpenCell(metadata); + } else { + closeAfterInteractOutside(); + } + }} + onComposerSubmit={onComposerSubmit} + style={{ zIndex: 50 }} + > +
+ + )} +
+ ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/Loading.tsx b/examples/nextjs-ai-spreadsheet/app/Loading.tsx new file mode 100644 index 00000000000..e89154a5f8b --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/Loading.tsx @@ -0,0 +1,11 @@ +export function Loading() { + return ( +
+ Loading +
+ ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/NotificationsPopover.tsx b/examples/nextjs-ai-spreadsheet/app/NotificationsPopover.tsx new file mode 100644 index 00000000000..25796add24b --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/NotificationsPopover.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Suspense } from "react"; +import { Popover as PopoverPrimitive } from "radix-ui"; +import { BellIcon } from "lucide-react"; +import { + useInboxNotifications, + useMarkAllInboxNotificationsAsRead, + useUnreadInboxNotificationsCount, +} from "@liveblocks/react/suspense"; +import { InboxNotification, InboxNotificationList } from "@liveblocks/react-ui"; +import { Button } from "@/components/ui/button"; + +export function NotificationsPopover() { + return ( + + + + + + + + + Loading… +
+ } + > + + + + + + ); +} + +function UnreadBadge() { + const { count } = useUnreadInboxNotificationsCount(); + + if (count <= 0) { + return null; + } + + return ( + + {count > 9 ? "9+" : count} + + ); +} + +function Inbox() { + const { inboxNotifications } = useInboxNotifications(); + const markAllAsRead = useMarkAllInboxNotificationsAsRead(); + + return ( + <> +
+ Notifications + +
+ +
+ {inboxNotifications.length === 0 ? ( +
+ No notifications yet +
+ ) : ( + + {inboxNotifications.map((inboxNotification) => ( + + ))} + + )} +
+ + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/OrderContext.tsx b/examples/nextjs-ai-spreadsheet/app/OrderContext.tsx new file mode 100644 index 00000000000..066bf9025bb --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/OrderContext.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; + +// Current visual order of the grid, provided by the Table to its cell renderer +// so each cell can translate its visual (row, col) into stable (rowId, colId). +export type Order = { + rowIds: string[]; + colIds: string[]; +}; + +const OrderContext = createContext(null); + +export function OrderProvider({ + order, + children, +}: { + order: Order; + children: ReactNode; +}) { + return {children}; +} + +export function useOrder(): Order { + const context = useContext(OrderContext); + if (!context) { + throw new Error("useOrder must be used within an OrderProvider"); + } + return context; +} diff --git a/examples/nextjs-ai-spreadsheet/app/Providers.tsx b/examples/nextjs-ai-spreadsheet/app/Providers.tsx new file mode 100644 index 00000000000..3f7b7474a14 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/Providers.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react/suspense"; +import { PropsWithChildren } from "react"; + +export function Providers({ children }: PropsWithChildren) { + return ( + { + const search = new URLSearchParams( + userIds.map((userId) => ["userIds", userId]) + ); + const response = await fetch(`/api/users?${search}`); + if (!response.ok) { + throw new Error("Problem resolving users"); + } + return await response.json(); + }} + // Suggest users to @mention when writing a comment. + resolveMentionSuggestions={async ({ text }) => { + const response = await fetch( + `/api/users/search?text=${encodeURIComponent(text)}` + ); + if (!response.ok) { + throw new Error("Problem resolving mention suggestions"); + } + return await response.json(); + }} + > + {children} + + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/Room.tsx b/examples/nextjs-ai-spreadsheet/app/Room.tsx new file mode 100644 index 00000000000..41cea21943c --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/Room.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { ReactNode } from "react"; +import { LiveList, LiveMap, LiveObject } from "@liveblocks/client"; +import { ClientSideSuspense, RoomProvider } from "@liveblocks/react/suspense"; +import { nanoid } from "nanoid"; +import { + cellKey, + DEFAULT_COLS, + DEFAULT_ROWS, + type CellData, + type CellFormat, +} from "@/liveblocks.config"; +import { useExampleRoomId } from "@/hooks/use-example-room-id"; +import { Loading } from "./Loading"; + +export function Room({ children }: { children: ReactNode }) { + const roomId = useExampleRoomId(); + + return ( + + }>{children} + + ); +} + +// Builds an initial example table +function createInitialStorage() { + const rowIds = Array.from({ length: DEFAULT_ROWS }, () => nanoid()); + const colIds = Array.from({ length: DEFAULT_COLS }, () => nanoid()); + + const cellEntries: [string, LiveObject][] = []; + const seed = (row: number, col: number, value: string, format?: CellFormat) => + cellEntries.push([ + cellKey(rowIds[row], colIds[col]), + new LiveObject(format ? { value, format } : { value }), + ]); + + seed(0, 0, "Team Budget — Q1", { bold: true }); + + const headerFormat: CellFormat = { bold: true, background: "#dbeafe" }; + seed(2, 0, "Category", headerFormat); + seed(2, 1, "Planned", { ...headerFormat, align: "right" }); + seed(2, 2, "Actual", { ...headerFormat, align: "right" }); + seed(2, 3, "Variance", { ...headerFormat, align: "right" }); + + const rows: [string, number, number][] = [ + ["Marketing", 12000, 10450], + ["Engineering", 45000, 47200], + ["Design", 18000, 16800], + ["Operations", 9000, 8700], + ]; + rows.forEach(([name, planned, actual], index) => { + const r = 3 + index; + const variance = actual - planned; + seed(r, 0, name); + seed(r, 1, String(planned), { numberFormat: "currency", align: "right" }); + seed(r, 2, String(actual), { numberFormat: "currency", align: "right" }); + seed(r, 3, String(variance), { + numberFormat: "currency", + align: "right", + color: variance < 0 ? "#ef4444" : "#22c55e", + }); + }); + + return { + rowIds: new LiveList(rowIds), + colIds: new LiveList(colIds), + cells: new LiveMap>(cellEntries), + colWidths: new LiveMap([[colIds[0], 180]]), + rowHeights: new LiveMap(), + }; +} diff --git a/examples/nextjs-ai-spreadsheet/app/SelectionContext.tsx b/examples/nextjs-ai-spreadsheet/app/SelectionContext.tsx new file mode 100644 index 00000000000..4b3aa38831d --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/SelectionContext.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; +import type { ReactNode } from "react"; + +// The cells currently selected in the grid, expressed with stable ids so the +// toolbar can apply formatting/comments to exactly the right logical cells even +// after rows/columns have been reordered. +export type Selection = { + rowIds: string[]; + colIds: string[]; + // The single "active" cell (top-left of the selection), used for comments. + anchor: { rowId: string; colId: string }; +}; + +type SetSelection = (selection: Selection | null) => void; + +// Split into two contexts: the current selection *value* (changes on every +// click) and the *setter* (referentially stable for the lifetime of the +// provider). This lets the grid subscribe to only the setter, so moving the +// selection never re-renders the subtree — only consumers that +// actually read the value (the toolbar) re-render. +const SelectionStateContext = createContext(null); +const SelectionSetContext = createContext(null); + +export function SelectionProvider({ children }: { children: ReactNode }) { + const [selection, setSelection] = useState(null); + return ( + + + {children} + + + ); +} + +// Read the current selection. Re-renders when the selection changes. +export function useSelectionValue(): Selection | null { + return useContext(SelectionStateContext); +} + +// Get the (stable) selection setter without subscribing to selection changes. +export function useSetSelection(): SetSelection { + const setSelection = useContext(SelectionSetContext); + if (!setSelection) { + throw new Error("useSetSelection must be used within a SelectionProvider"); + } + return setSelection; +} diff --git a/examples/nextjs-ai-spreadsheet/app/Spreadsheet.tsx b/examples/nextjs-ai-spreadsheet/app/Spreadsheet.tsx new file mode 100644 index 00000000000..67c6aca69b3 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/Spreadsheet.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import { AvatarStack } from "@liveblocks/react-ui"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useExampleRoomId } from "@/hooks/use-example-room-id"; +import { NotificationsPopover } from "./NotificationsPopover"; +import { SelectionProvider } from "./SelectionContext"; +import { CellThreadProvider } from "./CellThreadContext"; +import { Toolbar } from "./Toolbar"; +import { Table } from "./Table"; +import { Chat } from "./Chat"; + +export function Spreadsheet() { + const roomId = useExampleRoomId(); + const [chatOpen, setChatOpen] = useState(true); + + return ( + + + +
+
+
+
+ + + +
+ + AI Spreadsheet + +
+
+ + +
+
+ + setChatOpen((open) => !open)} + /> + +
+
+ {/* Absolute fill so Handsontable gets a concrete-sized box. */} +
+ + + + {chatOpen ? ( + + ) : null} + + + + + + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/Table.tsx b/examples/nextjs-ai-spreadsheet/app/Table.tsx new file mode 100644 index 00000000000..c8de2a27758 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/Table.tsx @@ -0,0 +1,557 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { HotTable, type HotTableRef } from "@handsontable/react-wrapper"; +import { registerAllModules } from "handsontable/registry"; +import type { CellChange, ChangeSource } from "handsontable/common"; +import { HyperFormula } from "hyperformula"; +import { shallow } from "@liveblocks/client"; +import { + useOthers, + useStorage, + useThreads, + useUpdateMyPresence, +} from "@liveblocks/react/suspense"; +import { + cellKey, + DEFAULT_COL_WIDTH, + DEFAULT_ROW_HEIGHT, + type CellFormat, +} from "@/liveblocks.config"; +import { colIndexToLetters } from "@/lib/a1"; +import { formatDisplayValue } from "@/lib/format"; +import { useSpreadsheetActions } from "./useSpreadsheetActions"; +import { useSetSelection } from "./SelectionContext"; +import { OrderProvider } from "./OrderContext"; +import { CommentOverlay } from "./CommentOverlay"; + +registerAllModules(); + +// A presence highlight on a single cell. `top`/`right`/`bottom`/`left` mark +// which of the cell's edges sit on the *boundary* of that user's selected +// region, so the renderer can draw one box around the whole region (a single +// cell has all four edges, a range only has the outer ones). +type CellSelector = { + name: string; + color: string; + top: boolean; + right: boolean; + bottom: boolean; + left: boolean; +}; +type HotInstance = NonNullable; + +// Removes `moved` (array of indices) from `ids`, then re-inserts them at +// `finalIndex` — mirrors Handsontable's manual move so we can apply it to the +// shared id order ourselves and cancel Handsontable's own (visual) move. +function reorder(ids: string[], moved: number[], finalIndex: number): string[] { + const movedIds = moved.map((index) => ids[index]); + const next = [...ids]; + for (const index of [...moved].sort((a, b) => b - a)) { + next.splice(index, 1); + } + next.splice(finalIndex, 0, ...movedIds); + return next; +} + +// Sort comparator: numeric when both look like numbers, otherwise locale text. +// Empty cells sort to the bottom. +function compareValues(a: string, b: string): number { + if (a === b) { + return 0; + } + if (a === "") { + return 1; + } + if (b === "") { + return -1; + } + const na = Number(a.replace(/[$,%\s]/g, "")); + const nb = Number(b.replace(/[$,%\s]/g, "")); + if (!Number.isNaN(na) && !Number.isNaN(nb)) { + return na - nb; + } + return a.localeCompare(b); +} + +export function Table() { + const hotRef = useRef(null); + const actions = useSpreadsheetActions(); + const setSelection = useSetSelection(); + const updateMyPresence = useUpdateMyPresence(); + + // Stable id order (re-used by the cell renderer and every grid handler). + const rowIds = useStorage((root) => [...root.rowIds], shallow); + const colIds = useStorage((root) => [...root.colIds], shallow); + + // Sparse value map → only changes when a value (not formatting) changes, so + // the Handsontable data array isn't rebuilt on formatting/size edits. + // (In immutable form, the `cells` LiveMap reads as a plain object.) + const values = useStorage((root) => { + const out: Record = {}; + for (const [key, cell] of Object.entries(root.cells)) { + if (cell.value) { + out[key] = cell.value; + } + } + return out; + }, shallow); + + // Per-cell formatting, presence selections, and which cells have a thread — + // these are NOT part of the Handsontable `data` array, so the function + // renderer reads them from refs and we trigger a repaint when they change. + const cellsFormat = useStorage((root) => { + const out: Record = {}; + for (const [key, cell] of Object.entries(root.cells)) { + if (cell.format) { + out[key] = cell.format; + } + } + return out; + }, shallow); + + // Each other user's selected region, as the set of cell keys they're editing. + // (Humans publish a single cell; the AI publishes every cell of a multi-cell + // edit.) We compute the per-cell box edges separately, below, where the row + // and column order is available. + const othersSelections = useOthers((others) => { + const out: { name: string; color: string; keys: string[] }[] = []; + for (const other of others) { + const cells = other.presence.selectedCells; + if (!cells || cells.length === 0) { + continue; + } + const keys: string[] = []; + for (const cell of cells) { + if (cell?.rowId && cell?.colId) { + keys.push(cellKey(cell.rowId, cell.colId)); + } + } + if (keys.length) { + out.push({ + name: other.info?.name ?? "Someone", + color: other.info?.color ?? "#888888", + keys, + }); + } + } + return out; + }, shallow); + + // Turn each user's set of cells into per-cell box edges: a cell's edge is part + // of the outline only when the neighbouring cell (in the current row/column + // order) isn't also in that same user's selection. A single cell keeps all + // four edges; a rectangular range only outlines its perimeter. + const presenceByCell = useMemo(() => { + const out: Record = {}; + const rowIndex = new Map(rowIds.map((id, i) => [id, i])); + const colIndex = new Map(colIds.map((id, i) => [id, i])); + for (const sel of othersSelections) { + const keySet = new Set(sel.keys); + const inSet = (r: number, c: number) => { + const rowId = rowIds[r]; + const colId = colIds[c]; + return ( + rowId !== undefined && + colId !== undefined && + keySet.has(cellKey(rowId, colId)) + ); + }; + for (const key of sel.keys) { + const [rowId, colId] = key.split(":"); + const r = rowIndex.get(rowId); + const c = colIndex.get(colId); + if (r === undefined || c === undefined) { + continue; + } + (out[key] ??= []).push({ + name: sel.name, + color: sel.color, + top: !inSet(r - 1, c), + bottom: !inSet(r + 1, c), + left: !inSet(r, c - 1), + right: !inSet(r, c + 1), + }); + } + } + return out; + }, [othersSelections, rowIds, colIds]); + + const { threads } = useThreads(); + const threadKeys = useMemo(() => { + const set = new Set(); + for (const thread of threads) { + if (thread.resolved) { + continue; + } + const { rowId, colId } = thread.metadata; + if (rowId && colId) { + set.add(cellKey(rowId, colId)); + } + } + return set; + }, [threads]); + + const colWidths = useStorage( + (root) => + [...root.colIds].map((id) => root.colWidths[id] ?? DEFAULT_COL_WIDTH), + shallow + ); + const rowHeights = useStorage( + (root) => + [...root.rowIds].map((id) => root.rowHeights[id] ?? DEFAULT_ROW_HEIGHT), + shallow + ); + + const data = useMemo( + () => rowIds.map((r) => colIds.map((c) => values[cellKey(r, c)] ?? "")), + [rowIds, colIds, values] + ); + + // Refs so the (stable) Handsontable callbacks and function renderer always + // read the latest order / data without being re-created. + const rowIdsRef = useRef(rowIds); + rowIdsRef.current = rowIds; + const colIdsRef = useRef(colIds); + colIdsRef.current = colIds; + const valuesRef = useRef(values); + valuesRef.current = values; + const cellsFormatRef = useRef(cellsFormat); + cellsFormatRef.current = cellsFormat; + const presenceByCellRef = useRef(presenceByCell); + presenceByCellRef.current = presenceByCell; + const threadKeysRef = useRef(threadKeys); + threadKeysRef.current = threadKeys; + const lastSelKey = useRef(""); + + // We cancel Handsontable's own sort (see `beforeColumnSort`), so the + // columnSorting plugin's per-column state never advances and the `sortOrder` + // it hands us is always "asc". Track the direction ourselves to toggle + // asc → desc on repeated clicks of the same column header. + const sortRef = useRef<{ colId: string; sortOrder: "asc" | "desc" } | null>( + null + ); + + const order = useMemo(() => ({ rowIds, colIds }), [rowIds, colIds]); + + // Plain Handsontable function renderer: paints value + formatting + presence + // borders + comment marker directly onto the (recycled)
, synchronously + // on every Handsontable render. Every style is set or reset on every call, so + // a recycled/repainted cell never keeps a previous cell's content or styles. + const renderCell = useCallback( + ( + _instance: HotInstance, + td: HTMLTableCellElement, + row: number, + col: number, + _prop: string | number, + value: unknown + ) => { + // Reset everything this renderer may set (idempotent on recycled tds). + td.style.background = ""; + td.style.boxShadow = ""; + td.style.fontWeight = ""; + td.style.fontStyle = ""; + td.style.textDecoration = ""; + td.style.color = ""; + td.style.textAlign = ""; + td.classList.remove("has-comment"); + td.removeAttribute("title"); + + const rowId = rowIdsRef.current[row]; + const colId = colIdsRef.current[col]; + const raw = value == null ? "" : String(value); + + // Transient frame (order not resolved yet): still show the raw value. + if (!rowId || !colId) { + td.textContent = raw; + return; + } + + const key = cellKey(rowId, colId); + const format = cellsFormatRef.current[key]; + + td.textContent = formatDisplayValue(raw, format?.numberFormat); + + if (format) { + if (format.bold) { + td.style.fontWeight = "600"; + } + if (format.italic) { + td.style.fontStyle = "italic"; + } + const decorations: string[] = []; + if (format.underline) { + decorations.push("underline"); + } + if (format.strike) { + decorations.push("line-through"); + } + if (decorations.length) { + td.style.textDecoration = decorations.join(" "); + } + if (format.color) { + td.style.color = format.color; + } + if (format.align) { + td.style.textAlign = format.align; + } + if (format.background) { + td.style.background = format.background; + } + } + + const selectors = presenceByCellRef.current[key]; + if (selectors && selectors.length) { + // Draw only the boundary edges of each user's region, so a multi-cell + // selection reads as one box rather than a border around every cell. + // Stack multiple users concentrically (first listed sits on top). + const shadows: string[] = []; + for (let i = 0; i < selectors.length; i++) { + const s = selectors[i]; + const w = 2 + i * 2; + if (s.top) { + shadows.push(`inset 0 ${w}px 0 0 ${s.color}`); + } + if (s.bottom) { + shadows.push(`inset 0 -${w}px 0 0 ${s.color}`); + } + if (s.left) { + shadows.push(`inset ${w}px 0 0 0 ${s.color}`); + } + if (s.right) { + shadows.push(`inset -${w}px 0 0 0 ${s.color}`); + } + } + if (shadows.length) { + td.style.boxShadow = shadows.join(", "); + } + td.title = selectors.map((s) => s.name).join(", "); + } + + if (threadKeysRef.current.has(key)) { + td.classList.add("has-comment"); + } + }, + [] + ); + + // Repaint the grid whenever formatting, presence, or the set of comment + // threads change. (Value/order changes flow through the `data` prop, which + // already triggers a Handsontable re-render.) + useEffect(() => { + hotRef.current?.hotInstance?.render(); + }, [cellsFormat, presenceByCell, threadKeys]); + + const colHeaders = useCallback( + (index: number) => colIndexToLetters(index), + [] + ); + const rowHeaders = useCallback((index: number) => String(index + 1), []); + + const afterChange = useCallback( + (changes: CellChange[] | null, source: ChangeSource) => { + if (!changes || source === "loadData") { + return; + } + const instance = hotRef.current?.hotInstance; + for (const [visualRow, prop, , newVal] of changes) { + if (typeof prop !== "number") { + continue; + } + const rowId = rowIdsRef.current[visualRow]; + const colId = colIdsRef.current[prop]; + if (!rowId || !colId) { + continue; + } + // With the Formulas plugin, `newVal` is the *computed* result. Persist + // the underlying source instead + const source = instance?.getSourceDataAtCell(visualRow, prop) ?? newVal; + actions.setCellValue( + rowId, + colId, + source === null || source === undefined ? "" : String(source) + ); + } + }, + [actions] + ); + + const onSelection = useCallback( + (row: number, col: number, row2: number, col2: number) => { + const r1 = Math.max(0, Math.min(row, row2)); + const r2 = Math.max(row, row2); + const c1 = Math.max(0, Math.min(col, col2)); + const c2 = Math.max(col, col2); + const selectedRowIds = rowIdsRef.current.slice(r1, r2 + 1); + const selectedColIds = colIdsRef.current.slice(c1, c2 + 1); + if (!selectedRowIds.length || !selectedColIds.length) { + return; + } + const anchor = { + rowId: rowIdsRef.current[Math.max(0, row)] ?? selectedRowIds[0], + colId: colIdsRef.current[Math.max(0, col)] ?? selectedColIds[0], + }; + const key = `${r1},${c1},${r2},${c2}`; + if (key === lastSelKey.current) { + return; + } + lastSelKey.current = key; + setSelection({ + rowIds: selectedRowIds, + colIds: selectedColIds, + anchor, + }); + // Humans only broadcast their single active cell, even when a range is + // selected — the multi-cell box is reserved for the AI's live edits. + updateMyPresence({ selectedCells: [anchor] }); + }, + [setSelection, updateMyPresence] + ); + + const onDeselect = useCallback(() => { + lastSelKey.current = ""; + setSelection(null); + updateMyPresence({ selectedCells: null }); + }, [setSelection, updateMyPresence]); + + const afterColumnResize = useCallback( + (newSize: number, column: number) => { + const colId = colIdsRef.current[column]; + if (colId) { + actions.setColWidth(colId, newSize); + } + }, + [actions] + ); + + const afterRowResize = useCallback( + (newSize: number, row: number) => { + const rowId = rowIdsRef.current[row]; + if (rowId) { + actions.setRowHeight(rowId, newSize); + } + }, + [actions] + ); + + // Cancel Handsontable's own visual move and reorder the shared id list + // instead. The grid stays a pure identity projection of Storage. + const beforeRowMove = useCallback( + ( + movedRows: number[], + finalIndex: number, + _drop: number | undefined, + movePossible: boolean + ) => { + if (!movePossible) { + return; + } + const next = reorder(rowIdsRef.current, movedRows, finalIndex); + actions.setRowOrder(next); + return false; + }, + [actions] + ); + + const beforeColumnMove = useCallback( + ( + movedColumns: number[], + finalIndex: number, + _drop: number | undefined, + movePossible: boolean + ) => { + if (!movePossible) { + return; + } + const next = reorder(colIdsRef.current, movedColumns, finalIndex); + actions.setColOrder(next); + return false; + }, + [actions] + ); + + const beforeColumnSort = useCallback( + ( + _current: unknown, + destination: { column: number; sortOrder?: "asc" | "desc" }[] + ) => { + const config = destination?.[0]; + if (!config) { + return false; + } + const colId = colIdsRef.current[config.column]; + if (!colId) { + return false; + } + const prev = sortRef.current; + const sortOrder: "asc" | "desc" = + prev && prev.colId === colId && prev.sortOrder === "asc" + ? "desc" + : "asc"; + sortRef.current = { colId, sortOrder }; + const sorted = [...rowIdsRef.current].sort((a, b) => + compareValues( + valuesRef.current[cellKey(a, colId)] ?? "", + valuesRef.current[cellKey(b, colId)] ?? "" + ) + ); + if (sortOrder === "desc") { + sorted.reverse(); + } + actions.setRowOrder(sorted); + return false; + }, + [actions] + ); + + return ( + + + target instanceof HTMLElement && target.closest(".lb-portal") !== null + } + afterColumnResize={afterColumnResize} + afterRowResize={afterRowResize} + beforeRowMove={beforeRowMove} + beforeColumnMove={beforeColumnMove} + beforeColumnSort={beforeColumnSort} + manualColumnResize={true} + manualRowResize={true} + manualRowMove={true} + manualColumnMove={true} + columnSorting={{ headerAction: false, indicator: false }} + width="100%" + height="100%" + licenseKey="non-commercial-and-evaluation" + autoWrapRow={true} + autoWrapCol={true} + autoRowSize={false} + autoColumnSize={false} + // renderAllRows={true} + // renderAllColumns={true} + stretchH="none" + rowHeaderWidth={48} + /> + + + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/Toolbar.tsx b/examples/nextjs-ai-spreadsheet/app/Toolbar.tsx new file mode 100644 index 00000000000..06d905b9894 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/Toolbar.tsx @@ -0,0 +1,515 @@ +"use client"; + +import { useMemo, type ReactNode } from "react"; +import { shallow } from "@liveblocks/client"; +import { + useCanRedo, + useCanUndo, + useRedo, + useStorage, + useUndo, +} from "@liveblocks/react/suspense"; +import { + AlignCenterIcon, + AlignLeftIcon, + AlignRightIcon, + BaselineIcon, + BoldIcon, + DollarSignIcon, + EraserIcon, + HashIcon, + ItalicIcon, + MessageSquarePlusIcon, + PaintBucketIcon, + PanelRightCloseIcon, + PanelRightOpenIcon, + PercentIcon, + PlusIcon, + Redo2Icon, + StrikethroughIcon, + TableIcon, + Trash2Icon, + UnderlineIcon, + Undo2Icon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { HelpButton } from "@/components/HelpButton"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + cellKey, + type CellFormat, + type NumberFormat, +} from "@/liveblocks.config"; +import { useSelectionValue } from "./SelectionContext"; +import { useCellThread } from "./CellThreadContext"; +import { + useSpreadsheetActions, + type CellTarget, +} from "./useSpreadsheetActions"; + +const TEXT_COLORS = [ + "#171717", + "#ef4444", + "#f97316", + "#eab308", + "#22c55e", + "#3b82f6", + "#8b5cf6", + "#ec4899", + "#fee2e2", + "#ffedd5", + "#fef9c3", + "#dcfce7", + "#dbeafe", + "#ede9fe", + "#fce7f3", + "#f3f4f6", +]; + +const FILL_COLORS = [ + "#fee2e2", + "#ffedd5", + "#fef9c3", + "#dcfce7", + "#dbeafe", + "#ede9fe", + "#fce7f3", + "#f3f4f6", + "#171717", + "#ef4444", + "#f97316", + "#eab308", + "#22c55e", + "#3b82f6", + "#8b5cf6", + "#ec4899", +]; + +export function Toolbar({ + chatOpen, + onToggleChat, +}: { + chatOpen: boolean; + onToggleChat: () => void; +}) { + // The grid keeps its selection when focus moves to the toolbar + // (`outsideClickDeselects={false}` on the ), so toolbar actions can + // read the live selection directly. + const selection = useSelectionValue(); + const { setOpenCell } = useCellThread(); + const actions = useSpreadsheetActions(); + const undo = useUndo(); + const redo = useRedo(); + const canUndo = useCanUndo(); + const canRedo = useCanRedo(); + + const rowIds = useStorage((root) => [...root.rowIds], shallow); + const colIds = useStorage((root) => [...root.colIds], shallow); + + // Toggle states reflect the active (anchor) cell, like a real spreadsheet. + const anchorFormat = useStorage( + (root) => + selection + ? root.cells[cellKey(selection.anchor.rowId, selection.anchor.colId)] + ?.format + : undefined, + shallow + ); + + const targets = useMemo(() => { + if (!selection) { + return []; + } + const result: CellTarget[] = []; + for (const rowId of selection.rowIds) { + for (const colId of selection.colIds) { + result.push({ rowId, colId }); + } + } + return result; + }, [selection]); + + const hasSelection = targets.length > 0; + + const toggle = (key: "bold" | "italic" | "underline" | "strike") => { + if (!hasSelection) { + return; + } + const patch: Partial = {}; + patch[key] = !anchorFormat?.[key]; + actions.applyFormat(targets, patch); + }; + + const setAlign = (align: CellFormat["align"]) => { + if (!hasSelection) { + return; + } + actions.applyFormat(targets, { + align: anchorFormat?.align === align ? undefined : align, + }); + }; + + const setNumberFormat = (numberFormat: NumberFormat) => { + if (!hasSelection) { + return; + } + actions.applyFormat(targets, { + numberFormat: numberFormat === "general" ? undefined : numberFormat, + }); + }; + + const setColor = (color: string | undefined) => { + if (hasSelection) { + actions.applyFormat(targets, { color }); + } + }; + + const setBackground = (background: string | undefined) => { + if (hasSelection) { + actions.applyFormat(targets, { background }); + } + }; + + const anchorRowIndex = selection + ? rowIds.indexOf(selection.anchor.rowId) + : -1; + const anchorColIndex = selection + ? colIds.indexOf(selection.anchor.colId) + : -1; + + return ( +
+ undo()} + disabled={!canUndo} + icon={} + /> + redo()} + disabled={!canRedo} + icon={} + /> + + + + toggle("bold")} + icon={} + /> + toggle("italic")} + icon={} + /> + toggle("underline")} + icon={} + /> + toggle("strike")} + icon={} + /> + + + + } + resetLabel="Automatic" + /> + } + resetLabel="No fill" + /> + + + + setAlign("left")} + icon={} + /> + setAlign("center")} + icon={} + /> + setAlign("right")} + icon={} + /> + + + + setNumberFormat("general")} + icon={} + /> + setNumberFormat("currency")} + icon={} + /> + setNumberFormat("percent")} + icon={} + /> + + + + actions.clearFormatting(targets)} + icon={} + /> + + + + + + + + + Insert / delete + + + Rows + actions.insertRow(anchorRowIndex, actions.nanoid())} + > + Insert row above + + + actions.insertRow(anchorRowIndex + 1, actions.nanoid()) + } + > + Insert row below + + selection && actions.deleteRows(selection.rowIds)} + > + Delete selected rows + + + Columns + + actions.insertColumn(anchorColIndex, actions.nanoid()) + } + > + Insert column left + + + actions.insertColumn(anchorColIndex + 1, actions.nanoid()) + } + > + Insert column right + + selection && actions.deleteColumns(selection.colIds)} + > + Delete selected columns + + + + + + + selection && setOpenCell(selection.anchor)} + icon={} + /> + +
+ + + + + + + {chatOpen ? "Hide AI chat" : "Show AI chat"} + + +
+
+ ); +} + +function ToolbarSeparator() { + return ; +} + +function ToolButton({ + label, + icon, + onClick, + disabled, + active, +}: { + label: string; + icon: ReactNode; + onClick: () => void; + disabled?: boolean; + active?: boolean; +}) { + return ( + + + + + {label} + + ); +} + +function ColorMenu({ + label, + icon, + colors, + current, + onSelect, + disabled, + resetLabel, +}: { + label: string; + icon: ReactNode; + colors: string[]; + current: string | undefined; + onSelect: (color: string | undefined) => void; + disabled?: boolean; + resetLabel: string; +}) { + return ( + + + + + + + + {label} + + + {label} +
+ {colors.map((color) => ( +
+ + onSelect(undefined)}> + {resetLabel} + +
+
+ ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/api/ai-chat/route.ts b/examples/nextjs-ai-spreadsheet/app/api/ai-chat/route.ts new file mode 100644 index 00000000000..2a990d91268 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/api/ai-chat/route.ts @@ -0,0 +1,287 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import { AI_USER_AVATAR, AI_USER_ID, AI_USER_NAME } from "@/database"; +import { + commentsText, + createSpreadsheetTools, + readStorage, + showAiEditing, + snapshotText, +} from "@/lib/spreadsheet-server"; +import type { JsonObject } from "@/liveblocks.config"; + +/** + * Generates an assistant reply that *edits the spreadsheet* and streams its + * answer into the room's chat feed using `@liveblocks/node`. + * + * The model runs with tools that write to Storage via `mutateStorage` and show + * the AI's live selection via `setPresence` — so everyone connected sees both + * the chat text and the grid fill in, in realtime, as the model works. + */ + +type ChatMessage = { role: "user" | "assistant"; content: string }; + +type ToolDisplay = { + name: string; + input: JsonObject; + output?: string; +}; + +type AssistantUpdate = { + content: string; + reasoning?: string; + tools?: ToolDisplay[]; + suggestions?: string[]; + usedTokens?: number; + maxTokens?: number; + streaming: boolean; +}; + +const MAX_TOKENS = 128_000; + +const AUTHOR = { + userId: AI_USER_ID, + name: AI_USER_NAME, + avatar: AI_USER_AVATAR, +} as const; + +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + }); + + const { roomId, feedId, messages, model } = (await request.json()) as { + roomId: string; + feedId: string; + messages: ChatMessage[]; + model?: string; + }; + + if ( + !roomId?.startsWith("liveblocks:examples:nextjs-ai-spreadsheet") || + !feedId + ) { + return new NextResponse("Invalid room or feed", { status: 400 }); + } + + try { + await liveblocks.createFeed({ + roomId, + feedId, + metadata: { title: "AI chat" }, + }); + } catch { + // Feed already exists, ignore. + } + + const created = await liveblocks.createFeedMessage({ + roomId, + feedId, + data: { role: "assistant", content: "", streaming: true, model, ...AUTHOR }, + }); + const messageId = created.id; + + const update = (data: AssistantUpdate) => + liveblocks.updateFeedMessage({ + roomId, + feedId, + messageId, + data: { role: "assistant", model, ...AUTHOR, ...data }, + }); + + // No mock fallback — the spreadsheet AI needs a real, tool-calling model. + if (!process.env.AI_GATEWAY_API_KEY) { + await update({ + content: + "I can't edit the spreadsheet without an AI provider key. Add an " + + "`AI_GATEWAY_API_KEY` to `.env.local` (see the Vercel AI Gateway docs) " + + "and try again.", + streaming: false, + }); + return NextResponse.json({ ok: true }); + } + + try { + await streamReply(liveblocks, roomId, messages, model, update); + } catch (error) { + const reason = error instanceof Error ? error.message : "Unknown error"; + await update({ + content: `Sorry, something went wrong.\n\n\`${reason}\``, + streaming: false, + }).catch(() => {}); + } + + return NextResponse.json({ ok: true }); +} + +type UpdateFn = (data: AssistantUpdate) => Promise; + +const SYSTEM_PROMPT = [ + "You are an assistant embedded in a realtime, multiplayer spreadsheet.", + "Use the provided tools to edit the spreadsheet directly — don't just describe", + "changes, make them. Reference cells in A1 notation (e.g. B2, A1:C5).", + "Prefer `setRangeValues` to fill tables in one call. You don't need to clear", + "cells first — `setRangeValues` and `setCellValue` overwrite existing values.", + "You can write spreadsheet formulas as cell values (anything starting with", + "`=`, e.g. `=SUM(A1:A5)`, `=A2*B2`, `=AVERAGE(B:B)`); they're evaluated", + "automatically by HyperFormula. Prefer formulas over pre-computed numbers for", + "totals and other derived values, so they stay correct when inputs change.", + "Keep your chat replies short (one or two sentences) and describe what you", + "did. Reply in Markdown.", + "Always check that cells use the right number format for their data: apply", + "the currency format to money, the percent format to rates/ratios, and keep", + "general for plain numbers and text. When you add or edit values, set (or", + "correct) the format with `formatCells` so columns stay consistent.", + "Use comments to highlight problems in the sheet: when you spot an error,", + "inconsistency, or something that needs the user's attention (e.g. a wrong", + "total, a typo, a suspicious value, or a missing entry), leave a short comment", + "on that cell with `addComment` explaining the issue, instead of silently", + "fixing it or only mentioning it in chat.", +].join(" "); + +// What the AI's tools can actually do — used to keep generated follow-up +const CAPABILITIES = [ + "The assistant can ONLY do the following to the spreadsheet:", + "- Set a single cell's value, or fill a rectangular range with values.", + "- Write spreadsheet formulas in cells, e.g. `=SUM(A1:A5)`, `=A1*B1`,", + ' `=AVERAGE(B2:B10)`, `=IF(A1>10,"high","low")`. They\'re evaluated', + " automatically (HyperFormula, ~Excel-compatible functions).", + "- Clear the values in a range.", + "- Format cells: bold, italic, underline, strikethrough, horizontal", + " alignment (left/center/right), text color, fill (background) color, and", + " number format (general, currency, or percent).", + "- Sort all rows by a column (ascending or descending).", + "- Insert or delete a row or a column.", + "- Add or delete a comment thread on a cell.", + "It CANNOT: add borders, merge cells, create charts, freeze rows/columns,", + "add images, or change fonts/font sizes.", + "Only suggest actions from the supported list above.", +].join("\n"); + +async function streamReply( + liveblocks: Liveblocks, + roomId: string, + messages: ChatMessage[], + model: string | undefined, + update: UpdateFn +) { + const { streamText, generateText, Output, stepCountIs } = await import("ai"); + const { z } = await import("zod"); + + showAiEditing(liveblocks, roomId, null); + + const storage = await readStorage(liveblocks, roomId); + const comments = await commentsText(liveblocks, roomId, storage); + + const tools = await createSpreadsheetTools(liveblocks, roomId); + + const result = streamText({ + model: model ?? "openai/gpt-5.4-mini", + system: `${SYSTEM_PROMPT}\n\n${snapshotText(storage)}${ + comments ? `\n\n${comments}` : "" + }`, + messages, + tools, + stopWhen: stepCountIs(16), + providerOptions: { + openai: { reasoningEffort: "low", reasoningSummary: "auto" }, + anthropic: { thinking: { type: "enabled", budgetTokens: 4096 } }, + google: { thinkingConfig: { includeThoughts: true } }, + }, + }); + + let content = ""; + let reasoning = ""; + const toolsDisplay: ToolDisplay[] = []; + const toolIndexById = new Map(); + let lastFlush = 0; + + const flush = async (force = false) => { + const now = Date.now(); + if (!force && now - lastFlush < 80) { + return; + } + lastFlush = now; + await update({ + content, + reasoning: reasoning || undefined, + tools: toolsDisplay.length ? toolsDisplay : undefined, + streaming: true, + }); + }; + + for await (const part of result.fullStream) { + if (part.type === "text-delta") { + content += part.text; + await flush(); + } else if (part.type === "reasoning-delta") { + reasoning += part.text; + await flush(); + } else if (part.type === "tool-call") { + toolIndexById.set(part.toolCallId, toolsDisplay.length); + toolsDisplay.push({ + name: part.toolName, + // Tool-call args from the AI SDK are JSON-serializable by construction. + input: (part.input ?? {}) as JsonObject, + }); + await flush(true); + } else if (part.type === "tool-result") { + const index = toolIndexById.get(part.toolCallId); + if (index !== undefined && toolsDisplay[index]) { + toolsDisplay[index].output = String(part.output ?? ""); + } + await flush(true); + } + } + + if (!reasoning) { + reasoning = (await result.reasoningText) ?? ""; + } + const usage = await result.usage; + + // Generate three contextual follow-up suggestions based on the updated sheet + let suggestions: string[] = []; + try { + const updatedStorage = await readStorage(liveblocks, roomId); + const { output } = await generateText({ + model: model ?? "openai/gpt-5.4-mini", + output: Output.object({ + schema: z.object({ + suggestions: z + .array(z.string()) + .length(3) + .describe("Three short next prompts the user might send."), + }), + }), + system: + "You suggest the user's likely next message in a spreadsheet AI chat. " + + "Return exactly 3 short, specific, actionable prompts (max ~6 words " + + "each) the user could tap next, as imperative phrases with no numbering. " + + "Every suggestion must be something the assistant can actually do.\n\n" + + CAPABILITIES, + prompt: + `Current spreadsheet:\n${snapshotText(updatedStorage)}\n\n` + + `The assistant just replied:\n${content || "(made edits to the sheet)"}\n\n` + + "Suggest 3 useful next prompts.", + }); + if (output.suggestions?.length) { + suggestions = output.suggestions.slice(0, 3); + } + } catch { + // Keep the static fallback suggestions. + } + + await update({ + content, + reasoning: reasoning || undefined, + tools: toolsDisplay.length ? toolsDisplay : undefined, + suggestions, + usedTokens: usage.totalTokens ?? 0, + maxTokens: MAX_TOKENS, + streaming: false, + }); +} diff --git a/examples/nextjs-ai-spreadsheet/app/api/liveblocks-auth/route.ts b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-auth/route.ts new file mode 100644 index 00000000000..a29bc4fe31c --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-auth/route.ts @@ -0,0 +1,32 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; +import { getRandomUser } from "@/database"; + +/** + * Authenticating your Liveblocks application + * https://liveblocks.io/docs/authentication + */ + +export async function POST(_request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + }); + + // Pick a random example user so each connection has a name and avatar that + // resolve through `resolveUsers` (used by AvatarStack, presence, and Comments). + const user = getRandomUser(); + + const session = liveblocks.prepareSession(`${user.id}`, { + userInfo: user.info, + }); + + // Use a naming pattern to allow access to rooms with a wildcard + session.allow(`liveblocks:examples:*`, session.FULL_ACCESS); + + const { status, body } = await session.authorize(); + return new NextResponse(body, { status }); +} diff --git a/examples/nextjs-ai-spreadsheet/app/api/liveblocks-webhook/route.ts b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-webhook/route.ts new file mode 100644 index 00000000000..8ba06514657 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/api/liveblocks-webhook/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { Liveblocks, WebhookHandler } from "@liveblocks/node"; +import { replyToComment } from "@/lib/spreadsheet-server"; + +// Add your webhook secret from the project's webhooks dashboard. Point a +// `commentCreated` webhook at this endpoint to enable AI comment replies. +const WEBHOOK_SECRET = process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY; + +export async function POST(request: Request) { + if (!WEBHOOK_SECRET) { + return new NextResponse("LIVEBLOCKS_WEBHOOK_SECRET_KEY is not set", { + status: 500, + }); + } + + const rawBody = await request.text(); + + let event; + try { + event = new WebhookHandler(WEBHOOK_SECRET).verifyRequest({ + headers: request.headers, + rawBody, + }); + } catch (error) { + console.error(error); + return new NextResponse("Could not verify webhook call", { status: 400 }); + } + + // Reply when the AI is @mentioned in a new comment. + if (event.type === "commentCreated" && process.env.LIVEBLOCKS_SECRET_KEY) { + const { roomId, threadId, commentId } = event.data; + const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY, + }); + try { + await replyToComment(liveblocks, roomId, threadId, commentId); + } catch (error) { + console.error(error); + } + } + + return NextResponse.json({ ok: true }); +} diff --git a/examples/nextjs-ai-spreadsheet/app/api/users/route.ts b/examples/nextjs-ai-spreadsheet/app/api/users/route.ts new file mode 100644 index 00000000000..d4f2e52c40f --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/api/users/route.ts @@ -0,0 +1,16 @@ +import { getUser } from "@/database"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const userIds = searchParams.getAll("userIds"); + + if (!userIds || !Array.isArray(userIds)) { + return new NextResponse("Missing or invalid userIds", { status: 400 }); + } + + return NextResponse.json( + userIds.map((userId) => getUser(userId)?.info || null), + { status: 200 } + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/api/users/search/route.ts b/examples/nextjs-ai-spreadsheet/app/api/users/search/route.ts new file mode 100644 index 00000000000..27dcd0c34c1 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/api/users/search/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUsers } from "@/database"; + +/** + * Returns a list of user IDs from a partial search input. + * For `resolveMentionSuggestions` in Providers.tsx (used by Comments). + */ + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const text = (searchParams.get("text") ?? "").toLowerCase(); + + const filteredUserIds = getUsers() + .filter((user) => user.info.name.toLowerCase().includes(text)) + .map((user) => user.id); + + return NextResponse.json(filteredUserIds); +} diff --git a/examples/nextjs-ai-spreadsheet/app/globals.css b/examples/nextjs-ai-spreadsheet/app/globals.css new file mode 100644 index 00000000000..81ac253f9df --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/globals.css @@ -0,0 +1,158 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "@liveblocks/react-ui/styles.css"; +@import "handsontable/styles/handsontable.min.css"; +@import "handsontable/styles/ht-theme-main.min.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.305 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Liveblocks Comments inside Handsontable cells */ +.lb-root { + --lb-accent: #4444ff; +} + +.lb-portal { + z-index: 500; +} + +/* Cell content (value + formatting + presence borders) is painted imperatively + into each
by the Handsontable function renderer (see Table.tsx). */ +.handsontable td { + /* Positioning context for the comment marker pseudo-element. */ + position: relative; + vertical-align: middle; + /* Align digits in columns of numbers. */ + font-variant-numeric: tabular-nums; +} + +/* Classic "this cell has a comment" marker: a small filled triangle hugging + the cell's top-right corner. The renderer toggles `.has-comment` on the + when a thread exists for that cell. */ +.handsontable td.has-comment::after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 9px; + height: 9px; + background: var(--lb-accent, #4444ff); + clip-path: polygon(0 0, 100% 0, 100% 100%); + pointer-events: none; + z-index: 2; +} diff --git a/examples/nextjs-ai-spreadsheet/app/layout.tsx b/examples/nextjs-ai-spreadsheet/app/layout.tsx new file mode 100644 index 00000000000..f724f5b353e --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/layout.tsx @@ -0,0 +1,35 @@ +import "./globals.css"; +import { ReactNode, Suspense } from "react"; +import { Providers } from "./Providers"; + +export const metadata = { + title: "Liveblocks · AI Spreadsheet", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + + + + + + {children} + + + + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/page.tsx b/examples/nextjs-ai-spreadsheet/app/page.tsx new file mode 100644 index 00000000000..c9ebeef5f7a --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/page.tsx @@ -0,0 +1,10 @@ +import { Room } from "./Room"; +import { Spreadsheet } from "./Spreadsheet"; + +export default function Page() { + return ( + + + + ); +} diff --git a/examples/nextjs-ai-spreadsheet/app/useSpreadsheetActions.ts b/examples/nextjs-ai-spreadsheet/app/useSpreadsheetActions.ts new file mode 100644 index 00000000000..8f6e45fb29d --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/app/useSpreadsheetActions.ts @@ -0,0 +1,225 @@ +"use client"; + +import { LiveMap, LiveObject } from "@liveblocks/client"; +import { useMutation } from "@liveblocks/react/suspense"; +import { nanoid } from "nanoid"; +import { + cellKey, + MIN_COL_WIDTH, + MIN_ROW_HEIGHT, + type CellData, + type CellFormat, +} from "@/liveblocks.config"; +import { isFormatEmpty, mergeFormat } from "@/lib/format"; + +type Cells = LiveMap>; + +export type CellTarget = { rowId: string; colId: string }; + +// --- Pure helpers operating on the mutable `cells` map ----------------------- + +function writeValue( + cells: Cells, + rowId: string, + colId: string, + value: string +): void { + const key = cellKey(rowId, colId); + const cell = cells.get(key); + + // Keep Storage sparse: an empty cell with no formatting is removed entirely. + if (value === "") { + if (cell) { + if (isFormatEmpty(cell.get("format"))) { + cells.delete(key); + } else { + cell.set("value", ""); + } + } + return; + } + + if (cell) { + cell.set("value", value); + } else { + cells.set(key, new LiveObject({ value })); + } +} + +function writeFormat( + cells: Cells, + rowId: string, + colId: string, + patch: Partial +): void { + const key = cellKey(rowId, colId); + const cell = cells.get(key); + const merged = mergeFormat(cell?.get("format"), patch); + + if (!cell) { + if (merged) { + cells.set(key, new LiveObject({ value: "", format: merged })); + } + return; + } + + cell.set("format", merged); + if ((cell.get("value") ?? "") === "" && isFormatEmpty(merged)) { + cells.delete(key); + } +} + +function clearFormatCell(cells: Cells, rowId: string, colId: string): void { + const key = cellKey(rowId, colId); + const cell = cells.get(key); + if (!cell) { + return; + } + if ((cell.get("value") ?? "") === "") { + cells.delete(key); + } else { + cell.set("format", undefined); + } +} + +// --- Hook -------------------------------------------------------------------- + +export function useSpreadsheetActions() { + const setCellValue = useMutation( + ({ storage }, rowId: string, colId: string, value: string) => { + writeValue(storage.get("cells"), rowId, colId, value); + }, + [] + ); + + const applyFormat = useMutation( + ({ storage }, targets: CellTarget[], patch: Partial) => { + const cells = storage.get("cells"); + for (const { rowId, colId } of targets) { + writeFormat(cells, rowId, colId, patch); + } + }, + [] + ); + + const clearFormatting = useMutation(({ storage }, targets: CellTarget[]) => { + const cells = storage.get("cells"); + for (const { rowId, colId } of targets) { + clearFormatCell(cells, rowId, colId); + } + }, []); + + const clearValues = useMutation(({ storage }, targets: CellTarget[]) => { + const cells = storage.get("cells"); + for (const { rowId, colId } of targets) { + writeValue(cells, rowId, colId, ""); + } + }, []); + + const setColWidth = useMutation(({ storage }, colId: string, width: number) => { + storage + .get("colWidths") + .set(colId, Math.max(MIN_COL_WIDTH, Math.round(width))); + }, []); + + const setRowHeight = useMutation( + ({ storage }, rowId: string, height: number) => { + storage + .get("rowHeights") + .set(rowId, Math.max(MIN_ROW_HEIGHT, Math.round(height))); + }, + [] + ); + + // Replaces the visual order with a permutation of the existing ids. Setting + // each index in place avoids clearing the list (no flicker, no migration). + const setRowOrder = useMutation(({ storage }, newRowIds: string[]) => { + const list = storage.get("rowIds"); + newRowIds.forEach((id, index) => { + if (list.get(index) !== id) { + list.set(index, id); + } + }); + }, []); + + const setColOrder = useMutation(({ storage }, newColIds: string[]) => { + const list = storage.get("colIds"); + newColIds.forEach((id, index) => { + if (list.get(index) !== id) { + list.set(index, id); + } + }); + }, []); + + const insertRow = useMutation( + ({ storage }, atIndex: number, newId: string) => { + storage.get("rowIds").insert(newId, atIndex); + }, + [] + ); + + const insertColumn = useMutation( + ({ storage }, atIndex: number, newId: string) => { + storage.get("colIds").insert(newId, atIndex); + }, + [] + ); + + const deleteRows = useMutation(({ storage }, rowIds: string[]) => { + const list = storage.get("rowIds"); + if (list.length <= rowIds.length) { + return; // never delete every row + } + const cells = storage.get("cells"); + const heights = storage.get("rowHeights"); + const colIds = [...storage.get("colIds")]; + + for (const rowId of rowIds) { + const index = [...list].indexOf(rowId); + if (index !== -1) { + list.delete(index); + } + for (const colId of colIds) { + cells.delete(cellKey(rowId, colId)); + } + heights.delete(rowId); + } + }, []); + + const deleteColumns = useMutation(({ storage }, colIds: string[]) => { + const list = storage.get("colIds"); + if (list.length <= colIds.length) { + return; // never delete every column + } + const cells = storage.get("cells"); + const widths = storage.get("colWidths"); + const rowIds = [...storage.get("rowIds")]; + + for (const colId of colIds) { + const index = [...list].indexOf(colId); + if (index !== -1) { + list.delete(index); + } + for (const rowId of rowIds) { + cells.delete(cellKey(rowId, colId)); + } + widths.delete(colId); + } + }, []); + + return { + nanoid, + setCellValue, + applyFormat, + clearFormatting, + clearValues, + setColWidth, + setRowHeight, + setRowOrder, + setColOrder, + insertRow, + insertColumn, + deleteRows, + deleteColumns, + }; +} diff --git a/examples/nextjs-ai-spreadsheet/components.json b/examples/nextjs-ai-spreadsheet/components.json new file mode 100644 index 00000000000..26ad91e754e --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx b/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx new file mode 100644 index 00000000000..d38de069aaf --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { CSSProperties, ReactNode, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "./ui/button"; + +const EXAMPLE_NAME = "Realtime AI spreadsheet"; +const EXAMPLE_URL = "https://liveblocks.io/examples/nextjs-ai-spreadsheet"; + +type Feature = { + icon: ReactNode; + title: string; + description: ReactNode; +}; + +const FEATURES: Feature[] = [ + { + icon: , + title: "Multiplayer spreadsheet", + description: + "Cells, formatting, sizes, and row/column order live in Liveblocks Storage and sync instantly to everyone.", + }, + { + icon: , + title: "An AI that edits cells", + description: + "Ask the chat to fill, format, or restructure the grid. You can also tag AI with @Liveblocks AI inside a comment.", + }, + { + icon: , + title: "See the AI working", + description: + "The AI appears as a participant — its selection border hops cell to cell in realtime via server-side presence.", + }, + { + icon: , + title: "Comments on any cell", + description: + "Leave threaded comments anchored to a cell. They follow the cell even when rows and columns are moved.", + }, +]; + +const styles: Record = { + backdrop: { + position: "fixed", + inset: 0, + zIndex: 2147483000, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 16, + background: "rgba(23, 23, 23, 0.2)", + }, + panel: { + background: "#ffffff", + border: "1px solid #e5e5e5", + borderRadius: 12, + boxShadow: + "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + width: "100%", + maxWidth: 448, + maxHeight: "80vh", + overflowY: "auto", + }, + header: { + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 16, + padding: 20, + borderBottom: "1px solid #e5e5e5", + }, + title: { fontSize: 14, fontWeight: 600, color: "#171717", margin: 0 }, + titleLink: { color: "inherit", textDecoration: "none" }, + desc: { fontSize: 14, color: "#737373", marginTop: 4, marginBottom: 0 }, + close: { + flexShrink: 0, + marginTop: -4, + marginRight: -4, + padding: 6, + borderRadius: 6, + border: "none", + background: "transparent", + color: "#737373", + cursor: "pointer", + lineHeight: 0, + }, + list: { + listStyle: "none", + margin: 0, + padding: 20, + display: "flex", + flexDirection: "column", + gap: 16, + }, + item: { display: "flex", alignItems: "flex-start", gap: 16 }, + iconWrap: { + flexShrink: 0, + marginTop: 2, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 28, + height: 28, + borderRadius: 6, + background: "#f5f5f5", + color: "#404040", + }, + featureTitle: { fontSize: 14, fontWeight: 500, color: "#171717", margin: 0 }, + featureDesc: { + fontSize: 14, + color: "#737373", + marginTop: 2, + marginBottom: 0, + }, +}; + +const HOVER_CSS = ` +.lb-help-title-link:hover { text-decoration: underline !important; } +.lb-help-close:hover { background:#f5f5f5 !important; color:#171717 !important; } +.lb-help, .lb-help * { box-sizing: border-box; } +`; + +export function HelpButton() { + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + if (!isOpen) { + return; + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setIsOpen(false); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [isOpen]); + + return ( + <> + + + + {isOpen && typeof document !== "undefined" + ? createPortal( +
setIsOpen(false)} + > +
event.stopPropagation()} + > +
+
+

+ + {EXAMPLE_NAME} + +

+

How to use this example

+
+ +
+ +
    + {FEATURES.map((feature) => ( +
  • + {feature.icon} +
    +

    {feature.title}

    +

    {feature.description}

    +
    +
  • + ))} +
+
+
, + document.body + ) + : null} + + ); +} + +function HelpIcon() { + return ( + + + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +function FeatureIconBase({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function TableIcon() { + return ( + + + + + ); +} + +function CommentIcon() { + return ( + + + + ); +} + +function SparklesIcon() { + return ( + + + + + ); +} + +function UsersIcon() { + return ( + + + + + + ); +} diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/artifact.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/artifact.tsx new file mode 100644 index 00000000000..c90cb5fe3dd --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/artifact.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { type LucideIcon, XIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/canvas.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/canvas.tsx new file mode 100644 index 00000000000..5aa83cb5e73 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/canvas.tsx @@ -0,0 +1,22 @@ +import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; +import type { ReactNode } from "react"; +import "@xyflow/react/dist/style.css"; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => ( + + + {children} + +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/chain-of-thought.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 00000000000..bebc66123d5 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + ChevronDownIcon, + DotIcon, + type LucideIcon, + WrenchIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, memo, useContext, useMemo } from "react"; + +type ChainOfThoughtContextValue = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const ChainOfThoughtContext = createContext( + null +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + "ChainOfThought components must be used within ChainOfThought" + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const chainOfThoughtContext = useMemo( + () => ({ isOpen, setIsOpen }), + [isOpen, setIsOpen] + ); + + return ( + +
+ {children} +
+
+ ); + } +); + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen, setIsOpen } = useChainOfThought(); + + return ( + + + + + {children ?? "Chain of Thought"} + + + + + ); + } +); + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: "complete" | "active" | "pending"; +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = "complete", + children, + ...props + }: ChainOfThoughtStepProps) => { + const statusStyles = { + complete: "text-muted-foreground", + active: "text-foreground", + pending: "text-muted-foreground/50", + }; + + return ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); + } +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ) +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ) +); + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children} + + + ); + } +); + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ) +); + +ChainOfThought.displayName = "ChainOfThought"; +ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; +ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; +ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; +ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; +ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; +ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/checkpoint.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/checkpoint.tsx new file mode 100644 index 00000000000..d9a5d326c88 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/checkpoint.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { BookmarkIcon, type LucideProps } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type CheckpointProps = HTMLAttributes; + +export const Checkpoint = ({ + className, + children, + ...props +}: CheckpointProps) => ( +
+ {children} + +
+); + +export type CheckpointIconProps = LucideProps; + +export const CheckpointIcon = ({ + className, + children, + ...props +}: CheckpointIconProps) => + children ?? ( + + ); + +export type CheckpointTriggerProps = ComponentProps & { + tooltip?: string; +}; + +export const CheckpointTrigger = ({ + children, + className, + variant = "ghost", + size = "sm", + tooltip, + ...props +}: CheckpointTriggerProps) => + tooltip ? ( + + + + + + {tooltip} + + + ) : ( + + ); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/code-block.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/code-block.tsx new file mode 100644 index 00000000000..b6865f0dc4b --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/code-block.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; + +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + code: "", +}); + +const lineNumberTransformer: ShikiTransformer = { + name: "line-numbers", + line(node, line) { + node.children.unshift({ + type: "element", + tagName: "span", + properties: { + className: [ + "inline-block", + "min-w-10", + "mr-4", + "text-right", + "select-none", + "text-muted-foreground", + ], + }, + children: [{ type: "text", value: String(line) }], + }); + }, +}; + +export async function highlightCode( + code: string, + language: BundledLanguage, + showLineNumbers = false +) { + const transformers: ShikiTransformer[] = showLineNumbers + ? [lineNumberTransformer] + : []; + + return await Promise.all([ + codeToHtml(code, { + lang: language, + theme: "one-light", + transformers, + }), + codeToHtml(code, { + lang: language, + theme: "one-dark-pro", + transformers, + }), + ]); +} + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const [html, setHtml] = useState(""); + const [darkHtml, setDarkHtml] = useState(""); + const mounted = useRef(false); + + useEffect(() => { + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { + if (!mounted.current) { + setHtml(light); + setDarkHtml(dark); + mounted.current = true; + } + }); + + return () => { + mounted.current = false; + }; + }, [code, language, showLineNumbers]); + + return ( + +
+
+
+
+ {children && ( +
+ {children} +
+ )} +
+
+ + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + setTimeout(() => setIsCopied(false), timeout); + } catch (error) { + onError?.(error as Error); + } + }; + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/confirmation.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/confirmation.tsx new file mode 100644 index 00000000000..2ec0aab5785 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/confirmation.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ToolUIPart } from "ai"; +import { + type ComponentProps, + createContext, + type ReactNode, + useContext, +} from "react"; + +type ToolUIPartApproval = + | { + id: string; + approved?: never; + reason?: never; + } + | { + id: string; + approved: boolean; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: false; + reason?: string; + } + | undefined; + +type ConfirmationContextValue = { + approval: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +const ConfirmationContext = createContext( + null +); + +const useConfirmation = () => { + const context = useContext(ConfirmationContext); + + if (!context) { + throw new Error("Confirmation components must be used within Confirmation"); + } + + return context; +}; + +export type ConfirmationProps = ComponentProps & { + approval?: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +export const Confirmation = ({ + className, + approval, + state, + ...props +}: ConfirmationProps) => { + if (!approval || state === "input-streaming" || state === "input-available") { + return null; + } + + return ( + + + + ); +}; + +export type ConfirmationTitleProps = ComponentProps; + +export const ConfirmationTitle = ({ + className, + ...props +}: ConfirmationTitleProps) => ( + +); + +export type ConfirmationRequestProps = { + children?: ReactNode; +}; + +export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + if (state !== "approval-requested") { + return null; + } + + return children; +}; + +export type ConfirmationAcceptedProps = { + children?: ReactNode; +}; + +export const ConfirmationAccepted = ({ + children, +}: ConfirmationAcceptedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when approved and in response states + if ( + !approval?.approved || + (state !== "approval-responded" && + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationRejectedProps = { + children?: ReactNode; +}; + +export const ConfirmationRejected = ({ + children, +}: ConfirmationRejectedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when rejected and in response states + if ( + approval?.approved !== false || + (state !== "approval-responded" && + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationActionsProps = ComponentProps<"div">; + +export const ConfirmationActions = ({ + className, + ...props +}: ConfirmationActionsProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + if (state !== "approval-requested") { + return null; + } + + return ( +
+ ); +}; + +export type ConfirmationActionProps = ComponentProps; + +export const ConfirmationAction = (props: ConfirmationActionProps) => ( + + )} + + ); +}; + +export type ContextContentProps = ComponentProps; + +export const ContextContent = ({ + className, + ...props +}: ContextContentProps) => ( + +); + +export type ContextContentHeaderProps = ComponentProps<"div">; + +export const ContextContentHeader = ({ + children, + className, + ...props +}: ContextContentHeaderProps) => { + const { usedTokens, maxTokens } = useContextValue(); + const usedPercent = usedTokens / maxTokens; + const displayPct = new Intl.NumberFormat("en-US", { + style: "percent", + maximumFractionDigits: 1, + }).format(usedPercent); + const used = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(usedTokens); + const total = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(maxTokens); + + return ( +
+ {children ?? ( + <> +
+

{displayPct}

+

+ {used} / {total} +

+
+
+ +
+ + )} +
+ ); +}; + +export type ContextContentBodyProps = ComponentProps<"div">; + +export const ContextContentBody = ({ + children, + className, + ...props +}: ContextContentBodyProps) => ( +
+ {children} +
+); + +export type ContextContentFooterProps = ComponentProps<"div">; + +export const ContextContentFooter = ({ + children, + className, + ...props +}: ContextContentFooterProps) => { + const { modelId, usage } = useContextValue(); + const costUSD = modelId + ? getUsage({ + modelId, + usage: { + input: usage?.inputTokens ?? 0, + output: usage?.outputTokens ?? 0, + }, + }).costUSD?.totalUSD + : undefined; + const totalCost = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(costUSD ?? 0); + + return ( +
+ {children ?? ( + <> + Total cost + {totalCost} + + )} +
+ ); +}; + +export type ContextInputUsageProps = ComponentProps<"div">; + +export const ContextInputUsage = ({ + className, + children, + ...props +}: ContextInputUsageProps) => { + const { usage, modelId } = useContextValue(); + const inputTokens = usage?.inputTokens ?? 0; + + if (children) { + return children; + } + + if (!inputTokens) { + return null; + } + + const inputCost = modelId + ? getUsage({ + modelId, + usage: { input: inputTokens, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const inputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(inputCost ?? 0); + + return ( +
+ Input + +
+ ); +}; + +export type ContextOutputUsageProps = ComponentProps<"div">; + +export const ContextOutputUsage = ({ + className, + children, + ...props +}: ContextOutputUsageProps) => { + const { usage, modelId } = useContextValue(); + const outputTokens = usage?.outputTokens ?? 0; + + if (children) { + return children; + } + + if (!outputTokens) { + return null; + } + + const outputCost = modelId + ? getUsage({ + modelId, + usage: { input: 0, output: outputTokens }, + }).costUSD?.totalUSD + : undefined; + const outputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(outputCost ?? 0); + + return ( +
+ Output + +
+ ); +}; + +export type ContextReasoningUsageProps = ComponentProps<"div">; + +export const ContextReasoningUsage = ({ + className, + children, + ...props +}: ContextReasoningUsageProps) => { + const { usage, modelId } = useContextValue(); + const reasoningTokens = usage?.reasoningTokens ?? 0; + + if (children) { + return children; + } + + if (!reasoningTokens) { + return null; + } + + const reasoningCost = modelId + ? getUsage({ + modelId, + usage: { reasoningTokens }, + }).costUSD?.totalUSD + : undefined; + const reasoningCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(reasoningCost ?? 0); + + return ( +
+ Reasoning + +
+ ); +}; + +export type ContextCacheUsageProps = ComponentProps<"div">; + +export const ContextCacheUsage = ({ + className, + children, + ...props +}: ContextCacheUsageProps) => { + const { usage, modelId } = useContextValue(); + const cacheTokens = usage?.cachedInputTokens ?? 0; + + if (children) { + return children; + } + + if (!cacheTokens) { + return null; + } + + const cacheCost = modelId + ? getUsage({ + modelId, + usage: { cacheReads: cacheTokens, input: 0, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const cacheCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cacheCost ?? 0); + + return ( +
+ Cache + +
+ ); +}; + +const TokensWithCost = ({ + tokens, + costText, +}: { + tokens?: number; + costText?: string; +}) => ( + + {tokens === undefined + ? "—" + : new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(tokens)} + {costText ? ( + • {costText} + ) : null} + +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/controls.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/controls.tsx new file mode 100644 index 00000000000..770a8262aab --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/controls.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Controls as ControlsPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type ControlsProps = ComponentProps; + +export const Controls = ({ className, ...props }: ControlsProps) => ( + button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!", + className + )} + {...props} + /> +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/conversation.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/conversation.tsx new file mode 100644 index 00000000000..aa380f573f8 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/edge.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/edge.tsx new file mode 100644 index 00000000000..3cec409d105 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/edge.tsx @@ -0,0 +1,140 @@ +import { + BaseEdge, + type EdgeProps, + getBezierPath, + getSimpleBezierPath, + type InternalNode, + type Node, + Position, + useInternalNode, +} from "@xyflow/react"; + +const Temporary = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: EdgeProps) => { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +const getHandleCoordsByPosition = ( + node: InternalNode, + handlePosition: Position +) => { + // Choose the handle type based on position - Left is for target, Right is for source + const handleType = handlePosition === Position.Left ? "target" : "source"; + + const handle = node.internals.handleBounds?.[handleType]?.find( + (h) => h.position === handlePosition + ); + + if (!handle) { + return [0, 0] as const; + } + + let offsetX = handle.width / 2; + let offsetY = handle.height / 2; + + // this is a tiny detail to make the markerEnd of an edge visible. + // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset + // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position + switch (handlePosition) { + case Position.Left: + offsetX = 0; + break; + case Position.Right: + offsetX = handle.width; + break; + case Position.Top: + offsetY = 0; + break; + case Position.Bottom: + offsetY = handle.height; + break; + default: + throw new Error(`Invalid handle position: ${handlePosition}`); + } + + const x = node.internals.positionAbsolute.x + handle.x + offsetX; + const y = node.internals.positionAbsolute.y + handle.y + offsetY; + + return [x, y] as const; +}; + +const getEdgeParams = ( + source: InternalNode, + target: InternalNode +) => { + const sourcePos = Position.Right; + const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); + const targetPos = Position.Left; + const [tx, ty] = getHandleCoordsByPosition(target, targetPos); + + return { + sx, + sy, + tx, + ty, + sourcePos, + targetPos, + }; +}; + +const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!(sourceNode && targetNode)) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetX: tx, + targetY: ty, + targetPosition: targetPos, + }); + + return ( + <> + + + + + + ); +}; + +export const Edge = { + Temporary, + Animated, +}; diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/image.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/image.tsx new file mode 100644 index 00000000000..542812a3287 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/image.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/lib/utils"; +import type { Experimental_GeneratedImage } from "ai"; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ + base64, + uint8Array, + mediaType, + ...props +}: ImageProps) => ( + {props.alt} +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/inline-citation.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/inline-citation.tsx new file mode 100644 index 00000000000..5977081bb43 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/inline-citation.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +export type InlineCitationProps = ComponentProps<"span">; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<"span">; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; +}; + +export const InlineCitationCardTrigger = ({ + sources, + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {sources[0] ? ( + <> + {new URL(sources[0]).hostname}{" "} + {sources.length > 1 && `+${sources.length - 1}`} + + ) : ( + "unknown" + )} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<"div">; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<"div">; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<"div">; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<"div">; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const [current, setCurrent] = useState(0); + const [count, setCount] = useState(0); + + useEffect(() => { + if (!api) { + return; + } + + setCount(api.scrollSnapList().length); + setCurrent(api.selectedScrollSnap() + 1); + + api.on("select", () => { + setCurrent(api.selectedScrollSnap() + 1); + }); + }, [api]); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +export type InlineCitationCarouselPrevProps = ComponentProps<"button">; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<"button">; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<"div"> & { + title?: string; + url?: string; + description?: string; +}; + +export const InlineCitationSource = ({ + title, + url, + description, + className, + children, + ...props +}: InlineCitationSourceProps) => ( +
+ {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {description && ( +

+ {description} +

+ )} + {children} +
+); + +export type InlineCitationQuoteProps = ComponentProps<"blockquote">; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/loader.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/loader.tsx new file mode 100644 index 00000000000..5f0cfce400c --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from "@/lib/utils"; +import type { HTMLAttributes } from "react"; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/message.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/message.tsx new file mode 100644 index 00000000000..63718a2f70a --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/message.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, UIMessage } from "ai"; +import { + ChevronLeftIcon, + ChevronRightIcon, + PaperclipIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { createContext, memo, useContext, useEffect, useState } from "react"; +import { Streamdown } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon-sm", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +type MessageBranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + "MessageBranch components must be used within MessageBranch" + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: MessageBranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = Array.isArray(children) ? children : [children]; + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden" + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const MessageBranchSelector = ({ + className, + from, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + className, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageAttachmentProps = HTMLAttributes & { + data: FileUIPart; + className?: string; + onRemove?: () => void; +}; + +export function MessageAttachment({ + data, + className, + onRemove, + ...props +}: MessageAttachmentProps) { + const filename = data.filename || ""; + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( +
+ {isImage ? ( + <> + {filename + {onRemove && ( + + )} + + ) : ( + <> + + +
+ +
+
+ +

{attachmentLabel}

+
+
+ {onRemove && ( + + )} + + )} +
+ ); +} + +export type MessageAttachmentsProps = ComponentProps<"div">; + +export function MessageAttachments({ + children, + className, + ...props +}: MessageAttachmentsProps) { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/model-selector.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/model-selector.tsx new file mode 100644 index 00000000000..ef6ebd7e8b8 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/model-selector.tsx @@ -0,0 +1,205 @@ +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { ComponentProps, ReactNode } from "react"; + +export type ModelSelectorProps = ComponentProps; + +export const ModelSelector = (props: ModelSelectorProps) => ( + +); + +export type ModelSelectorTriggerProps = ComponentProps; + +export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( + +); + +export type ModelSelectorContentProps = ComponentProps & { + title?: ReactNode; +}; + +export const ModelSelectorContent = ({ + className, + children, + title = "Model Selector", + ...props +}: ModelSelectorContentProps) => ( + + {title} + + {children} + + +); + +export type ModelSelectorDialogProps = ComponentProps; + +export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( + +); + +export type ModelSelectorInputProps = ComponentProps; + +export const ModelSelectorInput = ({ + className, + ...props +}: ModelSelectorInputProps) => ( + +); + +export type ModelSelectorListProps = ComponentProps; + +export const ModelSelectorList = (props: ModelSelectorListProps) => ( + +); + +export type ModelSelectorEmptyProps = ComponentProps; + +export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( + +); + +export type ModelSelectorGroupProps = ComponentProps; + +export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( + +); + +export type ModelSelectorItemProps = ComponentProps; + +export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( + +); + +export type ModelSelectorShortcutProps = ComponentProps; + +export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( + +); + +export type ModelSelectorSeparatorProps = ComponentProps< + typeof CommandSeparator +>; + +export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( + +); + +export type ModelSelectorLogoProps = Omit< + ComponentProps<"img">, + "src" | "alt" +> & { + provider: + | "moonshotai-cn" + | "lucidquery" + | "moonshotai" + | "zai-coding-plan" + | "alibaba" + | "xai" + | "vultr" + | "nvidia" + | "upstage" + | "groq" + | "github-copilot" + | "mistral" + | "vercel" + | "nebius" + | "deepseek" + | "alibaba-cn" + | "google-vertex-anthropic" + | "venice" + | "chutes" + | "cortecs" + | "github-models" + | "togetherai" + | "azure" + | "baseten" + | "huggingface" + | "opencode" + | "fastrouter" + | "google" + | "google-vertex" + | "cloudflare-workers-ai" + | "inception" + | "wandb" + | "openai" + | "zhipuai-coding-plan" + | "perplexity" + | "openrouter" + | "zenmux" + | "v0" + | "iflowcn" + | "synthetic" + | "deepinfra" + | "zhipuai" + | "submodel" + | "zai" + | "inference" + | "requesty" + | "morph" + | "lmstudio" + | "anthropic" + | "aihubmix" + | "fireworks-ai" + | "modelscope" + | "llama" + | "scaleway" + | "amazon-bedrock" + | "cerebras" + | (string & {}); +}; + +export const ModelSelectorLogo = ({ + provider, + className, + ...props +}: ModelSelectorLogoProps) => ( + {`${provider} +); + +export type ModelSelectorLogoGroupProps = ComponentProps<"div">; + +export const ModelSelectorLogoGroup = ({ + className, + ...props +}: ModelSelectorLogoGroupProps) => ( +
img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground", + className + )} + {...props} + /> +); + +export type ModelSelectorNameProps = ComponentProps<"span">; + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/node.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/node.tsx new file mode 100644 index 00000000000..75ac59a153f --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/node.tsx @@ -0,0 +1,71 @@ +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Handle, Position } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type NodeProps = ComponentProps & { + handles: { + target: boolean; + source: boolean; + }; +}; + +export const Node = ({ handles, className, ...props }: NodeProps) => ( + + {handles.target && } + {handles.source && } + {props.children} + +); + +export type NodeHeaderProps = ComponentProps; + +export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( + +); + +export type NodeTitleProps = ComponentProps; + +export const NodeTitle = (props: NodeTitleProps) => ; + +export type NodeDescriptionProps = ComponentProps; + +export const NodeDescription = (props: NodeDescriptionProps) => ( + +); + +export type NodeActionProps = ComponentProps; + +export const NodeAction = (props: NodeActionProps) => ; + +export type NodeContentProps = ComponentProps; + +export const NodeContent = ({ className, ...props }: NodeContentProps) => ( + +); + +export type NodeFooterProps = ComponentProps; + +export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( + +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/open-in-chat.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/open-in-chat.tsx new file mode 100644 index 00000000000..0c62a6ac4ac --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/open-in-chat.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + ChevronDownIcon, + ExternalLinkIcon, + MessageCircleIcon, +} from "lucide-react"; +import { type ComponentProps, createContext, useContext } from "react"; + +const providers = { + github: { + title: "Open in GitHub", + createUrl: (url: string) => url, + icon: ( + + GitHub + + + ), + }, + scira: { + title: "Open in Scira", + createUrl: (q: string) => + `https://scira.ai/?${new URLSearchParams({ + q, + })}`, + icon: ( + + Scira AI + + + + + + + + + ), + }, + chatgpt: { + title: "Open in ChatGPT", + createUrl: (prompt: string) => + `https://chatgpt.com/?${new URLSearchParams({ + hints: "search", + prompt, + })}`, + icon: ( + + OpenAI + + + ), + }, + claude: { + title: "Open in Claude", + createUrl: (q: string) => + `https://claude.ai/new?${new URLSearchParams({ + q, + })}`, + icon: ( + + Claude + + + ), + }, + t3: { + title: "Open in T3 Chat", + createUrl: (q: string) => + `https://t3.chat/new?${new URLSearchParams({ + q, + })}`, + icon: , + }, + v0: { + title: "Open in v0", + createUrl: (q: string) => + `https://v0.app?${new URLSearchParams({ + q, + })}`, + icon: ( + + v0 + + + + ), + }, + cursor: { + title: "Open in Cursor", + createUrl: (text: string) => { + const url = new URL("https://cursor.com/link/prompt"); + url.searchParams.set("text", text); + return url.toString(); + }, + icon: ( + + Cursor + + + ), + }, +}; + +const OpenInContext = createContext<{ query: string } | undefined>(undefined); + +const useOpenInContext = () => { + const context = useContext(OpenInContext); + if (!context) { + throw new Error("OpenIn components must be used within an OpenIn provider"); + } + return context; +}; + +export type OpenInProps = ComponentProps & { + query: string; +}; + +export const OpenIn = ({ query, ...props }: OpenInProps) => ( + + + +); + +export type OpenInContentProps = ComponentProps; + +export const OpenInContent = ({ className, ...props }: OpenInContentProps) => ( + +); + +export type OpenInItemProps = ComponentProps; + +export const OpenInItem = (props: OpenInItemProps) => ( + +); + +export type OpenInLabelProps = ComponentProps; + +export const OpenInLabel = (props: OpenInLabelProps) => ( + +); + +export type OpenInSeparatorProps = ComponentProps; + +export const OpenInSeparator = (props: OpenInSeparatorProps) => ( + +); + +export type OpenInTriggerProps = ComponentProps; + +export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => ( + + {children ?? ( + + )} + +); + +export type OpenInChatGPTProps = ComponentProps; + +export const OpenInChatGPT = (props: OpenInChatGPTProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.chatgpt.icon} + {providers.chatgpt.title} + + + + ); +}; + +export type OpenInClaudeProps = ComponentProps; + +export const OpenInClaude = (props: OpenInClaudeProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.claude.icon} + {providers.claude.title} + + + + ); +}; + +export type OpenInT3Props = ComponentProps; + +export const OpenInT3 = (props: OpenInT3Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.t3.icon} + {providers.t3.title} + + + + ); +}; + +export type OpenInSciraProps = ComponentProps; + +export const OpenInScira = (props: OpenInSciraProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.scira.icon} + {providers.scira.title} + + + + ); +}; + +export type OpenInv0Props = ComponentProps; + +export const OpenInv0 = (props: OpenInv0Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.v0.icon} + {providers.v0.title} + + + + ); +}; + +export type OpenInCursorProps = ComponentProps; + +export const OpenInCursor = (props: OpenInCursorProps) => { + const { query } = useOpenInContext(); + return ( + + + {providers.cursor.icon} + {providers.cursor.title} + + + + ); +}; diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/panel.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/panel.tsx new file mode 100644 index 00000000000..059cb7ac213 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/panel.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import { Panel as PanelPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +type PanelProps = ComponentProps; + +export const Panel = ({ className, ...props }: PanelProps) => ( + +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/plan.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/plan.tsx new file mode 100644 index 00000000000..be04d883bed --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/plan.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { ChevronsUpDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { createContext, useContext } from "react"; +import { Shimmer } from "./shimmer"; + +type PlanContextValue = { + isStreaming: boolean; +}; + +const PlanContext = createContext(null); + +const usePlan = () => { + const context = useContext(PlanContext); + if (!context) { + throw new Error("Plan components must be used within Plan"); + } + return context; +}; + +export type PlanProps = ComponentProps & { + isStreaming?: boolean; +}; + +export const Plan = ({ + className, + isStreaming = false, + children, + ...props +}: PlanProps) => ( + + + {children} + + +); + +export type PlanHeaderProps = ComponentProps; + +export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => ( + +); + +export type PlanTitleProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanTitle = ({ children, ...props }: PlanTitleProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanDescriptionProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanDescription = ({ + className, + children, + ...props +}: PlanDescriptionProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanActionProps = ComponentProps; + +export const PlanAction = (props: PlanActionProps) => ( + +); + +export type PlanContentProps = ComponentProps; + +export const PlanContent = (props: PlanContentProps) => ( + + + +); + +export type PlanFooterProps = ComponentProps<"div">; + +export const PlanFooter = (props: PlanFooterProps) => ( + +); + +export type PlanTriggerProps = ComponentProps; + +export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( + + + +); diff --git a/examples/nextjs-ai-spreadsheet/components/ai-elements/prompt-input.tsx b/examples/nextjs-ai-spreadsheet/components/ai-elements/prompt-input.tsx new file mode 100644 index 00000000000..1f071d08e75 --- /dev/null +++ b/examples/nextjs-ai-spreadsheet/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1413 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + CornerDownLeftIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void + ) => void; +}; + +const PromptInputController = createContext( + null +); +const ProviderAttachmentsContext = createContext( + null +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController()." + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments()." + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachmentFiles, setAttachmentFiles] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) { + return; + } + + setAttachmentFiles((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })) + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + attachmentsRef.current = attachmentFiles; + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }; + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachmentFiles, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachmentFiles, add, remove, clear, openFileDialog] + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [] + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput] + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { id: string }) => ReactNode; +}; + +export function PromptInputAttachments({ + children, + className, + ...props +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( +
+ {attachments.files.map((file) => ( + {children(file)} + ))} +
+ ); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text: string; + files: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + filesRef.current = files; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [] + ); + + const clearLocal = useCallback( + () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [] + ); + + const add = usingProvider ? controller.attachments.add : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const clear = usingProvider ? controller.attachments.clear : clearLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + if (globalDrop) return; // when global drop is on, let the document-level handler own drops + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider] + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }; + + const convertBlobUrlToDataUrl = async ( + url: string + ): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + }; + + const ctx = useMemo( + () => ({ + files: files.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog] + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ id, ...item }) => { + if (item.url && item.url.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }) + ) + .then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + }; + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + return usingProvider ? ( + inner + ) : ( + + {inner} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + onChange, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const form = e.currentTarget.form; + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }; + + const handlePaste: ClipboardEventHandler = (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }; + + const controlledProps = controller + ? { + value: controller.textInput.value, + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + } + : { + onChange, + }; + + return ( + setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + ); +}; + +export type PromptInputHeaderProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputHeader = ({ + className, + ...props +}: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputFooter = ({ + className, + ...props +}: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ + className, + ...props +}: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonProps = ComponentProps; + +export const PromptInputButton = ({ + variant = "ghost", + className, + size, + ...props +}: PromptInputButtonProps) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + return ( + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + + + {children ?? } + + +); + +export type PromptInputActionMenuContentProps = ComponentProps< + typeof DropdownMenuContent +>; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps< + typeof DropdownMenuItem +>; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ( + +); + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; +}; + +export const PromptInputSubmit = ({ + className, + variant = "default", + size = "icon-sm", + status, + children, + ...props +}: PromptInputSubmitProps) => { + let Icon = ; + + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } + + return ( + + {children ?? Icon} + + ); +}; + +interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + onstart: ((this: SpeechRecognition, ev: Event) => any) | null; + onend: ((this: SpeechRecognition, ev: Event) => any) | null; + onresult: + | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) + | null; + onerror: + | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) + | null; +} + +interface SpeechRecognitionEvent extends Event { + results: SpeechRecognitionResultList; + resultIndex: number; +} + +type SpeechRecognitionResultList = { + readonly length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +}; + +type SpeechRecognitionResult = { + readonly length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; +}; + +type SpeechRecognitionAlternative = { + transcript: string; + confidence: number; +}; + +interface SpeechRecognitionErrorEvent extends Event { + error: string; +} + +declare global { + interface Window { + SpeechRecognition: { + new (): SpeechRecognition; + }; + webkitSpeechRecognition: { + new (): SpeechRecognition; + }; + } +} + +export type PromptInputSpeechButtonProps = ComponentProps< + typeof PromptInputButton +> & { + textareaRef?: RefObject; + onTranscriptionChange?: (text: string) => void; +}; + +export const PromptInputSpeechButton = ({ + className, + textareaRef, + onTranscriptionChange, + ...props +}: PromptInputSpeechButtonProps) => { + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState( + null + ); + const recognitionRef = useRef(null); + + useEffect(() => { + if ( + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) + ) { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + const speechRecognition = new SpeechRecognition(); + + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + speechRecognition.lang = "en-US"; + + speechRecognition.onstart = () => { + setIsListening(true); + }; + + speechRecognition.onend = () => { + setIsListening(false); + }; + + speechRecognition.onresult = (event) => { + let finalTranscript = ""; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + finalTranscript += result[0]?.transcript ?? ""; + } + } + + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; + const currentValue = textarea.value; + const newValue = + currentValue + (currentValue ? " " : "") + finalTranscript; + + textarea.value = newValue; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + onTranscriptionChange?.(newValue); + } + }; + + speechRecognition.onerror = (event) => { + console.error("Speech recognition error:", event.error); + setIsListening(false); + }; + + recognitionRef.current = speechRecognition; + setRecognition(speechRecognition); + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [textareaRef, onTranscriptionChange]); + + const toggleListening = useCallback(() => { + if (!recognition) { + return; + } + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [recognition, isListening]); + + return ( + + + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( + + ); +}; + +export type WebPreviewBodyProps = ComponentProps<"iframe"> & { + loading?: ReactNode; +}; + +export const WebPreviewBody = ({ + className, + loading, + src, + ...props +}: WebPreviewBodyProps) => { + const { url } = useWebPreview(); + + return ( +
+