From 0e3b4e02b67eeb53e1783a5fbede149de0bf1e3d Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Mon, 17 Nov 2025 09:51:04 +1000 Subject: [PATCH] assistant mcp --- apps/ui-library/__registry__/index.tsx | 4 + .../app/example/assistant-demo/layout.tsx | 18 + .../app/example/assistant-demo/page.tsx | 5 + apps/ui-library/components.json | 5 +- apps/ui-library/config/docs.ts | 8 + .../content/docs/assistant-chat.mdx | 111 ++ apps/ui-library/package.json | 36 +- apps/ui-library/public/llms.txt | 2 + apps/ui-library/public/r/assistant.json | 86 + .../public/r/infinite-query-hook.json | 2 +- apps/ui-library/public/r/registry.json | 76 + apps/ui-library/registry/blocks.ts | 2 + .../default/blocks/assistant/README.md | 33 +- .../blocks/assistant/app/api/chat/route.ts | 113 -- .../assistant/components/assistant-widget.tsx | 557 +++---- .../blocks/assistant/registry-item.json | 15 +- .../supabase/functions/_shared/cors.ts | 5 + .../supabase/functions/chat/deno.json | 13 + .../supabase/functions/chat/index.ts | 63 + .../supabase/functions/mcp-server/index.ts | 201 +-- .../components/ai-elements/artifact.tsx | 147 ++ .../default/components/ai-elements/canvas.tsx | 22 + .../ai-elements/chain-of-thought.tsx | 228 +++ .../components/ai-elements/checkpoint.tsx | 68 + .../components/ai-elements/code-block.tsx | 179 +++ .../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 ++ .../default/components/ai-elements/edge.tsx | 140 ++ .../default/components/ai-elements/image.tsx | 24 + .../ai-elements/inline-citation.tsx | 287 ++++ .../default/components/ai-elements/loader.tsx | 96 ++ .../components/ai-elements/message.tsx | 448 ++++++ .../components/ai-elements/model-selector.tsx | 205 +++ .../default/components/ai-elements/node.tsx | 71 + .../components/ai-elements/open-in-chat.tsx | 365 +++++ .../default/components/ai-elements/panel.tsx | 15 + .../default/components/ai-elements/plan.tsx | 142 ++ .../components/ai-elements/prompt-input.tsx | 1377 +++++++++++++++++ .../default/components/ai-elements/queue.tsx | 274 ++++ .../components/ai-elements/reasoning.tsx | 178 +++ .../components/ai-elements/shimmer.tsx | 64 + .../components/ai-elements/sources.tsx | 77 + .../components/ai-elements/suggestion.tsx | 56 + .../default/components/ai-elements/task.tsx | 87 ++ .../default/components/ai-elements/tool.tsx | 163 ++ .../components/ai-elements/toolbar.tsx | 16 + .../components/ai-elements/web-preview.tsx | 263 ++++ .../default/components/ui/button-group.tsx | 83 + .../default/components/ui/carousel.tsx | 241 +++ .../default/components/ui/collapsible.tsx | 33 + .../default/components/ui/dropdown-menu.tsx | 257 +++ .../default/components/ui/input-group.tsx | 170 ++ .../default/components/ui/scroll-area.tsx | 58 + .../default/components/ui/separator.tsx | 28 + .../default/examples/assistant-demo.tsx | 97 ++ apps/ui-library/registry/examples.ts | 11 + pnpm-lock.yaml | 482 +++--- 60 files changed, 7717 insertions(+), 820 deletions(-) create mode 100644 apps/ui-library/app/example/assistant-demo/layout.tsx create mode 100644 apps/ui-library/app/example/assistant-demo/page.tsx create mode 100644 apps/ui-library/content/docs/assistant-chat.mdx create mode 100644 apps/ui-library/public/r/assistant.json delete mode 100644 apps/ui-library/registry/default/blocks/assistant/app/api/chat/route.ts create mode 100644 apps/ui-library/registry/default/blocks/assistant/supabase/functions/_shared/cors.ts create mode 100644 apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/deno.json create mode 100644 apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/index.ts create mode 100644 apps/ui-library/registry/default/components/ai-elements/artifact.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/canvas.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/chain-of-thought.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/checkpoint.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/code-block.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/confirmation.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/connection.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/context.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/controls.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/conversation.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/edge.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/image.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/inline-citation.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/loader.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/message.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/model-selector.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/node.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/open-in-chat.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/panel.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/plan.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/prompt-input.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/queue.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/reasoning.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/shimmer.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/sources.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/suggestion.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/task.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/tool.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/toolbar.tsx create mode 100644 apps/ui-library/registry/default/components/ai-elements/web-preview.tsx create mode 100644 apps/ui-library/registry/default/components/ui/button-group.tsx create mode 100644 apps/ui-library/registry/default/components/ui/carousel.tsx create mode 100644 apps/ui-library/registry/default/components/ui/collapsible.tsx create mode 100644 apps/ui-library/registry/default/components/ui/dropdown-menu.tsx create mode 100644 apps/ui-library/registry/default/components/ui/input-group.tsx create mode 100644 apps/ui-library/registry/default/components/ui/scroll-area.tsx create mode 100644 apps/ui-library/registry/default/components/ui/separator.tsx create mode 100644 apps/ui-library/registry/default/examples/assistant-demo.tsx diff --git a/apps/ui-library/__registry__/index.tsx b/apps/ui-library/__registry__/index.tsx index f20ed0206cd..0c5c453ea14 100644 --- a/apps/ui-library/__registry__/index.tsx +++ b/apps/ui-library/__registry__/index.tsx @@ -38,6 +38,10 @@ export const Index = { "infinite-query-hook-demo": { component: React.lazy(() => import("@/registry/default/examples/infinite-query-hook-demo.tsx")), } + , + "assistant-demo": { + component: React.lazy(() => import("@/registry/default/examples/assistant-demo.tsx")), + } }, } as const diff --git a/apps/ui-library/app/example/assistant-demo/layout.tsx b/apps/ui-library/app/example/assistant-demo/layout.tsx new file mode 100644 index 00000000000..f549f49515e --- /dev/null +++ b/apps/ui-library/app/example/assistant-demo/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { BaseInjector } from './../base-injector' + +export const metadata: Metadata = { + title: 'Assistant Widget Demo', + description: 'Demo for the Supabase AI Assistant block.', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <> + +
+
{children}
+
+ + ) +} diff --git a/apps/ui-library/app/example/assistant-demo/page.tsx b/apps/ui-library/app/example/assistant-demo/page.tsx new file mode 100644 index 00000000000..25a85f3b743 --- /dev/null +++ b/apps/ui-library/app/example/assistant-demo/page.tsx @@ -0,0 +1,5 @@ +import AssistantDemo from '@/registry/default/examples/assistant-demo' + +export default function Page() { + return +} diff --git a/apps/ui-library/components.json b/apps/ui-library/components.json index 80d94f1fc9c..bc18cd6d0d0 100644 --- a/apps/ui-library/components.json +++ b/apps/ui-library/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/registry/default/components", "utils": "@/lib/utils", @@ -17,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "registries": { + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" + } } diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts index da2017ab269..24dc1b95185 100644 --- a/apps/ui-library/config/docs.ts +++ b/apps/ui-library/config/docs.ts @@ -117,6 +117,14 @@ export const componentPages: SidebarNavGroup = { items: [], commandItemLabel: 'Infinite Query Hook', }, + { + title: 'Assistant', + supportedFrameworks: [], + href: '/docs/assistant-chat', + new: true, + items: [], + commandItemLabel: 'Assistant', + }, ], } diff --git a/apps/ui-library/content/docs/assistant-chat.mdx b/apps/ui-library/content/docs/assistant-chat.mdx new file mode 100644 index 00000000000..7c93fe86ab8 --- /dev/null +++ b/apps/ui-library/content/docs/assistant-chat.mdx @@ -0,0 +1,111 @@ +--- +title: Assistant +description: AI assistant widget that streams database-aware answers through Supabase Edge Functions and the Model Context Protocol (MCP). +--- + + + +## Installation + + + +## Folder structure + + + +## Overview + +The Assistant block adds a bottom-right chat bubble that authenticates with Supabase, streams model responses from an Edge Function, and can safely execute SQL through an MCP server. It ships with the [AI SDK Elements](https://sdk.vercel.ai/docs/ai-elements) components for a polished chat experience plus Supabase utilities for both browser and server code paths. + +The bundle contains four key pieces: + +1. **`components/assistant-widget.tsx`** – client component that renders the widget, handles chat state, and sends authenticated requests to a Supabase Edge Function. +2. **`supabase/functions/chat`** – Edge Function that receives browser requests, fans out to your MCP server, and streams completions back to the UI. +3. **`supabase/functions/mcp-server`** – Hono-based MCP server exposing tools such as `queryDatabase` so the model can run RLS-aware SQL. +4. **`lib/supabase/*`** – tiny helpers for browser/server Supabase clients and middleware. + +## Requirements + +- A Supabase project with authentication and Row Level Security enabled. +- OpenAI (or MCP compatible) API key configured in the Edge Functions. +- Supabase CLI ≥ `1.149.7` for running functions locally. +- Deno 2.x for Supabase Edge Functions. + +## Environment variables + +| Key | Where it is used | Notes | +| --- | ---------------- | ----- | +| `NEXT_PUBLIC_SUPABASE_URL` | Next.js client + Edge Functions | Base URL of your Supabase project. | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Next.js client | Anonymous key used by the browser client. | +| `NEXT_PUBLIC_ASSISTANT_CHAT_URL` | Next.js client | Optional override for the chat Edge Function endpoint. Defaults to `${NEXT_PUBLIC_SUPABASE_URL}/functions/v1/chat`. | +| `OPENAI_API_KEY` | `supabase/functions/chat` | Injected into the Edge Function to talk to your LLM provider. | +| `MCP_SERVER_URL` | `supabase/functions/chat` and `supabase/functions/mcp-server` | Used by the chat handler to find the MCP server endpoint. | + +Store these values in `.env.local` for the app and in a `.env` file that you pass to `supabase functions serve`. + +## Running locally + +1. Start Supabase: `supabase start` +2. Serve the MCP server: `supabase functions serve --no-verify-jwt --env-file .env.local mcp-server` +3. Serve the chat bridge: `supabase functions serve --no-verify-jwt --env-file .env.local chat` +4. Set `NEXT_PUBLIC_ASSISTANT_CHAT_URL=http://127.0.0.1:54321/functions/v1/chat` so the widget hits your local function. + +Both functions need to be running so that the Edge Function can call into the MCP server while streaming results back to the browser. + +## Adding the widget + +Place the widget close to your root layout so it is loaded once per page. The assistant automatically renders a floating toggle button and enforces Supabase authentication before sending requests. + +```tsx +import { AssistantWidget } from '@/components/assistant-widget' + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + + ) +} +``` + +When a signed-in user submits a prompt, the widget: + +1. Pulls the current Supabase session via the browser client. +2. Sends the prompt to `NEXT_PUBLIC_ASSISTANT_CHAT_URL` with the `Authorization` header set to the user's JWT. +3. Streams assistant messages, reasoning traces, tool invocations, and sources into the AI SDK Elements UI. + +## Customizing MCP tools + +The generated `supabase/functions/mcp-server/index.ts` uses the `mcp-lite` transport with Zod-powered schemas. Edit the included `queryDatabase` tool (or add your own) to expose domain-specific capabilities. Every tool receives a fully-authenticated Supabase client so RLS is enforced automatically. + +```ts +const mcp = new McpServer({ + name: 'supabase-assistant-mcp-server', + version: '1.0.0', + schemaAdapter: (schema) => z.toJSONSchema(schema as z.ZodType), +}) + +mcp.tool('queryDatabase', { + input: z.object({ sql: z.string() }), + async execute({ sql }, { data }) { + const client = createClient() + const { data: rows, error } = await client.rpc('run_generated_sql', { sql }) + if (error) throw error + return { content: rows } + }, +}) +``` + +## Deploying + +1. Deploy the MCP server: `supabase functions deploy --no-verify-jwt mcp-server` +2. Deploy the chat bridge: `supabase functions deploy --no-verify-jwt chat` +3. Update the production values for `NEXT_PUBLIC_ASSISTANT_CHAT_URL` and `MCP_SERVER_URL`. + +Once deployed, the block can be installed into any shadcn/ui project via `npx shadcn@latest add https://ui.supabase.com/r/assistant`. diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index 6ace5f764f7..d768d8eb597 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -17,6 +17,7 @@ "typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@ai-sdk/react": "2.0.45", "@hookform/resolvers": "^3.1.1", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "*", @@ -24,44 +25,50 @@ "@radix-ui/react-aspect-ratio": "*", "@radix-ui/react-avatar": "*", "@radix-ui/react-checkbox": "*", - "@radix-ui/react-collapsible": "*", + "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-context-menu": "*", - "@radix-ui/react-dialog": "*", - "@radix-ui/react-dropdown-menu": "*", - "@radix-ui/react-hover-card": "*", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.1.12", + "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-label": "*", "@radix-ui/react-menubar": "*", "@radix-ui/react-navigation-menu": "*", "@radix-ui/react-popover": "*", - "@radix-ui/react-progress": "*", + "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-radio-group": "*", - "@radix-ui/react-scroll-area": "*", - "@radix-ui/react-select": "*", - "@radix-ui/react-separator": "*", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "*", - "@radix-ui/react-slot": "*", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "*", "@radix-ui/react-tabs": "*", "@radix-ui/react-toast": "*", "@radix-ui/react-toggle": "*", "@radix-ui/react-toggle-group": "*", - "@radix-ui/react-tooltip": "*", + "@radix-ui/react-tooltip": "^1.1.8", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@react-router/fs-routes": "^7.4.0", "@supabase/postgrest-js": "catalog:", "@supabase/supa-mdx-lint": "0.2.6-alpha", - "@tanstack/react-query": "^5.83.0", "@supabase/vue-blocks": "workspace:*", + "@tanstack/react-query": "^5.83.0", + "@xyflow/react": "^12.9.2", + "ai": "^5.0.90", "axios": "^1.12.0", "class-variance-authority": "*", "cmdk": "^1.0.0", "common": "workspace:*", "common-tags": "^1.8.2", "contentlayer2": "0.4.6", + "embla-carousel-react": "^8.6.0", "eslint-config-supabase": "workspace:*", "framer-motion": "^11.0.3", "icons": "workspace:*", "jotai": "^2.8.0", - "lucide-react": "*", + "lucide-react": "^0.436.0", + "motion": "^12.23.24", + "nanoid": "^5.1.6", "next": "catalog:", "next-contentlayer2": "0.4.6", "next-themes": "^0.3.0", @@ -81,9 +88,12 @@ "remark-code-import": "^1.2.0", "remark-gfm": "^4.0.0", "sonner": "^1.5.0", + "streamdown": "^1.3.0", + "tokenlens": "^1.3.1", "ui": "workspace:*", "ui-patterns": "workspace:*", "unist-util-visit": "^5.0.0", + "use-stick-to-bottom": "^1.1.1", "vaul": "^0.9.6", "zod": "catalog:" }, @@ -106,7 +116,7 @@ "react-router": "^7.5.2", "rimraf": "^4.1.3", "shadcn": "^3.0.0", - "shiki": "^1.1.7", + "shiki": "^1.6.0", "tailwindcss": "catalog:", "tsconfig": "workspace:*", "tsx": "catalog:", diff --git a/apps/ui-library/public/llms.txt b/apps/ui-library/public/llms.txt index bea8b27be06..1f541dddfe2 100644 --- a/apps/ui-library/public/llms.txt +++ b/apps/ui-library/public/llms.txt @@ -15,6 +15,8 @@ Library of components for your project. The components integrate with Supabase a - Install shadcn/ui and use the components in your project - [Infinite Query Hook](https://supabase.com/ui/docs/infinite-query-hook) - React hook for infinite lists, fetching data from Supabase. +- [Assistant](https://supabase.com/ui/docs/assistant-chat) + - AI assistant widget that streams Supabase-backed answers through MCP and Edge Functions. - [Supabase Client Libraries](https://supabase.com/ui/docs/nextjs/client) - Supabase client for Next.js - [Current User Avatar](https://supabase.com/ui/docs/nextjs/current-user-avatar) diff --git a/apps/ui-library/public/r/assistant.json b/apps/ui-library/public/r/assistant.json new file mode 100644 index 00000000000..35752823355 --- /dev/null +++ b/apps/ui-library/public/r/assistant.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "assistant", + "type": "registry:block", + "title": "AI Assistant with Database Query", + "description": "AI chatbot widget with database query capabilities through MCP (Model Context Protocol)", + "dependencies": [ + "ai@latest", + "@ai-sdk/react@latest", + "@ai-sdk/openai@latest", + "@ai-sdk/mcp@latest", + "@supabase/ssr@latest", + "@supabase/supabase-js@latest", + "zod@latest", + "nanoid@latest", + "sonner@latest", + "lucide-react@latest" + ], + "registryDependencies": [ + "button", + "input", + "scroll-area", + "@ai-elements/conversation", + "@ai-elements/message", + "@ai-elements/prompt-input", + "@ai-elements/model-selector", + "@ai-elements/reasoning", + "@ai-elements/sources", + "@ai-elements/suggestion" + ], + "files": [ + { + "path": "registry/default/blocks/assistant/components/assistant-widget.tsx", + "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport {\n Conversation,\n ConversationContent,\n ConversationScrollButton,\n} from '@/registry/default/components/ai-elements/conversation'\nimport {\n Message,\n MessageBranch,\n MessageBranchContent,\n MessageContent,\n MessageResponse,\n} from '@/registry/default/components/ai-elements/message'\nimport {\n ModelSelector,\n ModelSelectorContent,\n ModelSelectorEmpty,\n ModelSelectorGroup,\n ModelSelectorInput,\n ModelSelectorItem,\n ModelSelectorList,\n ModelSelectorLogo,\n ModelSelectorLogoGroup,\n ModelSelectorName,\n ModelSelectorTrigger,\n} from '@/registry/default/components/ai-elements/model-selector'\nimport {\n PromptInput,\n PromptInputActionAddAttachments,\n PromptInputActionMenu,\n PromptInputActionMenuContent,\n PromptInputActionMenuTrigger,\n PromptInputAttachment,\n PromptInputAttachments,\n PromptInputBody,\n PromptInputButton,\n PromptInputFooter,\n PromptInputHeader,\n type PromptInputMessage,\n PromptInputSubmit,\n PromptInputTextarea,\n PromptInputTools,\n} from '@/registry/default/components/ai-elements/prompt-input'\nimport {\n Reasoning,\n ReasoningContent,\n ReasoningTrigger,\n} from '@/registry/default/components/ai-elements/reasoning'\nimport {\n Source,\n Sources,\n SourcesContent,\n SourcesTrigger,\n} from '@/registry/default/components/ai-elements/sources'\nimport { Suggestion, Suggestions } from '@/registry/default/components/ai-elements/suggestion'\nimport { Button } from '@/registry/default/components/ui/button'\nimport { useChat } from '@ai-sdk/react'\nimport type { Message as ChatMessage } from 'ai'\nimport { CheckIcon, GlobeIcon, Loader2, MessageSquare, MicIcon, Minimize2, X } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { toast } from 'sonner'\nimport { createClient } from '@/registry/default/blocks/assistant/lib/supabase/client'\n\nconst suggestions = [\n 'Show me my recent orders',\n 'How many active users do we have?',\n 'List all tasks due this week',\n \"Summarize today's signups\",\n]\n\nconst models = [\n {\n id: 'gpt-4o',\n name: 'GPT-4o',\n chef: 'OpenAI',\n chefSlug: 'openai',\n providers: ['openai', 'azure'],\n },\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini',\n chef: 'OpenAI',\n chefSlug: 'openai',\n providers: ['openai', 'azure'],\n },\n {\n id: 'claude-sonnet-4-20250514',\n name: 'Claude 4 Sonnet',\n chef: 'Anthropic',\n chefSlug: 'anthropic',\n providers: ['anthropic', 'azure', 'google', 'amazon-bedrock'],\n },\n]\n\ntype ComposerStatus = 'ready' | 'submitted' | 'streaming' | 'error'\n\ntype MessageMetadata = {\n sources?: { href: string; title: string }[]\n reasoning?: {\n content: string\n duration: number\n }\n}\n\ntype ToolInvocationItem = NonNullable[number]\n\nconst getMessageText = (content: ChatMessage['content']) => {\n if (typeof content === 'string') {\n return content\n }\n\n return content\n .map((part) => (part.type === 'text' ? part.text : ''))\n .join('')\n .trim()\n}\n\nconst SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\\/$/, '')\nconst DEFAULT_CHAT_API = SUPABASE_URL ? `${SUPABASE_URL}/functions/v1/chat` : '/functions/v1/chat'\nconst CHAT_API_URL = process.env.NEXT_PUBLIC_ASSISTANT_CHAT_URL ?? DEFAULT_CHAT_API\n\nexport function AssistantWidget() {\n const supabase = useMemo(() => createClient(), [])\n const fetchWithAuth = useCallback(\n async (\n input: Parameters[0],\n init?: Parameters[1]\n ): Promise => {\n const {\n data: { session },\n error: sessionError,\n } = await supabase.auth.getSession()\n\n if (sessionError || !session) {\n throw new Error('You must be signed in to chat with the assistant.')\n }\n\n const headers = new Headers(init?.headers ?? {})\n headers.set('Authorization', `Bearer ${session.access_token}`)\n\n return fetch(input, {\n ...init,\n headers,\n })\n },\n [supabase]\n )\n const [isOpen, setIsOpen] = useState(false)\n const [isMinimized, setIsMinimized] = useState(false)\n const [text, setText] = useState('')\n const [status, setStatus] = useState('ready')\n const [model, setModel] = useState(models[0].id)\n const [modelSelectorOpen, setModelSelectorOpen] = useState(false)\n const [useWebSearch, setUseWebSearch] = useState(false)\n const [useMicrophone, setUseMicrophone] = useState(false)\n\n const { messages, append, isLoading, error } = useChat({\n api: CHAT_API_URL,\n fetch: fetchWithAuth,\n onError: (chatError) => {\n toast.error('Failed to send message', {\n description: chatError.message,\n })\n setStatus('error')\n },\n })\n\n useEffect(() => {\n if (isLoading) {\n setStatus('streaming')\n return\n }\n\n if (!isLoading && status === 'streaming') {\n setStatus('ready')\n }\n }, [isLoading, status])\n\n useEffect(() => {\n if (!error && status === 'error') {\n setStatus('ready')\n }\n }, [error, status])\n\n const selectedModel = useMemo(() => models.find((entry) => entry.id === model), [model])\n\n const handleSubmit = async (message: PromptInputMessage) => {\n const hasText = Boolean(message.text?.trim())\n const hasAttachments = Boolean(message.files?.length)\n\n if (!(hasText || hasAttachments) || isLoading) {\n return\n }\n\n setStatus('submitted')\n\n if (message.files?.length) {\n toast.success('Files attached', {\n description: `${message.files.length} file(s) attached to message`,\n })\n }\n\n try {\n await append({\n role: 'user',\n content: message.text?.trim() ?? 'Sent with attachments',\n metadata: {\n model,\n useWebSearch,\n useMicrophone,\n },\n })\n } catch (submissionError) {\n console.error(submissionError)\n setStatus('error')\n return\n }\n\n setText('')\n }\n\n const handleSuggestionClick = async (suggestion: string) => {\n if (isLoading) return\n setStatus('submitted')\n try {\n await append({ role: 'user', content: suggestion })\n } catch (suggestionError) {\n console.error(suggestionError)\n setStatus('error')\n }\n }\n\n const renderToolInvocation = (tool: ToolInvocationItem) => {\n if (!tool) return null\n\n return (\n
\n
{tool.toolName}
\n {tool.state === 'call' && (\n
\n \n Running…\n
\n )}\n {tool.state === 'result' && (\n
\n {typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}\n
\n )}\n {tool.state === 'error' &&
{tool.error}
}\n
\n )\n }\n\n if (!isOpen) {\n return (\n setIsOpen(true)}\n className=\"fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-all hover:bg-primary/90\"\n aria-label=\"Open AI Assistant\"\n >\n \n \n )\n }\n\n return (\n \n
\n
\n \n
\n

AI Assistant

\n

Powered by MCP

\n
\n
\n
\n setIsMinimized((prev) => !prev)}\n >\n \n \n \n
\n
\n\n {!isMinimized && (\n <>\n
\n \n \n {messages.length === 0 && (\n
\n

Hi! I'm your AI assistant.

\n

I can help you query your data using natural language.

\n
\n )}\n {messages.map((message) => {\n const content = getMessageText(message.content)\n const hasTools = Boolean(message.toolInvocations?.length)\n const metadata = message.metadata as MessageMetadata | undefined\n\n return (\n \n \n \n
\n {metadata?.sources?.length ? (\n \n \n \n {metadata.sources.map(\n (source: { href: string; title: string }) => (\n \n )\n )}\n \n \n ) : null}\n\n {metadata?.reasoning && (\n \n \n {metadata.reasoning.content}\n \n )}\n\n \n {content}\n \n\n {hasTools && (\n
\n {message.toolInvocations?.map((tool) => renderToolInvocation(tool))}\n
\n )}\n
\n
\n
\n
\n )\n })}\n {isLoading && (\n
\n \n
\n )}\n
\n \n
\n
\n\n
\n \n {suggestions.map((suggestion) => (\n handleSuggestionClick(suggestion)}\n />\n ))}\n \n
\n \n \n \n {(attachment) => }\n \n \n \n setText(event.target.value)}\n placeholder=\"Ask me anything about your Supabase data...\"\n />\n \n \n \n \n \n \n \n \n \n setUseMicrophone((prev) => !prev)}\n variant={useMicrophone ? 'default' : 'ghost'}\n type=\"button\"\n >\n \n Toggle microphone\n \n setUseWebSearch((prev) => !prev)}\n variant={useWebSearch ? 'default' : 'ghost'}\n type=\"button\"\n >\n \n Toggle web search\n \n \n \n \n {selectedModel?.chefSlug && (\n \n )}\n {selectedModel?.name && (\n {selectedModel.name}\n )}\n \n \n \n \n \n No models found.\n {['OpenAI', 'Anthropic'].map((chef) => (\n \n {models\n .filter((entry) => entry.chef === chef)\n .map((entry) => (\n {\n setModel(entry.id)\n setModelSelectorOpen(false)\n }}\n >\n \n {entry.name}\n \n {entry.providers.map((provider) => (\n \n ))}\n \n {model === entry.id ? (\n \n ) : (\n
\n )}\n \n ))}\n \n ))}\n \n \n \n \n \n \n \n {error &&

{error.message}

}\n
\n
\n \n )}\n
\n )\n}\n", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/assistant/lib/supabase/server.ts", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { cookies } from 'next/headers'\n\n/**\n * If using Fluid compute: Don't put this client in a global variable. Always create a new client within each\n * function when using it.\n */\nexport async function createClient() {\n const cookieStore = await cookies()\n\n return createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return cookieStore.getAll()\n },\n setAll(cookiesToSet) {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options)\n )\n } catch {\n // The `setAll` method was called from a Server Component.\n // This can be ignored if you have middleware refreshing\n // user sessions.\n }\n },\n },\n }\n )\n}\n", + "type": "registry:file", + "target": "lib/supabase/server.ts" + }, + { + "path": "registry/default/blocks/assistant/lib/supabase/client.ts", + "content": "import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!\n )\n}\n", + "type": "registry:file", + "target": "lib/supabase/client.ts" + }, + { + "path": "registry/default/blocks/assistant/lib/supabase/middleware.ts", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // IMPORTANT: Avoid writing any logic between createServerClient and\n // supabase.auth.getUser(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n const {\n data: { user },\n } = await supabase.auth.getUser()\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely.\n\n return supabaseResponse\n}\n", + "type": "registry:file", + "target": "lib/supabase/middleware.ts" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/mcp-server/index.ts", + "content": "// Follow this setup guide to integrate the Deno language server with your editor:\n// https://deno.land/manual/getting_started/setup_your_environment\n// This enables autocomplete, go to definition, etc.\n//\n// Setup type definitions for built-in Supabase Runtime APIs\n/// \n\nimport { Hono } from \"hono\";\nimport { McpServer, StreamableHttpTransport } from \"mcp-lite\";\nimport { z } from \"zod\";\n\n// We create two Hono instances:\n// 1. `app` is the root handler for the Supabase Edge Function (must match the function name, e.g. /mcp-server)\n// 2. `mcpApp` handles the MCP protocol and health endpoints, mounted under the function route\n// This pattern is required because Supabase Edge Functions route all requests to //*\n\nconst mcp = new McpServer({\n name: \"supabase-assistant-mcp-server\",\n version: \"1.0.0\",\n schemaAdapter: (schema: unknown) => z.toJSONSchema(schema as z.ZodType),\n});\n\n// Temporary placeholder tool while wiring up the rest of the MCP server.\nmcp.tool(\"queryDatabase\", {\n description:\n \"Example placeholder tool that just echoes a string back to the caller.\",\n inputSchema: z.object({\n message: z\n .string()\n .default(\"Hello from the Supabase MCP server!\")\n .describe(\"Optional message you would like the tool to return\"),\n }),\n handler: async ({ message }: { message: string }) => {\n return {\n content: [\n {\n type: \"text\",\n text: `Example tool response: ${message}`,\n },\n ],\n };\n },\n});\n\nconst transport = new StreamableHttpTransport();\nconst httpHandler = transport.bind(mcp);\n\nconst app = new Hono();\nconst mcpApp = new Hono();\n\nmcpApp.get(\"/\", (c) => {\n return c.json({\n message: \"MCP Server on Supabase Edge Functions\",\n endpoints: {\n mcp: \"/mcp\",\n health: \"/health\",\n },\n });\n});\n\nmcpApp.get(\"/health\", (c) => {\n return c.json({\n message: \"Service is up and running\",\n });\n});\n\nmcpApp.all(\"/mcp\", async (c) => {\n const response = await httpHandler(c.req.raw);\n return response;\n});\n\n// Mount the MCP app at /mcp-server (matches the function name)\napp.route(\"/mcp-server\", mcpApp);\n\nexport default app;\n\n/* To invoke locally:\n\n 1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)\n 2. Make an HTTP request:\n\n curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/mcp-server/mcp' \\\n --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \\\n --header 'Content-Type: application/json' \\\n --data '{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":1}'\n\n 3. Test the health endpoint:\n\n curl 'http://127.0.0.1:54321/functions/v1/mcp-server/health'\n\n*/\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/index.ts" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/mcp-server/deno.json", + "content": "{\n \"compilerOptions\": {\n \"lib\": [\"deno.window\", \"deno.ns\"],\n \"strict\": true\n },\n \"imports\": {\n \"hono\": \"npm:hono@^4.6.14\",\n \"mcp-lite\": \"npm:mcp-lite@0.8.2\",\n \"zod\": \"npm:zod@^4.1.12\",\n \"@supabase/supabase-js\": \"npm:@supabase/supabase-js@^2\"\n }\n}\n", + "type": "registry:file", + "target": "supabase/functions/mcp-server/deno.json" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/chat/index.ts", + "content": "import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'\nimport { openai } from '@ai-sdk/openai'\nimport { streamText, type CoreMessage } from 'ai'\nimport { z } from 'zod'\n\ntype ChatRequestBody = {\n messages?: CoreMessage[]\n model?: string\n}\n\nconst queryDatabaseInputSchema = z.object({\n table: z.string().describe('The name of the table to query'),\n columns: z\n .string()\n .optional()\n .describe(\"Columns to select (comma-separated). Defaults to '*' for all columns.\"),\n filters: z\n .array(\n z.object({\n column: z.string().describe('The column to filter on'),\n operator: z\n .enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'in', 'is'])\n .describe('The filter operator'),\n value: z.any().describe('The value to filter by'),\n })\n )\n .optional()\n .describe('Array of filter conditions to apply'),\n limit: z.number().optional().describe('Maximum number of rows to return'),\n orderBy: z\n .object({\n column: z.string().describe('Column to order by'),\n ascending: z.boolean().optional().describe('Sort order (default: true)'),\n })\n .optional()\n .describe('Ordering options'),\n explanation: z.string().optional().describe('Optional explanation of what the query does'),\n})\n\nconst supabaseUrl = Deno.env.get('SUPABASE_URL')?.replace(/\\/$/, '')\nconst defaultMcpServerUrl = supabaseUrl ? `${supabaseUrl}/functions/v1/mcp-server` : undefined\n\nconst mcpServerUrl =\n Deno.env.get('MCP_SERVER_URL') ??\n Deno.env.get('NEXT_PUBLIC_MCP_SERVER_URL') ??\n defaultMcpServerUrl ??\n 'http://localhost:54321/functions/v1/mcp-server'\n\nconst corsHeaders: Record = {\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Headers': 'authorization, content-type',\n 'Access-Control-Allow-Methods': 'POST, OPTIONS',\n}\n\nconst systemPrompt = `You are a helpful AI assistant with access to a database.\nYou can help users query their database using natural language.\nUse the queryDatabase tool to fetch data from tables with filters and options.\nAlways provide clear explanations of what the query does.\nThe database respects Row Level Security (RLS), so users can only access their own data.\nWhen building queries:\n- Use the table parameter to specify which table to query\n- Use filters array to add conditions (eq, neq, gt, gte, lt, lte, like, ilike, in, is)\n- Use limit to control the number of results\n- Use orderBy to sort results by a specific column`\n\nDeno.serve(async (req) => {\n if (req.method === 'OPTIONS') {\n return new Response('ok', { headers: corsHeaders })\n }\n\n if (req.method !== 'POST') {\n return new Response('Method Not Allowed', {\n status: 405,\n headers: corsHeaders,\n })\n }\n\n const authHeader = req.headers.get('Authorization')\n if (!authHeader) {\n return new Response(JSON.stringify({ error: 'Unauthorized' }), {\n status: 401,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n let body: ChatRequestBody\n try {\n body = await req.json()\n } catch {\n return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), {\n status: 400,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n if (!body.messages || !Array.isArray(body.messages)) {\n return new Response(JSON.stringify({ error: 'Request must include a messages array' }), {\n status: 400,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n const modelId = body.model && typeof body.model === 'string' ? body.model : 'gpt-4o'\n\n let mcpClient: Awaited> | null = null\n let clientClosed = false\n\n const closeMcpClient = async () => {\n if (!mcpClient || clientClosed) return\n clientClosed = true\n try {\n await mcpClient.close()\n } catch (closeError) {\n console.error('Failed to close MCP client:', closeError)\n }\n }\n\n try {\n mcpClient = await createMCPClient({\n transport: {\n type: 'http',\n url: mcpServerUrl,\n headers: {\n Authorization: authHeader,\n },\n },\n })\n\n const tools = await mcpClient.tools({\n schemas: {\n queryDatabase: {\n inputSchema: queryDatabaseInputSchema,\n },\n },\n })\n\n const result = await streamText({\n model: openai(modelId),\n messages: body.messages,\n tools,\n system: systemPrompt,\n onFinish: closeMcpClient,\n })\n\n return result.toTextStreamResponse({ headers: corsHeaders })\n } catch (error) {\n await closeMcpClient()\n console.error('Assistant chat error:', error)\n return new Response(\n JSON.stringify({\n error: 'Failed to process chat request',\n details: error instanceof Error ? error.message : String(error),\n }),\n {\n status: 500,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n }\n )\n }\n})\n", + "type": "registry:file", + "target": "supabase/functions/chat/index.ts" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/chat/deno.json", + "content": "{\n \"compilerOptions\": {\n \"lib\": [\"deno.window\", \"deno.ns\"],\n \"strict\": true\n },\n \"imports\": {\n \"hono\": \"npm:hono@^4.6.14\",\n \"zod\": \"npm:zod@^4.1.12\",\n \"ai\": \"npm:ai@^5.0.90\",\n \"@ai-sdk/openai\": \"npm:@ai-sdk/openai@^2.0.32\",\n \"@ai-sdk/mcp\": \"npm:@ai-sdk/mcp@latest\"\n }\n}\n", + "type": "registry:file", + "target": "supabase/functions/chat/deno.json" + }, + { + "path": "registry/default/blocks/assistant/README.md", + "content": "# AI Assistant Block\n\nAn AI-powered chatbot widget with database query capabilities using the Model Context Protocol (MCP).\n\n## Features\n\n- Fixed bottom-right chat widget with minimize/expand controls\n- Natural language database queries using AI\n- Row Level Security (RLS) enforcement\n- Streaming responses with real-time feedback\n- Tool execution visualization\n- Secure SQL query execution with validation\n- Rich AI SDK Elements (Conversation, Messages, Prompt Input, Model Selector, Suggestions, Sources, Reasoning) for a modern assistant UX\n\n## Architecture\n\nThe block consists of four main pieces that work together end to end:\n\n1. **Assistant Widget** (`components/assistant-widget.tsx`): React component providing the chat UI, built with AI SDK Elements\n2. **Chat Edge Function** (`supabase/functions/chat/`): Supabase Edge Function that streams chat completions, bootstraps the MCP client, and is invoked directly from the browser\n3. **Hono MCP Server** (`supabase/functions/mcp-server/`): Supabase Edge Function exposing MCP tools (e.g. `queryDatabase`) via the `mcp-lite` transport\n4. **Supabase Client Utilities** (`lib/supabase/`): Server and browser helpers for authenticated Supabase access\n\n## Prerequisites\n\n- Supabase project with authentication enabled\n- OpenAI API key (or another MCP-compatible LLM provider)\n- Supabase CLI installed\n- Docker (for Supabase local dev)\n- Node.js and npm/pnpm installed\n- Deno 2.x (Supabase Edge Functions runtime)\n\n## Setup Instructions\n\n### 1. Install the Block\n\n```bash\nnpx shadcn@latest add https://ui.supabase.com/r/assistant\n```\n\n### 2. Install UI + AI SDK Elements\n\nThe assistant widget depends on both core UI primitives and the AI SDK Element registry entries. Install them (or make sure they already exist in your project) using the shadcn CLI:\n\n```bash\nnpx shadcn@latest add button input scroll-area \\\n @ai-elements/conversation \\\n @ai-elements/message \\\n @ai-elements/prompt-input \\\n @ai-elements/model-selector \\\n @ai-elements/reasoning \\\n @ai-elements/sources \\\n @ai-elements/suggestion\n```\n\nEach AI Element ships ready-to-style JSX + Tailwind so you can compose advanced assistant surfaces without rebuilding primitives.\n\n### 3. Set Up Environment Variables\n\nAdd the following to your `.env.local`:\n\n```env\n# OpenAI API Key\nOPENAI_API_KEY=your_openai_api_key\n\n# Supabase Configuration\nNEXT_PUBLIC_SUPABASE_URL=your_supabase_url\nNEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key\n\n# Chat Edge Function (used by the browser)\nNEXT_PUBLIC_ASSISTANT_CHAT_URL=https://your-project.supabase.co/functions/v1/chat\n\n# MCP Server URL (consumed by the edge functions)\nMCP_SERVER_URL=https://your-project.supabase.co/functions/v1/mcp-server\n```\n\n### 4. Build and Deploy the Hono MCP Server\n\nSupabase Edge Functions route every request to `//*`, so the MCP server needs the \"two Hono apps\" pattern outlined in the [Supabase mcp-lite guide](https://supabase.com/docs/guides/functions/examples/mcp-server-mcp-lite):\n\n1. **Scaffold (optional):** `npm create mcp-lite@latest` and choose the **Supabase Edge Functions (MCP server)** template to bootstrap `supabase/functions/mcp-server`.\n2. **Import map:** Ensure `supabase/functions/mcp-server/deno.json` includes the dependencies the block uses:\n\n```jsonc\n{\n \"compilerOptions\": {\n \"lib\": [\"deno.window\", \"deno.ns\"],\n \"strict\": true\n },\n \"imports\": {\n \"hono\": \"npm:hono@^4.6.14\",\n \"mcp-lite\": \"npm:mcp-lite@0.8.2\",\n \"zod\": \"npm:zod@^4.1.12\",\n \"@supabase/supabase-js\": \"npm:@supabase/supabase-js@^2.48.0\"\n }\n}\n```\n\n3. **Hono entrypoint:** Update `supabase/functions/mcp-server/index.ts` to mount the MCP transport behind `/mcp-server/mcp`:\n\n```ts\nimport { Hono } from \"hono\";\nimport { McpServer, StreamableHttpTransport } from \"mcp-lite\";\nimport { createClient } from \"@supabase/supabase-js\";\nimport { z } from \"zod\";\n\nconst mcp = new McpServer({\n name: \"supabase-assistant-mcp-server\",\n version: \"1.0.0\",\n schemaAdapter: (schema) => z.toJSONSchema(schema as z.ZodType),\n});\n\n// ...define queryDatabase and other tools that create an authenticated Supabase client\n\nconst transport = new StreamableHttpTransport();\nconst httpHandler = transport.bind(mcp);\n\nconst mcpApp = new Hono();\nmcpApp.get(\"/\", (c) =>\n c.json({\n message: \"MCP Server on Supabase Edge Functions\",\n endpoints: { mcp: \"/mcp\", health: \"/health\" },\n })\n);\nmcpApp.get(\"/health\", (c) => c.json({ status: \"ok\" }));\nmcpApp.all(\"/mcp\", (c) => httpHandler(c.req.raw));\n\nconst app = new Hono();\napp.route(\"/mcp-server\", mcpApp);\n\nDeno.serve((req) => app.fetch(req));\n```\n\nThis structure keeps Supabase's root handler (`/mcp-server`) separate from your MCP protocol endpoints while still serving the `StreamableHttpTransport`.\n\n4. **Serve locally:**\n\n```bash\nsupabase start\nsupabase functions serve --no-verify-jwt mcp-server\n```\n\nYour MCP endpoint will be available at `http://localhost:54321/functions/v1/mcp-server/mcp`.\n\n5. **Deploy globally:**\n\n```bash\nsupabase functions deploy --no-verify-jwt mcp-server\n```\n\n### 5. Build and Deploy the Chat Edge Function\n\nThe chat edge function (located at `supabase/functions/chat/`) replaces the `/api/chat` Next.js route and is what the browser calls directly.\n\n1. Confirm the provided `deno.json` contains the AI + MCP dependencies required by the block.\n2. Run `supabase start` if you haven't already.\n3. Serve **both** edge functions in separate terminals so the MCP server and chat handler run side by side:\n\n```bash\nsupabase functions serve --env-file .env.local --no-verify-jwt mcp-server\nsupabase functions serve --env-file .env.local --no-verify-jwt chat\n```\n\n4. For local development, set `NEXT_PUBLIC_ASSISTANT_CHAT_URL=http://127.0.0.1:54321/functions/v1/chat`. In production, point it to `https://.supabase.co/functions/v1/chat`.\n5. Ensure `MCP_SERVER_URL` is available to the chat function (either via the Supabase dashboard or an `.env` file passed to `supabase functions serve`) so it can reach the MCP server endpoint.\n\n### 6. Add the Widget to Your App\n\n```tsx\nimport { AssistantWidget } from \"@/components/assistant-widget\";\n\nexport default function Layout({ children }) {\n return (\n
\n {children}\n \n
\n );\n}\n```\n\n## Local Development & Testing\n\n- Start Supabase locally: `supabase start`\n- Serve and iterate on the MCP function: `supabase functions serve --no-verify-jwt mcp-server`\n- Run the chat edge function alongside it: `supabase functions serve --no-verify-jwt chat`\n- Test with Claude Code: `claude mcp add my-mcp-server -t http http://localhost:54321/functions/v1/mcp-server/mcp`\n- Inspect traffic using the MCP inspector: `npx @modelcontextprotocol/inspector`\n- When everything looks good, deploy via `supabase functions deploy --no-verify-jwt mcp-server`\n\n## Widget UI with AI SDK Elements\n\nThe block ships with AI SDK Element primitives (Conversation, Message, PromptInput, ModelSelector, etc.) located under `components/ai-elements`. Combine them to build an opinionated assistant surface, like the following demo:\n\n```tsx\n\"use client\";\n\nimport {\n MessageBranch,\n MessageBranchContent,\n MessageBranchNext,\n MessageBranchPage,\n MessageBranchPrevious,\n MessageBranchSelector,\n} from \"@/components/ai-elements/message\";\nimport {\n Conversation,\n ConversationContent,\n ConversationScrollButton,\n} from \"@/components/ai-elements/conversation\";\nimport { Message, MessageContent } from \"@/components/ai-elements/message\";\nimport {\n PromptInput,\n PromptInputActionAddAttachments,\n PromptInputActionMenu,\n PromptInputActionMenuContent,\n PromptInputActionMenuTrigger,\n PromptInputAttachment,\n PromptInputAttachments,\n PromptInputBody,\n PromptInputButton,\n PromptInputFooter,\n PromptInputHeader,\n type PromptInputMessage,\n PromptInputSubmit,\n PromptInputTextarea,\n PromptInputTools,\n} from \"@/components/ai-elements/prompt-input\";\nimport {\n ModelSelector,\n ModelSelectorContent,\n ModelSelectorEmpty,\n ModelSelectorGroup,\n ModelSelectorInput,\n ModelSelectorItem,\n ModelSelectorList,\n ModelSelectorLogo,\n ModelSelectorLogoGroup,\n ModelSelectorName,\n ModelSelectorTrigger,\n} from \"@/components/ai-elements/model-selector\";\nimport {\n Reasoning,\n ReasoningContent,\n ReasoningTrigger,\n} from \"@/components/ai-elements/reasoning\";\nimport { MessageResponse } from \"@/components/ai-elements/message\";\nimport {\n Source,\n Sources,\n SourcesContent,\n SourcesTrigger,\n} from \"@/components/ai-elements/sources\";\nimport { Suggestion, Suggestions } from \"@/components/ai-elements/suggestion\";\nimport type { ToolUIPart } from \"ai\";\nimport { CheckIcon, GlobeIcon, MicIcon } from \"lucide-react\";\nimport { nanoid } from \"nanoid\";\nimport { useCallback, useState } from \"react\";\nimport { toast } from \"sonner\";\n\n// ...rest of the Example component from the block's source (see components/assistant-widget.tsx)\n```\n\nThe complete example (available in this block) demonstrates:\n\n- Branching messages with sources, reasoning traces, and MCP tool output\n- Scrollable conversation history with streaming responses\n- Prompt composer with attachments, microphone toggle, and model selector\n- Reusable suggestions to quickly seed the assistant\n\nUse the snippet as a starting point for your own assistant surface or trim it down for a lighter footprint.\n\n## Usage\n\n### For End Users\n\n1. Click the chat icon in the bottom-right corner to open the assistant\n2. Type natural language queries about your data\n3. The AI will translate your questions into SQL and execute them via the `queryDatabase` MCP tool\n4. Results are displayed in the chat interface with tool execution feedback\n5. Minimize or close the widget as needed\n\n### Example Queries\n\n- \"Show me all my tasks\"\n- \"How many orders did I place last month?\"\n- \"List my recent transactions\"\n- \"What's the total revenue from my sales?\"\n\n## Security Considerations\n\n### Query Builder Approach\n\nThe MCP server uses Supabase's query builder for safe data access:\n\n- Uses the `queryDatabase` tool with structured parameters (table, filters, orderBy, limit)\n- No raw SQL injection risks\n- Queries execute with the authenticated user's context\n- All operations go through Supabase's security layer\n\n### Row Level Security (RLS)\n\nAll queries respect Supabase RLS policies:\n\n- The authenticated user's token is passed to the MCP server\n- Queries run with user context\n- Users can only access data allowed by RLS policies\n\n### Authentication\n\n- Local development uses `--no-verify-jwt` for speed; production deployments should enforce authentication per the [MCP authorization spec](https://modelcontextprotocol.io/spec)\n- Never expose production data without adding proper auth and auditing\n\n#### MCP middleware helpers\n\n`mcp-lite` ships with Hono-style middleware so you can enforce authentication, logging, and rate limiting *before* a tool executes. This keeps your auth logic centralized instead of duplicating it inside each tool handler:\n\n```ts\n// Logging\nmcp.use(async ({ req }, next) => {\n const start = Date.now();\n await next();\n console.log(`${req.method} took ${Date.now() - start}ms`);\n});\n\n// Authentication\nmcp.use(async (ctx, next) => {\n const token = ctx.req.header(\"Authorization\");\n if (!token) throw new Error(\"Unauthorized\");\n\n const user = await validateToken(token);\n ctx.state.user = user;\n await next();\n});\n\n// Rate limiting (example)\nmcp.use(async (ctx, next) => {\n const userId = ctx.state.user?.id;\n if (await isRateLimited(userId)) {\n throw new Error(\"Rate limit exceeded\");\n }\n await next();\n});\n```\n\nInside your tools you can then rely on `ctx.state.user` (or whatever you set) rather than re-validating headers manually. See the [`mcp-lite` middleware docs](https://github.com/modelcontextprotocol/mcp-lite#middleware) for more patterns.\n\n### Best Practices\n\n1. **Enable RLS on all tables:** Ensure all tables have RLS enabled\n2. **Create appropriate policies:** Define policies that match your security requirements\n3. **Monitor edge function logs:** Check logs for suspicious queries or errors\n4. **Rate limiting:** Consider adding rate limiting to prevent abuse\n5. **Query complexity:** Set statement timeouts in Postgres to prevent long-running queries\n6. **Validate tool inputs:** Sanitize and validate every argument received by MCP tools\n\n## Customization\n\n### Change the AI Model\n\nEdit `supabase/functions/chat/index.ts`:\n\n```ts\nconst result = await streamText({\n model: openai(\"gpt-4o\"), // Change to another model (OpenAI, Anthropic, Google, etc.)\n // ... rest of config\n});\n```\n\n### Customize the System Prompt\n\nEdit the `system` parameter in `supabase/functions/chat/index.ts` to change the assistant's behavior.\n\n### Style the Widget\n\nAI SDK Elements are standard React components, so you can:\n\n- Override Tailwind classes\n- Reorder or remove sections (e.g., hide the model selector)\n- Inject custom suggestion lists or streaming loaders\n\n### Add More MCP Tools\n\nExtend the MCP server with additional tools in `supabase/functions/mcp-server/index.ts` following the same Hono pattern:\n\n```ts\nmcp.tool(\"myCustomTool\", {\n description: \"Description of what this tool does\",\n inputSchema: z.object({\n param1: z.string(),\n param2: z.number().optional(),\n }),\n handler: async (args, { req }) => {\n const supabaseClient = createClient(\n Deno.env.get(\"SUPABASE_URL\") ?? \"\",\n Deno.env.get(\"SUPABASE_ANON_KEY\") ?? \"\",\n {\n global: {\n headers: { Authorization: req.headers.get(\"Authorization\")! },\n },\n }\n );\n\n // Tool implementation\n return {\n content: [{ type: \"text\", text: \"Result\" }],\n };\n },\n});\n```\n\n## Troubleshooting\n\n### \"Unauthorized\" Error\n\n- Ensure the user is authenticated before using the widget\n- Check that `SUPABASE_URL` and `SUPABASE_ANON_KEY` are set correctly\n- Verify the Authorization header is forwarded to the MCP server\n\n### \"Query execution failed\"\n\n- Verify the table exists and is accessible\n- Check that RLS policies allow the user to access the data\n- Verify the column names in filters are correct\n- Check edge function logs: `supabase functions logs mcp-server`\n\n### \"Function not found\" or 404\n\n- Verify the chat function is deployed at `/functions/v1/chat` and `NEXT_PUBLIC_ASSISTANT_CHAT_URL` points to it\n- Make sure the Hono app is mounted at `/mcp-server`\n- Confirm you are calling `/functions/v1/mcp-server/mcp`\n- Restart `supabase functions serve` after changing import maps\n", + "type": "registry:file", + "target": "README.assistant.md" + } + ] +} \ No newline at end of file diff --git a/apps/ui-library/public/r/infinite-query-hook.json b/apps/ui-library/public/r/infinite-query-hook.json index d795b7b6e1a..5ed44d9e635 100644 --- a/apps/ui-library/public/r/infinite-query-hook.json +++ b/apps/ui-library/public/r/infinite-query-hook.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts", - "content": "'use client'\n\nimport { createClient } from '@/registry/default/fixtures/lib/supabase/client'\nimport { PostgrestQueryBuilder } from '@supabase/postgrest-js'\nimport { type SupabaseClient } from '@supabase/supabase-js'\nimport { useEffect, useRef, useSyncExternalStore } from 'react'\n\nconst supabase = createClient()\n\n// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.\ntype SupabaseClientType = typeof supabase\n\n// Utility type to check if the type is any\ntype IfAny = 0 extends 1 & T ? Y : N\n\n// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.\ntype Database =\n SupabaseClientType extends SupabaseClient\n ? IfAny<\n U,\n {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n },\n U\n >\n : {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n }\n\n// Change this to the database schema you want to use\ntype DatabaseSchema = Database['public']\n\n// Extracts the table names from the database type\ntype SupabaseTableName = keyof DatabaseSchema['Tables']\n\n// Extracts the table definition from the database type\ntype SupabaseTableData = DatabaseSchema['Tables'][T]['Row']\n\ntype SupabaseSelectBuilder = ReturnType<\n PostgrestQueryBuilder['select']\n>\n\n// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\ntype SupabaseQueryHandler = (\n query: SupabaseSelectBuilder\n) => SupabaseSelectBuilder\n\ninterface UseInfiniteQueryProps {\n // The table name to query\n tableName: T\n // The columns to select, defaults to `*`\n columns?: string\n // The number of items to fetch per page, defaults to `20`\n pageSize?: number\n // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\n trailingQuery?: SupabaseQueryHandler\n}\n\ninterface StoreState {\n data: TData[]\n count: number\n isSuccess: boolean\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n hasInitialFetch: boolean\n}\n\ntype Listener = () => void\n\nfunction createStore, T extends SupabaseTableName>(\n props: UseInfiniteQueryProps\n) {\n const { tableName, columns = '*', pageSize = 20, trailingQuery } = props\n\n let state: StoreState = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n }\n\n const listeners = new Set()\n\n const notify = () => {\n listeners.forEach((listener) => listener())\n }\n\n const setState = (newState: Partial>) => {\n state = { ...state, ...newState }\n notify()\n }\n\n const fetchPage = async (skip: number) => {\n if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return\n\n setState({ isFetching: true })\n\n let query = supabase\n .from(tableName)\n .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder\n\n if (trailingQuery) {\n query = trailingQuery(query)\n }\n const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)\n\n if (error) {\n console.error('An unexpected error occurred:', error)\n setState({ error })\n } else {\n setState({\n data: [...state.data, ...(newData as TData[])],\n count: count || 0,\n isSuccess: true,\n error: null,\n })\n }\n setState({ isFetching: false })\n }\n\n const fetchNextPage = async () => {\n if (state.isFetching) return\n await fetchPage(state.data.length)\n }\n\n const initialize = async () => {\n setState({ isLoading: true, isSuccess: false, data: [] })\n await fetchNextPage()\n setState({ isLoading: false, hasInitialFetch: true })\n }\n\n return {\n getState: () => state,\n subscribe: (listener: Listener) => {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n fetchNextPage,\n initialize,\n }\n}\n\n// Empty initial state to avoid hydration errors.\nconst initialState: any = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n}\n\nfunction useInfiniteQuery<\n TData extends SupabaseTableData,\n T extends SupabaseTableName = SupabaseTableName,\n>(props: UseInfiniteQueryProps) {\n const storeRef = useRef(createStore(props))\n\n const state = useSyncExternalStore(\n storeRef.current.subscribe,\n () => storeRef.current.getState(),\n () => initialState as StoreState\n )\n\n useEffect(() => {\n // Recreate store if props change\n if (\n storeRef.current.getState().hasInitialFetch &&\n (props.tableName !== props.tableName ||\n props.columns !== props.columns ||\n props.pageSize !== props.pageSize)\n ) {\n storeRef.current = createStore(props)\n }\n\n if (!state.hasInitialFetch && typeof window !== 'undefined') {\n storeRef.current.initialize()\n }\n }, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch])\n\n return {\n data: state.data,\n count: state.count,\n isSuccess: state.isSuccess,\n isLoading: state.isLoading,\n isFetching: state.isFetching,\n error: state.error,\n hasMore: state.count > state.data.length,\n fetchNextPage: storeRef.current.fetchNextPage,\n }\n}\n\nexport {\n useInfiniteQuery,\n type SupabaseQueryHandler,\n type SupabaseTableData,\n type SupabaseTableName,\n type UseInfiniteQueryProps,\n}\n", + "content": "'use client'\n\nimport { createClient } from '@/registry/default/fixtures/lib/supabase/client'\nimport { PostgrestQueryBuilder, type PostgrestClientOptions } from '@supabase/postgrest-js'\nimport { type SupabaseClient } from '@supabase/supabase-js'\nimport { useEffect, useRef, useSyncExternalStore } from 'react'\n\nconst supabase = createClient()\n\n// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.\ntype SupabaseClientType = typeof supabase\n\n// Utility type to check if the type is any\ntype IfAny = 0 extends 1 & T ? Y : N\n\n// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.\ntype Database =\n SupabaseClientType extends SupabaseClient\n ? IfAny<\n U,\n {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n },\n U\n >\n : {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n }\n\n// Change this to the database schema you want to use\ntype DatabaseSchema = Database['public']\n\n// Extracts the table names from the database type\ntype SupabaseTableName = keyof DatabaseSchema['Tables']\n\n// Extracts the table definition from the database type\ntype SupabaseTableData = DatabaseSchema['Tables'][T]['Row']\n\n// Default client options for PostgrestQueryBuilder\ntype DefaultClientOptions = PostgrestClientOptions\n\ntype SupabaseSelectBuilder = ReturnType<\n PostgrestQueryBuilder<\n DefaultClientOptions,\n DatabaseSchema,\n DatabaseSchema['Tables'][T],\n T\n >['select']\n>\n\n// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\ntype SupabaseQueryHandler = (\n query: SupabaseSelectBuilder\n) => SupabaseSelectBuilder\n\ninterface UseInfiniteQueryProps {\n // The table name to query\n tableName: T\n // The columns to select, defaults to `*`\n columns?: string\n // The number of items to fetch per page, defaults to `20`\n pageSize?: number\n // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\n trailingQuery?: SupabaseQueryHandler\n}\n\ninterface StoreState {\n data: TData[]\n count: number\n isSuccess: boolean\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n hasInitialFetch: boolean\n}\n\ntype Listener = () => void\n\nfunction createStore, T extends SupabaseTableName>(\n props: UseInfiniteQueryProps\n) {\n const { tableName, columns = '*', pageSize = 20, trailingQuery } = props\n\n let state: StoreState = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n }\n\n const listeners = new Set()\n\n const notify = () => {\n listeners.forEach((listener) => listener())\n }\n\n const setState = (newState: Partial>) => {\n state = { ...state, ...newState }\n notify()\n }\n\n const fetchPage = async (skip: number) => {\n if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return\n\n setState({ isFetching: true })\n\n let query = supabase\n .from(tableName)\n .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder\n\n if (trailingQuery) {\n query = trailingQuery(query)\n }\n const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)\n\n if (error) {\n console.error('An unexpected error occurred:', error)\n setState({ error })\n } else {\n setState({\n data: [...state.data, ...(newData as TData[])],\n count: count || 0,\n isSuccess: true,\n error: null,\n })\n }\n setState({ isFetching: false })\n }\n\n const fetchNextPage = async () => {\n if (state.isFetching) return\n await fetchPage(state.data.length)\n }\n\n const initialize = async () => {\n setState({ isLoading: true, isSuccess: false, data: [] })\n await fetchNextPage()\n setState({ isLoading: false, hasInitialFetch: true })\n }\n\n return {\n getState: () => state,\n subscribe: (listener: Listener) => {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n fetchNextPage,\n initialize,\n }\n}\n\n// Empty initial state to avoid hydration errors.\nconst initialState: any = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n}\n\nfunction useInfiniteQuery<\n TData extends SupabaseTableData,\n T extends SupabaseTableName = SupabaseTableName,\n>(props: UseInfiniteQueryProps) {\n const storeRef = useRef(createStore(props))\n\n const state = useSyncExternalStore(\n storeRef.current.subscribe,\n () => storeRef.current.getState(),\n () => initialState as StoreState\n )\n\n useEffect(() => {\n // Recreate store if props change\n if (\n storeRef.current.getState().hasInitialFetch &&\n (props.tableName !== props.tableName ||\n props.columns !== props.columns ||\n props.pageSize !== props.pageSize)\n ) {\n storeRef.current = createStore(props)\n }\n\n if (!state.hasInitialFetch && typeof window !== 'undefined') {\n storeRef.current.initialize()\n }\n }, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch])\n\n return {\n data: state.data,\n count: state.count,\n isSuccess: state.isSuccess,\n isLoading: state.isLoading,\n isFetching: state.isFetching,\n error: state.error,\n hasMore: state.count > state.data.length,\n fetchNextPage: storeRef.current.fetchNextPage,\n }\n}\n\nexport {\n useInfiniteQuery,\n type SupabaseQueryHandler,\n type SupabaseTableData,\n type SupabaseTableName,\n type UseInfiniteQueryProps,\n}\n", "type": "registry:hook" } ] diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json index f541beefd3d..4e25aa1d464 100644 --- a/apps/ui-library/public/r/registry.json +++ b/apps/ui-library/public/r/registry.json @@ -1417,6 +1417,82 @@ } ] }, + { + "name": "assistant", + "type": "registry:block", + "title": "AI Assistant with Database Query", + "description": "AI chatbot widget with database query capabilities through MCP (Model Context Protocol)", + "registryDependencies": [ + "button", + "input", + "scroll-area", + "@ai-elements/conversation", + "@ai-elements/message", + "@ai-elements/prompt-input", + "@ai-elements/model-selector", + "@ai-elements/reasoning", + "@ai-elements/sources", + "@ai-elements/suggestion" + ], + "dependencies": [ + "ai@latest", + "@ai-sdk/react@latest", + "@ai-sdk/openai@latest", + "@ai-sdk/mcp@latest", + "@supabase/ssr@latest", + "@supabase/supabase-js@latest", + "zod@latest", + "nanoid@latest", + "sonner@latest", + "lucide-react@latest" + ], + "files": [ + { + "path": "registry/default/blocks/assistant/components/assistant-widget.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/assistant/lib/supabase/server.ts", + "type": "registry:file", + "target": "lib/supabase/server.ts" + }, + { + "path": "registry/default/blocks/assistant/lib/supabase/client.ts", + "type": "registry:file", + "target": "lib/supabase/client.ts" + }, + { + "path": "registry/default/blocks/assistant/lib/supabase/middleware.ts", + "type": "registry:file", + "target": "lib/supabase/middleware.ts" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/mcp-server/index.ts", + "type": "registry:file", + "target": "supabase/functions/mcp-server/index.ts" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/mcp-server/deno.json", + "type": "registry:file", + "target": "supabase/functions/mcp-server/deno.json" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/chat/index.ts", + "type": "registry:file", + "target": "supabase/functions/chat/index.ts" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/chat/deno.json", + "type": "registry:file", + "target": "supabase/functions/chat/deno.json" + }, + { + "path": "registry/default/blocks/assistant/README.md", + "type": "registry:file", + "target": "README.assistant.md" + } + ] + }, { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "supabase-client-nextjs", diff --git a/apps/ui-library/registry/blocks.ts b/apps/ui-library/registry/blocks.ts index fc993d76be1..668dc24e343 100644 --- a/apps/ui-library/registry/blocks.ts +++ b/apps/ui-library/registry/blocks.ts @@ -5,6 +5,7 @@ import { registryItemAppend } from './utils' import currentUserAvatar from './default/blocks/current-user-avatar/registry-item.json' with { type: 'json' } import dropzone from './default/blocks/dropzone/registry-item.json' with { type: 'json' } import infiniteQueryHook from './default/blocks/infinite-query-hook/registry-item.json' with { type: 'json' } +import assistant from './default/blocks/assistant/registry-item.json' with { type: 'json' } import realtimeAvatarStack from './default/blocks/realtime-avatar-stack/registry-item.json' with { type: 'json' } import realtimeChat from './default/blocks/realtime-chat/registry-item.json' with { type: 'json' } import realtimeCursor from './default/blocks/realtime-cursor/registry-item.json' with { type: 'json' } @@ -54,4 +55,5 @@ export const blocks = [ ...combine(realtimeChat as RegistryItem), // infinite query hook is intentionally not combined with the clients since it depends on clients having database types. infiniteQueryHook as RegistryItem, + assistant as RegistryItem, ] as RegistryItem[] diff --git a/apps/ui-library/registry/default/blocks/assistant/README.md b/apps/ui-library/registry/default/blocks/assistant/README.md index 90fe59db920..2b9f99db4ae 100644 --- a/apps/ui-library/registry/default/blocks/assistant/README.md +++ b/apps/ui-library/registry/default/blocks/assistant/README.md @@ -17,7 +17,7 @@ An AI-powered chatbot widget with database query capabilities using the Model Co The block consists of four main pieces that work together end to end: 1. **Assistant Widget** (`components/assistant-widget.tsx`): React component providing the chat UI, built with AI SDK Elements -2. **Chat API Route** (`app/api/chat/route.ts`): Next.js API route handling chat requests with MCP integration +2. **Chat Edge Function** (`supabase/functions/chat/`): Supabase Edge Function that streams chat completions, bootstraps the MCP client, and is invoked directly from the browser 3. **Hono MCP Server** (`supabase/functions/mcp-server/`): Supabase Edge Function exposing MCP tools (e.g. `queryDatabase`) via the `mcp-lite` transport 4. **Supabase Client Utilities** (`lib/supabase/`): Server and browser helpers for authenticated Supabase access @@ -67,8 +67,11 @@ OPENAI_API_KEY=your_openai_api_key NEXT_PUBLIC_SUPABASE_URL=your_supabase_url NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key -# MCP Server URL (update after deploying edge function) -NEXT_PUBLIC_MCP_SERVER_URL=https://your-project.supabase.co/functions/v1/mcp-server/mcp +# Chat Edge Function (used by the browser) +NEXT_PUBLIC_ASSISTANT_CHAT_URL=https://your-project.supabase.co/functions/v1/chat + +# MCP Server URL (consumed by the edge functions) +MCP_SERVER_URL=https://your-project.supabase.co/functions/v1/mcp-server ``` ### 4. Build and Deploy the Hono MCP Server @@ -145,7 +148,23 @@ Your MCP endpoint will be available at `http://localhost:54321/functions/v1/mcp- supabase functions deploy --no-verify-jwt mcp-server ``` -### 5. Add the Widget to Your App +### 5. Build and Deploy the Chat Edge Function + +The chat edge function (located at `supabase/functions/chat/`) replaces the `/api/chat` Next.js route and is what the browser calls directly. + +1. Confirm the provided `deno.json` contains the AI + MCP dependencies required by the block. +2. Run `supabase start` if you haven't already. +3. Serve **both** edge functions in separate terminals so the MCP server and chat handler run side by side: + +```bash +supabase functions serve --env-file .env.local --no-verify-jwt mcp-server +supabase functions serve --env-file .env.local --no-verify-jwt chat +``` + +4. For local development, set `NEXT_PUBLIC_ASSISTANT_CHAT_URL=http://127.0.0.1:54321/functions/v1/chat`. In production, point it to `https://.supabase.co/functions/v1/chat`. +5. Ensure `MCP_SERVER_URL` is available to the chat function (either via the Supabase dashboard or an `.env` file passed to `supabase functions serve`) so it can reach the MCP server endpoint. + +### 6. Add the Widget to Your App ```tsx import { AssistantWidget } from "@/components/assistant-widget"; @@ -164,6 +183,7 @@ export default function Layout({ children }) { - Start Supabase locally: `supabase start` - Serve and iterate on the MCP function: `supabase functions serve --no-verify-jwt mcp-server` +- Run the chat edge function alongside it: `supabase functions serve --no-verify-jwt chat` - Test with Claude Code: `claude mcp add my-mcp-server -t http http://localhost:54321/functions/v1/mcp-server/mcp` - Inspect traffic using the MCP inspector: `npx @modelcontextprotocol/inspector` - When everything looks good, deploy via `supabase functions deploy --no-verify-jwt mcp-server` @@ -338,7 +358,7 @@ Inside your tools you can then rely on `ctx.state.user` (or whatever you set) ra ### Change the AI Model -Edit `app/api/chat/route.ts`: +Edit `supabase/functions/chat/index.ts`: ```ts const result = await streamText({ @@ -349,7 +369,7 @@ const result = await streamText({ ### Customize the System Prompt -Edit the `system` parameter in `app/api/chat/route.ts` to change the assistant's behavior. +Edit the `system` parameter in `supabase/functions/chat/index.ts` to change the assistant's behavior. ### Style the Widget @@ -406,6 +426,7 @@ mcp.tool("myCustomTool", { ### "Function not found" or 404 +- Verify the chat function is deployed at `/functions/v1/chat` and `NEXT_PUBLIC_ASSISTANT_CHAT_URL` points to it - Make sure the Hono app is mounted at `/mcp-server` - Confirm you are calling `/functions/v1/mcp-server/mcp` - Restart `supabase functions serve` after changing import maps diff --git a/apps/ui-library/registry/default/blocks/assistant/app/api/chat/route.ts b/apps/ui-library/registry/default/blocks/assistant/app/api/chat/route.ts deleted file mode 100644 index 1d049fe8d6a..00000000000 --- a/apps/ui-library/registry/default/blocks/assistant/app/api/chat/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { openai } from "@ai-sdk/openai"; -import { streamText } from "ai"; -import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp"; -import { z } from "zod"; -import { createClient } from "@/registry/default/blocks/assistant/lib/supabase/server"; - -export const maxDuration = 60; - -export async function POST(req: Request) { - try { - const { messages } = await req.json(); - - // Get Supabase auth session - const supabase = await createClient(); - - const { - data: { session }, - } = await supabase.auth.getSession(); - - if (!session) { - return new Response("Unauthorized", { status: 401 }); - } - - // Initialize MCP client with auth token - const mcpServerUrl = - process.env.NEXT_PUBLIC_MCP_SERVER_URL || - "http://localhost:54321/functions/v1/mcp-server"; - - const mcpClient = await createMCPClient({ - transport: { - type: "http", - url: mcpServerUrl, - headers: { - Authorization: `Bearer ${session.access_token}`, - }, - }, - }); - - // Define MCP tools with schemas for type safety - const tools = await mcpClient.tools({ - schemas: { - queryDatabase: { - inputSchema: z.object({ - table: z.string().describe("The name of the table to query"), - columns: z - .string() - .optional() - .describe("Columns to select (comma-separated). Defaults to '*' for all columns."), - filters: z - .array( - z.object({ - column: z.string().describe("The column to filter on"), - operator: z.enum(["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "is"]).describe("The filter operator"), - value: z.any().describe("The value to filter by"), - }) - ) - .optional() - .describe("Array of filter conditions to apply"), - limit: z - .number() - .optional() - .describe("Maximum number of rows to return"), - orderBy: z - .object({ - column: z.string().describe("Column to order by"), - ascending: z.boolean().optional().describe("Sort order (default: true)"), - }) - .optional() - .describe("Ordering options"), - explanation: z - .string() - .optional() - .describe("Optional explanation of what the query does"), - }), - }, - }, - }); - - // Stream the AI response with MCP tools - const result = await streamText({ - model: openai("gpt-4o"), - messages, - tools, - system: `You are a helpful AI assistant with access to a database. -You can help users query their database using natural language. -Use the queryDatabase tool to fetch data from tables with filters and options. -Always provide clear explanations of what the query does. -The database respects Row Level Security (RLS), so users can only access their own data. -When building queries: -- Use the table parameter to specify which table to query -- Use filters array to add conditions (eq, neq, gt, gte, lt, lte, like, ilike, in, is) -- Use limit to control the number of results -- Use orderBy to sort results by a specific column`, - onFinish: async () => { - await mcpClient.close(); - }, - }); - - return result.toDataStreamResponse(); - } catch (error) { - console.error("Chat API error:", error); - return new Response( - JSON.stringify({ - error: "Failed to process chat request", - details: error instanceof Error ? error.message : String(error), - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - } - ); - } -} diff --git a/apps/ui-library/registry/default/blocks/assistant/components/assistant-widget.tsx b/apps/ui-library/registry/default/blocks/assistant/components/assistant-widget.tsx index cd307ba614f..de61e6763fe 100644 --- a/apps/ui-library/registry/default/blocks/assistant/components/assistant-widget.tsx +++ b/apps/ui-library/registry/default/blocks/assistant/components/assistant-widget.tsx @@ -1,20 +1,36 @@ -"use client"; +'use client' -import { useEffect, useMemo, useState } from "react"; -import { useChat } from "@ai-sdk/react"; -import type { Message as ChatMessage } from "ai"; +import { cn } from '@/lib/utils' +import { createClient } from '@/registry/default/blocks/assistant/lib/supabase/client' import { Conversation, ConversationContent, ConversationScrollButton, -} from "@/components/ai-elements/conversation"; +} from '@/registry/default/components/ai-elements/conversation' import { + Message, MessageBranch, MessageBranchContent, - Message, + MessageBranchNext, + MessageBranchPage, + MessageBranchPrevious, + MessageBranchSelector, MessageContent, MessageResponse, -} from "@/components/ai-elements/message"; +} from '@/registry/default/components/ai-elements/message' +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorEmpty, + ModelSelectorGroup, + ModelSelectorInput, + ModelSelectorItem, + ModelSelectorList, + ModelSelectorLogo, + ModelSelectorLogoGroup, + ModelSelectorName, + ModelSelectorTrigger, +} from '@/registry/default/components/ai-elements/model-selector' import { PromptInput, PromptInputActionAddAttachments, @@ -31,215 +47,250 @@ import { PromptInputSubmit, PromptInputTextarea, PromptInputTools, -} from "@/components/ai-elements/prompt-input"; -import { - ModelSelector, - ModelSelectorContent, - ModelSelectorEmpty, - ModelSelectorGroup, - ModelSelectorInput, - ModelSelectorItem, - ModelSelectorList, - ModelSelectorLogo, - ModelSelectorLogoGroup, - ModelSelectorName, - ModelSelectorTrigger, -} from "@/components/ai-elements/model-selector"; -import { - Sources, - SourcesContent, - SourcesTrigger, - Source, -} from "@/components/ai-elements/sources"; +} from '@/registry/default/components/ai-elements/prompt-input' import { Reasoning, ReasoningContent, ReasoningTrigger, -} from "@/components/ai-elements/reasoning"; -import { Suggestions, Suggestion } from "@/components/ai-elements/suggestion"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +} from '@/registry/default/components/ai-elements/reasoning' import { - CheckIcon, - GlobeIcon, - MessageSquare, - MicIcon, - Minimize2, - X, - Loader2, -} from "lucide-react"; -import { toast } from "sonner"; + Source, + Sources, + SourcesContent, + SourcesTrigger, +} from '@/registry/default/components/ai-elements/sources' +import { Suggestion, Suggestions } from '@/registry/default/components/ai-elements/suggestion' +import { Button } from '@/registry/default/components/ui/button' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' +import { GlobeIcon, MessageSquare, MicIcon, Minimize2, X } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' +import { toast } from 'sonner' const suggestions = [ - "Show me my recent orders", - "How many active users do we have?", - "List all tasks due this week", + 'Show me my recent orders', + 'How many active users do we have?', + 'List all tasks due this week', "Summarize today's signups", -]; +] const models = [ { - id: "gpt-4o", - name: "GPT-4o", - chef: "OpenAI", - chefSlug: "openai", - providers: ["openai", "azure"], + id: 'gpt-4o', + name: 'GPT-4o', + chef: 'OpenAI', + chefSlug: 'openai', + providers: ['openai', 'azure'], }, { - id: "gpt-4o-mini", - name: "GPT-4o Mini", - chef: "OpenAI", - chefSlug: "openai", - providers: ["openai", "azure"], + id: 'gpt-4o-mini', + name: 'GPT-4o Mini', + chef: 'OpenAI', + chefSlug: 'openai', + providers: ['openai', 'azure'], }, { - id: "claude-sonnet-4-20250514", - name: "Claude 4 Sonnet", - chef: "Anthropic", - chefSlug: "anthropic", - providers: ["anthropic", "azure", "google", "amazon-bedrock"], + id: 'claude-sonnet-4-20250514', + name: 'Claude 4 Sonnet', + chef: 'Anthropic', + chefSlug: 'anthropic', + providers: ['anthropic', 'azure', 'google', 'amazon-bedrock'], }, -]; + { + id: 'gemini-2.0-flash-exp', + name: 'Gemini 2.0 Flash', + chef: 'Google', + chefSlug: 'google', + providers: ['google'], + }, +] -type ComposerStatus = "ready" | "submitted" | "streaming" | "error"; +type TextPart = { + type: 'text' + text: string +} -type MessageMetadata = { - sources?: { href: string; title: string }[]; - reasoning?: { - content: string; - duration: number; - }; -}; +type SourcePart = { + type: 'source-url' + url: string + title?: string +} -type ToolInvocationItem = NonNullable[number]; +type ReasoningPart = { + type: 'reasoning' + text: string +} -const getMessageText = (content: ChatMessage["content"]) => { - if (typeof content === "string") { - return content; - } +type FormattedMessage = { + key: string + from: 'user' | 'assistant' + sources?: { href: string; title: string }[] + versions: { id: string; content: string }[] + reasoning?: { content: string; duration?: number } +} - return content - .map((part) => (part.type === "text" ? part.text : "")) - .join("") - .trim(); -}; +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, '') +const DEFAULT_CHAT_API = SUPABASE_URL ? `${SUPABASE_URL}/functions/v1/chat` : '/functions/v1/chat' +const CHAT_API_URL = 'https://idglwaxxhycmeyjvbbbr.supabase.co/functions/v1/chat' export function AssistantWidget() { - const [isOpen, setIsOpen] = useState(false); - const [isMinimized, setIsMinimized] = useState(false); - const [text, setText] = useState(""); - const [status, setStatus] = useState("ready"); - const [model, setModel] = useState(models[0].id); - const [modelSelectorOpen, setModelSelectorOpen] = useState(false); - const [useWebSearch, setUseWebSearch] = useState(false); - const [useMicrophone, setUseMicrophone] = useState(false); + const supabase = useMemo(() => createClient(), []) + const getAuthHeaders = useCallback(async () => { + let { + data: { session }, + error: sessionError, + } = await supabase.auth.getSession() - const { messages, append, isLoading, error } = useChat({ - api: "/api/chat", + if (sessionError) { + throw sessionError + } + + if (!session) { + const { data, error } = await supabase.auth.signInAnonymously() + + if (error || !data.session) { + throw error ?? new Error('Unable to authenticate anonymously.') + } + + session = data.session + } + + return { + Authorization: `Bearer ${session.access_token}`, + } + }, [supabase]) + + const [isOpen, setIsOpen] = useState(false) + const [isMinimized, setIsMinimized] = useState(false) + const [text, setText] = useState('') + const [model, setModel] = useState(models[0].id) + const [modelSelectorOpen, setModelSelectorOpen] = useState(false) + const [useWebSearch, setUseWebSearch] = useState(false) + const [useMicrophone, setUseMicrophone] = useState(false) + + const { messages, sendMessage, status, error } = useChat({ + transport: new DefaultChatTransport({ + api: CHAT_API_URL, + }), onError: (chatError) => { - toast.error("Failed to send message", { + toast.error('Failed to send message', { description: chatError.message, - }); - setStatus("error"); + }) }, - }); + }) - useEffect(() => { - if (isLoading) { - setStatus("streaming"); - return; - } + const selectedModelData = models.find((entry) => entry.id === model) - if (!isLoading && status === "streaming") { - setStatus("ready"); - } - }, [isLoading, status]); + const formattedMessages = useMemo(() => { + return messages.map((message) => { + const parts = message.parts ?? [] - useEffect(() => { - if (!error && status === "error") { - setStatus("ready"); - } - }, [error, status]); + const textParts = + parts.filter((part): part is TextPart => part?.type === 'text') ?? + (Array.isArray(message.content) + ? message.content.filter( + (part): part is TextPart => typeof part === 'object' && part.type === 'text' + ) + : []) - const selectedModel = useMemo( - () => models.find((entry) => entry.id === model), - [model] - ); + const versions = + textParts.length > 0 + ? textParts.map((part, index) => ({ + id: `${message.id}-${index}`, + content: part.text, + })) + : [ + { + id: `${message.id}-content`, + content: + typeof message.content === 'string' + ? message.content + : message.content + ?.map((part) => + typeof part === 'string' ? part : part.type === 'text' ? part.text : '' + ) + .join('') + .trim() ?? '', + }, + ] + + const sources = parts + .filter((part): part is SourcePart => part?.type === 'source-url') + .map((part) => ({ + href: part.url, + title: part.title ?? part.url, + })) + + const reasoningPart = parts.find((part): part is ReasoningPart => part?.type === 'reasoning') + + return { + key: message.id, + from: message.role === 'assistant' ? 'assistant' : 'user', + sources, + versions, + reasoning: reasoningPart + ? { + content: reasoningPart.text, + } + : undefined, + } + }) + }, [messages]) const handleSubmit = async (message: PromptInputMessage) => { - const hasText = Boolean(message.text?.trim()); - const hasAttachments = Boolean(message.files?.length); + const hasText = Boolean(message.text?.trim()) + const hasAttachments = Boolean(message.files?.length) - if (!(hasText || hasAttachments) || isLoading) { - return; + if (!(hasText || hasAttachments) || status === 'streaming') { + return } - setStatus("submitted"); - if (message.files?.length) { - toast.success("Files attached", { + toast.success('Files attached', { description: `${message.files.length} file(s) attached to message`, - }); + }) } try { - await append({ - role: "user", - content: message.text?.trim() ?? "Sent with attachments", - metadata: { - model, - useWebSearch, - useMicrophone, + const headers = await getAuthHeaders() + await sendMessage( + { + text: message.text?.trim() ?? 'Sent with attachments', + files: message.files, }, - }); - } catch (submissionError) { - console.error(submissionError); - setStatus("error"); - return; + { + headers, + body: { + model, + webSearch: useWebSearch, + }, + } + ) + setText('') + } catch (sendError) { + toast.error('Failed to send message', { + description: sendError instanceof Error ? sendError.message : 'Unknown error occurred.', + }) } - - setText(""); - }; + } const handleSuggestionClick = async (suggestion: string) => { - if (isLoading) return; - setStatus("submitted"); + if (status === 'streaming' || status === 'submitted') return try { - await append({ role: "user", content: suggestion }); - } catch (suggestionError) { - console.error(suggestionError); - setStatus("error"); + const headers = await getAuthHeaders() + await sendMessage( + { text: suggestion }, + { + headers, + body: { model, webSearch: useWebSearch }, + } + ) + } catch (sendError) { + toast.error('Failed to send message', { + description: sendError instanceof Error ? sendError.message : 'Unknown error occurred.', + }) } - }; - - const renderToolInvocation = (tool: ToolInvocationItem) => { - if (!tool) return null; - - return ( -
-
{tool.toolName}
- {tool.state === "call" && ( -
- - Running… -
- )} - {tool.state === "result" && ( -
- {typeof tool.result === "string" - ? tool.result - : JSON.stringify(tool.result, null, 2)} -
- )} - {tool.state === "error" && ( -
{tool.error}
- )} -
- ); - }; + } if (!isOpen) { return ( @@ -250,14 +301,16 @@ export function AssistantWidget() { > - ); + ) } + const submitDisabled = (!text.trim() && status !== 'streaming') || status === 'streaming' + return (
@@ -277,94 +330,67 @@ export function AssistantWidget() { > -
{!isMinimized && ( - <> +
- {messages.length === 0 && ( -
-

Hi! I'm your AI assistant.

-

I can help you query your data using natural language.

-
- )} - {messages.map((message) => { - const content = getMessageText(message.content); - const hasTools = Boolean(message.toolInvocations?.length); - const metadata = message.metadata as MessageMetadata | undefined; - - return ( - - - + {formattedMessages.map(({ key, versions, sources, reasoning, from }) => ( + + + {versions.map((version) => ( +
- {metadata?.sources?.length ? ( + {sources && sources.length > 0 && ( - + - {metadata.sources.map( - (source: { href: string; title: string }) => ( - - ) - )} + {sources.map((source) => ( + + ))} - ) : null} + )} - {metadata?.reasoning && ( - + {reasoning && ( + - - {metadata.reasoning.content} - + {reasoning.content} )} - {content} + {version.content} - - {hasTools && ( -
- {message.toolInvocations?.map((tool) => - renderToolInvocation(tool) - )} -
- )}
-
-
- ); - })} - {isLoading && ( -
- -
- )} + ))} +
+ {versions.length > 1 && ( + + + + + + )} +
+ ))}
-
+
{suggestions.map((suggestion) => ( ))} -
+
@@ -398,45 +424,36 @@ export function AssistantWidget() { setUseMicrophone((prev) => !prev)} - variant={useMicrophone ? "default" : "ghost"} + variant={useMicrophone ? 'default' : 'ghost'} type="button" > - Toggle microphone + Microphone setUseWebSearch((prev) => !prev)} - variant={useWebSearch ? "default" : "ghost"} + variant={useWebSearch ? 'default' : 'ghost'} type="button" > - Toggle web search + Search - + - {selectedModel?.chefSlug && ( - + {selectedModelData?.chefSlug && ( + )} - {selectedModel?.name && ( - - {selectedModel.name} - + {selectedModelData?.name && ( + {selectedModelData.name} )} - - No models found. - - {["OpenAI", "Anthropic"].map((chef) => ( + No models found. + {['OpenAI', 'Anthropic', 'Google'].map((chef) => ( {models .filter((entry) => entry.chef === chef) @@ -445,26 +462,21 @@ export function AssistantWidget() { key={entry.id} value={entry.id} onSelect={() => { - setModel(entry.id); - setModelSelectorOpen(false); + setModel(entry.id) + setModelSelectorOpen(false) }} > - - - {entry.name} - + + {entry.name} {entry.providers.map((provider) => ( - + ))} {model === entry.id ? ( - + + Selected + ) : (
)} @@ -476,19 +488,14 @@ export function AssistantWidget() { - + - {error && ( -

{error.message}

- )} + {error &&

{error.message}

}
- +
)}
- ); + ) } diff --git a/apps/ui-library/registry/default/blocks/assistant/registry-item.json b/apps/ui-library/registry/default/blocks/assistant/registry-item.json index 6406d69015f..07c31163534 100644 --- a/apps/ui-library/registry/default/blocks/assistant/registry-item.json +++ b/apps/ui-library/registry/default/blocks/assistant/registry-item.json @@ -32,11 +32,6 @@ "path": "registry/default/blocks/assistant/components/assistant-widget.tsx", "type": "registry:component" }, - { - "path": "registry/default/blocks/assistant/app/api/chat/route.ts", - "type": "registry:page", - "target": "app/api/chat/route.ts" - }, { "path": "registry/default/blocks/assistant/lib/supabase/server.ts", "type": "registry:file", @@ -62,6 +57,16 @@ "type": "registry:file", "target": "supabase/functions/mcp-server/deno.json" }, + { + "path": "registry/default/blocks/assistant/supabase/functions/chat/index.ts", + "type": "registry:file", + "target": "supabase/functions/chat/index.ts" + }, + { + "path": "registry/default/blocks/assistant/supabase/functions/chat/deno.json", + "type": "registry:file", + "target": "supabase/functions/chat/deno.json" + }, { "path": "registry/default/blocks/assistant/README.md", "type": "registry:file", diff --git a/apps/ui-library/registry/default/blocks/assistant/supabase/functions/_shared/cors.ts b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/_shared/cors.ts new file mode 100644 index 00000000000..85ed8ec3f5f --- /dev/null +++ b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/_shared/cors.ts @@ -0,0 +1,5 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', +} diff --git a/apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/deno.json b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/deno.json new file mode 100644 index 00000000000..3c2f9a89941 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/deno.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "lib": ["deno.window", "deno.ns"], + "strict": true + }, + "imports": { + "hono": "npm:hono@^4.6.14", + "zod": "npm:zod@^4.1.12", + "ai": "npm:ai@^5.0.90", + "@ai-sdk/openai": "npm:@ai-sdk/openai@^2.0.32", + "@ai-sdk/mcp": "npm:@ai-sdk/mcp@latest" + } +} diff --git a/apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/index.ts b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/index.ts new file mode 100644 index 00000000000..ddacde6830b --- /dev/null +++ b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/chat/index.ts @@ -0,0 +1,63 @@ +import { openai } from '@ai-sdk/openai' +import { streamText, type CoreMessage } from 'ai' +import { corsHeaders } from '../_shared/cors.ts' + +type ChatRequestBody = { + messages?: CoreMessage[] + model?: string +} + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + let body: ChatRequestBody + try { + body = await req.json() + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON payload' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + if (!body.messages || !Array.isArray(body.messages)) { + return new Response(JSON.stringify({ error: 'Request must include a messages array' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + const modelId = body.model && typeof body.model === 'string' ? body.model : 'gpt-4o' + + try { + const result = await streamText({ + model: openai(modelId), + messages: body.messages, + system: 'You are a helpful AI assistant.', + }) + + return result.toTextStreamResponse({ headers: corsHeaders }) + } catch (error) { + console.error('Assistant chat error:', error) + return new Response( + JSON.stringify({ + error: 'Failed to process chat request', + details: error instanceof Error ? error.message : String(error), + }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ) + } +}) diff --git a/apps/ui-library/registry/default/blocks/assistant/supabase/functions/mcp-server/index.ts b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/mcp-server/index.ts index 75b4d860189..3d397388875 100644 --- a/apps/ui-library/registry/default/blocks/assistant/supabase/functions/mcp-server/index.ts +++ b/apps/ui-library/registry/default/blocks/assistant/supabase/functions/mcp-server/index.ts @@ -7,7 +7,6 @@ import { Hono } from "hono"; import { McpServer, StreamableHttpTransport } from "mcp-lite"; -import { createClient } from "@supabase/supabase-js"; import { z } from "zod"; // We create two Hono instances: @@ -21,199 +20,25 @@ const mcp = new McpServer({ schemaAdapter: (schema: unknown) => z.toJSONSchema(schema as z.ZodType), }); -// Define the queryDatabase tool +// Temporary placeholder tool while wiring up the rest of the MCP server. mcp.tool("queryDatabase", { description: - "Query a table in the Supabase database with Row Level Security (RLS) applied. Use this to fetch data from tables with optional filters and options.", + "Example placeholder tool that just echoes a string back to the caller.", inputSchema: z.object({ - table: z + message: z .string() - .describe("The name of the table to query"), - columns: z - .string() - .optional() - .describe("Columns to select (comma-separated). Defaults to '*' for all columns."), - filters: z - .array( - z.object({ - column: z.string().describe("The column to filter on"), - operator: z.enum(["eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "is"]).describe("The filter operator"), - value: z.any().describe("The value to filter by"), - }) - ) - .optional() - .describe("Array of filter conditions to apply"), - limit: z - .number() - .optional() - .describe("Maximum number of rows to return"), - orderBy: z - .object({ - column: z.string().describe("Column to order by"), - ascending: z.boolean().optional().describe("Sort order (default: true)"), - }) - .optional() - .describe("Ordering options"), - explanation: z - .string() - .optional() - .describe("Optional explanation of what the query does"), + .default("Hello from the Supabase MCP server!") + .describe("Optional message you would like the tool to return"), }), - handler: async ( - args: { - table: string; - columns?: string; - filters?: Array<{ column: string; operator: string; value: any }>; - limit?: number; - orderBy?: { column: string; ascending?: boolean }; - explanation?: string; - }, - { req }: { req: Request } - ) => { - try { - // Create authenticated Supabase client with user context - const supabaseClient = createClient( - Deno.env.get("SUPABASE_URL") ?? "", - Deno.env.get("SUPABASE_ANON_KEY") ?? "", + handler: async ({ message }: { message: string }) => { + return { + content: [ { - global: { - headers: { Authorization: req.headers.get("Authorization")! }, - }, - } - ); - - // Verify user is authenticated - const authHeader = req.headers.get("Authorization"); - if (!authHeader) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Unauthorized: Missing authentication token", - }), - }, - ], - }; - } - - const token = authHeader.replace("Bearer ", ""); - const { data: userData, error: authError } = - await supabaseClient.auth.getUser(token); - - if (authError || !userData?.user) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Unauthorized: Invalid authentication token", - }), - }, - ], - }; - } - - // Build the query using Supabase query builder - let query = supabaseClient.from(args.table).select(args.columns || "*"); - - // Apply filters if provided - if (args.filters && args.filters.length > 0) { - for (const filter of args.filters) { - switch (filter.operator) { - case "eq": - query = query.eq(filter.column, filter.value); - break; - case "neq": - query = query.neq(filter.column, filter.value); - break; - case "gt": - query = query.gt(filter.column, filter.value); - break; - case "gte": - query = query.gte(filter.column, filter.value); - break; - case "lt": - query = query.lt(filter.column, filter.value); - break; - case "lte": - query = query.lte(filter.column, filter.value); - break; - case "like": - query = query.like(filter.column, filter.value); - break; - case "ilike": - query = query.ilike(filter.column, filter.value); - break; - case "in": - query = query.in(filter.column, filter.value); - break; - case "is": - query = query.is(filter.column, filter.value); - break; - } - } - } - - // Apply ordering if provided - if (args.orderBy) { - query = query.order(args.orderBy.column, { - ascending: args.orderBy.ascending ?? true, - }); - } - - // Apply limit if provided - if (args.limit) { - query = query.limit(args.limit); - } - - // Execute the query - const { data, error } = await query; - - if (error) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: `Query execution failed: ${error.message}`, - table: args.table, - explanation: args.explanation, - }), - }, - ], - }; - } - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - success: true, - table: args.table, - explanation: args.explanation, - results: data, - rowCount: Array.isArray(data) ? data.length : 0, - user: userData.user.email, - }), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: `Unexpected error: ${ - error instanceof Error ? error.message : String(error) - }`, - }), - }, - ], - }; - } + type: "text", + text: `Example tool response: ${message}`, + }, + ], + }; }, }); diff --git a/apps/ui-library/registry/default/components/ai-elements/artifact.tsx b/apps/ui-library/registry/default/components/ai-elements/artifact.tsx new file mode 100644 index 00000000000..7e35e4f713d --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/artifact.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Button } from "@/registry/default/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/canvas.tsx b/apps/ui-library/registry/default/components/ai-elements/canvas.tsx new file mode 100644 index 00000000000..5aa83cb5e73 --- /dev/null +++ b/apps/ui-library/registry/default/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/apps/ui-library/registry/default/components/ai-elements/chain-of-thought.tsx b/apps/ui-library/registry/default/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 00000000000..02922a3f814 --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/registry/default/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/registry/default/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + BrainIcon, + ChevronDownIcon, + DotIcon, + type LucideIcon, +} 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/apps/ui-library/registry/default/components/ai-elements/checkpoint.tsx b/apps/ui-library/registry/default/components/ai-elements/checkpoint.tsx new file mode 100644 index 00000000000..183dbc4daf9 --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/checkpoint.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Button } from "@/registry/default/components/ui/button"; +import { Separator } from "@/registry/default/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/code-block.tsx b/apps/ui-library/registry/default/components/ai-elements/code-block.tsx new file mode 100644 index 00000000000..8555675826b --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/code-block.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { Button } from "@/registry/default/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { Element } from "hast"; +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: Element, line: number) { + 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/apps/ui-library/registry/default/components/ai-elements/confirmation.tsx b/apps/ui-library/registry/default/components/ai-elements/confirmation.tsx new file mode 100644 index 00000000000..cd6250654ac --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/confirmation.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { Alert, AlertDescription } from "@/registry/default/components/ui/alert"; +import { Button } from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/controls.tsx b/apps/ui-library/registry/default/components/ai-elements/controls.tsx new file mode 100644 index 00000000000..770a8262aab --- /dev/null +++ b/apps/ui-library/registry/default/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/apps/ui-library/registry/default/components/ai-elements/conversation.tsx b/apps/ui-library/registry/default/components/ai-elements/conversation.tsx new file mode 100644 index 00000000000..f8b86c0d496 --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/edge.tsx b/apps/ui-library/registry/default/components/ai-elements/edge.tsx new file mode 100644 index 00000000000..3cec409d105 --- /dev/null +++ b/apps/ui-library/registry/default/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/apps/ui-library/registry/default/components/ai-elements/image.tsx b/apps/ui-library/registry/default/components/ai-elements/image.tsx new file mode 100644 index 00000000000..542812a3287 --- /dev/null +++ b/apps/ui-library/registry/default/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/apps/ui-library/registry/default/components/ai-elements/inline-citation.tsx b/apps/ui-library/registry/default/components/ai-elements/inline-citation.tsx new file mode 100644 index 00000000000..dd9ca96f22f --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/inline-citation.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { Badge } from "@/registry/default/components/ui/badge"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from "@/registry/default/components/ui/carousel"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/loader.tsx b/apps/ui-library/registry/default/components/ai-elements/loader.tsx new file mode 100644 index 00000000000..5f0cfce400c --- /dev/null +++ b/apps/ui-library/registry/default/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/apps/ui-library/registry/default/components/ai-elements/message.tsx b/apps/ui-library/registry/default/components/ai-elements/message.tsx new file mode 100644 index 00000000000..4a12bc9513b --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/message.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { Button } from "@/registry/default/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "@/registry/default/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/model-selector.tsx b/apps/ui-library/registry/default/components/ai-elements/model-selector.tsx new file mode 100644 index 00000000000..bdaff197930 --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/model-selector.tsx @@ -0,0 +1,205 @@ +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/registry/default/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/registry/default/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { ReactNode, ComponentProps } 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 [&>img]:ring-border", + className + )} + {...props} + /> +); + +export type ModelSelectorNameProps = ComponentProps<"span">; + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +); diff --git a/apps/ui-library/registry/default/components/ai-elements/node.tsx b/apps/ui-library/registry/default/components/ai-elements/node.tsx new file mode 100644 index 00000000000..8279dd1e34c --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/node.tsx @@ -0,0 +1,71 @@ +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/open-in-chat.tsx b/apps/ui-library/registry/default/components/ai-elements/open-in-chat.tsx new file mode 100644 index 00000000000..91277625dc9 --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/open-in-chat.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { Button } from "@/registry/default/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/panel.tsx b/apps/ui-library/registry/default/components/ai-elements/panel.tsx new file mode 100644 index 00000000000..059cb7ac213 --- /dev/null +++ b/apps/ui-library/registry/default/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/apps/ui-library/registry/default/components/ai-elements/plan.tsx b/apps/ui-library/registry/default/components/ai-elements/plan.tsx new file mode 100644 index 00000000000..382858dd836 --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/plan.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Button } from "@/registry/default/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/registry/default/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/registry/default/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/apps/ui-library/registry/default/components/ai-elements/prompt-input.tsx b/apps/ui-library/registry/default/components/ai-elements/prompt-input.tsx new file mode 100644 index 00000000000..e0c3b9057d2 --- /dev/null +++ b/apps/ui-library/registry/default/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1377 @@ +"use client"; + +import { Button } from "@/registry/default/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/registry/default/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/registry/default/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/registry/default/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/registry/default/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/registry/default/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 [attachements, setAttachements] = 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; + } + + setAttachements((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) => { + setAttachements((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(() => { + setAttachements((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachements, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachements, 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 anchorRef = useRef(null); + const formRef = useRef(null); + + // Find nearest form to scope drag & drop + useEffect(() => { + const root = anchorRef.current?.closest("form"); + if (root instanceof HTMLFormElement) { + formRef.current = root; + } + }, []); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + if (accept.includes("image/*")) { + return f.type.startsWith("image/"); + } + // NOTE: keep simple; expand as needed + return true; + }, + [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 add = usingProvider + ? (files: File[] | FileList) => controller.attachments.add(files) + : addLocal; + + const remove = usingProvider + ? (id: string) => controller.attachments.remove(id) + : (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 clear = usingProvider + ? () => controller.attachments.clear() + : () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }); + + 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; + + 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]); + + 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 files) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + [usingProvider, files] + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + }; + + const convertBlobUrlToDataUrl = async (url: string): Promise => { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }; + + 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:")) { + return { + ...item, + url: await convertBlobUrlToDataUrl(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 (error) { + // Don't clear on error - user may want to retry + } + }); + }; + + // Render with or without local provider + const inner = ( + <> +