diff --git a/CLAUDE.md b/CLAUDE.md index 4a20697..952194f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Seed is a generative art studio that turns natural language into p5.js sketches with parameter controls, version history, and multi-format exports. +Seed turns natural language into p5.js sketches you can shape with live controls and export 🌱 ## Tech Stack - **Frontend**: Next.js 16 with React 19, TypeScript - **Styling**: Tailwind CSS 4 with shadcn/ui components (Radix-based) -- **AI/Chat**: Vercel AI SDK with ai-elements components - **State Management**: Zustand (lightweight stores) - **Backend**: Convex (serverless database and API) - **Auth**: Clerk (JWT-based authentication) @@ -58,15 +57,34 @@ This project follows a **colocation-first** approach: code lives closest to wher ### File Naming -- All files use **kebab-case**: `magnet-lines.tsx`, `split-panel-store.ts` +- All files use **kebab-case**: `magnet-lines.tsx`, `canvas-store.ts` - Stores: `{feature}-store.ts` - Hooks: `use-{name}.ts` +### Naming Conventions + +**Default exports use generic names** — the file path provides semantic meaning: + +- Components: `export default function Component()` +- Pages: `export default function Page()` +- Layouts: `export default function Layout()` +- Hooks: `export default function useHookName()` (keeps name for React rules of hooks) +- Props: `Props` (type or interface) + +**Named exports use descriptive names** — they need to self-identify: + +- Components: `AppSidebarHeader`, `CustomComponent` +- Props: `AppSidebarHeaderProps`, `CustomComponentProps` + +**Other conventions:** + +- Constants: `SCREAMING_SNAKE_CASE` (e.g., `MOBILE_BREAKPOINT`, `SIDEBAR_COOKIE_NAME`) +- Types: PascalCase (e.g., `SidebarState`) + ### Exceptions -- **`/components/seed-provider.tsx`**: Must stay separate due to Next.js constraints. Root layout needs `metadata` export (Server Component), but providers need `"use client"` (Client Component). Next.js doesn't allow both in the same file. +- **`/components/provider.tsx`**: Must stay separate due to Next.js constraints. Root layout needs `metadata` export (Server Component), but providers need `"use client"` (Client Component). Next.js doesn't allow both in the same file. - **`/components/ui/`**: shadcn-installed primitives. Never modify directly. -- **`/components/ai-elements/`**: AI SDK registry components built on top of `/components/ui/`. Never modify directly. To customize, create wrapper components or override styles via Tailwind classes. ## Architecture @@ -77,9 +95,8 @@ This project follows a **colocation-first** approach: code lives closest to wher layout.tsx # Studio shell (sidebar, nav - all inline) /{feature}/page.tsx # Feature pages (components inline) /components - /ai-elements # AI SDK components (chat, messages) - DO NOT MODIFY /ui # shadcn primitives - DO NOT MODIFY - seed-provider.tsx # Root providers (exception - see Code Organization) + provider.tsx # Root providers (exception - see Code Organization) {name}.tsx # Only shared components (used 2+ places) /convex # Backend (colocate by domain) schema.ts # Database schema (source of truth) @@ -108,11 +125,11 @@ This project follows a **colocation-first** approach: code lives closest to wher - shadcn components use CVA (class-variance-authority) for variants - Theme variables defined in `app/globals.css` (OKLCh color space) - Fonts: Inter (UI), JetBrains Mono (code) -- **NEVER modify files in `/components/ui/` or `/components/ai-elements/`** - these are registry-installed components. To customize, create wrapper components or override styles via Tailwind classes. +- **NEVER modify files in `/components/ui/`** - these are shadcn-installed primitives. To customize, create wrapper components or override styles via Tailwind classes. ## Authentication -- Middleware in `middleware.ts` protects routes +- Middleware in `proxy.ts` protects routes - Public routes: `/`, `/studio` (login page) - Protected routes: `/studio/*` (redirects to sign-in if unauthenticated) - Use Clerk's `` and `` components for conditional rendering @@ -120,7 +137,7 @@ This project follows a **colocation-first** approach: code lives closest to wher ## State Management - Uses Zustand for client-side state management -- Stores live in `/stores` -- Each store is a separate file named `{feature}-store.ts` (e.g., `sidebar-store.ts`) -- Use selector hooks for optimized re-renders (e.g., `useSidebarOpen()`) +- Stores live in `/stores` (currently empty - add stores as needed) +- Each store is a separate file named `{feature}-store.ts` +- Stores use default exports: `export default create(...)` - Server state (database) is managed by Convex, not Zustand diff --git a/README.md b/README.md index 8ea5c99..bbcdcff 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # Seed -AI-powered motion graphics you describe and control. Live controls, multi-format export 🌱 +Seed turns natural language into p5.js sketches you can shape with live controls and export 🌱 diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts deleted file mode 100644 index 440d4d1..0000000 --- a/app/api/chat/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createAgentUIStreamResponse } from "ai"; - -import { createSeedAgent, hasApiKey } from "@/lib/seed-agent"; - -/** - * Chat API endpoint for the Seed agent. - * - * POST /api/chat - * - * Request body: - * - messages: UIMessage[] - The conversation messages - * - * Response: - * - Streaming UI message response with reasoning and sources - * - * Errors: - * - Returns 400 if request is invalid or OPENAI_API_KEY is missing - */ -export async function POST(request: Request) { - // Parse request body - let body: { messages: unknown[] }; - - try { - body = await request.json(); - } catch { - return Response.json( - { - error: "Invalid request", - message: "Could not parse request body as JSON.", - }, - { status: 400 } - ); - } - - const { messages } = body; - - // Validate messages array - if (!Array.isArray(messages)) { - return Response.json( - { - error: "Invalid request", - message: "The 'messages' field must be an array.", - }, - { status: 400 } - ); - } - - // Check if OpenAI API key is available - if (!hasApiKey()) { - return Response.json( - { - error: "API key missing", - message: - "The OPENAI_API_KEY environment variable is not set. Please add it to your .env file.", - }, - { status: 400 } - ); - } - - // Create the Seed agent (no configuration needed) - const agent = createSeedAgent(); - - // Return streaming response - return createAgentUIStreamResponse({ - agent, - uiMessages: messages, - abortSignal: request.signal, - sendSources: true, - sendReasoning: true, - }); -} diff --git a/app/favicon.ico b/app/favicon.ico index 21ab9ba..718d6fe 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/global-error.tsx b/app/global-error.tsx deleted file mode 100644 index 5688195..0000000 --- a/app/global-error.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -export default function GlobalError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - console.error(error); - }, [error]); - - return ( - - -
-
- - - - - -
-
-

