diff --git a/apps/www/@/components/ui/badge.tsx b/apps/www/@/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/apps/www/@/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/apps/www/@/components/ui/drawer.tsx b/apps/www/@/components/ui/drawer.tsx new file mode 100644 index 0000000..6a0ef53 --- /dev/null +++ b/apps/www/@/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/apps/www/@/components/ui/popover.tsx b/apps/www/@/components/ui/popover.tsx new file mode 100644 index 0000000..483dc69 --- /dev/null +++ b/apps/www/@/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/apps/www/@/components/ui/scroll-area.tsx b/apps/www/@/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0b4a48d --- /dev/null +++ b/apps/www/@/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/apps/www/@/components/ui/select.tsx b/apps/www/@/components/ui/select.tsx new file mode 100644 index 0000000..a45647c --- /dev/null +++ b/apps/www/@/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/apps/www/@/components/ui/sheet.tsx b/apps/www/@/components/ui/sheet.tsx new file mode 100644 index 0000000..a37f17b --- /dev/null +++ b/apps/www/@/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/apps/www/@/components/ui/sidebar.tsx b/apps/www/@/components/ui/sidebar.tsx new file mode 100644 index 0000000..e8c9005 --- /dev/null +++ b/apps/www/@/components/ui/sidebar.tsx @@ -0,0 +1,193 @@ +"use client"; +import { cn } from "@/lib/utils"; +import Link, { LinkProps } from "next/link"; +import React, { useState, createContext, useContext } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Cancel01Icon, Menu01Icon } from "hugeicons-react"; + +interface Links { + label: string; + href: string; + icon: React.JSX.Element | React.ReactNode; +} + +interface SidebarContextProps { + open: boolean; + setOpen: React.Dispatch>; + animate: boolean; +} + +const SidebarContext = createContext( + undefined, +); + +export const useSidebar = () => { + const context = useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + return context; +}; + +export const SidebarProvider = ({ + children, + open: openProp, + setOpen: setOpenProp, + animate = true, +}: { + children: React.ReactNode; + open?: boolean; + setOpen?: React.Dispatch>; + animate?: boolean; +}) => { + const [openState, setOpenState] = useState(false); + + const open = openProp !== undefined ? openProp : openState; + const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState; + + return ( + + {children} + + ); +}; + +export const Sidebar = ({ + children, + open, + setOpen, + animate, +}: { + children: React.ReactNode; + open?: boolean; + setOpen?: React.Dispatch>; + animate?: boolean; +}) => { + return ( + + {children} + + ); +}; + +export const SidebarBody = (props: React.ComponentProps) => { + return ( + <> + + + + {/* )} /> */} + + ); +}; + +export const DesktopSidebar = ({ + className, + children, + ...props +}: React.ComponentProps) => { + const { open, setOpen, animate } = useSidebar(); + return ( + <> + animate && setOpen(true)} + onMouseLeave={() => animate && setOpen(false)} + {...props} + > + {children} + + + ); +}; + +export const MobileSidebar = ({ + className, + children, + ...props +}: React.ComponentProps<"div">) => { + const { open, setOpen } = useSidebar(); + return ( + +
+
+ setOpen(!open)} + /> +
+ + {open && ( + +
setOpen(!open)} + > + +
+ {children} +
+ )} +
+
+
+ ); +}; + +export const SidebarLink = ({ + link, + className, + ...props +}: { + link: Links; + className?: string; + props?: LinkProps; +}) => { + const { open, animate } = useSidebar(); + return ( + + {link.icon} + + + {link.label} + + + ); +}; \ No newline at end of file diff --git a/apps/www/@/components/ui/skeleton.tsx b/apps/www/@/components/ui/skeleton.tsx new file mode 100644 index 0000000..01b8b6d --- /dev/null +++ b/apps/www/@/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/apps/www/@/components/ui/switch.tsx b/apps/www/@/components/ui/switch.tsx new file mode 100644 index 0000000..bc69cf2 --- /dev/null +++ b/apps/www/@/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/apps/www/@/hooks/use-mobile.tsx b/apps/www/@/hooks/use-mobile.tsx new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/apps/www/@/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/apps/www/@/lib/actions/chat.ts b/apps/www/@/lib/actions/chat.ts new file mode 100644 index 0000000..7b56d42 --- /dev/null +++ b/apps/www/@/lib/actions/chat.ts @@ -0,0 +1,222 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { kv } from "@vercel/kv"; + +import { type Chat } from "@/lib/types"; +import { currentUser } from "@clerk/nextjs/server"; + +export async function getChats(userId?: string | null) { + const user = await currentUser(); + + if (!userId) { + return []; + } + + if (!user || userId !== user.id) { + return { + error: "Unauthorized", + }; + } + + try { + const pipeline = kv.pipeline(); + const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1, { + rev: true, + }); + + for (const chat of chats) { + pipeline.hgetall(chat); + } + + const results = await pipeline.exec(); + + return results as Chat[]; + } catch (error) { + return []; + } +} + +export async function getChat(id: string) { + const user = await currentUser(); + + if (!user) { + return { + error: "Unauthorized", + }; + } + + const chat = await kv.hgetall(`chat:${id}`); + + if (!chat || chat.userId !== user.id) { + return { + error: "Unauthorized", + }; + } + + return chat; +} + +export async function removeChat(id: string) { + const user = await currentUser(); + + if (!user) { + return { + error: "Unauthorized", + }; + } + + // Convert uid to string for consistent comparison with session.user.id + const uid = String(await kv.hget(`chat:${id}`, "userId")); + + if (uid !== user.id) { + return { + error: "Unauthorized", + }; + } + + await kv.del(`chat:${id}`); + await kv.zrem(`user:chat:${user.id}`, `chat:${id}`); + + return { success: true }; +} + +export async function renameChat(id: string, name: string) { + const user = await currentUser(); + + if (!user) { + return { + error: "Unauthorized", + }; + } + + if (name.length < 3) { + return { + error: "Name must be at least 3 characters", + }; + } + + const chat = await kv.hgetall(`chat:${id}`); + + if (!chat || "error" in chat) { + return { + error: "An error occurred while renaming the chat", + }; + } + + await kv.hset(`chat:${id}`, { title: name }); + + return { success: true }; +} + +export async function clearChats() { + const user = await currentUser(); + + if (!user) { + return { + error: "Unauthorized", + }; + } + + const chats: string[] = await kv.zrange(`user:chat:${user.id}`, 0, -1); + if (!chats.length) { + return redirect("/"); + } + const pipeline = kv.pipeline(); + + for (const chat of chats) { + pipeline.del(chat); + pipeline.zrem(`user:chat:${user.id}`, chat); + } + + await pipeline.exec(); + + revalidatePath("/"); + return redirect("/"); +} + +export async function getSharedChat(id: string) { + const chat = await kv.hgetall(`chat:${id}`); + + if (!chat || !chat.sharePath) { + return null; + } + + return chat; +} + +export async function shareChat(id: string) { + const user = await currentUser(); + + if (!user) { + return { + error: "Unauthorized", + }; + } + + const chat = await kv.hgetall(`chat:${id}`); + + if (!chat || chat.userId !== user.id) { + return { + error: "Something went wrong", + }; + } + + const payload = { + ...chat, + sharePath: `/share/${chat.id}`, + }; + + await kv.hmset(`chat:${chat.id}`, payload); + + return payload; +} + +export async function unshareChat(id: string) { + const chat = await kv.hgetall(`chat:${id}`); + if (!chat || !chat.sharePath) { + return { + error: chat?.sharePath ? "Chat is not shared" : "Something went wrong", + }; + } + + await kv.hdel(`chat:${id}`, "sharePath"); + + return { success: true }; +} + +export async function saveChat(chat: Chat) { + const user = await currentUser(); + + if (user) { + const pipeline = kv.pipeline(); + pipeline.hmset(`chat:${chat.id}`, chat); + pipeline.zadd(`user:chat:${chat.userId}`, { + score: Date.now(), + member: `chat:${chat.id}`, + }); + + await pipeline.exec(); + } else { + return; + } +} + +export async function refreshHistory(path: string) { + redirect(path); +} + +export async function getMissingKeys() { + const keysRequired = [ + "OPENAI_API_KEY", + "CLERK_SECRET_KEY", + "KV_URL", + "KV_REST_API_URL", + "KV_REST_API_TOKEN", + "KV_REST_API_READ_ONLY_TOKEN", + ]; + return keysRequired + .map((key) => (process.env[key] ? "" : key)) + .filter((key) => key !== ""); +} diff --git a/apps/www/@/lib/components.ts b/apps/www/@/lib/components.ts new file mode 100644 index 0000000..24a212b --- /dev/null +++ b/apps/www/@/lib/components.ts @@ -0,0 +1,27 @@ +import { UILibrary } from "@/lib/types"; +import shadcnUIComponentsDump from "public/content/components/shadcn.json"; + +/** + * + * @param uiLibrary - The UI library to get the components for. + * @returns The components for the UI library. + * + */ +export function getUILibraryComponents(uiLibrary: UILibrary) { + let uiLibraryComponents; + + switch (uiLibrary) { + case "nextui": + uiLibraryComponents = shadcnUIComponentsDump; + break; + case "flowbite": + uiLibraryComponents = shadcnUIComponentsDump; + break; + case "shadcn": + default: + uiLibraryComponents = shadcnUIComponentsDump; + break; + } + + return uiLibraryComponents; +} \ No newline at end of file diff --git a/apps/www/@/lib/core/ai/agents/component/generation/component-abstract.tsx b/apps/www/@/lib/core/ai/agents/component/generation/component-abstract.tsx new file mode 100644 index 0000000..0c3e6d1 --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/component/generation/component-abstract.tsx @@ -0,0 +1,19 @@ +import { createStreamableUI } from "ai/rsc"; +import { CoreMessage } from "ai"; +import { streamingAgent, StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { LLMSelection, UILibrary } from "@/lib/types"; + +export async function componentAbstract( + uiStream: ReturnType, + messages: CoreMessage[], + uiLibrary: UILibrary, + language: string, + llm: LLMSelection, + update?: boolean, +): Promise { + const SYSTEM_PROMPT = `As an expert React component designer, provide a brief confirmation message that you will create a new component using ${uiLibrary}. Your response should be a single sentence starting with "I will create" and briefly summarizing the component's main functionality, mentioning the use of ${uiLibrary}. Do not include any code or detailed explanations. + +Please respond in ${language}.`; + + return streamingAgent(uiStream, messages, SYSTEM_PROMPT, update, llm); +} diff --git a/apps/www/@/lib/core/ai/agents/component/generation/component-generator.tsx b/apps/www/@/lib/core/ai/agents/component/generation/component-generator.tsx new file mode 100644 index 0000000..07b3e1c --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/component/generation/component-generator.tsx @@ -0,0 +1,129 @@ +import { ComponentSpecificationSchema } from "@/lib/schema/component/specification"; +import { createStreamableUI, createStreamableValue } from "ai/rsc"; +import { StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { streamText } from "ai"; +import { camelCaseToSpaces } from "@/lib/utils"; +import { getModel } from "@/lib/registry"; +import ComponentCard from "components/component-card"; +import { ReactIcon } from "hugeicons-react"; +import { LLMSelection } from "@/lib/types"; + +export async function componentGenerator( + uiStream: ReturnType, + specification: ComponentSpecificationSchema, + messageId: string, + update: boolean = false, + llm: LLMSelection, + language: string, +): Promise { + let fullResponse = ""; + let hasError = false; + const streamableAnswer = createStreamableValue(""); + + const componentCard = ( + } + iteration={1} + /> + ); + + if (update) { + uiStream.update(componentCard); + } else { + uiStream.append(componentCard); + } + + // uiStream.update(); + + console.log("generating component with model", llm); + + try { + await streamText({ + model: getModel(llm), + system: `You are an expert React and Next.js developer tasked with generating a production-ready component based on a provided JSON specification. Your goal is to create a fully functional, polished, and comprehensive component that exceeds expectations in terms of quality and completeness. + +Key Requirements: + +1. Completeness: Develop a FULL component with all necessary features, hooks, and logic. Do not take shortcuts or provide minimal implementations. + +2. Production-Ready: The component should be thoroughly developed, tested, and optimized as if it were going directly into a production environment. + +3. Specification Adherence: Meticulously follow the JSON specification, implementing every detail provided, including but not limited to: + - Component name and file structure + - Props and their types + - State management (using appropriate hooks) + - UI structure and layout + - Styling (including responsive design if applicable) + - Event handlers and user interactions + - Performance optimizations + - Accessibility features + +4. Best Practices: Implement React and Next.js best practices, including: + - Functional components with hooks + - Proper state management (useState, useReducer, useContext as needed) + - Efficient rendering and memoization (useMemo, useCallback) + - Error boundaries and fallback UI + - Lazy loading and code splitting where appropriate + +5. Extensibility: Design the component to be easily extendable and reusable across different parts of an application. + +6. Documentation: Include JSDoc comments for props, functions, and complex logic to enhance maintainability. + +7. Styling: Implement styling using the method specified in the JSON (e.g., CSS modules, Styled Components, Tailwind CSS). + +8. Testing Considerations: While not writing tests, structure the component in a way that facilitates easy unit and integration testing. + +9. Error Handling: Implement robust error handling and provide meaningful error messages or fallback UI. + +10. Performance: Optimize the component for performance, considering factors like render efficiency and bundle size. + +Output Instructions: +- Provide ONLY the complete component code, including all necessary imports. +- Do not include any explanations or comments outside of the code itself. +- Ensure the code is properly formatted and indented for readability. + +Remember, the goal is to create a component that is as complete and production-ready as possible. Do not hesitate to expand on the specification where it makes sense to create a more robust and feature-rich component. + +Please write comments etc. in ${language}.`, + messages: [ + { + role: "user", + content: `Specification: + + \`\`\` + ${JSON.stringify(specification)} + \`\`\` + `, + }, + ], + onFinish: (event) => { + fullResponse = event.text; + streamableAnswer.done(); + }, + }) + .then(async (result) => { + for await (const text of result.textStream) { + if (text) { + fullResponse = text; + streamableAnswer.update(fullResponse); + } + } + }) + .catch((err) => { + hasError = true; + fullResponse = "Error: " + err.message; + streamableAnswer.update(fullResponse); + }); + + return { response: fullResponse, hasError }; + } catch (err) { + const errorMessage = `Error: ${err instanceof Error ? err.message : "An unknown error occurred"}`; + streamableAnswer.update(errorMessage); + streamableAnswer.done(errorMessage); + return { response: errorMessage, hasError: true }; + } +} diff --git a/apps/www/@/lib/core/ai/agents/component/generation/component-specification.tsx b/apps/www/@/lib/core/ai/agents/component/generation/component-specification.tsx new file mode 100644 index 0000000..e8a39dd --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/component/generation/component-specification.tsx @@ -0,0 +1,103 @@ +import { + ComponentSpecificationSchema, + PartialComponentSpecificationSchema, + getComponentSpecificationSchema, +} from "@/lib/schema/component/specification"; +import { getModel } from "@/lib/registry"; +import { CoreMessage, streamObject } from "ai"; +import { createStreamableValue } from "ai/rsc"; +import { LLMSelection, UILibrary } from "@/lib/types"; +import { getUILibraryComponents } from "@/lib/components"; +// disabled for prod -> only local testing for logging specification +// import fs from "fs"; + +export async function componentSpecification( + messages: CoreMessage[], + uiLibrary: UILibrary, + language: string, + llm: LLMSelection, +): Promise { + const objectStream = + createStreamableValue(); + + let finalComponentSpecification: PartialComponentSpecificationSchema = {}; + const componentSpecificationSchema = + getComponentSpecificationSchema(uiLibrary); + + try { + await streamObject({ + model: getModel(llm), + system: `As an expert NextJS/React engineer, your task is to generate a highly detailed JSON schema for a new component. This schema will be used to create a production-ready component in a subsequent step. Your primary objectives are: + +1. Create an exhaustive and detailed specification, leaving no aspect of the component undefined. +2. Maximize the use of components from the provided UI Library, even if not explicitly requested by the user. +3. Make informed assumptions about necessary subcomponents to ensure a comprehensive and modular design. +4. Prioritize code modularity and reusability by leveraging external components whenever possible. +5. Include specifications for props, state management, event handlers, and any necessary hooks. +6. Consider accessibility, responsiveness, and performance optimizations in your schema. +7. Provide detailed styling information, including responsive design considerations. +8. Specify error handling and fallback UI where appropriate. +9. Include TypeScript type definitions for all props and major functions. + +Remember, the more detailed and comprehensive your schema, the better the resulting component will be. Don't hesitate to include components or features that weren't explicitly requested if they would enhance the overall functionality and user experience of the component. + +Please respond in ${language}.`, + messages, + schema: componentSpecificationSchema, + }) + .then(async (result) => { + for await (const obj of result.partialObjectStream) { + if (obj) { + objectStream.update(obj); + finalComponentSpecification = { + ...finalComponentSpecification, + ...obj, + }; + } + } + }) + .finally(() => { + objectStream.done(); + }); + + const uiLibraryComponents = getUILibraryComponents(uiLibrary); + + // fill in importStatement and exampleUsage + const uiLibraryImports = ( + (finalComponentSpecification as ComponentSpecificationSchema) + .uiLibraryImports?.imports || [] + ).map((uiLibraryImport) => { + const component = uiLibraryComponents.find( + (c) => c.name === uiLibraryImport.name, + ); + + return { + ...uiLibraryImport, + importStatement: component?.docs.import?.code || "", + exampleUsage: component?.docs.use[0].code || "", + }; + }); + + const specification = { + ...finalComponentSpecification, + uiLibraryImports: { + imports: [...(uiLibraryImports ?? [])], + }, + } as ComponentSpecificationSchema; + + // Validate final specification + if (!specification) { + throw new Error( + "Generated component specification is incomplete or invalid", + ); + } + + // disable in prod + // fs.writeFileSync("knost.json", JSON.stringify(specification, null, 2)); + + return specification; + } catch (error) { + console.error("Error generating component specification:", error); + throw error; + } +} diff --git a/apps/www/@/lib/core/ai/agents/component/generation/component-summarizer.tsx b/apps/www/@/lib/core/ai/agents/component/generation/component-summarizer.tsx new file mode 100644 index 0000000..63665e9 --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/component/generation/component-summarizer.tsx @@ -0,0 +1,39 @@ +import { createStreamableUI } from "ai/rsc"; +import { streamingAgent, StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { LLMSelection, UILibrary } from "@/lib/types"; + +export async function componentSummarizer( + uiStream: ReturnType, + code: string, + uiLibrary: UILibrary, + language: string, + llm: LLMSelection, + update?: boolean, +): Promise { + const SYSTEM_PROMPT = `As the expert React component designer who created this component, provide a comprehensive code review and integration guide. Your response should: + +1. Summarize the component's purpose, key features, and functionality. +2. Highlight the use of ${uiLibrary} and any other significant libraries or technologies. +3. Provide a detailed code review, discussing architecture, best practices, and any notable implementations. +4. Offer clear instructions on how to integrate this component into a NextJS/React project. +5. Mention any potential optimizations or areas for future enhancement. + +Aim for a thorough analysis that showcases the component's strengths and provides valuable insights for developers integrating it into their projects. + +Note (only applies if the ui library is "shadcn"): The installation for shadcn is \`npx shadcn@latest init\` and installing specific components is \`npx shadcn@latest add [component(s)]\` + +Please respond in ${language}.`; + + return streamingAgent( + uiStream, + [ + { + role: "user", + content: `Please create a summary of the following component: ${code}`, + }, + ], + SYSTEM_PROMPT, + update, + llm, + ); +} diff --git a/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration-abstract.tsx b/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration-abstract.tsx new file mode 100644 index 0000000..db1c927 --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration-abstract.tsx @@ -0,0 +1,16 @@ +import { createStreamableUI } from "ai/rsc"; +import { CoreMessage } from "ai"; +import { streamingAgent, StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { LLMSelection } from "@/lib/types"; + +export async function componentIterationAbstract( + uiStream: ReturnType, + messages: CoreMessage[], + language: string, + llm: LLMSelection, + update?: boolean, +): Promise { + const SYSTEM_PROMPT = `As an expert React component designer, provide a brief confirmation message about the changes you will make to an existing component. Your response should be a single sentence starting with "I will update" and briefly summarizing the main modifications to be made. Do not include any code or detailed explanations. Please respond in ${language}.`; + + return streamingAgent(uiStream, messages, SYSTEM_PROMPT, update, llm); +} diff --git a/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration-summarizer.tsx b/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration-summarizer.tsx new file mode 100644 index 0000000..c98fe37 --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration-summarizer.tsx @@ -0,0 +1,47 @@ +import { createStreamableUI } from "ai/rsc"; +import { streamingAgent, StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { LLMSelection, UILibrary } from "@/lib/types"; + +export async function componentIterationSummarizer( + uiStream: ReturnType, + originalCode: string, + updatedCode: string, + uiLibrary: UILibrary, + language: string, + llm: LLMSelection, + update?: boolean, +): Promise { + const SYSTEM_PROMPT = `As an expert React component designer, analyze the changes made to a component and provide a comprehensive summary. Your response should: + +1. List all changes made to the component, both in terms of functionality and structure. +2. Describe what was done for each change and how it was implemented. +3. Explain the reasoning behind each modification and its impact on the component's overall functionality. +4. Highlight any new or modified usage of ${uiLibrary} or other libraries. +5. Discuss any improvements in code quality, performance, or best practices. +6. Mention any potential further optimizations or areas for future enhancement. + +Provide a thorough analysis that clearly communicates the evolution of the component and the rationale behind each change. + +Please respond in ${language}.`; + + return streamingAgent( + uiStream, + [ + { + role: "user", + content: `Please analyze and summarize the changes made to the following component: + +Original component: +${originalCode} + +Updated component: +${updatedCode} + +Provide a detailed explanation of all modifications, their implementation, and their impact.`, + }, + ], + SYSTEM_PROMPT, + update, + llm, + ); +} diff --git a/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration.tsx b/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration.tsx new file mode 100644 index 0000000..d94445a --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/component/iteration/component-iteration.tsx @@ -0,0 +1,99 @@ +import { createStreamableUI, createStreamableValue } from "ai/rsc"; +import { streamingAgent, StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { LLMSelection, UILibrary } from "@/lib/types"; +import { kv } from "@vercel/kv"; +import ComponentCard from "components/component-card"; +import { ReactIcon } from "hugeicons-react"; +import { camelCaseToSpaces } from "@/lib/utils"; +import { streamText } from "ai"; +import { getModel } from "@/lib/registry"; + +export async function componentIterator( + uiStream: ReturnType, + existingCode: string, + userQuery: string, + uiLibrary: UILibrary, + llm: LLMSelection, + componentName: string, + fileName: string, + messageId: string, + iteration: number, + language: string, +): Promise { + const SYSTEM_PROMPT = `You are an expert React component editor. Your task is to modify the given component based on the user's request. Follow these guidelines: + +1. Analyze the existing component code and the user's modification request. +2. Make precise, targeted changes to fulfill the user's request. +3. Maintain the component's original structure and purpose while implementing the requested changes. +4. Ensure the modified code adheres to best practices for React, TypeScript, and ${uiLibrary}. +5. Provide clear comments explaining significant changes. +7. Output only the modified component code, without any additional explanations or text. + +Please make comments etc. in ${language}.`; + + const messages = [ + { + role: "system" as const, + content: SYSTEM_PROMPT, + }, + { + role: "user" as const, + content: `Here's the existing component code: + +${existingCode} + +User's modification request: ${userQuery} + +Please modify the component according to the user's request.`, + }, + ]; + + let fullResponse = ""; + let hasError = false; + const streamableAnswer = createStreamableValue(""); + + const componentCard = ( + } + iteration={iteration} + /> + ); + + uiStream.update(componentCard); + + try { + await streamText({ + model: getModel(llm), + system: SYSTEM_PROMPT, + messages, + onFinish: (event) => { + fullResponse = event.text; + streamableAnswer.done(); + }, + }) + .then(async (result) => { + for await (const text of result.textStream) { + if (text) { + fullResponse = text; + streamableAnswer.update(fullResponse); + } + } + }) + .catch((err) => { + hasError = true; + fullResponse = "Error: " + err.message; + streamableAnswer.update(fullResponse); + }); + + return { response: fullResponse, hasError }; + } catch (err) { + const errorMessage = `Error: ${err instanceof Error ? err.message : "An unknown error occurred"}`; + streamableAnswer.update(errorMessage); + streamableAnswer.done(errorMessage); + return { response: errorMessage, hasError: true }; + } +} diff --git a/apps/www/@/lib/core/ai/agents/index.tsx b/apps/www/@/lib/core/ai/agents/index.tsx new file mode 100644 index 0000000..5460feb --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/index.tsx @@ -0,0 +1,11 @@ +export * from "./component/generation/component-specification"; +export * from "./component/generation/component-abstract"; +export * from "./component/generation/component-generator"; +export * from "./component/generation/component-summarizer"; +export * from "./component/iteration/component-iteration"; +export * from "./component/iteration/component-iteration-abstract"; +export * from "./component/iteration/component-iteration-summarizer"; +export * from "./inquire/inquire"; +export * from "./inquire/prompt-suggestor"; +export * from "./task-manager"; +export * from "./language-identifier"; diff --git a/apps/www/@/lib/core/ai/agents/inquire/inquire.tsx b/apps/www/@/lib/core/ai/agents/inquire/inquire.tsx new file mode 100644 index 0000000..c3290e5 --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/inquire/inquire.tsx @@ -0,0 +1,25 @@ +import { createStreamableUI } from "ai/rsc"; +import { CoreMessage } from "ai"; +import { streamingAgent, StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { LLMSelection } from "@/lib/types"; + +export async function inquire( + uiStream: ReturnType, + messages: CoreMessage[], + language: string, + llm: LLMSelection, +): Promise { + const SYSTEM_PROMPT = `As a professional Senior Software Engineer specializing in NextJS/React components, provide a polite and concise response when insufficient information is provided. + +Your response should: +- Gracefully acknowledge the user's request +- Explain that you need more specific details about the desired component to provide meaningful assistance +- Maintain a helpful and professional tone +- Keep the response brief and clear + +Example tone: "I appreciate your interest in creating a React component. However, I'll need a bit more detail about your specific requirements to ensure I can provide you with the most suitable solution." + +Please respond in ${language}.`; + + return streamingAgent(uiStream, messages, SYSTEM_PROMPT, true, llm); +} diff --git a/apps/www/@/lib/core/ai/agents/inquire/prompt-suggestor.tsx b/apps/www/@/lib/core/ai/agents/inquire/prompt-suggestor.tsx new file mode 100644 index 0000000..722fbcc --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/inquire/prompt-suggestor.tsx @@ -0,0 +1,90 @@ +import { createStreamableUI, createStreamableValue } from "ai/rsc"; +import { streamObject, streamText } from "ai"; +import { StreamResponse } from "@/lib/core/ai/agents/streamingAgent"; +import { getModel } from "@/lib/registry"; +import PromptSuggestions from "components/prompt-suggestions"; +import { LLMSelection } from "@/lib/types"; +import { z } from "zod"; +import { PartialComponentSpecificationSchema } from "@/lib/schema/component/specification"; +import { + PartialPromptSuggestionsSchema, + PromptSuggestionsSchema, +} from "@/lib/schema/prompt-suggestions"; +import ExamplePrompts, { EXAMPLE_PROMPTS } from "components/example-prompts"; + +export async function promptSuggestor( + uiStream: ReturnType, + context: string, + update: boolean = false, + llm: LLMSelection, + language: string, +): Promise { + let hasError = false; + const streamableAnswer = createStreamableValue(""); + const objectStream = createStreamableValue(); + + let finalPromptSuggestions: PartialPromptSuggestionsSchema = {}; + + try { + await streamObject({ + model: getModel(llm), + system: `You are an AI assistant helping users generate React components by suggesting relevant follow-up prompts. Based on the conversation context, generate exactly 3 natural prompts that would help users create more UI components or enhance existing ones. + +Key Requirements: +1. Generate exactly 3 prompts focused on React component creation +2. Make them specific and actionable for UI/component development +3. If the context suggests a specific component type, suggest related variations +4. If no clear component context exists, default to basic Next.js component suggestions like: + - "Create a responsive navigation bar with dropdown menus" + - "Build a card grid layout for displaying content" + - "Design a form component with validation" +5. Format the response as a JSON array of strings +6. Write prompts in ${language}`, + messages: [ + { + role: "user", + content: `Context: ${context} + +Generate 3 relevant follow-up prompts.`, + }, + ], + schema: PromptSuggestionsSchema, + }) + .then(async (result) => { + for await (const obj of result.partialObjectStream) { + if (obj) { + objectStream.update(obj); + finalPromptSuggestions = { + ...finalPromptSuggestions, + ...obj, + }; + } + } + }) + .finally(() => { + objectStream.done(); + }); + + const suggestionsTitle = + finalPromptSuggestions.suggestionsTitle ?? "Prompt Suggestions"; + const suggestions = + (finalPromptSuggestions.suggestions as string[]) ?? EXAMPLE_PROMPTS; + + const promptSuggestions = ( + + ); + + if (update) { + uiStream.update(promptSuggestions); + } else { + uiStream.append(promptSuggestions); + } + + return { response: JSON.stringify(finalPromptSuggestions), hasError }; + } catch (err) { + const errorMessage = `Error: ${err instanceof Error ? err.message : "An unknown error occurred"}`; + streamableAnswer.update("Error generating suggestions"); + streamableAnswer.done(); + return { response: errorMessage, hasError: true }; + } +} diff --git a/apps/www/@/lib/core/ai/agents/language-identifier.tsx b/apps/www/@/lib/core/ai/agents/language-identifier.tsx new file mode 100644 index 0000000..58c42ed --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/language-identifier.tsx @@ -0,0 +1,19 @@ +import { CoreMessage, generateObject } from "ai"; +import { LanguageSchema } from "@/lib/schema/language"; +import { getModel } from "@/lib/registry"; + +export async function languageIdentifier(prompt: string) { + try { + const result = await generateObject({ + model: getModel(), + system: `You are a language identification expert. Your task is to determine the primary language used in the user's prompt. If you're uncertain about the language, default to English. Provide your answer as the full name of the language (e.g., "English", "French", "Spanish", etc.).`, + messages: [{ role: "user", content: prompt }], + schema: LanguageSchema, + }); + + return result; + } catch (error) { + console.error("Error in language identification:", error); + return null; + } +} diff --git a/apps/www/@/lib/core/ai/agents/streamingAgent.tsx b/apps/www/@/lib/core/ai/agents/streamingAgent.tsx new file mode 100644 index 0000000..91cf406 --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/streamingAgent.tsx @@ -0,0 +1,68 @@ +import { createStreamableUI, createStreamableValue } from "ai/rsc"; +import { CoreMessage, streamText } from "ai"; +import { getModel } from "@/lib/registry"; +import { BotMessage, PlainMessage } from "components/chat-message"; +import { LLMSelection } from "@/lib/types"; + +export interface StreamResponse { + response: string; + hasError: boolean; +} + +export async function streamingAgent( + uiStream: ReturnType, + messages: CoreMessage[], + systemPrompt: string, + update: boolean = false, + llm: LLMSelection, +): Promise { + let fullResponse = ""; + let hasError = false; + const streamableAnswer = createStreamableValue(""); + + let textNode: React.ReactNode; + + if (!update) { + textNode = ; + } else { + textNode = ; + } + + if (update) { + uiStream.update(textNode); + } else { + uiStream.append(textNode); + } + + try { + await streamText({ + model: getModel(llm), + messages, + system: systemPrompt, + onFinish: (event) => { + fullResponse = event.text; + streamableAnswer.done(); + }, + }) + .then(async (result) => { + for await (const text of result.textStream) { + if (text) { + fullResponse = text; + streamableAnswer.update(fullResponse); + } + } + }) + .catch((err) => { + hasError = true; + fullResponse = "Error: " + err.message; + streamableAnswer.update(fullResponse); + }); + + return { response: fullResponse, hasError }; + } catch (err) { + const errorMessage = `Error: ${err instanceof Error ? err.message : "An unknown error occurred"}`; + streamableAnswer.update(errorMessage); + streamableAnswer.done(errorMessage); + return { response: errorMessage, hasError: true }; + } +} diff --git a/apps/www/@/lib/core/ai/agents/task-manager.tsx b/apps/www/@/lib/core/ai/agents/task-manager.tsx new file mode 100644 index 0000000..7eb94a7 --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/task-manager.tsx @@ -0,0 +1,29 @@ +import { CoreMessage, generateObject } from "ai"; +import { nextActionSchema } from "@/lib/schema/next-action"; +import { getModel } from "@/lib/registry"; + +export async function taskManager(messages: CoreMessage[], language: string) { + try { + const result = await generateObject({ + model: getModel(), + system: `As a professional and experienced senior software engineer, your primary objective is to determine if the information provided by the user is sufficient to generate a React component. +You have the following options to choose from: + +1. **"generate_component"**: Use this option whenever possible to create a React component based on the user's prompt, even if the component could be simple (e.g., "A Dashboard," "Generate a Button"). You are encouraged to make reasonable assumptions to fill in gaps, providing useful, functional components with the available information. +2. **"inquire"**: Use this option if you cannot reasonably generate a component with the information provided. It should be used when essential details are missing or theres no message provider whatsoever, and there is no feasible way to proceed without clarification. +3. **"iterate_component"**: Select this option when refining or improving a previously generated component based on user feedback. + +Make your choices thoughtfully to deliver the most valuable and efficient assistance possible. + +Please always respond in ${language}. +`, + messages, + schema: nextActionSchema, + }); + + return result; + } catch (error) { + console.error(error); + return null; + } +} diff --git a/apps/www/@/lib/core/ai/agents/title-generator.tsx b/apps/www/@/lib/core/ai/agents/title-generator.tsx new file mode 100644 index 0000000..c09451d --- /dev/null +++ b/apps/www/@/lib/core/ai/agents/title-generator.tsx @@ -0,0 +1,42 @@ +import { streamText } from "ai"; +import { getModel } from "@/lib/registry"; +const SYSTEM_PROMPT = `As an AI specializing in generating catchy and relevant titles for React UI components and design patterns, your task is to create a fitting, good-sounding title based on the given query or conversation context. + +Follow these guidelines: +1. Keep the title concise, ideally under 60 characters +2. Make it descriptive and relevant to the React component or pattern +3. Use engaging language to capture interest +4. Avoid using special characters, periods, or excessive punctuation +5. Capitalize the first letter of each major word + +e.g. "Interactive Pricing Calculator", "SaaS Hero Section", "Contact Form with Validation" + +If the query is not directly related to React UI components or design patterns, generate a title that frames it in terms of React component architecture, such as "User Settings Panel" for account management or "Analytics Dashboard Grid" for data visualization. + +Respond with only the generated title, without any additional explanation, formatting or punctuation`; + +export async function generateTitle(query: string): Promise { + let title = ""; + + await streamText({ + model: getModel(), + messages: [ + { + role: "user", + content: `Generate a title for the following query: ${query}`, + }, + ], + system: SYSTEM_PROMPT, + onFinish: (event) => { + title = event.text; + }, + }).then(async (result) => { + for await (const text of result.textStream) { + if (text) { + title = text; + } + } + }); + + return title; +} diff --git a/apps/www/@/lib/core/ai/index.tsx b/apps/www/@/lib/core/ai/index.tsx new file mode 100644 index 0000000..8894e2d --- /dev/null +++ b/apps/www/@/lib/core/ai/index.tsx @@ -0,0 +1,320 @@ +import { + StreamableValue, + createAI, + createStreamableUI, + createStreamableValue, + getAIState, + getMutableAIState, + } from "ai/rsc"; + import React, { ReactNode } from "react"; + import { CoreMessage, generateId } from "ai"; + import { AIMessage, Chat, LLMSelection, UILibrary } from "@/lib/types/chat"; + import { + BotMessage, + PlainMessage, + UserMessage, + } from "components/chat-message"; + import ComponentCard, { ComponentCardProps } from "components/component-card"; + import { currentUser } from "@clerk/nextjs/server"; + import { saveChat } from "@/lib/actions/chat"; + import ErrorCard from "components/error-card"; + import { workflow } from "@/lib/core/workflow"; + import { generateTitle } from "@/lib/core/ai/agents/title-generator"; + import PromptSuggestions from "components/prompt-suggestions"; + import DisclaimerBadge from "components/disclaimer-badge"; + import { isProviderEnabled } from "@/lib/registry"; + + const MAX_MESSAGES = 6; + + async function submitUserMessage( + formData?: FormData, + uiLibrary: UILibrary = "shadcn", + llm: LLMSelection = "openai:gpt-4o", + ) { + "use server"; + + const aiState = getMutableAIState(); + const uiStream = createStreamableUI(); + const isGenerating = createStreamableValue(true); + const isComponentCard = createStreamableValue(false); + + const aiMessages = [...aiState.get().messages]; + const messages = aiMessages + .filter( + (m) => + m.role !== "tool" && + m.type !== "end" && + m.type !== "component_card" && + m.type !== "component_specification" && + m.type !== "component_iteration" && + m.type !== "prompt_suggestions", + ) + .map((m) => { + const { role, content } = m; + return { role, content } as CoreMessage; + }); + + messages.splice(0, Math.max(messages.length - MAX_MESSAGES, 0)); + + const content = formData?.get("input") as string; + const providerId = llm.split(":")[0]; + console.log(`Using model: ${llm}`); + + if (!isProviderEnabled(providerId)) { + throw new Error( + `Provider ${providerId} is not available. (API key not configured)`, + ); + } + + if (content) { + aiState.update({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id: generateId(), + role: "user", + content, + type: "input", + }, + ], + }); + messages.push({ + role: "user", + content, + }); + } + + const id = generateId(); + + workflow( + { + uiStream, + isGenerating, + isComponentCard, + }, + aiState, + content, + messages, + false, // TODO: implement skip logic + uiLibrary, + llm, + id, + ); + + return { + id, + isGenerating: isGenerating.value, + isComponentCard: isComponentCard.value, + display: uiStream.value, + }; + } + + export type AIState = Chat; + + export type UIState = { + id: string; + display: ReactNode; + isGenerating?: StreamableValue; + isComponentCard?: StreamableValue; + }[]; + + const initialAIState: AIState = { + id: generateId(), + title: "", + createdAt: new Date(), + userId: "", + path: "", + messages: [], + }; + + const initialUIState: UIState = []; + + export const AI = createAI({ + actions: { + submitUserMessage, + }, + initialUIState, + initialAIState, + onGetUIState: async () => { + "use server"; + + try { + const user = await currentUser(); + + if (user) { + const aiState = getAIState() as Chat; + + if (aiState) { + return getUIStateFromAIState(aiState); + } + } + } catch (error) { + console.error("Error in onGetUIState:", error); + } + + return initialUIState; + }, + onSetAIState: async ({ state, done }) => { + "use server"; + + try { + const user = await currentUser(); + + if (user) { + const { id, messages, createdAt, path } = state; + let { title } = state; + const userId = user.id; + + if (!title || title.trim().length === 0) { + title = await generateTitle( + messages.find((m) => m.role === "user")?.content ?? "", + ); + } + + const chat: Chat = { + id, + title, + createdAt, + userId, + path: path || `/chat/${id}`, + messages, + }; + + await saveChat(chat); + } + } catch (error) { + console.error("Error in onSetAIState:", error); + } + }, + }); + + export const getUIStateFromAIState = (aiState: AIState): UIState => { + const messages = Array.isArray(aiState.messages) ? aiState.messages : []; + + return messages + .map((message, index) => { + const { role, content, id, type } = message; + + if (type === "component_specification") return null; + + switch (role) { + case "user": + switch (type) { + case "input": + return { + id, + display: {content}, + }; + } + case "assistant": + const answer = createStreamableValue(); + answer.done(content); + + switch (type) { + case "inquiry": + return { + id, + display: , + }; + case "answer": + return { + id, + display: , + }; + case "follow_up": + return { + id, + display: , + }; + case "end": + const raw_result = JSON.parse(content === "undefined" ? "{}" : content); + const result = raw_result.result; + const relatedMessages = aiState.messages.filter( + (m) => m.id === id, + ); + let mergedContent = ""; + + relatedMessages.forEach((m) => { + if (m.role === "user") return; + if (m.type === "component_card") { + const raw = JSON.parse(m.content === "undefined" ? "{}" : m.content); + mergedContent += `\n\n${raw.result.code}`; + } else if (m.role === "assistant" && m.type !== "end") { + mergedContent += `\n\n${m.content}`; + } + }); + + return { + id, + display: ( + + ), + }; + } + case "tool": + try { + const toolOutput = JSON.parse(content === "undefined" ? "{}" : content); + switch (type) { + case "component_card": + case "component_iteration": + const code = createStreamableValue(); + code.done(toolOutput.result.code); + + const componentCard = toolOutput.result as ComponentCardProps; + componentCard.code = code.value; + + const isComponentCard = createStreamableValue(false); + isComponentCard.done(true); + + return { + id, + display: ( + + ), + isComponentCard: isComponentCard.value, + }; + case "prompt_suggestions": + // only display prompt suggestions if its the last message + if ( + index !== messages.length - 1 || + (aiState.sharePath && + aiState.path.split("/")[2] === + aiState.sharePath.split("/")[2]) + ) + return null; + + const title = toolOutput.result.suggestionsTitle; + const prompts = toolOutput.result.suggestions; + + return { + id, + display: ( + + ), + }; + } + } catch (error: any) { + return { + id, + display: ( + + ), + }; + } + default: + return { + id, + display: ( + + ), + }; + } + }) + .filter((message) => message !== null) as UIState; + }; \ No newline at end of file diff --git a/apps/www/@/lib/core/workflow/index.tsx b/apps/www/@/lib/core/workflow/index.tsx new file mode 100644 index 0000000..efe01ff --- /dev/null +++ b/apps/www/@/lib/core/workflow/index.tsx @@ -0,0 +1,333 @@ +"use server"; + +import { SpinnerMessage } from "components/chat-message"; +import { nextActionSchema } from "@/lib/schema/next-action"; +import { CoreMessage, generateId } from "ai"; +import { + createStreamableUI, + createStreamableValue, + getMutableAIState, +} from "ai/rsc"; +import { z } from "zod"; +import { + taskManager, + inquire, + promptSuggestor, + languageIdentifier, + componentSpecification, + componentAbstract, + componentGenerator, + componentSummarizer, + componentIterationAbstract, + componentIterator, + componentIterationSummarizer, +} from "@/lib/core/ai/agents"; +import { camelCaseToSpaces } from "@/lib/utils"; +import ComponentCardSkeleton from "components/component-card-skeleton"; +import ErrorCard from "components/error-card"; +import { ComponentCardProps } from "components/component-card"; +import { AI } from "@/lib/core/ai"; +import { AIMessage, LLMSelection, UILibrary } from "@/lib/types/chat"; +import DisclaimerBadge from "components/disclaimer-badge"; + +export async function workflow( + uiState: { + uiStream: ReturnType; + isGenerating: ReturnType>; + isComponentCard: ReturnType>; + }, + aiState: ReturnType>, + query: string, + messages: CoreMessage[], + skip: boolean, + uiLibrary: UILibrary, + llm: LLMSelection, + messageId?: string, +) { + const { uiStream, isComponentCard } = uiState; + + try { + const id = messageId ?? generateId(); + + uiStream.update(); + + let languageObject: { + object: { + language: string; + }; + } = { object: { language: "English" } }; + + languageObject = (await languageIdentifier(query)) ?? languageObject; + const language = languageObject.object.language; + + console.log(`Using Language for response: ${language}`); + + let action: { + object: { + next: z.infer["next"]; + } | any; + } = { object: { next: "generate_component" } }; + + if (!skip) action = (await taskManager(messages, language)) ?? action; + + if (action.object.next === "inquire") { + const inquiry = await inquire(uiStream, messages, language, llm); + + if (inquiry.hasError) { + throw new Error("An error occured while generating the inquiry"); + } + + const promptSuggestions = await promptSuggestor( + uiStream, + query, + false, + llm, + language, + ); + + if (promptSuggestions.hasError) { + throw new Error("An error occured while generating related prompts"); + } + + console.log(promptSuggestions.response); + + aiState.done({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id, + role: "assistant", + content: inquiry.response, + type: "inquiry", + }, + { + id, + role: "tool", + content: JSON.stringify({ + result: { + ...JSON.parse(promptSuggestions.response === "undefined" ? "{}" : promptSuggestions.response), + }, + }), + type: "prompt_suggestions", + }, + ], + }); + + return; + } + + if (action.object.next === "generate_component") { + const abstract = await componentAbstract( + uiStream, + messages, + uiLibrary, + language, + llm, + true, + ); + + uiStream.append(); + + const specification = await componentSpecification( + messages, + uiLibrary, + language, + llm, + ); + + const fileName = specification.fileName; + const title = camelCaseToSpaces(specification.componentName); + const component = await componentGenerator( + uiStream, + specification, + id, + true, + llm, + language, + ); + + isComponentCard.done(true); + + if (component.hasError) { + throw new Error( + "An error occured while generating the component. Please try again later!", + ); + } + + const summary = await componentSummarizer( + uiStream, + component.response, + uiLibrary, + language, + llm, + ); + + const disclaimerContent = `${abstract.response}\n\n${component.response}\n\n${summary.response}`; + uiStream.append( + , + ); + + aiState.done({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id, + content: abstract.response, + role: "assistant", + type: "answer", + }, + { + id, + content: JSON.stringify(specification), + role: "tool", + type: "component_specification", + }, + { + id, + role: "tool", + type: "component_card", + content: JSON.stringify({ + result: { + messageId: id, + code: component.response, + fileName, + title, + iteration: 1, + } as ComponentCardProps, + }), + }, + { + id, + role: "assistant", + content: summary.response, + type: "follow_up", + }, + { + id, + role: "assistant", + content: JSON.stringify({ + result: { + content: disclaimerContent, + llm, + }, + }), + type: "end", + }, + ], + }); + } else if (action.object.next === "iterate_component") { + const iterationAbstract = await componentIterationAbstract( + uiStream, + messages, + language, + llm, + true, + ); + + uiStream.append(); + + const lastComponentCardRaw = aiState + .get() + .messages.find( + (message: AIMessage) => message.type === "component_card", + ); + + console.log(lastComponentCardRaw); + + const lastComponentCardJSON = JSON.parse(lastComponentCardRaw.content ?? "{}"); + + const lastComponentCard = + lastComponentCardJSON.result as ComponentCardProps; + + const code = lastComponentCard.code.toString(); + const fileName = lastComponentCard.fileName ?? "untitled.tsx"; + const title = lastComponentCard.title ?? "Untitled"; + const messageId = lastComponentCard.messageId; + const iteration = lastComponentCard.iteration + 1; + + const componentIteration = await componentIterator( + uiStream, + code, + query, + uiLibrary, + llm, + title, + fileName, + messageId, + iteration, + language, + ); + + if (componentIteration.hasError) { + throw new Error( + "An error occured while generating the component. Please try again later!", + ); + } + + const iterationSummary = await componentIterationSummarizer( + uiStream, + code, + componentIteration.response, + uiLibrary, + language, + llm, + true, + ); + + const iterationDisclaimerContent = `${iterationAbstract.response}\n\n${componentIteration.response}\n\n${iterationSummary.response}`; + uiStream.append( + , + ); + + aiState.done({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id, + content: iterationAbstract.response, + role: "assistant", + type: "answer", + }, + { + id, + role: "assistant", + type: "component_iteration", + content: JSON.stringify({ + result: { + messageId: id, + code: componentIteration.response, + fileName, + title, + iteration: iteration, + } as ComponentCardProps, + }), + }, + { + id, + role: "assistant", + content: iterationSummary.response, + type: "follow_up", + }, + { + id, + role: "assistant", + content: JSON.stringify({ + result: { llm }, + }), + type: "end", + }, + ], + }); + } + } catch (error: any) { + console.error("Error in processEvents:", error); + uiStream.update( + , + ); + } finally { + uiStream.done(); + } +} \ No newline at end of file diff --git a/apps/www/@/lib/hooks/use-app-settings.tsx b/apps/www/@/lib/hooks/use-app-settings.tsx new file mode 100644 index 0000000..ee9fe7a --- /dev/null +++ b/apps/www/@/lib/hooks/use-app-settings.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import { LLMSelection } from "@/lib/types"; + +const LOCAL_STORAGE_KEY = "app-settings"; + +interface AppSettings { + llm: LLMSelection; + uiLibrary: string; +} + +const defaultSettings: AppSettings = { + llm: "openai:gpt-4o", + uiLibrary: "shadcn", +}; + +interface AppSettingsContext { + settings: AppSettings; + updateSettings: (newSettings: Partial) => void; +} + +const AppSettingsContext = createContext( + undefined, +); + +export function useAppSettings() { + const context = useContext(AppSettingsContext); + if (!context) { + throw new Error( + "useAppSettings must be used within an AppSettingsProvider", + ); + } + return context; +} + +interface AppSettingsProviderProps { + children: React.ReactNode; +} + +export function AppSettingsProvider({ children }: AppSettingsProviderProps) { + const [settings, setSettings] = useState(() => { + if (typeof window !== "undefined") { + const savedSettings = localStorage.getItem(LOCAL_STORAGE_KEY); + if (savedSettings && savedSettings !== "undefined") { + return JSON.parse(savedSettings ?? {}); + } else { + return defaultSettings; + } + } + return defaultSettings; + }); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(settings)); + }, [settings]); + + const updateSettings = (newSettings: Partial) => { + setSettings((prevSettings) => ({ + ...prevSettings, + ...newSettings, + })); + }; + + const contextValue: AppSettingsContext = { + settings, + updateSettings, + }; + + return ( + + {children} + + ); +} diff --git a/apps/www/@/lib/hooks/use-app-state.tsx b/apps/www/@/lib/hooks/use-app-state.tsx new file mode 100644 index 0000000..0f568f0 --- /dev/null +++ b/apps/www/@/lib/hooks/use-app-state.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import { Chat } from "@/lib/types"; +import { renameChat } from "@/lib/actions/chat"; +import { toast } from "sonner"; + +const LOCAL_STORAGE_KEY = "app-state"; + +interface AppState { + chat: Chat | null; + isGenerating: boolean; + prompt: string; // New prompt state + setChat: (chat: Chat) => void; + setIsGenerating: (isGenerating: boolean) => void; + setPrompt: (prompt: string) => void; // New setter for prompt + setSharePath: (isChatPublic: boolean) => void; + setChatName: (chatName: string) => void; +} + +interface AppStateContext { + chat: Chat | null; + isGenerating: boolean; + prompt: string; + setChat: (chat: Chat | null) => void; + setIsGenerating: (isGenerating: boolean) => void; + setPrompt: (prompt: string) => void; // New setter for prompt + setSharePath: (sharePath: string | undefined) => void; + setChatName: (chatName: string) => void; +} + +const AppStateContext = createContext(undefined); + +export function useAppState() { + const context = useContext(AppStateContext); + if (!context) { + throw new Error("useAppState must be used within an AppStateProvider"); + } + return context; +} + +interface AppStateProviderProps { + children: React.ReactNode; +} + +export function AppStateProvider({ children }: AppStateProviderProps) { + const [state, setState] = useState(() => { + if (typeof window !== "undefined") { + const savedState = localStorage.getItem(LOCAL_STORAGE_KEY); + if (savedState && savedState !== "undefined") { + return JSON.parse(savedState ?? {}); + } else { + return { chat: null, isGenerating: false, prompt: "" }; + } + } + return { chat: null, isGenerating: false, prompt: "" }; + }); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); + }, [state]); + + const setChat = (chat: Chat | null) => { + setState({ ...state, chat }); + }; + + const setIsGenerating = (isGenerating: boolean) => { + setState({ ...state, isGenerating }); + }; + + const setPrompt = (prompt: string) => { + setState({ ...state, prompt }); + }; + + const setSharePath = (sharePath: string | undefined) => { + setState({ + ...state, + chat: { ...state.chat, sharePath } as Chat, + }); + }; + + const setChatName = async (chatName: string) => { + if (!state.chat) { + return toast.error("No chat found to rename"); + } + + const updatedChat = await renameChat(state.chat.id, chatName); + + if ("error" in updatedChat) { + toast.error(updatedChat.error || "Failed to rename chat"); + return; + } + + setState({ + ...state, + chat: { + ...state.chat, + title: chatName, + }, + }); + }; + + const contextValue: AppStateContext = { + chat: state.chat, + isGenerating: state.isGenerating, + prompt: state.prompt, + setChat, + setIsGenerating, + setPrompt, + setSharePath, + setChatName, + }; + + return ( + + {children} + + ); +} diff --git a/apps/www/@/lib/hooks/use-component-preview.tsx b/apps/www/@/lib/hooks/use-component-preview.tsx new file mode 100644 index 0000000..3329314 --- /dev/null +++ b/apps/www/@/lib/hooks/use-component-preview.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useState, + useCallback, +} from "react"; +import { AIMessage } from "@/lib/types"; + +const LOCAL_STORAGE_KEY = "component-preview-state"; + +interface PreviewState { + messageId: string | null; + isOpen: boolean; + code: string; + title: string; + fileName: string; + previewHtml: string; +} + +interface ComponentPreviewContext { + isPreviewOpen: boolean; + togglePreview: ( + messageId: string, + code: string, + title: string, + fileName: string, + ) => void; + previewCode: string; + previewTitle: string; + previewFileName: string; + previewHtml: string; + activeMessageId: string | null; + componentCards: AIMessage[]; + setPreviewCode: (code: string) => void; + setComponentCards: (cards: AIMessage[]) => void; + closePreview: () => void; + setPreviewHtml: (html: string) => void; +} + +const ComponentPreviewContext = createContext< + ComponentPreviewContext | undefined +>(undefined); + +export function useComponentPreview() { + const context = useContext(ComponentPreviewContext); + + if (!context) { + throw new Error( + "useComponentPreview must be used within a ComponentPreviewProvider", + ); + } + + return context; +} + +interface ComponentPreviewProviderProps { + children: React.ReactNode; +} + +export function ComponentPreviewProvider({ + children, +}: ComponentPreviewProviderProps) { + const [state, setState] = useState(() => { + if (typeof window !== "undefined") { + const savedState = localStorage.getItem(LOCAL_STORAGE_KEY); + if (savedState && savedState !== "undefined") { + return JSON.parse(savedState ?? {}); + } else { + return { + isOpen: false, + code: "", + messageId: null, + title: "", + fileName: "", + previewHtml: "" + }; + } + } + + return { + isOpen: false, + code: "", + messageId: null, + title: "", + fileName: "", + previewHtml: "" + }; + }); + + const [componentCards, setComponentCards] = useState([]); + + const togglePreview = useCallback( + (messageId: string, code: string, title: string, fileName: string) => { + setState((prevState) => ({ + ...prevState, + isOpen: prevState.messageId !== messageId || !prevState.isOpen, + messageId: prevState.messageId !== messageId ? messageId : null, + code: prevState.messageId !== messageId ? code : prevState.code, + title: prevState.messageId !== messageId ? title : prevState.title, + fileName: + prevState.messageId !== messageId ? fileName : prevState.fileName, + })); + }, + [], + ); + + const setPreviewCode = useCallback((code: string) => { + setState((prevState) => ({ ...prevState, code })); + }, []); + + const closePreview = useCallback(() => { + setState((prevState) => ({ ...prevState, isOpen: false, messageId: null })); + }, []); + + const setPreviewHtml = useCallback((html: string) => { + setState((prevState) => ({ ...prevState, previewHtml: html })); + }, []); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); + }, [state]); + + const contextValue: ComponentPreviewContext = { + isPreviewOpen: state.isOpen, + previewCode: state.code, + previewTitle: state.title, + previewFileName: state.fileName, + previewHtml: state.previewHtml, + activeMessageId: state.messageId, + componentCards, + togglePreview, + setPreviewCode, + setComponentCards, + closePreview, + setPreviewHtml, + }; + + return ( + + {children} + + ); +} diff --git a/apps/www/@/lib/hooks/use-copy-to-clipboard.tsx b/apps/www/@/lib/hooks/use-copy-to-clipboard.tsx new file mode 100644 index 0000000..e011d69 --- /dev/null +++ b/apps/www/@/lib/hooks/use-copy-to-clipboard.tsx @@ -0,0 +1,33 @@ +"use client"; + +import * as React from "react"; + +export interface useCopyToClipboardProps { + timeout?: number; +} + +export function useCopyToClipboard({ + timeout = 2000, +}: useCopyToClipboardProps) { + const [isCopied, setIsCopied] = React.useState(false); + + const copyToClipboard = (value: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + return; + } + + if (!value) { + return; + } + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, timeout); + }); + }; + + return { isCopied, copyToClipboard }; +} diff --git a/apps/www/@/lib/hooks/use-debounce.ts b/apps/www/@/lib/hooks/use-debounce.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/www/@/lib/hooks/use-enter-submit.tsx b/apps/www/@/lib/hooks/use-enter-submit.tsx new file mode 100644 index 0000000..3aaeb88 --- /dev/null +++ b/apps/www/@/lib/hooks/use-enter-submit.tsx @@ -0,0 +1,23 @@ +import { useRef, type RefObject } from "react"; + +export function useEnterSubmit(): { + formRef: RefObject; + onKeyDown: (event: React.KeyboardEvent) => void; +} { + const formRef = useRef(null); + + const handleKeyDown = ( + event: React.KeyboardEvent, + ): void => { + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + formRef.current?.requestSubmit(); + event.preventDefault(); + } + }; + + return { formRef, onKeyDown: handleKeyDown }; +} diff --git a/apps/www/@/lib/hooks/use-local-storage.tsx b/apps/www/@/lib/hooks/use-local-storage.tsx new file mode 100644 index 0000000..f32bfaf --- /dev/null +++ b/apps/www/@/lib/hooks/use-local-storage.tsx @@ -0,0 +1,25 @@ +'use client' +import { useEffect, useState } from "react"; + +export const useLocalStorage = ( + key: string, + initialValue: T, +): [T, (value: T) => void] => { + const [storedValue, setStoredValue] = useState(initialValue); + + useEffect(() => { + // Retrieve from localStorage + const item = window.localStorage.getItem(key); + if (item && item !== "undefined") { + setStoredValue(JSON.parse(item ?? {})); + } + }, [key]); + + const setValue = (value: T) => { + // Save state + setStoredValue(value); + // Save to localStorage + window.localStorage.setItem(key, JSON.stringify(value)); + }; + return [storedValue, setValue]; +}; diff --git a/apps/www/@/lib/hooks/use-scroll-to-bottom.tsx b/apps/www/@/lib/hooks/use-scroll-to-bottom.tsx new file mode 100644 index 0000000..b75ad53 --- /dev/null +++ b/apps/www/@/lib/hooks/use-scroll-to-bottom.tsx @@ -0,0 +1,31 @@ +import { useEffect, useRef, RefObject } from "react"; + +export function useScrollToBottom(): [ + RefObject, + RefObject, +] { + const containerRef = useRef(null); + const endRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + const end = endRef.current; + + if (container && end) { + const observer = new MutationObserver(() => { + end.scrollIntoView({ behavior: "instant", block: "end" }); + }); + + observer.observe(container, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + + return () => observer.disconnect(); + } + }, []); + + return [containerRef, endRef]; +} diff --git a/apps/www/@/lib/hooks/use-streamable-text.tsx b/apps/www/@/lib/hooks/use-streamable-text.tsx new file mode 100644 index 0000000..5dd78a0 --- /dev/null +++ b/apps/www/@/lib/hooks/use-streamable-text.tsx @@ -0,0 +1,27 @@ +import { StreamableValue, readStreamableValue } from "ai/rsc"; +import { useEffect, useState } from "react"; + +export const useStreamableText = ( + content: string | StreamableValue, +) => { + const [rawContent, setRawContent] = useState( + typeof content === "string" ? content : "", + ); + + useEffect(() => { + (async () => { + if (typeof content === "string") { + setRawContent(content); + } else { + let value = ""; + for await (const delta of readStreamableValue(content)) { + if (typeof delta === "string") { + setRawContent((value = value + delta)); + } + } + } + })(); + }, [content]); + + return rawContent; +}; diff --git a/apps/www/@/lib/registry.ts b/apps/www/@/lib/registry.ts new file mode 100644 index 0000000..8f840c9 --- /dev/null +++ b/apps/www/@/lib/registry.ts @@ -0,0 +1,32 @@ +import { experimental_createProviderRegistry as createProviderRegistry } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { google } from "@ai-sdk/google"; +import { mistral } from "@ai-sdk/mistral"; +import { LLMSelection } from "@/lib/types"; + +export const registry = createProviderRegistry({ + openai, + anthropic, + google, + mistral, +}); + +export function getModel(llm?: LLMSelection) { + return registry.languageModel(llm ?? "openai:gpt-4o-mini"); +} + +export function isProviderEnabled(providerId: string): boolean { + switch (providerId) { + case "openai": + return !!process.env.OPENAI_API_KEY; + case "anthropic": + return !!process.env.ANTHROPIC_API_KEY; + case "google": + return !!process.env.GOOGLE_GENERATIVE_AI_API_KEY; + case "mistral": + return !!process.env.MISTRAL_API_KEY; + default: + return false; + } +} \ No newline at end of file diff --git a/apps/www/@/lib/schema/component/specification.tsx b/apps/www/@/lib/schema/component/specification.tsx new file mode 100644 index 0000000..fa80c1a --- /dev/null +++ b/apps/www/@/lib/schema/component/specification.tsx @@ -0,0 +1,236 @@ +import { z } from "zod"; +import { DeepPartial } from "ai"; +import shadcnUIComponentsDump from "public/content/components/shadcn.json"; +// import nextUIComponentsDump from "public/content/components/nextui.json"; +// import flowbiteComponentsDump from "public/content/components/flowbite.json"; +// import lucideIconsDump from "public/content/icons/lucide.json"; +import { UILibrary } from "@/lib/types"; +import { getUILibraryComponents } from "@/lib/components"; +const lucideIcons = [] as const; + +const uiLibraryName = "nextui"; +const uiLibrary = [ + ...(uiLibraryName === "nextui" + ? shadcnUIComponentsDump + : shadcnUIComponentsDump), +] as const; + +// Enum for common types +const propTypes = z + .enum(["string", "number", "boolean", "ReactNode"]) + .describe( + "Specifies the data type of the property, such as string, number, boolean, or ReactNode.", + ); + +// Schema for individual properties of the component +const propSchema = z.object({ + name: z + .string() + .describe( + "The unique name assigned to the property, typically descriptive of its function.", + ), + type: propTypes.describe( + "Defines the type of the property. Choose from string, number, boolean, or ReactNode.", + ), + required: z + .boolean() + .default(false) + .describe( + "Indicates whether this property is mandatory for the component.", + ), + description: z + .string() + .optional() + .describe( + "Provides additional context or usage information for the property.", + ), +}); + +// Schema for states used in the component +const stateSchema = z.object({ + name: z + .string() + .describe( + "The state variable name, representing component behavior (e.g., isLoading, hasError).", + ), + type: propTypes.describe( + "Specifies the type of state, such as boolean or string.", + ), + description: z + .string() + .optional() + .describe( + "An optional explanation providing further detail on the state's role.", + ), + initialValue: z + .any() + .optional() + .describe( + "The default value assigned to the state when the component is initialized.", + ), +}); + +// Schema for accessibility settings +const accessibilitySchema = z.object({ + ariaLabel: z + .string() + .describe( + "Defines an ARIA label to enhance accessibility for assistive technologies.", + ), + role: z + .string() + .optional() + .describe("Specifies an optional role attribute, such as button or link."), + ariaDescribedBy: z + .string() + .optional() + .describe( + "References the ID of an element providing additional description.", + ), + ariaControls: z + .string() + .optional() + .describe("References the ID of an element that this element controls."), + keyboardNavigation: z + .boolean() + .optional() + .describe( + "Indicates whether keyboard navigation is supported by the component.", + ), +}); + +// Schema for icon library imports +const iconLibraryImportSchema = z.object({ + iconLibrary: z + .enum(["lucide"]) + .describe("The icon library being used for the component."), + imports: z + .array( + z.object({ + name: z + .enum(lucideIcons.map((icon) => (icon as any)?.name ?? "") as [string, ...string[]]) + .describe( + "The name of the icon being imported from the icon library.", + ), + reason: z + .string() + .describe( + "A reasoned justification for using this particular icon in the component.", + ), + }), + ) + .describe("A list of icons imported from an icon library."), +}); + +export function getComponentSpecificationSchema(uiLibrary: UILibrary) { + const uiLibraryComponents = [...getUILibraryComponents(uiLibrary)] as const; + + // Main schema for the component specification + const uiLibraryImportSchema = z.object({ + imports: z + .array( + z.object({ + name: z + .enum( + uiLibraryComponents.map((component) => component.name) as [ + string, + ...string[], + ], + ) + .describe( + "The name of the UI component being imported from a library.", + ), + importStatement: z + .string() + .min(0) + .max(0) + .describe("Leave this field blank."), + exampleUsage: z + .string() + .min(0) + .max(0) + .describe("Leave this field blank."), + reason: z + .string() + .describe( + "A professional justification for importing this specific UI component.", + ), + }), + ) + .describe( + "A list of UI components imported from a UI library for use in the component.", + ), + }); + + return z.object({ + componentName: z + .string() + .describe( + "The official name of the component, formatted using CamelCase. e.g. 'ComponentName'", + ), + fileName: z + .string() + .describe( + "A fitting name to store the component file. e.g. 'component-name.tsx'", + ), + isClientComponent: z + .boolean() + .default(false) + .describe( + "Indicates if the component is a client component, which allows it to use React hooks and other client-side features.", + ), + props: z + .array(propSchema) + .describe( + "A list of properties (props) that the component accepts, with associated metadata.", + ), + uiLibraryImports: uiLibraryImportSchema + .optional() + .describe( + "Lists UI components sourced from a UI library that are utilized by the component. Make use of as much reusable components from the library as possible. Even if task doesnt specifically ask for it, try to use a button, input, card, etc. from the library.", + ), + + iconLibraryImports: iconLibraryImportSchema + .optional() + .describe( + "Lists icons from an icon library that are utilized by the component.", + ), + + acceptsChildren: z + .boolean() + .default(false) + .describe( + "Indicates if the component accepts children elements within it.", + ), + + childrenType: propTypes + .optional() + .describe( + "Defines the type of children accepted, such as text or ReactNode.", + ), + + states: z + .array(stateSchema) + .describe( + "A list of state variables managed by the component, including their types and default values.", + ), + + accessibility: accessibilitySchema + .optional() + .describe( + "Specifies accessibility attributes and settings for the component, such as ARIA labels.", + ), + }); +} + +const componentSpecificationSchemaFallback = + getComponentSpecificationSchema("shadcn"); + +export type ComponentSpecificationSchema = z.infer< + typeof componentSpecificationSchemaFallback & { + uiLibraryImports: any; + } +>; + +export type PartialComponentSpecificationSchema = + DeepPartial; diff --git a/apps/www/@/lib/schema/language.tsx b/apps/www/@/lib/schema/language.tsx new file mode 100644 index 0000000..46e4879 --- /dev/null +++ b/apps/www/@/lib/schema/language.tsx @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const LanguageSchema = z.object({ + language: z + .string() + .describe("The detected language of the message") + .default("English"), +}); + +export type Language = z.infer; diff --git a/apps/www/@/lib/schema/next-action.tsx b/apps/www/@/lib/schema/next-action.tsx new file mode 100644 index 0000000..8321776 --- /dev/null +++ b/apps/www/@/lib/schema/next-action.tsx @@ -0,0 +1,13 @@ +import { DeepPartial } from "ai"; +import { z } from "zod"; + +export const nextActionSchema = z.object({ + next: z + .enum(["inquire", "generate_component", "iterate_component"]) + .describe( + "The next action to be taken based on the user's prompt and the information available.", + ), +}); + +export type PartialNextAction = DeepPartial; +export type NextAction = z.infer; diff --git a/apps/www/@/lib/schema/prompt-suggestions.tsx b/apps/www/@/lib/schema/prompt-suggestions.tsx new file mode 100644 index 0000000..aa5f07a --- /dev/null +++ b/apps/www/@/lib/schema/prompt-suggestions.tsx @@ -0,0 +1,18 @@ +import { DeepPartial } from "ai"; +import { z } from "zod"; + +export const PromptSuggestionsSchema = z.object({ + suggestionsTitle: z + .string() + .describe( + 'A title for "Suggested Prompts" in the resepective language of the user.', + ), + suggestions: z + .array(z.string()) + .describe( + "Three follow-up prompts that would help users dive deeper into their current topic or explore related areas", + ), +}); + +export type PromptSuggestions = z.infer; +export type PartialPromptSuggestionsSchema = DeepPartial; diff --git a/apps/www/@/lib/stores/chatStore.ts b/apps/www/@/lib/stores/chatStore.ts new file mode 100644 index 0000000..2876939 --- /dev/null +++ b/apps/www/@/lib/stores/chatStore.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; +import { getChats } from "@/lib/actions/chat"; +import { Chat } from "@/lib/types"; + +interface ChatStore { + chats: Chat[] | null; + isLoading: boolean; + error: string | null; + fetchChats: (userId: string) => Promise; +} + +export const useChatStore = create((set) => ({ + chats: null, + isLoading: false, + error: null, + fetchChats: async (userId: string) => { + set({ isLoading: true, error: null }); + try { + const fetchedChats = await getChats(userId); + if ("error" in fetchedChats) { + set({ error: fetchedChats.error, isLoading: false }); + } else { + set({ chats: fetchedChats, isLoading: false }); + } + } catch (error) { + set({ error: (error as Error).message, isLoading: false }); + } + }, +})); diff --git a/apps/www/@/lib/types/index.d.ts b/apps/www/@/lib/types/index.d.ts new file mode 100644 index 0000000..7f1779f --- /dev/null +++ b/apps/www/@/lib/types/index.d.ts @@ -0,0 +1,54 @@ +import { CoreMessage, ToolContent } from "ai"; + +export interface Chat extends Record { + id: string; + title: string; + createdAt: Date; + userId: string; + path: string; + messages: AIMessage[]; + sharePath?: string; +} + +export type AIMessageType = + | "component_specification" + | "component_card" + | "component_iteration" + | "answer" + | "skip" + | "input" + | "inquiry" + | "prompt_suggestions" + | "tool" + | "follow_up" + | "end"; + +export type AIMessage = Omit & { + id: string; + content: string; + type: AIMessageType; +}; + +export type ExamplePrompt = { + label: string; + prompt: string; +}; + +export type LLMSelection = + | "openai:gpt-4o" + | "openai:gpt-4o-mini" + | "openai:gpt-4o-turbo" + | "anthropic:claude-3-5-sonnet-20241022" + | "anthropic:claude-3-5-sonnet-20240620" + | "anthropic:claude-3-5-haiku-20241022" + | "mistral:mistral-large-latest" + | "mistral:mistral-small-latest" + | "mistral:pixtral-12b-2409" + | "google:gemini-1.5-flash" + | "google:gemini-1.5-pro"; + +export type UserRole = "user" | "admin" | "contributor" | "demo"; + +export type UILibrary = "nextui" | "shadcn" | "flowbite"; + +export type IconLibrary = "lucide-react"; diff --git a/apps/www/@/lib/utils.ts b/apps/www/@/lib/utils.ts index 9fe6fde..5bf90c6 100644 --- a/apps/www/@/lib/utils.ts +++ b/apps/www/@/lib/utils.ts @@ -1,22 +1,68 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" +import { CoreMessage } from "ai"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export function formatDate(input: string | number): string { - const date = new Date(input) +export const dateConvert = (input: string) => { + const date = new Date(input) + return date.getTime() +} + + + +/** + * Takes an array of AIMessage and modifies each message where the role is 'tool'. + * Changes the role to 'assistant' and converts the content to a JSON string. + * Returns the modified messages as an array of CoreMessage. + * + * @param aiMessages - Array of AIMessage + * @returns modifiedMessages - Array of modified messages + */ +export function transformToolMessages(messages: CoreMessage[]): CoreMessage[] { + return messages.map((message) => + message.role === "tool" + ? { + ...message, + role: "assistant", + content: JSON.stringify(message.content), + type: "tool", + } + : message, + ) as CoreMessage[]; +} + +/** + * Sanitizes a URL by replacing spaces with '%20' + * @param url - The URL to sanitize + * @returns The sanitized URL + */ +export function sanitizeUrl(url: string): string { + return url.replace(/\s+/g, "%20"); +} + +/** + * Converts a camelCase string to a string with spaces between words. + * @param str - The camelCase string to convert. + * @returns The string with spaces between words. + */ +export function camelCaseToSpaces(str: string): string { + return str.replace(/([A-Z])([A-Z])([a-z])|([a-z])([A-Z])/g, "$1$4 $2$3$5"); +} + +/** + * Formats a date to a string with the format "month day, year". + * @param input - The date to format. + * @returns The formatted date string. + */ +export function formatDate(input: string | number | Date): string { + const date = new Date(input); return date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", - }) -} - - -export const dateConvert = (input: string) => { - const date = new Date(input) - return date.getTime() + }); } \ No newline at end of file diff --git a/apps/www/app/(www)/chat/[id]/layout.tsx b/apps/www/app/(www)/chat/[id]/layout.tsx new file mode 100644 index 0000000..120cfee --- /dev/null +++ b/apps/www/app/(www)/chat/[id]/layout.tsx @@ -0,0 +1,19 @@ +import ComponentPreviewPanel from "components/component-preview-panel"; +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; + +export default function ChatWrapperLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + + {children} + + + +
+ ); +} diff --git a/apps/www/app/(www)/chat/[id]/page.tsx b/apps/www/app/(www)/chat/[id]/page.tsx new file mode 100644 index 0000000..d6688a7 --- /dev/null +++ b/apps/www/app/(www)/chat/[id]/page.tsx @@ -0,0 +1,20 @@ +import { Chat } from "components/chat"; +import { getMissingKeys } from "@/lib/actions/chat"; +import { AI } from "@/lib/core/ai"; +import { notFound } from "next/navigation"; + +export const metadata = { + title: "Chat Details - Synth UI", +}; + +export default async function ChatDetailsPage({ params }: { params: { id: string } }) { + const { id } = params; + if (!id) return notFound(); + let missingKeys = await getMissingKeys(); + if (!Array.isArray(missingKeys)) missingKeys = []; + return ( + + + + ); +} \ No newline at end of file diff --git a/apps/www/app/(www)/chat/layout.tsx b/apps/www/app/(www)/chat/layout.tsx new file mode 100644 index 0000000..5d397e5 --- /dev/null +++ b/apps/www/app/(www)/chat/layout.tsx @@ -0,0 +1,20 @@ +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import ComponentPreviewPanel from "components/component-preview-panel"; +import Sidebar from "components/sidebar"; +export default function ChatLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+ +
{children}
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/www/app/(www)/chat/page.tsx b/apps/www/app/(www)/chat/page.tsx new file mode 100644 index 0000000..7d4b4de --- /dev/null +++ b/apps/www/app/(www)/chat/page.tsx @@ -0,0 +1,22 @@ +import { Chat } from "components/chat"; +import { getMissingKeys } from "@/lib/actions/chat"; +import { AI } from "@/lib/core/ai"; +import { generateId } from "ai"; + + +export const metadata = { + title: "New Chat - FARM UI", +}; +// export const runtime = "edge"; +export default async function ChatPage() { + const id = generateId() || ""; + let missingKeys = await getMissingKeys(); + if (!Array.isArray(missingKeys)) missingKeys = []; + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/apps/www/app/(www)/chat/share/[id]/page.tsx b/apps/www/app/(www)/chat/share/[id]/page.tsx new file mode 100644 index 0000000..08aed5a --- /dev/null +++ b/apps/www/app/(www)/chat/share/[id]/page.tsx @@ -0,0 +1,63 @@ +import { type Metadata } from "next"; +import { notFound } from "next/navigation"; +import { formatDate } from "@/lib/utils"; +import { getSharedChat } from "@/lib/actions/chat"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ChatList } from "components/chat-list"; +import { AI, getUIStateFromAIState, UIState } from "@/lib/core/ai"; +import { ModeToggle } from "components/mode-toggle"; + +interface SharePageProps { + params: { + id: string; + }; +} + +export async function generateMetadata({ + params, +}: SharePageProps): Promise { + const chat = await getSharedChat(params.id); + + return { + title: (chat?.title.slice(0, 50) ?? "Untitled") + " - Shared - Synth UI", + }; +} + +export default async function SharePage({ params }: SharePageProps) { + const chat = await getSharedChat(params.id); + + if (!chat || !chat?.sharePath) { + notFound(); + } + + const uiState: UIState = getUIStateFromAIState(chat); + + return ( + <> +
+
+
+
+

+ {chat.title} +

+
+ {formatDate(chat.createdAt)} · {chat.messages.length} messages +
+
+
+
+ +
+ + + +
+
+
+ +
+
+ + ); +} \ No newline at end of file diff --git a/apps/www/app/(www)/layout.tsx b/apps/www/app/(www)/layout.tsx index 5dabdf0..6645184 100644 --- a/apps/www/app/(www)/layout.tsx +++ b/apps/www/app/(www)/layout.tsx @@ -3,6 +3,8 @@ import Footer from "components/ui/Footer"; import Image from "next/image"; import bgback from "/public/bg-back.png"; import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { SidebarProvider } from "@/components/ui/sidebar"; export default function RootLayout({ children, @@ -11,7 +13,7 @@ export default function RootLayout({ }) { return ( <> - back bg + /> */} -
{children}
-