From 4390066a749c2a6093c8f4c49d7ecbf4b5a21279 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 5 Nov 2025 22:05:25 +0900 Subject: [PATCH 01/82] chore: command palette ui --- .../playground/command-palette.tsx | 195 ++++++++++++++++++ .../playground/playground.tsx | 2 + 2 files changed, 197 insertions(+) create mode 100644 editor/grida-canvas-hosted/playground/command-palette.tsx diff --git a/editor/grida-canvas-hosted/playground/command-palette.tsx b/editor/grida-canvas-hosted/playground/command-palette.tsx new file mode 100644 index 0000000000..a952194ea0 --- /dev/null +++ b/editor/grida-canvas-hosted/playground/command-palette.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { + GearIcon, + ImageIcon, + BarChartIcon, + GlobeIcon, + MagicWandIcon, +} from "@radix-ui/react-icons"; +import { useHotkeys } from "react-hotkeys-hook"; +import { cn } from "@/components/lib/utils"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; + +/** + * Hook to detect double shift key press (like IntelliJ) + */ +function useDoubleShiftPress(onDoubleShift: () => void) { + const [lastShiftPressTime, setLastShiftPressTime] = useState(0); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") { + const now = Date.now(); + const timeSinceLastPress = now - lastShiftPressTime; + + // If shift is pressed twice within 300ms, trigger callback + if (timeSinceLastPress < 300 && timeSinceLastPress > 0) { + onDoubleShift(); + setLastShiftPressTime(0); // Reset to prevent multiple triggers + } else { + setLastShiftPressTime(now); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [lastShiftPressTime, onDoubleShift]); +} + +// Command palette data structure +type CommandAction = { + id: string; + label: string; + icon?: React.ReactNode; + shortcut?: string; + onExecute?: () => void; +}; + +type CommandGroup = { + heading: string; + actions: CommandAction[]; +}; + +const COMMAND_GROUPS: CommandGroup[] = [ + { + heading: "Image Editing", + actions: [ + { + id: "create-image", + label: "Create Image", + icon: , + }, + { + id: "edit-image", + label: "Edit Image", + icon: , + }, + { + id: "remove-background", + label: "Remove Background", + }, + { + id: "upscale-resolution", + label: "Upscale Resolution", + }, + ], + }, + { + heading: "Elements", + actions: [ + { + id: "insert-maps", + label: "Insert Maps", + icon: , + }, + { + id: "insert-charts", + label: "Insert Charts", + icon: , + }, + ], + }, + { + heading: "Help", + actions: [ + { + id: "preferences", + label: "Preferences", + icon: , + shortcut: "⌘,", + }, + { + id: "keyboard-shortcuts", + label: "Keyboard Shortcuts", + }, + ], + }, +]; + +export function CommandPalette() { + const [open, setOpen] = useState(false); + + // Handle cmd+k, cmd+p, cmd+shift+p + useHotkeys( + "meta+k, ctrl+k, meta+p, ctrl+p, meta+shift+p, ctrl+shift+p", + (e) => { + e.preventDefault(); + e.stopPropagation(); + setOpen((open) => !open); + }, + { + enableOnFormTags: true, + } + ); + + // Handle double shift key press (like IntelliJ) + useDoubleShiftPress(() => setOpen(true)); + + return ( + + + {/* Transparent overlay - provides click-outside-to-dismiss without dimming */} + + + + + Command Palette + + + + + + No results found. + + + {COMMAND_GROUPS.map((group, groupIndex) => ( + + {groupIndex > 0 && } + + {group.actions.map((action) => ( + { + action.onExecute?.(); + setOpen(false); + }} + > + {action.icon} + {action.label} + {action.shortcut && ( + {action.shortcut} + )} + + ))} + + + ))} + + + + + + ); +} diff --git a/editor/grida-canvas-hosted/playground/playground.tsx b/editor/grida-canvas-hosted/playground/playground.tsx index 150b95bc3f..9bc51eb7da 100644 --- a/editor/grida-canvas-hosted/playground/playground.tsx +++ b/editor/grida-canvas-hosted/playground/playground.tsx @@ -133,6 +133,7 @@ import { CursorChat } from "@/components/multiplayer/cursor-chat"; import { distro } from "../distro"; import { WithSize } from "@/grida-canvas-react/viewport/size"; import { useDPR } from "@/grida-canvas-react/viewport/hooks/use-dpr"; +import { CommandPalette } from "./command-palette"; // Custom hook for managing UI layout state function useUILayout() { @@ -477,6 +478,7 @@ function Consumer({ {ui.help_fab && } + ); } From 6b6a7dc2b4600f23049986a470f07fa5d40276b0 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 5 Nov 2025 22:38:14 +0900 Subject: [PATCH 02/82] chore: update dependencies and refactor AI image generation logic --- .../(api)/private/ai/generate/image/route.ts | 8 +- editor/app/(dev)/canvas/actions.ts | 4 +- editor/app/(dev)/canvas/tools/ai/generate.ts | 29 +- editor/app/(dev)/canvas/tools/ai/page.tsx | 4 +- editor/app/(dev)/canvas/tools/ai/schema.ts | 2 +- .../[campaign]/_components/settings.tsx | 4 +- .../(resources)/tags/tag-form-dialog.tsx | 4 +- .../playground/toolbar.tsx | 2 +- editor/grida-forms/schema/zod.ts | 2 +- editor/lib/templating/index.ts | 2 +- editor/lib/templating/template.ts | 2 +- editor/package.json | 12 +- editor/scaffolds/playground-forms/actions.ts | 2 +- .../scaffolds/playground-forms/playground.tsx | 2 +- .../template-editor/about-variable-table.tsx | 2 +- .../template-editor/template-editor.tsx | 2 +- .../scaffolds/theme-editor/palette-editor.tsx | 2 +- editor/theme/palettes/types.ts | 2 +- editor/theme/palettes/utils/index.ts | 2 +- pnpm-lock.yaml | 277 +++++++++--------- 20 files changed, 190 insertions(+), 176 deletions(-) diff --git a/editor/app/(api)/private/ai/generate/image/route.ts b/editor/app/(api)/private/ai/generate/image/route.ts index dabc8f4630..789f2842aa 100644 --- a/editor/app/(api)/private/ai/generate/image/route.ts +++ b/editor/app/(api)/private/ai/generate/image/route.ts @@ -156,9 +156,9 @@ async function upload_generated_to_library({ height: number; }; }) { - const { mimeType, uint8Array } = file; + const { mediaType, uint8Array } = file; - const ext = mime.extension(mimeType); + const ext = mime.extension(mediaType); const name = v4(); const folder = "generated"; const path = `${folder}/${name}${ext ? `.${ext}` : ""}`; @@ -167,7 +167,7 @@ async function upload_generated_to_library({ await service_role.library.storage .from("library") .upload(path, uint8Array, { - contentType: mimeType, + contentType: mediaType, }); if (upload_err) throw new Error(upload_err.message); @@ -179,7 +179,7 @@ async function upload_generated_to_library({ bytes: uint8Array.length, category: "generated", path: uploaded.path, - mimetype: mimeType, + mimetype: mediaType, // generator: request.model, prompt: request.prompt, diff --git a/editor/app/(dev)/canvas/actions.ts b/editor/app/(dev)/canvas/actions.ts index e378866d79..556b24e7df 100644 --- a/editor/app/(dev)/canvas/actions.ts +++ b/editor/app/(dev)/canvas/actions.ts @@ -2,8 +2,8 @@ import { streamObject } from "ai"; import { openai } from "@ai-sdk/openai"; -import { createStreamableValue } from "ai/rsc"; -import { z } from "zod"; +import { createStreamableValue } from '@ai-sdk/rsc'; +import { z } from 'zod/v3'; const title_description = z.object({ h1: z.string().optional(), diff --git a/editor/app/(dev)/canvas/tools/ai/generate.ts b/editor/app/(dev)/canvas/tools/ai/generate.ts index 6b788bdca9..21883453c1 100644 --- a/editor/app/(dev)/canvas/tools/ai/generate.ts +++ b/editor/app/(dev)/canvas/tools/ai/generate.ts @@ -3,7 +3,7 @@ import { streamObject, experimental_generateImage, - type CoreUserMessage, + type UserModelMessage, type UserContent, type TextPart, type FilePart, @@ -11,10 +11,10 @@ import { type Tool, } from "ai"; import { openai } from "@ai-sdk/openai"; -import { createStreamableValue } from "ai/rsc"; +import { createStreamableValue } from '@ai-sdk/rsc'; import { request_schema } from "./schema"; import assert from "assert"; -import { z } from "zod"; +import { z } from 'zod/v3'; const MODEL = process.env.NEXT_PUBLIC_OPENAI_BEST_MODEL_ID || "gpt-4o-mini"; @@ -30,7 +30,7 @@ export async function generate({ user, prompt, modelId, - maxTokens = undefined, + maxOutputTokens = undefined, temperature = undefined, topP = undefined, }: { @@ -41,20 +41,20 @@ export async function generate({ attachments?: UserAttachment[]; }; modelId?: string; - maxTokens?: number; + maxOutputTokens?: number; temperature?: number; topP?: number; }) { const model = openai(modelId ?? MODEL); const model_config = { - maxTokens: maxTokens, + maxOutputTokens: maxOutputTokens, temperature: temperature, topP: topP, }; assert(prompt || user, "Prompt or user is required"); - let message: CoreUserMessage | null = null; + let message: UserModelMessage | null = null; if (user) { const content: UserContent = [ { @@ -71,14 +71,14 @@ export async function generate({ type: "file", data: f.url, filename: f.filename, - mimeType: f.mimeType, + mediaType: f.mimeType, } satisfies FilePart; } case "image": { return { type: "image", image: f.url, - mimeType: f.mimeType, + mediaType: f.mimeType, } satisfies ImagePart; } } @@ -92,18 +92,15 @@ export async function generate({ }; } - const messages = { - system: system, - messages: message ? [message] : undefined, - prompt: prompt, - }; - const stream = createStreamableValue({}); (async () => { const { partialObjectStream } = await streamObject({ model, ...model_config, - ...messages, + system: system, + ...(message + ? { messages: [message] } + : { prompt: prompt || "Generate content" }), schema: request_schema, }); diff --git a/editor/app/(dev)/canvas/tools/ai/page.tsx b/editor/app/(dev)/canvas/tools/ai/page.tsx index f7212d3174..33246621a2 100644 --- a/editor/app/(dev)/canvas/tools/ai/page.tsx +++ b/editor/app/(dev)/canvas/tools/ai/page.tsx @@ -11,7 +11,7 @@ import { presets } from "./_data/presets"; import { ModelParams } from "./_components/model-params"; import { Label } from "@/components/ui/label"; import { MinimalChatBox } from "@/components/chat"; -import { readStreamableValue } from "ai/rsc"; +import { readStreamableValue } from '@ai-sdk/rsc'; import { Canvas } from "./_components/canvas"; import { generate, type UserAttachment } from "./generate"; import { type StreamingResponse } from "./schema"; @@ -61,7 +61,7 @@ export default function PlaygroundPage() { setStreamBusy(true); generating.current = true; - generate({ modelId, system, user, maxTokens: 16384, temperature: 1 }) + generate({ modelId, system, user, maxOutputTokens: 16384, temperature: 1 }) .then(async ({ output }) => { for await (const delta of readStreamableValue(output)) { setResponse(delta as any); diff --git a/editor/app/(dev)/canvas/tools/ai/schema.ts b/editor/app/(dev)/canvas/tools/ai/schema.ts index 2ec0b5c0d9..cc68914c94 100644 --- a/editor/app/(dev)/canvas/tools/ai/schema.ts +++ b/editor/app/(dev)/canvas/tools/ai/schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod/v3'; const _$ = "grida-portable-html-tailwind-json"; diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx index 2c1e45c816..0d679f38e8 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { z } from "zod"; +import { z } from 'zod/v3'; import { CalendarIcon, InfoIcon } from "lucide-react"; import { format } from "date-fns"; import { useCampaign } from "../store"; @@ -195,7 +195,7 @@ function Body({ const [activeTab, setActiveTab] = useState("general"); const form = useForm({ - resolver: zodResolver(formSchema), + resolver: zodResolver(formSchema) as any, defaultValues: defaultValues, }); diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/tags/tag-form-dialog.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/tags/tag-form-dialog.tsx index 7a05e37110..9627d354dd 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/tags/tag-form-dialog.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/tags/tag-form-dialog.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { z } from 'zod/v3'; import { Dialog, DialogContent, @@ -75,7 +75,7 @@ export function TagFormDialog({ }; const form = useForm({ - resolver: zodResolver(tagFormSchema), + resolver: zodResolver(tagFormSchema) as any, defaultValues, }); diff --git a/editor/grida-canvas-hosted/playground/toolbar.tsx b/editor/grida-canvas-hosted/playground/toolbar.tsx index b01b3a5694..2cd7a815fe 100644 --- a/editor/grida-canvas-hosted/playground/toolbar.tsx +++ b/editor/grida-canvas-hosted/playground/toolbar.tsx @@ -19,7 +19,7 @@ import { useMetaEnter } from "@/hooks/use-meta-enter"; import { Cross2Icon, FrameIcon } from "@radix-ui/react-icons"; import { PopoverClose } from "@radix-ui/react-popover"; import { useLocalStorage } from "@uidotdev/usehooks"; -import { readStreamableValue } from "ai/rsc"; +import { readStreamableValue } from '@ai-sdk/rsc'; import { CANVAS_PLAYGROUND_LOCALSTORAGE_PREFERENCES_BASE_AI_PROMPT_KEY } from "./k"; import { toolmode_to_toolbar_value, diff --git a/editor/grida-forms/schema/zod.ts b/editor/grida-forms/schema/zod.ts index 7eabef2e80..2a7bb5e9ce 100644 --- a/editor/grida-forms/schema/zod.ts +++ b/editor/grida-forms/schema/zod.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod/v3'; export const zFormInputType = z.enum([ "text", diff --git a/editor/lib/templating/index.ts b/editor/lib/templating/index.ts index 6358b76459..156c54819b 100644 --- a/editor/lib/templating/index.ts +++ b/editor/lib/templating/index.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod/v3'; export namespace TemplateVariables { export type Context = diff --git a/editor/lib/templating/template.ts b/editor/lib/templating/template.ts index b42d180602..2c37c18ce6 100644 --- a/editor/lib/templating/template.ts +++ b/editor/lib/templating/template.ts @@ -1,7 +1,7 @@ import { create } from "handlebars"; import { ObjectPath } from "./@types"; import { v4 as uuid } from "uuid"; -import { z } from "zod"; +import { z } from 'zod/v3'; import type { TemplateVariables } from "."; import type { i18n } from "i18next"; import type { Translation } from "@/i18n/resources"; diff --git a/editor/package.json b/editor/package.json index d34f570591..e48cede519 100644 --- a/editor/package.json +++ b/editor/package.json @@ -15,8 +15,10 @@ }, "packageManager": "pnpm@10.10.0", "dependencies": { - "@ai-sdk/openai": "^1.3.20", - "@ai-sdk/replicate": "^0.2.7", + "@ai-sdk/openai": "^2.0.62", + "@ai-sdk/react": "^1.2.12", + "@ai-sdk/replicate": "^1.0.17", + "@ai-sdk/rsc": "^1.0.87", "@app/database": "workspace:*", "@blocknote/core": "^0.29.1", "@blocknote/mantine": "^0.29.1", @@ -48,7 +50,7 @@ "@grida/vn": "workspace:*", "@headless-tree/core": "^1.0.1", "@headless-tree/react": "^1.0.1", - "@hookform/resolvers": "^4.1.3", + "@hookform/resolvers": "^5.2.2", "@mdx-js/loader": "^3.0.1", "@mdx-js/react": "^3.1.0", "@monaco-editor/react": "^4.6.0", @@ -123,7 +125,7 @@ "@visx/visx": "^3.11.0", "@visx/xychart": "^3.11.0", "@xyflow/react": "^12.1.0", - "ai": "4.3.9", + "ai": "^5.0.87", "ajv": "^8.13.0", "axios": "1.6.7", "canvas-confetti": "^1.9.3", @@ -221,7 +223,7 @@ "y-webrtc": "^10.3.0", "y-websocket": "^3.0.0", "yjs": "^13.6.27", - "zod": "^3.24.4", + "zod": "^4.1.8", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/editor/scaffolds/playground-forms/actions.ts b/editor/scaffolds/playground-forms/actions.ts index 2a1b0d372b..165a6dad51 100644 --- a/editor/scaffolds/playground-forms/actions.ts +++ b/editor/scaffolds/playground-forms/actions.ts @@ -2,7 +2,7 @@ import { streamObject } from "ai"; import { openai } from "@ai-sdk/openai"; -import { createStreamableValue } from "ai/rsc"; +import { createStreamableValue } from '@ai-sdk/rsc'; import { GENzJSONForm } from "@/grida-forms/schema/zod"; import { service_role } from "@/lib/supabase/server"; diff --git a/editor/scaffolds/playground-forms/playground.tsx b/editor/scaffolds/playground-forms/playground.tsx index 004d446dce..be2e858b05 100644 --- a/editor/scaffolds/playground-forms/playground.tsx +++ b/editor/scaffolds/playground-forms/playground.tsx @@ -22,7 +22,7 @@ import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { forms_examples } from "./k"; import { generate } from "./actions"; -import { readStreamableValue } from "ai/rsc"; +import { readStreamableValue } from '@ai-sdk/rsc'; import Link from "next/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import PlaygroundPreview from "./preview"; diff --git a/editor/scaffolds/template-editor/about-variable-table.tsx b/editor/scaffolds/template-editor/about-variable-table.tsx index c42c45ea8c..eb5c514711 100644 --- a/editor/scaffolds/template-editor/about-variable-table.tsx +++ b/editor/scaffolds/template-editor/about-variable-table.tsx @@ -7,7 +7,7 @@ import { TableRow, } from "@/components/ui/table"; import { TemplateVariables } from "@/lib/templating"; -import { z } from "zod"; +import { z } from 'zod/v3'; interface AboutVariable { key: string; diff --git a/editor/scaffolds/template-editor/template-editor.tsx b/editor/scaffolds/template-editor/template-editor.tsx index 88a7c28af8..70ec6b5758 100644 --- a/editor/scaffolds/template-editor/template-editor.tsx +++ b/editor/scaffolds/template-editor/template-editor.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { getDefaultTexts, render } from "@/lib/templating/template"; -import { z } from "zod"; +import { z } from 'zod/v3'; import { Select, SelectContent, diff --git a/editor/scaffolds/theme-editor/palette-editor.tsx b/editor/scaffolds/theme-editor/palette-editor.tsx index ae0b1a1c7f..01c8240c38 100644 --- a/editor/scaffolds/theme-editor/palette-editor.tsx +++ b/editor/scaffolds/theme-editor/palette-editor.tsx @@ -9,7 +9,7 @@ import { MoonIcon, SunIcon } from "@radix-ui/react-icons"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { z } from "zod"; +import { z } from 'zod/v3'; import { cn } from "@/components/lib/utils"; import type { Theme } from "@/theme/palettes/types"; import palettes from "@/theme/palettes"; diff --git a/editor/theme/palettes/types.ts b/editor/theme/palettes/types.ts index 7e3266d2d5..54cad64006 100644 --- a/editor/theme/palettes/types.ts +++ b/editor/theme/palettes/types.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod/v3'; export const HSL = z.object({ h: z.number(), diff --git a/editor/theme/palettes/utils/index.ts b/editor/theme/palettes/utils/index.ts index 7b4a957ea4..d6f1b6923d 100644 --- a/editor/theme/palettes/utils/index.ts +++ b/editor/theme/palettes/utils/index.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod/v3'; import type { Theme, Palette } from "../types"; export function stringfyThemeVariables(theme: z.infer) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ffe5c155c..fb14f414ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,11 +296,17 @@ importers: editor: dependencies: '@ai-sdk/openai': - specifier: ^1.3.20 - version: 1.3.22(zod@3.25.42) + specifier: ^2.0.62 + version: 2.0.62(zod@4.1.12) + '@ai-sdk/react': + specifier: ^1.2.12 + version: 1.2.12(react@19.0.0)(zod@4.1.12) '@ai-sdk/replicate': - specifier: ^0.2.7 - version: 0.2.8(zod@3.25.42) + specifier: ^1.0.17 + version: 1.0.17(zod@4.1.12) + '@ai-sdk/rsc': + specifier: ^1.0.87 + version: 1.0.87(react@19.0.0)(zod@4.1.12) '@app/database': specifier: workspace:* version: link:../database @@ -395,8 +401,8 @@ importers: specifier: ^1.0.1 version: 1.0.1(@headless-tree/core@1.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@hookform/resolvers': - specifier: ^4.1.3 - version: 4.1.3(react-hook-form@7.56.4(react@19.0.0)) + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.56.4(react@19.0.0)) '@mdx-js/loader': specifier: ^3.0.1 version: 3.1.0(acorn@8.14.1)(webpack@5.98.0(esbuild@0.25.4)) @@ -411,7 +417,7 @@ importers: version: 15.3.2(@mdx-js/loader@3.1.0(acorn@8.14.1)(webpack@5.98.0(esbuild@0.25.4)))(@mdx-js/react@3.1.0(@types/react@19.1.3)(react@19.0.0)) '@next/third-parties': specifier: 15.3.2 - version: 15.3.2(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + version: 15.3.2(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) '@number-flow/react': specifier: ^0.5.7 version: 0.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -510,7 +516,7 @@ importers: version: 0.0.38(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@sentry/nextjs': specifier: ^9.17.0 - version: 9.24.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.98.0(esbuild@0.25.4)) + version: 9.24.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.98.0(esbuild@0.25.4)) '@stepperize/react': specifier: ^3.1.1 version: 3.1.1(react@19.0.0) @@ -591,7 +597,7 @@ importers: version: 10.3.1(react@19.0.0) '@vercel/analytics': specifier: ^1.3.1 - version: 1.5.0(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3)) + version: 1.5.0(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3)) '@vercel/edge-config': specifier: ^1.2.1 version: 1.4.0(@opentelemetry/api@1.9.0) @@ -600,10 +606,10 @@ importers: version: 1.6.0 '@vercel/sdk': specifier: ^1.5.0 - version: 1.7.7(zod@3.25.42) + version: 1.7.7(zod@4.1.12) '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.2.0(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3)) + version: 1.2.0(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3)) '@visx/responsive': specifier: ^3.10.2 version: 3.12.0(react@19.0.0) @@ -620,8 +626,8 @@ importers: specifier: ^12.1.0 version: 12.6.4(@types/react@19.1.3)(immer@9.0.21)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) ai: - specifier: 4.3.9 - version: 4.3.9(react@19.0.0)(zod@3.25.42) + specifier: ^5.0.87 + version: 5.0.87(zod@4.1.12) ajv: specifier: ^8.13.0 version: 8.17.1 @@ -765,13 +771,13 @@ importers: version: 1.0.0 next: specifier: 15.3.2 - version: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) openai: specifier: ^4.96.0 - version: 4.104.0(encoding@0.1.13)(ws@8.18.2)(zod@3.25.42) + version: 4.104.0(encoding@0.1.13)(ws@8.18.2)(zod@4.1.12) p-queue: specifier: ^7.2.0 version: 7.4.1 @@ -914,8 +920,8 @@ importers: specifier: ^13.6.27 version: 13.6.27 zod: - specifier: ^3.24.4 - version: 3.25.42 + specifier: ^4.1.8 + version: 4.1.12 zustand: specifier: ^5.0.3 version: 5.0.5(@types/react@19.1.3)(immer@9.0.21)(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)) @@ -1296,17 +1302,17 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} - '@ai-sdk/openai@1.3.22': - resolution: {integrity: sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw==} + '@ai-sdk/gateway@2.0.6': + resolution: {integrity: sha512-FmhR6Tle09I/RUda8WSPpJ57mjPWzhiVVlB50D+k+Qf/PBW0CBtnbAUxlNSR5v+NIZNLTK3C56lhb23ntEdxhQ==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@2.2.7': - resolution: {integrity: sha512-kM0xS3GWg3aMChh9zfeM+80vEZfXzR3JEUBdycZLtbRZ2TRT8xOj3WodGHPb06sUK5yD7pAXC/P7ctsi2fvUGQ==} + '@ai-sdk/openai@2.0.62': + resolution: {integrity: sha512-ZHUhUV6yyBBb0bCbuqAkML7nYIOWyXZYbZQ59mlr1TpIJzSHjQzF4BndZHIIieOMm4ZrpZw15Cn78BTyaIAUwQ==} engines: {node: '>=18'} peerDependencies: - zod: ^3.23.8 + zod: ^3.25.76 || ^4.1.8 '@ai-sdk/provider-utils@2.2.8': resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} @@ -1314,12 +1320,22 @@ packages: peerDependencies: zod: ^3.23.8 + '@ai-sdk/provider-utils@3.0.16': + resolution: {integrity: sha512-lsWQY9aDXHitw7C1QRYIbVGmgwyT98TF3MfM8alNIXKpdJdi+W782Rzd9f1RyOfgRmZ08gJ2EYNDhWNK7RqpEA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@1.1.3': resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} engines: {node: '>=18'} - '@ai-sdk/react@1.2.9': - resolution: {integrity: sha512-/VYm8xifyngaqFDLXACk/1czDRCefNCdALUyp+kIX6DUIYUWTM93ISoZ+qJ8+3E+FiJAKBQz61o8lIIl+vYtzg==} + '@ai-sdk/provider@2.0.0': + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} + engines: {node: '>=18'} + + '@ai-sdk/react@1.2.12': + resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -1328,14 +1344,24 @@ packages: zod: optional: true - '@ai-sdk/replicate@0.2.8': - resolution: {integrity: sha512-l9t4+RzbAn8osstkbWs6l++Nava+4LO4dsaddnE0GQM5E0BEIgMTJ14hoyfE02Ep0rJZ0M2HlXGqv5heW47P8A==} + '@ai-sdk/replicate@1.0.17': + resolution: {integrity: sha512-aDpCcdcIQdQWOUIMMIVjAGc5k95X3VN3itoq13hVaIPxeF/5iWyGn07uYcjPUey0V0X0k7/cYOOe6e7lkov+nA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/rsc@1.0.87': + resolution: {integrity: sha512-G+WzlzfF4m2ZDDRSmndTMog4xLAIxblQqZhP+L7iaXrhTuWKvWqpli5cxa+GMjm/W4tGwYxYsa7ShsT7GEfSRA==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.25.76 || ^4.1.8 + peerDependenciesMeta: + zod: + optional: true - '@ai-sdk/ui-utils@1.2.8': - resolution: {integrity: sha512-nls/IJCY+ks3Uj6G/agNhXqQeLVqhNfoJbuNgCny+nX2veY5ADB91EcZUqVeQ/ionul2SeUswPY6Q/DxteY29Q==} + '@ai-sdk/ui-utils@1.2.11': + resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} engines: {node: '>=18'} peerDependencies: zod: ^3.23.8 @@ -3040,10 +3066,10 @@ packages: react: '*' react-dom: '*' - '@hookform/resolvers@4.1.3': - resolution: {integrity: sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==} + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: - react-hook-form: ^7.0.0 + react-hook-form: ^7.55.0 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -5026,6 +5052,9 @@ packages: '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -6122,6 +6151,10 @@ packages: '@aws-sdk/credential-provider-web-identity': optional: true + '@vercel/oidc@3.0.3': + resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} + engines: {node: '>= 20'} + '@vercel/sdk@1.7.7': resolution: {integrity: sha512-1qtW7eMUza7WPxuYFzZvR3ajj4v+QBqXa2e9whK8hlOYpCrvBdezYq2oseBtIO0numkIY2974RTHyKmLZQ1pSg==} hasBin: true @@ -6491,15 +6524,11 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} - ai@4.3.9: - resolution: {integrity: sha512-P2RpV65sWIPdUlA4f1pcJ11pB0N1YmqPVLEmC4j8WuBwKY0L3q9vGhYPh0Iv+spKHKyn0wUbMfas+7Z6nTfS0g==} + ai@5.0.87: + resolution: {integrity: sha512-9Cjx7o8IY9zAczigX0Tk/BaQwjPe/M6DpEjejKSBNrf8mOPIvyM+pJLqJSC10IsKci3FPsnaizJeJhoetU1Wfw==} engines: {node: '>=18'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - react: - optional: true + zod: ^3.25.76 || ^4.1.8 ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} @@ -8276,6 +8305,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -13820,16 +13853,16 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - zod-to-json-schema@3.24.5: - resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} peerDependencies: zod: ^3.24.1 zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} - zod@3.25.42: - resolution: {integrity: sha512-PcALTLskaucbeHc41tU/xfjfhcz8z0GdhhDcSgrCTmSazUuqnYqiXO63M0QUBVwpBlsLsNVn5qHSC5Dw3KZvaQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -13889,52 +13922,73 @@ snapshots: '@adobe/css-tools@4.4.2': {} - '@ai-sdk/openai@1.3.22(zod@3.25.42)': + '@ai-sdk/gateway@2.0.6(zod@4.1.12)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.42) - zod: 3.25.42 + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) + '@vercel/oidc': 3.0.3 + zod: 4.1.12 - '@ai-sdk/provider-utils@2.2.7(zod@3.25.42)': + '@ai-sdk/openai@2.0.62(zod@4.1.12)': dependencies: - '@ai-sdk/provider': 1.1.3 - nanoid: 3.3.11 - secure-json-parse: 2.7.0 - zod: 3.25.42 + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) + zod: 4.1.12 - '@ai-sdk/provider-utils@2.2.8(zod@3.25.42)': + '@ai-sdk/provider-utils@2.2.8(zod@4.1.12)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 3.25.42 + zod: 4.1.12 + + '@ai-sdk/provider-utils@3.0.16(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 4.1.12 '@ai-sdk/provider@1.1.3': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.9(react@19.0.0)(zod@3.25.42)': + '@ai-sdk/provider@2.0.0': dependencies: - '@ai-sdk/provider-utils': 2.2.7(zod@3.25.42) - '@ai-sdk/ui-utils': 1.2.8(zod@3.25.42) + json-schema: 0.4.0 + + '@ai-sdk/react@1.2.12(react@19.0.0)(zod@4.1.12)': + dependencies: + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.12) + '@ai-sdk/ui-utils': 1.2.11(zod@4.1.12) react: 19.0.0 swr: 2.3.3(react@19.0.0) throttleit: 2.1.0 optionalDependencies: - zod: 3.25.42 + zod: 4.1.12 - '@ai-sdk/replicate@0.2.8(zod@3.25.42)': + '@ai-sdk/replicate@1.0.17(zod@4.1.12)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.42) - zod: 3.25.42 + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) + zod: 4.1.12 + + '@ai-sdk/rsc@1.0.87(react@19.0.0)(zod@4.1.12)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) + ai: 5.0.87(zod@4.1.12) + jsondiffpatch: 0.6.0 + react: 19.0.0 + optionalDependencies: + zod: 4.1.12 - '@ai-sdk/ui-utils@1.2.8(zod@3.25.42)': + '@ai-sdk/ui-utils@1.2.11(zod@4.1.12)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.7(zod@3.25.42) - zod: 3.25.42 - zod-to-json-schema: 3.24.5(zod@3.25.42) + '@ai-sdk/provider-utils': 2.2.8(zod@4.1.12) + zod: 4.1.12 + zod-to-json-schema: 3.24.6(zod@4.1.12) '@algolia/autocomplete-core@1.17.9(@algolia/client-search@5.20.2)(algoliasearch@5.20.2)(search-insights@2.17.3)': dependencies: @@ -16698,7 +16752,7 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@hookform/resolvers@4.1.3(react-hook-form@7.56.4(react@19.0.0))': + '@hookform/resolvers@5.2.2(react-hook-form@7.56.4(react@19.0.0))': dependencies: '@standard-schema/utils': 0.3.0 react-hook-form: 7.56.4(react@19.0.0) @@ -17271,12 +17325,6 @@ snapshots: '@next/swc-win32-x64-msvc@15.3.2': optional: true - '@next/third-parties@15.3.2(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': - dependencies: - next: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - react: 19.0.0 - third-party-capital: 1.0.20 - '@next/third-parties@15.3.2(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': dependencies: next: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -18752,7 +18800,7 @@ snapshots: '@sentry/core@9.24.0': {} - '@sentry/nextjs@9.24.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.98.0(esbuild@0.25.4))': + '@sentry/nextjs@9.24.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.98.0(esbuild@0.25.4))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.34.0 @@ -18765,7 +18813,7 @@ snapshots: '@sentry/vercel-edge': 9.24.0 '@sentry/webpack-plugin': 3.5.0(encoding@0.1.13)(webpack@5.98.0(esbuild@0.25.4)) chalk: 3.0.0 - next: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) resolve: 1.22.8 rollup: 4.35.0 stacktrace-parser: 0.1.11 @@ -18899,6 +18947,8 @@ snapshots: '@speed-highlight/core@1.2.7': {} + '@standard-schema/spec@1.0.0': {} + '@standard-schema/utils@0.3.0': {} '@stepperize/react@3.1.1(react@19.0.0)': @@ -20050,9 +20100,9 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.0.0 - '@vercel/analytics@1.5.0(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3))': + '@vercel/analytics@1.5.0(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3))': optionalDependencies: - next: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 svelte: 4.2.19 vue: 3.5.13(typescript@5.8.3) @@ -20067,13 +20117,15 @@ snapshots: '@vercel/functions@1.6.0': {} - '@vercel/sdk@1.7.7(zod@3.25.42)': + '@vercel/oidc@3.0.3': {} + + '@vercel/sdk@1.7.7(zod@4.1.12)': dependencies: - zod: 3.25.42 + zod: 4.1.12 - '@vercel/speed-insights@1.2.0(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3))': + '@vercel/speed-insights@1.2.0(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(svelte@4.2.19)(vue@3.5.13(typescript@5.8.3))': optionalDependencies: - next: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 svelte: 4.2.19 vue: 3.5.13(typescript@5.8.3) @@ -20680,17 +20732,13 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@4.3.9(react@19.0.0)(zod@3.25.42): + ai@5.0.87(zod@4.1.12): dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.7(zod@3.25.42) - '@ai-sdk/react': 1.2.9(react@19.0.0)(zod@3.25.42) - '@ai-sdk/ui-utils': 1.2.8(zod@3.25.42) + '@ai-sdk/gateway': 2.0.6(zod@4.1.12) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.16(zod@4.1.12) '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 - zod: 3.25.42 - optionalDependencies: - react: 19.0.0 + zod: 4.1.12 ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: @@ -22832,6 +22880,8 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -25725,33 +25775,6 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): - dependencies: - '@next/env': 15.3.2 - '@swc/counter': 0.1.3 - '@swc/helpers': 0.5.15 - busboy: 1.6.0 - caniuse-lite: 1.0.30001717 - postcss: 8.4.31 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.27.1)(babel-plugin-macros@3.1.0)(react@19.0.0) - optionalDependencies: - '@next/swc-darwin-arm64': 15.3.2 - '@next/swc-darwin-x64': 15.3.2 - '@next/swc-linux-arm64-gnu': 15.3.2 - '@next/swc-linux-arm64-musl': 15.3.2 - '@next/swc-linux-x64-gnu': 15.3.2 - '@next/swc-linux-x64-musl': 15.3.2 - '@next/swc-win32-arm64-msvc': 15.3.2 - '@next/swc-win32-x64-msvc': 15.3.2 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.52.0 - sharp: 0.34.1 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.3.2 @@ -25925,7 +25948,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.104.0(encoding@0.1.13)(ws@8.18.2)(zod@3.25.42): + openai@4.104.0(encoding@0.1.13)(ws@8.18.2)(zod@4.1.12): dependencies: '@types/node': 18.19.109 '@types/node-fetch': 2.6.12 @@ -25936,7 +25959,7 @@ snapshots: node-fetch: 2.7.0(encoding@0.1.13) optionalDependencies: ws: 8.18.2 - zod: 3.25.42 + zod: 4.1.12 transitivePeerDependencies: - encoding @@ -28355,14 +28378,6 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.6(@babel/core@7.27.1)(babel-plugin-macros@3.1.0)(react@19.0.0): - dependencies: - client-only: 0.0.1 - react: 19.0.0 - optionalDependencies: - '@babel/core': 7.27.1 - babel-plugin-macros: 3.1.0 - styled-jsx@5.1.6(@babel/core@7.27.4)(babel-plugin-macros@3.1.0)(react@19.0.0): dependencies: client-only: 0.0.1 @@ -29603,13 +29618,13 @@ snapshots: cookie: 1.0.2 youch-core: 0.3.3 - zod-to-json-schema@3.24.5(zod@3.25.42): + zod-to-json-schema@3.24.6(zod@4.1.12): dependencies: - zod: 3.25.42 + zod: 4.1.12 zod@3.22.3: {} - zod@3.25.42: {} + zod@4.1.12: {} zustand@4.5.7(@types/react@19.1.3)(immer@9.0.21)(react@19.0.0): dependencies: From 67b045f4517f86543a855701cdbd69a4cd7c9318 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 6 Nov 2025 05:18:00 +0900 Subject: [PATCH 03/82] chore --- editor/grida-canvas-react/use-editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/grida-canvas-react/use-editor.tsx b/editor/grida-canvas-react/use-editor.tsx index 58ca76df95..523051b271 100644 --- a/editor/grida-canvas-react/use-editor.tsx +++ b/editor/grida-canvas-react/use-editor.tsx @@ -99,7 +99,7 @@ export function useCurrentEditor() { const editor = React.useContext(EditorContext); if (!editor) { throw new Error( - "useCurrentEditor must be used within an EditorContextV2.Provider" + "useCurrentEditor must be used within an EditorContext.Provider" ); } return editor; From cec1099ea840c24be79902651c10ee101de891e7 Mon Sep 17 00:00:00 2001 From: Universe Date: Fri, 7 Nov 2025 17:12:09 +0900 Subject: [PATCH 04/82] add ai elements --- editor/app/globals.css | 6 + editor/components.json | 3 + editor/components/ai-elements/README.md | 488 ++++++ editor/components/ai-elements/actions.tsx | 65 + editor/components/ai-elements/code-block.tsx | 179 +++ editor/components/ai-elements/context.tsx | 408 +++++ .../components/ai-elements/conversation.tsx | 100 ++ editor/components/ai-elements/message.tsx | 80 + .../components/ai-elements/prompt-input.tsx | 1352 +++++++++++++++++ editor/components/ai-elements/queue.tsx | 274 ++++ editor/components/ai-elements/reasoning.tsx | 178 +++ editor/components/ai-elements/response.tsx | 22 + editor/components/ai-elements/shimmer.tsx | 64 + editor/components/ai-elements/tool.tsx | 163 ++ editor/components/ui/badge.tsx | 2 +- editor/components/ui/button.tsx | 18 +- editor/components/ui/command.tsx | 9 +- editor/components/ui/dialog.tsx | 18 +- editor/components/ui/input-group.tsx | 36 +- editor/components/ui/input.tsx | 8 +- editor/components/ui/select.tsx | 36 +- editor/package.json | 42 +- pnpm-lock.yaml | 1154 ++++++++++++-- 23 files changed, 4502 insertions(+), 203 deletions(-) create mode 100644 editor/components/ai-elements/README.md create mode 100644 editor/components/ai-elements/actions.tsx create mode 100644 editor/components/ai-elements/code-block.tsx create mode 100644 editor/components/ai-elements/context.tsx create mode 100644 editor/components/ai-elements/conversation.tsx create mode 100644 editor/components/ai-elements/message.tsx create mode 100644 editor/components/ai-elements/prompt-input.tsx create mode 100644 editor/components/ai-elements/queue.tsx create mode 100644 editor/components/ai-elements/reasoning.tsx create mode 100644 editor/components/ai-elements/response.tsx create mode 100644 editor/components/ai-elements/shimmer.tsx create mode 100644 editor/components/ai-elements/tool.tsx diff --git a/editor/app/globals.css b/editor/app/globals.css index a662ef8a4e..766339fbe2 100644 --- a/editor/app/globals.css +++ b/editor/app/globals.css @@ -1 +1,7 @@ @import "./ui.css"; + +/* + Streamdown styles for AI Elements Response component + https://github.com/vercel/streamdown?tab=readme-ov-file#installation + */ +@source "../node_modules/streamdown/dist/index.js"; diff --git a/editor/components.json b/editor/components.json index 10ed05994e..175cd620a8 100644 --- a/editor/components.json +++ b/editor/components.json @@ -13,5 +13,8 @@ "components": "@/components", "utils": "@/components/lib/utils", "hooks": "@/components/hooks" + }, + "registries": { + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" } } diff --git a/editor/components/ai-elements/README.md b/editor/components/ai-elements/README.md new file mode 100644 index 0000000000..777776f1c7 --- /dev/null +++ b/editor/components/ai-elements/README.md @@ -0,0 +1,488 @@ +# AI Elements Components + +These components are from [AI Elements](https://ai-sdk.dev/elements), a component library built by Vercel specifically for AI applications. + +## Installed Components + +### 1. Context Component (`context.tsx`) ✨ NEW + +**Purpose:** Display AI model context window usage with cost estimation + +**From:** https://ai-sdk.dev/elements/components/context + +**Features:** + +- 🎯 Circular progress icon showing % used +- 📊 HoverCard with token breakdown +- 💰 Real-time cost via `tokenlens` +- 📈 Input/Output token details +- 💵 Total cost estimation + +**Usage:** + +```tsx +import { + Context, + ContextTrigger, + ContextContent, + ContextContentHeader, + ContextContentBody, + ContextInputUsage, + ContextOutputUsage, + ContextContentFooter, +} from "@/components/ai-elements/context"; + + + {/* Hover button */} + + {/* Progress bar */} + + {/* Input tokens + cost */} + {/* Output tokens + cost */} + + {/* Total cost */} + +; +``` + +**Integration:** Currently used in `AgentPanel` to track conversation token usage and costs. + +--- + +### 2. Conversation Component (`conversation.tsx`) ✨ NEW + +**Purpose:** Stick-to-bottom conversation container with empty state and scroll button + +**From:** https://ai-sdk.dev/elements/components/conversation + +**Features:** + +- 📜 Automatically scrolls to newest message +- 🪄 `ConversationEmptyState` for initial UI +- 🧲 `ConversationScrollButton` appears when user scrolls up +- ♿ Accessible via `role="log"` +- 🔧 Supports render-prop children for custom layouts + +**Usage:** + +```tsx +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; + + + + {messages.length === 0 ? ( + + ) : ( + messages.map((message) => ( + + )) + )} + + +; +``` + +**Integration:** Used in `MessageList` to manage auto-scrolling and empty state UX. + +--- + +### 3. Tool Component (`tool.tsx`) + +**Purpose:** Display tool invocations with collapsible details + +**Features:** + +- Shows tool name with icon +- Status badges (Pending, Running, Completed, Error) +- Collapsible parameters and results +- Error handling +- Animated states + +**Usage:** + +```tsx +import { + Tool, + ToolHeader, + ToolContent, + ToolParameter, + ToolResult, +} from "@/components/ai-elements/tool"; + + + + + + + + +; +``` + +--- + +### 4. Message Component (`message.tsx`) + +**Purpose:** Professional message display for user/assistant chat + +**Features:** + +- User/Assistant role styling +- Avatar support +- Variants: `contained` (chat bubbles) or `flat` (full-width) +- Responsive layout +- Proper spacing + +**Usage:** + +```tsx +import { Message, MessageContent } from "@/components/ai-elements/message"; + + + Hello! How can I help you? +; +``` + +--- + +### 5. Actions Component (`actions.tsx`) + +**Purpose:** Quick action buttons for AI interactions + +**Features:** + +- Icon buttons with tooltips +- Consistent sizing +- Accessibility support +- Flexible layout + +**Usage:** + +```tsx +import { Actions, Action } from "@/components/ai-elements/actions"; +import { UndoIcon, RefreshIcon } from "lucide-react"; + + + + + + + + +; +``` + +--- + +### 6. CodeBlock Component (`code-block.tsx`) + +**Purpose:** Syntax-highlighted code display with copy button + +**Features:** + +- Shiki-based syntax highlighting +- Copy to clipboard +- Line numbers +- Multiple language support + +**Usage:** + +```tsx +import { CodeBlock } from "@/components/ai-elements/code-block"; + +; +``` + +--- + +### 7. Prompt Input Component (`prompt-input.tsx`) + +**Purpose:** Professional input component with file attachments, submit states, and extensibility + +**Features:** + +- Auto-resizing textarea +- File attachment support (drag & drop, paste) +- Submit button with proper loading states +- Model selection dropdown +- Speech input support +- Customizable tools/actions +- Provider-based state management + +**Usage:** + +```tsx +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputHeader, + type PromptInputMessage, + PromptInputProvider, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from "@/components/ai-elements/prompt-input"; + + + + {/* Optional header content */} + + + + + {/* Optional tool buttons */} + + + +; +``` + +--- + +### 8. Reasoning Component (`reasoning.tsx`) + +**Purpose:** Collapsible component for displaying AI reasoning/thinking process + +**Features:** + +- Auto-opens when streaming starts +- Auto-closes when streaming finishes (with delay) +- Shows "Thinking..." shimmer during streaming +- Displays duration after completion +- Collapsible trigger with chevron icon +- Markdown rendering via Response component + +**Usage:** + +```tsx +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; + + + + {reasoningText} +; +``` + +**Use case:** Display chain-of-thought or reasoning steps from models that support it (e.g., OpenAI o1, Anthropic Claude with extended thinking). + +--- + +### 9. Shimmer Component (`shimmer.tsx`) + +**Purpose:** Animated shimmer effect for loading states + +**Features:** + +- Smooth gradient animation +- Customizable duration +- Works as text wrapper or standalone + +**Usage:** + +```tsx +import { Shimmer } from "@/components/ai-elements/shimmer"; + +Loading...; +``` + +--- + +### 10. Response Component (`response.tsx`) + +**Purpose:** Renders Markdown responses from LLMs with smart streaming support + +**Features:** + +- Markdown rendering with [Streamdown](https://github.com/vercel/streamdown) +- Smart streaming - automatically completes incomplete formatting during real-time streaming +- Support for tables, blockquotes, code blocks, inline code +- Math equation support (LaTeX) +- Syntax highlighting for code blocks +- Copy-to-clipboard for code blocks +- GFM features (tables, task lists, strikethrough) +- Dark/light theme support +- Accessible + +**Usage:** + +```tsx +import { Response } from "@/components/ai-elements/response"; + +// Simple usage +**Hello!** This is a markdown response. + +// With streaming text +{streamingText} +``` + +**Important:** The Response component requires adding this to `globals.css`: + +```css +@source "../node_modules/streamdown/dist/index.js"; +``` + +(This has already been added to `app/globals.css`) + +**Usage with AI SDK:** + +```tsx +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { Response } from "@/components/ai-elements/response"; + +{ + messages.map((message) => ( + + + {message.parts.map((part, i) => { + if (part.type === "text") { + return {part.text}; + } + return null; + })} + + + )); +} +``` + +--- + +## Integration with Current Agent + +### Current Implementation + +- `components/ai-agent/message-item.tsx` - Basic message display +- `components/ai-agent/message-list.tsx` - Simple list of messages +- `components/ai-agent/agent-input.tsx` - Input field + +### Migration Plan + +#### Phase 1: Replace Message Display with Response Component + +Replace `message-item.tsx` with AI Elements Message and Response components: + +```tsx +// Before +
+ +
{message.content}
+
+ +// After - with markdown support + + + {message.content} + + +``` + +**Benefits:** + +- ✅ Markdown formatting (bold, italic, code, lists) +- ✅ Smart streaming - handles incomplete markdown during streaming +- ✅ Code blocks with syntax highlighting +- ✅ Tables, blockquotes, math equations +- ✅ Much better UX than plain text + +#### Phase 2: Add Tool Visualization + +When message has tool calls, show them properly: + +```tsx + + {message.content} + + {message.toolCalls?.map((tool) => ( + + + + {Object.entries(tool.arguments).map(([key, value]) => ( + + ))} + {toolResult && } + + + ))} + +``` + +#### Phase 3: Add Quick Actions + +After assistant messages: + +```tsx + + editor.undo()}> + + + + + + +``` + +--- + +## Benefits + +✅ **Professional UI**: Production-ready components +✅ **Better UX**: Users see exactly what's happening +✅ **Less Code**: Replace custom implementations +✅ **Maintained**: Updates from Vercel team +✅ **Accessible**: ARIA support built-in + +--- + +## Current Status + +✅ **Installed Components:** + +1. Tool - For tool execution visualization ✅ +2. Message - For professional message display ✅ +3. Actions - For quick action buttons ✅ +4. CodeBlock - For syntax highlighting ✅ +5. PromptInput - For professional input (currently used!) ✅ +6. Reasoning - For displaying AI thinking process ✅ +7. Shimmer - For loading animations ✅ +8. Response - For markdown rendering (currently used!) ✅ + +## Next Steps + +1. **Add Reasoning component** to show AI thinking when using chain-of-thought models +2. Integrate Tool component to show detailed tool execution (parameters, results) +3. Migrate message container to use Message component wrapper +4. Add Actions for undo/retry/variations after messages +5. Test with all tool types + +--- + +**Documentation:** https://ai-sdk.dev/elements diff --git a/editor/components/ai-elements/actions.tsx b/editor/components/ai-elements/actions.tsx new file mode 100644 index 0000000000..53988dc3fd --- /dev/null +++ b/editor/components/ai-elements/actions.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/components/lib/utils/index"; +import type { ComponentProps } from "react"; + +export type ActionsProps = ComponentProps<"div">; + +export const Actions = ({ className, children, ...props }: ActionsProps) => ( +
+ {children} +
+); + +export type ActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const Action = ({ + tooltip, + children, + label, + className, + variant = "ghost", + size = "sm", + ...props +}: ActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; diff --git a/editor/components/ai-elements/code-block.tsx b/editor/components/ai-elements/code-block.tsx new file mode 100644 index 0000000000..7738f3da3d --- /dev/null +++ b/editor/components/ai-elements/code-block.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/lib/utils/index"; +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/editor/components/ai-elements/context.tsx b/editor/components/ai-elements/context.tsx new file mode 100644 index 0000000000..7d22dfa670 --- /dev/null +++ b/editor/components/ai-elements/context.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/components/lib/utils/index"; +import type { LanguageModelUsage } from "ai"; +import { type ComponentProps, createContext, useContext } from "react"; +import { getUsage } from "tokenlens"; + +const PERCENT_MAX = 100; +const ICON_RADIUS = 10; +const ICON_VIEWBOX = 24; +const ICON_CENTER = 12; +const ICON_STROKE_WIDTH = 2; + +type ModelId = string; + +type ContextSchema = { + usedTokens: number; + maxTokens: number; + usage?: LanguageModelUsage; + modelId?: ModelId; +}; + +const ContextContext = createContext(null); + +const useContextValue = () => { + const context = useContext(ContextContext); + + if (!context) { + throw new Error("Context components must be used within Context"); + } + + return context; +}; + +export type ContextProps = ComponentProps & ContextSchema; + +export const Context = ({ + usedTokens, + maxTokens, + usage, + modelId, + ...props +}: ContextProps) => ( + + + +); + +const ContextIcon = () => { + const { usedTokens, maxTokens } = useContextValue(); + const circumference = 2 * Math.PI * ICON_RADIUS; + const usedPercent = usedTokens / maxTokens; + const dashOffset = circumference * (1 - usedPercent); + + return ( + + + + + ); +}; + +export type ContextTriggerProps = ComponentProps; + +export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => { + const { usedTokens, maxTokens } = useContextValue(); + const usedPercent = usedTokens / maxTokens; + const renderedPercent = new Intl.NumberFormat("en-US", { + style: "percent", + maximumFractionDigits: 1, + }).format(usedPercent); + + return ( + + {children ?? ( + + )} + + ); +}; + +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/editor/components/ai-elements/conversation.tsx b/editor/components/ai-elements/conversation.tsx new file mode 100644 index 0000000000..39cf707252 --- /dev/null +++ b/editor/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/lib/utils/index"; +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/editor/components/ai-elements/message.tsx b/editor/components/ai-elements/message.tsx new file mode 100644 index 0000000000..3b2396d3ff --- /dev/null +++ b/editor/components/ai-elements/message.tsx @@ -0,0 +1,80 @@ +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar"; +import { cn } from "@/components/lib/utils/index"; +import type { UIMessage } from "ai"; +import { cva, type VariantProps } from "class-variance-authority"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +const messageContentVariants = cva( + "is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm", + { + variants: { + variant: { + contained: [ + "max-w-[80%] px-4 py-3", + "group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground", + "group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground", + ], + flat: [ + "group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", + "group-[.is-assistant]:text-foreground", + ], + }, + }, + defaultVariants: { + variant: "contained", + }, + } +); + +export type MessageContentProps = HTMLAttributes & + VariantProps; + +export const MessageContent = ({ + children, + className, + variant, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageAvatarProps = ComponentProps & { + src: string; + name?: string; +}; + +export const MessageAvatar = ({ + src, + name, + className, + ...props +}: MessageAvatarProps) => ( + + + {name?.slice(0, 2) || "ME"} + +); diff --git a/editor/components/ai-elements/prompt-input.tsx b/editor/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000000..39ee19e123 --- /dev/null +++ b/editor/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1352 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/components/lib/utils/index"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SendIcon, + 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, +}: 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 = ( + <> +