Something went wrong

-

- A critical error occurred. Please try again. -

-
- -
- - - ); -} diff --git a/app/globals.css b/app/globals.css index 5eb13f4..d436fc7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -36,14 +36,10 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - - --font-mono: var(--font-jetbrains-mono); - - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; --shadow-2xs: var(--shadow-2xs); --shadow-xs: var(--shadow-xs); --shadow-sm: var(--shadow-sm); @@ -52,103 +48,90 @@ --shadow-lg: var(--shadow-lg); --shadow-xl: var(--shadow-xl); --shadow-2xl: var(--shadow-2xl); + --font-mono: var(--font-jetbrains-mono); } :root { --radius: 0; - --background: oklch(0.99 0 0); - --foreground: oklch(0 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.09 0 0); --card: oklch(1 0 0); - --card-foreground: oklch(0 0 0); - --popover: oklch(0.99 0 0); - --popover-foreground: oklch(0 0 0); - --primary: oklch(0.648 0.2 131.684); - --primary-foreground: oklch(0.986 0.031 120.757); - --secondary: oklch(0.94 0 0); - --secondary-foreground: oklch(0 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.44 0 0); - --accent: oklch(0.94 0 0); - --accent-foreground: oklch(0 0 0); - --destructive: oklch(0.577 0.245 27.325); + --card-foreground: oklch(0.09 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.09 0 0); + --primary: oklch(0.09 0 0); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.965 0 0); + --secondary-foreground: oklch(0.09 0 0); + --muted: oklch(0.965 0 0); + --muted-foreground: oklch(0.45 0 0); + --accent: oklch(0.965 0 0); + --accent-foreground: oklch(0.09 0 0); + --destructive: oklch(0.55 0.22 25); --destructive-foreground: oklch(1 0 0); - --border: oklch(0.92 0 0); - --input: oklch(0.94 0 0); - --ring: oklch(0.841 0.238 128.85); - --chart-1: oklch(0.871 0.15 154.449); - --chart-2: oklch(0.723 0.219 149.579); - --chart-3: oklch(0.627 0.194 149.214); - --chart-4: oklch(0.527 0.154 150.069); - --chart-5: oklch(0.448 0.119 151.328); - --sidebar: oklch(0.99 0 0); - --sidebar-foreground: oklch(0 0 0); - --sidebar-primary: oklch(0.648 0.2 131.684); - --sidebar-primary-foreground: oklch(0.986 0.031 120.757); - --sidebar-accent: oklch(0.94 0 0); - --sidebar-accent-foreground: oklch(0 0 0); - --sidebar-border: oklch(0.94 0 0); - --sidebar-ring: oklch(0.841 0.238 128.85); - --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); - --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --border: oklch(0.9 0 0); + --input: oklch(0.9 0 0); + --ring: oklch(0.09 0 0); + --chart-1: oklch(0.35 0 0); + --chart-2: oklch(0.5 0 0); + --chart-3: oklch(0.65 0 0); + --chart-4: oklch(0.8 0 0); + --chart-5: oklch(0.2 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.09 0 0); + --sidebar-primary: oklch(0.09 0 0); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.965 0 0); + --sidebar-accent-foreground: oklch(0.09 0 0); + --sidebar-border: oklch(0.9 0 0); + --sidebar-ring: oklch(0.09 0 0); + --shadow-2xs: 0 1px 2px 0 oklch(0 0 0 / 0.04); + --shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05); --shadow-sm: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); - --shadow: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + 0 1px 3px 0 oklch(0 0 0 / 0.06), 0 1px 2px -1px oklch(0 0 0 / 0.06); + --shadow: 0 1px 3px 0 oklch(0 0 0 / 0.06), 0 1px 2px -1px oklch(0 0 0 / 0.06); --shadow-md: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + 0 4px 6px -1px oklch(0 0 0 / 0.06), 0 2px 4px -2px oklch(0 0 0 / 0.06); --shadow-lg: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + 0 10px 15px -3px oklch(0 0 0 / 0.06), 0 4px 6px -4px oklch(0 0 0 / 0.06); --shadow-xl: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); - --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); + 0 20px 25px -5px oklch(0 0 0 / 0.06), 0 8px 10px -6px oklch(0 0 0 / 0.06); + --shadow-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.15); } .dark { - --background: oklch(0 0 0); - --foreground: oklch(1 0 0); + --background: oklch(0.1 0 0); + --foreground: oklch(0.98 0 0); --card: oklch(0.14 0 0); - --card-foreground: oklch(1 0 0); - --popover: oklch(0.18 0 0); - --popover-foreground: oklch(1 0 0); - --primary: oklch(0.648 0.2 131.684); - --primary-foreground: oklch(0.986 0.031 120.757); - --secondary: oklch(0.25 0 0); - --secondary-foreground: oklch(1 0 0); - --muted: oklch(0.23 0 0); - --muted-foreground: oklch(0.72 0 0); - --accent: oklch(0.32 0 0); - --accent-foreground: oklch(1 0 0); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0 0 0); - --border: oklch(0.26 0 0); - --input: oklch(0.32 0 0); - --ring: oklch(0.405 0.101 131.063); - --chart-1: oklch(0.871 0.15 154.449); - --chart-2: oklch(0.723 0.219 149.579); - --chart-3: oklch(0.627 0.194 149.214); - --chart-4: oklch(0.527 0.154 150.069); - --chart-5: oklch(0.448 0.119 151.328); - --sidebar: oklch(0.18 0 0); - --sidebar-foreground: oklch(1 0 0); - --sidebar-primary: oklch(0.768 0.233 130.85); - --sidebar-primary-foreground: oklch(0.986 0.031 120.757); - --sidebar-accent: oklch(0.32 0 0); - --sidebar-accent-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.32 0 0); - --sidebar-ring: oklch(0.405 0.101 131.063); - --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); - --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); - --shadow-sm: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); - --shadow: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); - --shadow-md: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); - --shadow-lg: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); - --shadow-xl: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); - --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); + --card-foreground: oklch(0.98 0 0); + --popover: oklch(0.14 0 0); + --popover-foreground: oklch(0.98 0 0); + --primary: oklch(0.98 0 0); + --primary-foreground: oklch(0.1 0 0); + --secondary: oklch(0.18 0 0); + --secondary-foreground: oklch(0.98 0 0); + --muted: oklch(0.18 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.18 0 0); + --accent-foreground: oklch(0.98 0 0); + --destructive: oklch(0.6 0.22 25); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(1 0 0 / 0.1); + --input: oklch(1 0 0 / 0.12); + --ring: oklch(0.98 0 0); + --chart-1: oklch(0.98 0 0); + --chart-2: oklch(0.75 0 0); + --chart-3: oklch(0.55 0 0); + --chart-4: oklch(0.35 0 0); + --chart-5: oklch(0.85 0 0); + --sidebar: oklch(0.08 0 0); + --sidebar-foreground: oklch(0.98 0 0); + --sidebar-primary: oklch(0.98 0 0); + --sidebar-primary-foreground: oklch(0.1 0 0); + --sidebar-accent: oklch(0.16 0 0); + --sidebar-accent-foreground: oklch(0.98 0 0); + --sidebar-border: oklch(1 0 0 / 0.08); + --sidebar-ring: oklch(0.98 0 0); } @layer base { @@ -158,6 +141,10 @@ html { font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif; } + html, + body { + overscroll-behavior: none; + } body { @apply bg-background text-foreground; } diff --git a/app/layout.tsx b/app/layout.tsx index 4843d00..e04baa2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,7 @@ import "./globals.css"; import type { Metadata } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; -import { SeedProvider } from "@/components/seed-provider"; +import Provider from "@/components/provider"; // ============================================================================= // Fonts @@ -28,14 +28,14 @@ const jetbrainsMono = JetBrains_Mono({ export const metadata: Metadata = { title: "Seed", description: - "Generative art studio that turns natural language into p5.js sketches with parameter controls, version history, and multi-format exports", + "Seed turns natural language into p5.js sketches you can shape with live controls and export 🌱", }; // ============================================================================= // Layout // ============================================================================= -export default function RootLayout({ +export default function Layout({ children, }: Readonly<{ children: React.ReactNode; @@ -47,7 +47,7 @@ export default function RootLayout({ className={`${inter.variable} ${jetbrainsMono.variable}`} > - {children} + {children} ); diff --git a/app/not-found.tsx b/app/not-found.tsx deleted file mode 100644 index f949edb..0000000 --- a/app/not-found.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { FileQuestion } from "lucide-react"; -import Link from "next/link"; - -import { Button } from "@/components/ui/button"; - -export default function NotFound() { - return ( -
-
- -
-
-

- 404 - Page Not Found -

-

- The page you're looking for doesn't exist or has been moved. -

-
-
- - -
-
- ); -} diff --git a/app/page.tsx b/app/page.tsx index ffd0905..f83a92a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,13 @@ import MagnetLines from "@/components/magnet-lines"; -export default function Home() { +// ============================================================================= +// Page +// ============================================================================= + +export default function Page() { return (
- {/* https://reactbits.dev/animations/magnet-lines */}
- 🌱 void; -}) { - useEffect(() => { - console.error(error); - }, [error]); - - return ( -
-
- -
-
-

- Something went wrong -

-

- An unexpected error occurred. Please try again or contact support if - the problem persists. -

-
- -
- ); -} diff --git a/app/studio/layout.tsx b/app/studio/layout.tsx index 62f5023..6639555 100644 --- a/app/studio/layout.tsx +++ b/app/studio/layout.tsx @@ -14,6 +14,7 @@ import { LogOut, Monitor, Moon, + Plus, Sun, } from "lucide-react"; import Link from "next/link"; @@ -23,12 +24,6 @@ import * as React from "react"; import MagnetLines from "@/components/magnet-lines"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, -} from "@/components/ui/breadcrumb"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -39,100 +34,111 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { LayersIcon, type LayersIconHandle } from "@/components/ui/layers"; -import { PlusIcon, type PlusIconHandle } from "@/components/ui/plus"; -import { Separator } from "@/components/ui/separator"; +import { LayersIcon } from "@/components/ui/layers"; +import { PlusIcon } from "@/components/ui/plus"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, - SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, - SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; // ============================================================================= -// Icons +// Types // ============================================================================= -function GoogleIcon() { - return ( - - - - ); -} +type AnimatedIconHandle = { + startAnimation: () => void; + stopAnimation: () => void; +}; + +type SidebarMenuItemType = { + title: string; + url: string; + icon: React.ElementType; +}; + +type AppSidebarMenuItemProps = { + item: SidebarMenuItemType; + isActive: boolean; +}; // ============================================================================= -// Breadcrumb +// Constants // ============================================================================= -const ROUTE_CONFIG: Record = { - "/studio/new": "New Sketch", - "/studio/sketches": "Sketches", -}; +const SIDEBAR_MENU_ITEMS: SidebarMenuItemType[] = [ + { + title: "New Sketch", + url: "/studio/new", + icon: PlusIcon, + }, + { + title: "Sketches", + url: "/studio/sketches", + icon: LayersIcon, + }, +]; -function StudioBreadcrumb(): React.ReactNode { - const pathname = usePathname(); - const pageTitle = ROUTE_CONFIG[pathname]; +// ============================================================================= +// Components +// ============================================================================= - if (!pageTitle) { - return null; - } +function AppSidebarMenuItem({ item, isActive }: AppSidebarMenuItemProps) { + const iconRef = React.useRef(null); + + const handleMouseEnter = () => { + iconRef.current?.startAnimation(); + }; + + const handleMouseLeave = () => { + iconRef.current?.stopAnimation(); + }; return ( - - - - {pageTitle} - - - + + + + + {item.title} + + + ); } -// ============================================================================= -// Sidebar Logo -// ============================================================================= +function AppSidebarContent() { + const pathname = usePathname(); -function SidebarLogo() { return ( - - - - -
🌱
- -
-
-
+ + + + {SIDEBAR_MENU_ITEMS.map((item) => ( + + ))} + + + ); } -// ============================================================================= -// Nav User -// ============================================================================= - -function NavUser() { +function AppSidebarFooter() { const { isMobile } = useSidebar(); const { user } = useUser(); const { openUserProfile, signOut } = useClerk(); @@ -250,93 +256,11 @@ function NavUser() { ); } -// ============================================================================= -// App Sidebar -// ============================================================================= - -type IconHandle = PlusIconHandle | LayersIconHandle; - -const navItems = [ - { - title: "New Sketch", - url: "/studio/new", - icon: PlusIcon, - }, - { - title: "Sketches", - url: "/studio/sketches", - icon: LayersIcon, - }, -] as const; - -type NavItem = (typeof navItems)[number]; - -function NavMenuItem({ item, isActive }: { item: NavItem; isActive: boolean }) { - const iconRef = React.useRef(null); - - const handleMouseEnter = () => { - iconRef.current?.startAnimation(); - }; - - const handleMouseLeave = () => { - iconRef.current?.stopAnimation(); - }; - - return ( - - - - - {item.title} - - - - ); -} - -function AppSidebar({ ...props }: React.ComponentProps) { - const pathname = usePathname(); - - return ( - - - - - - - - - {navItems.map((item) => ( - - ))} - - - - - - - - - ); -} - // ============================================================================= // Layout // ============================================================================= -export default function StudioLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function Layout({ children }: { children: React.ReactNode }) { return ( <> @@ -354,36 +278,27 @@ export default function StudioLayout({ />
- 🌱 -
- - - -
-
- - - - - - Toggle Sidebar ⌘B - - - - -
+ + + + + + + + + + +
{children}
diff --git a/app/studio/new/page.tsx b/app/studio/new/page.tsx index 2ee4b21..ae73a9d 100644 --- a/app/studio/new/page.tsx +++ b/app/studio/new/page.tsx @@ -1,741 +1,9 @@ -"use client"; - -import { useChat } from "@ai-sdk/react"; -import { type CodeHighlighterPlugin, createCodePlugin } from "@streamdown/code"; -import { DefaultChatTransport } from "ai"; -import { - ArrowDownIcon, - ChevronDownIcon, - CodeIcon, - CopyIcon, - ExternalLinkIcon, - SquareIcon, - XIcon, -} from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import React, { - type HTMLAttributes, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { createPortal } from "react-dom"; - -import { ConversationEmptyState } from "@/components/ai-elements/conversation"; -import { Loader } from "@/components/ai-elements/loader"; -import { - Message, - MessageContent, - MessageResponse, -} from "@/components/ai-elements/message"; -import { - PromptInput, - PromptInputFooter, - PromptInputSubmit, - PromptInputTextarea, -} from "@/components/ai-elements/prompt-input"; -import { - Reasoning, - ReasoningContent, - ReasoningTrigger, -} from "@/components/ai-elements/reasoning"; -import { - Tool, - ToolContent, - ToolHeader, - ToolOutput, -} from "@/components/ai-elements/tool"; -import { PageContainer } from "@/components/page-container"; -import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useIsMacOS } from "@/hooks/use-ismacos"; -import { type SeedAgentUIMessage } from "@/lib/seed-agent"; -import { cn } from "@/lib/utils"; -import { - useSplitPanelShortcuts, - useSplitPanelStore, -} from "@/stores/split-panel-store"; - -// ============================================================================= -// Code Plugin (Syntax Highlighting) -// ============================================================================= - -const baseCodePlugin = createCodePlugin({ - themes: ["vitesse-light", "vitesse-dark"], -}); - -const codePlugin: CodeHighlighterPlugin = { - ...baseCodePlugin, - highlight: (options, callback) => { - const normalizedLang = options.language.replace(/_+$/, ""); - const language = baseCodePlugin.supportsLanguage( - normalizedLang as typeof options.language - ) - ? normalizedLang - : "plaintext"; - return baseCodePlugin.highlight( - { ...options, language: language as typeof options.language }, - callback - ); - }, -}; - -// ============================================================================= -// Example Prompts -// ============================================================================= - -const examplePrompts = [ - "Flow field of particles following Perlin noise currents, leaving trailing paths like wind through tall grass", - "Recursive tree using L-systems that grows and sways, with leaves that change color through the seasons", - "Reaction-diffusion pattern morphing between animal print textures — leopard spots dissolving into zebra stripes", - "Voronoi diagram of glowing cells that pulse and divide like organisms under a microscope", -]; - -// ============================================================================= -// Streaming Context (for auto-collapse behavior) -// ============================================================================= - -const StreamingContext = React.createContext(false); - -// ============================================================================= -// Collapsible Code Block -// ============================================================================= - -const AUTO_COLLAPSE_DELAY = 1000; - -function CollapsibleCodeBlock({ - children, - ...props -}: HTMLAttributes) { - const [isOpen, setIsOpen] = useState(true); - const [hasAutoCollapsed, setHasAutoCollapsed] = useState(false); - const isStreaming = React.useContext(StreamingContext); - - useEffect(() => { - if (!isStreaming && isOpen && !hasAutoCollapsed) { - const timer = setTimeout(() => { - setIsOpen(false); - setHasAutoCollapsed(true); - }, AUTO_COLLAPSE_DELAY); - - return () => clearTimeout(timer); - } - }, [isStreaming, isOpen, hasAutoCollapsed]); - - return ( - - -
- - Code -
- -
- -
{children}
-
-
- ); -} - -const streamdownComponents = { - pre: CollapsibleCodeBlock, -}; - -// ============================================================================= -// Link Safety Modal -// ============================================================================= - -interface LinkSafetyModalProps { - url: string; - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; -} - -function LinkSafetyModal({ - url, - isOpen, - onClose, - onConfirm, -}: LinkSafetyModalProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - if (!isOpen) return null; - - return createPortal( -
-
-
e.stopPropagation()} - > - -
- -

Open external link?

-
-

- You're about to visit an external website. -

-
-

{url}

-
-
- - -
-
-
, - document.body - ); -} - -const renderLinkSafetyModal = (props: LinkSafetyModalProps) => ( - -); - -// ============================================================================= -// Panel Tab -// ============================================================================= - -function PanelTab({ - position, - action, - onClick, - tooltipSide, - shortcutKey, -}: { - position: "left" | "right"; - action: "collapse" | "expand"; - onClick: () => void; - tooltipSide: "left" | "right"; - shortcutKey: "[" | "]"; -}) { - const isMacOS = useIsMacOS(); - const label = action === "collapse" ? "Close" : "Open"; - - return ( - - - - - - {label} {isMacOS ? "⌘" : "Ctrl+"} - {shortcutKey} - - - ); -} - -// ============================================================================= -// Chat Panel -// ============================================================================= - -function ChatPanel() { - const [apiKeyError, setApiKeyError] = useState(null); - - const { messages, sendMessage, status, stop } = useChat({ - transport: new DefaultChatTransport({ - api: "/api/chat", - }), - onError: (error) => { - if (error.message.includes("API key missing")) { - setApiKeyError(error.message); - } - }, - }); - - const [input, setInput] = useState(""); - const [isAtBottom, setIsAtBottom] = useState(true); - const scrollRef = useRef(null); - - const isStreaming = status === "streaming"; - const isSubmitted = status === "submitted"; - - const handleSubmit = ({ text }: { text: string }) => { - // If streaming and no text, just stop - if (isStreaming && !text.trim()) { - stop(); - return; - } - - // If streaming with text, stop first then send - if (isStreaming && text.trim()) { - stop(); - // Small delay to ensure stop completes - setTimeout(() => { - setApiKeyError(null); - sendMessage({ text }); - setInput(""); - }, 100); - return; - } - - // Normal submit - if (!text.trim()) return; - setApiKeyError(null); - sendMessage({ text }); - setInput(""); - }; - - // Handle stop button click (when no text in input) - const handleStopClick = () => { - if (isStreaming) { - stop(); - } - }; - - const scrollToBottom = useCallback(() => { - if (scrollRef.current) { - scrollRef.current.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior: "smooth", - }); - } - }, []); - - const checkIfAtBottom = useCallback(() => { - const container = scrollRef.current; - if (!container) return; - const { scrollTop, scrollHeight, clientHeight } = container; - const threshold = 100; - setIsAtBottom(scrollHeight - scrollTop - clientHeight < threshold); - }, []); - - useEffect(() => { - const container = scrollRef.current; - if (!container) return; - - container.addEventListener("scroll", checkIfAtBottom); - const handleTransitionEnd = () => checkIfAtBottom(); - container.addEventListener("transitionend", handleTransitionEnd); - checkIfAtBottom(); - - return () => { - container.removeEventListener("scroll", checkIfAtBottom); - container.removeEventListener("transitionend", handleTransitionEnd); - }; - }, [checkIfAtBottom]); - - useEffect(() => { - if (isAtBottom) { - scrollToBottom(); - } - const timer = setTimeout(checkIfAtBottom, 100); - return () => clearTimeout(timer); - }, [messages, isAtBottom, scrollToBottom, checkIfAtBottom]); - - return ( -
-
-
- {messages.length === 0 ? ( - - 🌱 -
-

- Create something beautiful -

-

- Describe the generative art you want to create -

-
-
- {examplePrompts.map((prompt) => ( - - ))} -
-
- ) : ( - -
- {messages.map((message, messageIndex) => { - const isLastAssistantMessage = - message.role === "assistant" && - messageIndex === - messages.findLastIndex((m) => m.role === "assistant"); - const isThisMessageStreaming = - isLastAssistantMessage && status === "streaming"; - - return ( - - - {message.parts.map((part, index) => { - switch (part.type) { - case "text": - return ( - - {part.text} - - ); - case "tool-web_search": - return ( - - - {part.state === "output-available" && ( - - - - )} - - ); - case "tool-generate_sketch": - return ( - - - - {part.state === "output-available" && - part.output && ( - - - - )} - - {part.state === "output-available" && - part.output?.response && ( - - {part.output.response} - - )} - - ); - case "reasoning": - if (!part.text || part.text.trim() === "") { - return null; - } - return ( - - - - {part.text} - - - ); - case "step-start": - return null; - default: - return null; - } - })} - - - ); - })} - - {status === "submitted" && ( - - -
- - Thinking... -
-
-
- )} - - {apiKeyError && ( - - -
-

API Key Missing

-

- {apiKeyError} -

-
-
-
- )} -
-
- )} -
- - {!isAtBottom && messages.length > 0 && ( - - )} -
- - {/* Clean, minimal input */} -
- - setInput(e.target.value)} - placeholder={ - isStreaming - ? "Type to interrupt and ask something else..." - : "Describe what you want to create..." - } - disabled={isSubmitted} - /> - - {/* Empty spacer to keep submit button on the right */} -
- {isStreaming && !input.trim() ? ( - // Stop button when streaming with no input - - ) : ( - // Normal submit button - - )} - - -
-
- ); -} - -function LeftPanel() { - const { view, toggleRightPanel } = useSplitPanelStore(); - const showCollapseTab = view === "split"; - - return ( -
- - {showCollapseTab && ( - - )} -
- ); -} - -// ============================================================================= -// Right Panel (Preview) -// ============================================================================= - -function RightPanel() { - const { view, toggleLeftPanel } = useSplitPanelStore(); - const showCollapseTab = view === "split"; - - return ( -
-
- {/* Preview panel content */} -
- {showCollapseTab && ( - - )} -
- ); -} - -// ============================================================================= -// Split Panel Layout -// ============================================================================= - -function SplitPanelLayout() { - const { view, setView } = useSplitPanelStore(); - - useSplitPanelShortcuts(); - - const restoreSplit = () => setView("split"); - - const isLeftVisible = view !== "right-expanded"; - const isRightVisible = view !== "left-expanded"; - - const leftPanelWidth = "calc(50% - 4px)"; - const rightPanelWidth = "calc(50% - 4px)"; - - return ( -
- - {isLeftVisible && ( - - - - )} - - {isRightVisible && ( - - - - )} - - - {view === "right-expanded" && ( - - )} - - {view === "left-expanded" && ( - - )} -
- ); -} +import Container from "@/components/container"; // ============================================================================= // Page // ============================================================================= -export default function NewSketchPage() { - return ( - - - - ); +export default function Page() { + return {/* Content */}; } diff --git a/app/studio/not-found.tsx b/app/studio/not-found.tsx deleted file mode 100644 index c81f57e..0000000 --- a/app/studio/not-found.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { FileQuestion } from "lucide-react"; -import Link from "next/link"; - -import { Button } from "@/components/ui/button"; - -export default function StudioNotFound() { - return ( -
-
- -
-
-

Page Not Found

-

- The page you're looking for doesn't exist or has been moved. -

-
- -
- ); -} diff --git a/app/studio/page.tsx b/app/studio/page.tsx index 655a484..10e4878 100644 --- a/app/studio/page.tsx +++ b/app/studio/page.tsx @@ -1,13 +1,16 @@ import { auth } from "@clerk/nextjs/server"; import { redirect } from "next/navigation"; -export default async function StudioPage() { +// ============================================================================= +// Page +// ============================================================================= + +export default async function Page() { const { userId } = await auth(); if (userId) { redirect("/studio/new"); } - // Logged out users see the sign-in page via the layout return null; } diff --git a/app/studio/sketches/page.tsx b/app/studio/sketches/page.tsx index 2105fcb..ae73a9d 100644 --- a/app/studio/sketches/page.tsx +++ b/app/studio/sketches/page.tsx @@ -1,5 +1,9 @@ -import { PageContainer } from "@/components/page-container"; +import Container from "@/components/container"; -export default function AllSketchesPage() { - return {/* TODO: Add all sketches */}; +// ============================================================================= +// Page +// ============================================================================= + +export default function Page() { + return {/* Content */}; } diff --git a/components/ai-elements/artifact.tsx b/components/ai-elements/artifact.tsx deleted file mode 100644 index 9aa048b..0000000 --- a/components/ai-elements/artifact.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { type LucideIcon, XIcon } from "lucide-react"; -import type { ComponentProps, HTMLAttributes } from "react"; - -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -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/components/ai-elements/canvas.tsx b/components/ai-elements/canvas.tsx deleted file mode 100644 index dffcaa8..0000000 --- a/components/ai-elements/canvas.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import "@xyflow/react/dist/style.css"; - -import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; -import type { ReactNode } from "react"; - -type CanvasProps = ReactFlowProps & { - children?: ReactNode; -}; - -export const Canvas = ({ children, ...props }: CanvasProps) => ( - - - {children} - -); diff --git a/components/ai-elements/chain-of-thought.tsx b/components/ai-elements/chain-of-thought.tsx deleted file mode 100644 index e6896f1..0000000 --- a/components/ai-elements/chain-of-thought.tsx +++ /dev/null @@ -1,232 +0,0 @@ -"use client"; - -import { useControllableState } from "@radix-ui/react-use-controllable-state"; -import { - BrainIcon, - ChevronDownIcon, - DotIcon, - type LucideIcon, -} from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; -import { createContext, memo, useContext, useMemo } from "react"; - -import { Badge } from "@/components/ui/badge"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { cn } from "@/lib/utils"; - -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/components/ai-elements/checkpoint.tsx b/components/ai-elements/checkpoint.tsx deleted file mode 100644 index f595e1e..0000000 --- a/components/ai-elements/checkpoint.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { BookmarkIcon, type LucideProps } from "lucide-react"; -import type { ComponentProps, HTMLAttributes } from "react"; - -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -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/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx deleted file mode 100644 index 9c40be2..0000000 --- a/components/ai-elements/code-block.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -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"; - -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -type CodeBlockProps = HTMLAttributes & { - code: string; - language: BundledLanguage; - showLineNumbers?: boolean; -}; - -type CodeBlockContextType = { - code: string; -}; - -const CodeBlockContext = createContext({ - code: "", -}); - -const lineNumberTransformer: ShikiTransformer = { - name: "line-numbers", - line(node, line) { - node.children.unshift({ - type: "element", - tagName: "span", - properties: { - className: [ - "inline-block", - "min-w-10", - "mr-4", - "text-right", - "select-none", - "text-muted-foreground", - ], - }, - children: [{ type: "text", value: String(line) }], - }); - }, -}; - -export async function highlightCode( - code: string, - language: BundledLanguage, - showLineNumbers = false -) { - const transformers: ShikiTransformer[] = showLineNumbers - ? [lineNumberTransformer] - : []; - - return await Promise.all([ - codeToHtml(code, { - lang: language, - theme: "vitesse-light", - transformers, - }), - codeToHtml(code, { - lang: language, - theme: "vitesse-dark", - 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/components/ai-elements/confirmation.tsx b/components/ai-elements/confirmation.tsx deleted file mode 100644 index 562f456..0000000 --- a/components/ai-elements/confirmation.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client"; - -import type { ToolUIPart } from "ai"; -import { - type ComponentProps, - createContext, - type ReactNode, - useContext, -} from "react"; - -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -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/components/ai-elements/controls.tsx b/components/ai-elements/controls.tsx deleted file mode 100644 index 196d197..0000000 --- a/components/ai-elements/controls.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { Controls as ControlsPrimitive } from "@xyflow/react"; -import type { ComponentProps } from "react"; - -import { cn } from "@/lib/utils"; - -export type ControlsProps = ComponentProps; - -export const Controls = ({ className, ...props }: ControlsProps) => ( - button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!", - className - )} - {...props} - /> -); diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx deleted file mode 100644 index 2442af2..0000000 --- a/components/ai-elements/conversation.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { ArrowDownIcon } from "lucide-react"; -import type { ComponentProps } from "react"; -import { useCallback } from "react"; -import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; - -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -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/components/ai-elements/edge.tsx b/components/ai-elements/edge.tsx deleted file mode 100644 index acff359..0000000 --- a/components/ai-elements/edge.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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/components/ai-elements/image.tsx b/components/ai-elements/image.tsx deleted file mode 100644 index fbe06fd..0000000 --- a/components/ai-elements/image.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Experimental_GeneratedImage } from "ai"; - -import { cn } from "@/lib/utils"; - -export type ImageProps = Experimental_GeneratedImage & { - className?: string; - alt?: string; -}; - -export const Image = ({ - base64, - // Excluded from spread to avoid invalid HTML attribute - // eslint-disable-next-line @typescript-eslint/no-unused-vars - uint8Array, - mediaType, - ...props -}: ImageProps) => ( - // eslint-disable-next-line @next/next/no-img-element - {props.alt} -); diff --git a/components/ai-elements/inline-citation.tsx b/components/ai-elements/inline-citation.tsx deleted file mode 100644 index 558edda..0000000 --- a/components/ai-elements/inline-citation.tsx +++ /dev/null @@ -1,298 +0,0 @@ -"use client"; - -import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; -import { - type ComponentProps, - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; - -import { Badge } from "@/components/ui/badge"; -import { - Carousel, - type CarouselApi, - CarouselContent, - CarouselItem, -} from "@/components/ui/carousel"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { cn } from "@/lib/utils"; - -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; - } - - const updateState = () => { - setCount(api.scrollSnapList().length); - setCurrent(api.selectedScrollSnap() + 1); - }; - - // Subscribe to events - setState in callbacks is allowed - api.on("select", updateState); - api.on("reInit", updateState); - - // Initialize values via reInit event or deferred call - updateState(); - - return () => { - api.off("select", updateState); - api.off("reInit", updateState); - }; - }, [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/components/ai-elements/loader.tsx b/components/ai-elements/loader.tsx deleted file mode 100644 index a7981ea..0000000 --- a/components/ai-elements/loader.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { HTMLAttributes } from "react"; - -import { cn } from "@/lib/utils"; - -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/components/ai-elements/message.tsx b/components/ai-elements/message.tsx deleted file mode 100644 index 3fe3efa..0000000 --- a/components/ai-elements/message.tsx +++ /dev/null @@ -1,462 +0,0 @@ -"use client"; - -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, - useMemo, - useState, -} from "react"; -import { Streamdown } from "streamdown"; - -import { Button } from "@/components/ui/button"; -import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; - -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 = useMemo( - () => (Array.isArray(children) ? 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 = ({ - // Excluded to use consistent internal styling - // eslint-disable-next-line @typescript-eslint/no-unused-vars - className, - // Part of public API for future styling based on message role - // eslint-disable-next-line @typescript-eslint/no-unused-vars - 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 ? ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - {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/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx deleted file mode 100644 index 3e42792..0000000 --- a/components/ai-elements/model-selector.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { ComponentProps, ReactNode } from "react"; - -import { - Command, - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - CommandShortcut, -} from "@/components/ui/command"; -import { - Dialog, - DialogContent, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; - -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) => ( - // eslint-disable-next-line @next/next/no-img-element - {`${provider} -); - -export type ModelSelectorLogoGroupProps = ComponentProps<"div">; - -export const ModelSelectorLogoGroup = ({ - className, - ...props -}: ModelSelectorLogoGroupProps) => ( -
img]:bg-background dark:[&>img]:bg-foreground flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1", - className - )} - {...props} - /> -); - -export type ModelSelectorNameProps = ComponentProps<"span">; - -export const ModelSelectorName = ({ - className, - ...props -}: ModelSelectorNameProps) => ( - -); diff --git a/components/ai-elements/node.tsx b/components/ai-elements/node.tsx deleted file mode 100644 index 645e874..0000000 --- a/components/ai-elements/node.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Handle, Position } from "@xyflow/react"; -import type { ComponentProps } from "react"; - -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { cn } from "@/lib/utils"; - -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/components/ai-elements/open-in-chat.tsx b/components/ai-elements/open-in-chat.tsx deleted file mode 100644 index 77e616a..0000000 --- a/components/ai-elements/open-in-chat.tsx +++ /dev/null @@ -1,366 +0,0 @@ -"use client"; - -import { - ChevronDownIcon, - ExternalLinkIcon, - MessageCircleIcon, -} from "lucide-react"; -import { type ComponentProps, createContext, useContext } from "react"; - -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; - -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/components/ai-elements/panel.tsx b/components/ai-elements/panel.tsx deleted file mode 100644 index cf0e8df..0000000 --- a/components/ai-elements/panel.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Panel as PanelPrimitive } from "@xyflow/react"; -import type { ComponentProps } from "react"; - -import { cn } from "@/lib/utils"; - -type PanelProps = ComponentProps; - -export const Panel = ({ className, ...props }: PanelProps) => ( - -); diff --git a/components/ai-elements/plan.tsx b/components/ai-elements/plan.tsx deleted file mode 100644 index 7dcf9e1..0000000 --- a/components/ai-elements/plan.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import { ChevronsUpDownIcon } from "lucide-react"; -import type { ComponentProps } from "react"; -import { createContext, useContext } from "react"; - -import { Button } from "@/components/ui/button"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { cn } from "@/lib/utils"; - -import { 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/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx deleted file mode 100644 index 6bee72d..0000000 --- a/components/ai-elements/prompt-input.tsx +++ /dev/null @@ -1,1423 +0,0 @@ -"use client"; - -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"; - -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupTextarea, -} from "@/components/ui/input-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; - -// ============================================================================ -// Provider Context & Types -// ============================================================================ - -export type AttachmentsContext = { - files: (FileUIPart & { id: string })[]; - add: (files: File[] | FileList) => void; - remove: (id: string) => void; - clear: () => void; - openFileDialog: () => void; - fileInputRef: RefObject; -}; - -export type TextInputContext = { - value: string; - setInput: (v: string) => void; - clear: () => void; -}; - -export type PromptInputControllerProps = { - textInput: TextInputContext; - attachments: AttachmentsContext; - /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ - __registerFileInput: ( - ref: RefObject, - open: () => void - ) => void; -}; - -const PromptInputController = createContext( - null -); -const ProviderAttachmentsContext = createContext( - null -); - -export const usePromptInputController = () => { - const ctx = useContext(PromptInputController); - if (!ctx) { - throw new Error( - "Wrap your component inside to use usePromptInputController()." - ); - } - return ctx; -}; - -// Optional variants (do NOT throw). Useful for dual-mode components. -const useOptionalPromptInputController = () => - useContext(PromptInputController); - -export const useProviderAttachments = () => { - const ctx = useContext(ProviderAttachmentsContext); - if (!ctx) { - throw new Error( - "Wrap your component inside to use useProviderAttachments()." - ); - } - return ctx; -}; - -const useOptionalProviderAttachments = () => - useContext(ProviderAttachmentsContext); - -export type PromptInputProviderProps = PropsWithChildren<{ - initialInput?: string; -}>; - -/** - * Optional global provider that lifts PromptInput state outside of PromptInput. - * If you don't use it, PromptInput stays fully self-managed. - */ -export function PromptInputProvider({ - initialInput: initialTextInput = "", - children, -}: PromptInputProviderProps) { - // ----- textInput state - const [textInput, setTextInput] = useState(initialTextInput); - const clearInput = useCallback(() => setTextInput(""), []); - - // ----- attachments state (global when wrapped) - const [attachmentFiles, setAttachmentFiles] = useState< - (FileUIPart & { id: string })[] - >([]); - const fileInputRef = useRef(null); - const openRef = useRef<() => void>(() => {}); - - const add = useCallback((files: File[] | FileList) => { - const incoming = Array.from(files); - if (incoming.length === 0) { - return; - } - - setAttachmentFiles((prev) => - prev.concat( - incoming.map((file) => ({ - id: nanoid(), - type: "file" as const, - url: URL.createObjectURL(file), - mediaType: file.type, - filename: file.name, - })) - ) - ); - }, []); - - const remove = useCallback((id: string) => { - setAttachmentFiles((prev) => { - const found = prev.find((f) => f.id === id); - if (found?.url) { - URL.revokeObjectURL(found.url); - } - return prev.filter((f) => f.id !== id); - }); - }, []); - - const clear = useCallback(() => { - setAttachmentFiles((prev) => { - for (const f of prev) { - if (f.url) { - URL.revokeObjectURL(f.url); - } - } - return []; - }); - }, []); - - // Keep a ref to attachments for cleanup on unmount (avoids stale closure) - const attachmentsRef = useRef(attachmentFiles); - - // Update ref in effect to avoid accessing during render - useEffect(() => { - attachmentsRef.current = attachmentFiles; - }, [attachmentFiles]); - - // Cleanup blob URLs on unmount to prevent memory leaks - useEffect(() => { - return () => { - for (const f of attachmentsRef.current) { - if (f.url) { - URL.revokeObjectURL(f.url); - } - } - }; - }, []); - - const openFileDialog = useCallback(() => { - openRef.current?.(); - }, []); - - const attachments = useMemo( - () => ({ - files: attachmentFiles, - add, - remove, - clear, - openFileDialog, - fileInputRef, - }), - [attachmentFiles, add, remove, clear, openFileDialog] - ); - - const __registerFileInput = useCallback( - (ref: RefObject, open: () => void) => { - fileInputRef.current = ref.current; - openRef.current = open; - }, - [] - ); - - const controller = useMemo( - () => ({ - textInput: { - value: textInput, - setInput: setTextInput, - clear: clearInput, - }, - attachments, - __registerFileInput, - }), - [textInput, clearInput, attachments, __registerFileInput] - ); - - return ( - - - {children} - - - ); -} - -// ============================================================================ -// Component Context & Hooks -// ============================================================================ - -const LocalAttachmentsContext = createContext(null); - -export const usePromptInputAttachments = () => { - // Dual-mode: prefer provider if present, otherwise use local - const provider = useOptionalProviderAttachments(); - const local = useContext(LocalAttachmentsContext); - const context = provider ?? local; - if (!context) { - throw new Error( - "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" - ); - } - return context; -}; - -export type PromptInputAttachmentProps = HTMLAttributes & { - data: FileUIPart & { id: string }; - className?: string; -}; - -export function PromptInputAttachment({ - data, - className, - ...props -}: PromptInputAttachmentProps) { - const attachments = usePromptInputAttachments(); - - const filename = data.filename || ""; - - const mediaType = - data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; - const isImage = mediaType === "image"; - - const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); - - return ( - - -
-
-
- {isImage ? ( - // eslint-disable-next-line @next/next/no-img-element - {filename - ) : ( -
- -
- )} -
- -
- - {attachmentLabel} -
-
- -
- {isImage && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {filename -
- )} -
-
-

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

- {data.mediaType && ( -

- {data.mediaType} -

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